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 8043cb6bdf..f4f8f743b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -168,11 +168,6 @@ 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.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 @@ -604,36 +599,6 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) - fun consume( - event: ChessGameEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: LiveChessGameChallengeEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: LiveChessGameAcceptEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: LiveChessMoveEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: LiveChessGameEndEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - fun consumeRegularEvent( event: Event, relay: NormalizedRelayUrl?, @@ -2965,11 +2930,6 @@ object LocalCache : ILocalCache, ICacheProvider { is CalendarDateSlotEvent -> consume(event, relay, wasVerified) is CalendarTimeSlotEvent -> consume(event, relay, wasVerified) is CalendarRSVPEvent -> consume(event, relay, wasVerified) - is ChessGameEvent -> 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 ChannelCreateEvent -> consume(event, relay, wasVerified) is ChannelListEvent -> consume(event, relay, wasVerified) is ChannelHideMessageEvent -> 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 8a0335bf0f..01d9b07a6f 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,7 +31,6 @@ 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 @@ -80,7 +79,6 @@ 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) @@ -103,7 +101,6 @@ class RelaySubscriptionsCoordinator( profile, hashtags, geohashes, - chess, nwc, ) 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 new file mode 100644 index 0000000000..fdb617d026 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt @@ -0,0 +1,251 @@ +/** + * 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 com.vitorpamplona.amethyst.commons.chess.ChessEventPublisher +import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetcher +import com.vitorpamplona.amethyst.commons.chess.IUserMetadataProvider +import com.vitorpamplona.amethyst.commons.chess.RelayGameSummary +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 + +/** + * Android implementation of ChessEventPublisher. + * Wraps account.signAndComputeBroadcast() for event publishing. + */ +class AndroidChessPublisher( + private val account: Account, +) : ChessEventPublisher { + override suspend fun publishChallenge( + gameId: String, + playerColor: Color, + opponentPubkey: String?, + timeControl: String?, + ): Boolean = + try { + val template = + LiveChessGameChallengeEvent.build( + gameId = gameId, + playerColor = playerColor, + opponentPubkey = opponentPubkey, + timeControl = timeControl, + ) + account.signAndComputeBroadcast(template) + true + } catch (e: Exception) { + false + } + + override suspend fun publishAccept( + gameId: String, + challengeEventId: String, + challengerPubkey: String, + ): Boolean = + 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, + fen = move.fen, + opponentPubkey = move.opponentPubkey, + ) + account.signAndComputeBroadcast(template) + true + } catch (e: Exception) { + false + } + + override suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean = + try { + val template = + LiveChessGameEndEvent.build( + gameId = gameEnd.gameId, + result = gameEnd.result, + termination = gameEnd.termination, + winnerPubkey = gameEnd.winnerPubkey, + opponentPubkey = gameEnd.opponentPubkey, + pgn = gameEnd.pgn ?: "", + ) + account.signAndComputeBroadcast(template) + true + } catch (e: Exception) { + 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 +} + +/** + * Android implementation of ChessRelayFetcher. + * Uses LocalCache for game state (hybrid approach during migration). + */ +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 + + 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 + + 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 } + + 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 + + return ChessGameEvents( + challenge = challengeEvent, + accept = acceptEvent, + moves = moveEvents, + end = endEvent, + drawOffers = emptyList(), // TODO: Fetch draw offers + ) + } + + override suspend fun fetchChallenges(): List { + val challengeNotes = + LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note -> + note.event is LiveChessGameChallengeEvent + } + return challengeNotes.mapNotNull { it.event as? LiveChessGameChallengeEvent } + } + + 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() + + return gameIds.mapNotNull { gameId -> + val events = fetchGameEvents(gameId) + val challenge = events.challenge ?: return@mapNotNull null + val accept = events.accept + + // Determine white/black pubkeys + val challengerColor = challenge.playerColor() ?: Color.WHITE + val whitePubkey = + if (challengerColor == Color.WHITE) { + challenge.pubKey + } else { + accept?.pubKey ?: return@mapNotNull null + } + val blackPubkey = + if (challengerColor == Color.BLACK) { + challenge.pubKey + } else { + accept?.pubKey ?: return@mapNotNull null + } + + val lastMove = events.moves.maxByOrNull { it.createdAt } + + RelayGameSummary( + gameId = gameId, + whitePubkey = whitePubkey, + blackPubkey = blackPubkey, + moveCount = events.moves.size, + lastMoveTime = lastMove?.createdAt ?: challenge.createdAt, + isActive = events.end == null, + ) + } + } +} + +/** + * Android implementation of IUserMetadataProvider. + * Wraps LocalCache.getOrCreateUser() for user metadata lookup. + */ +class AndroidMetadataProvider : IUserMetadataProvider { + override fun getDisplayName(pubkey: String): String { + val user = LocalCache.getOrCreateUser(pubkey) + return user.info?.bestName() + ?: user.pubkeyDisplayHex() + } + + override fun getPictureUrl(pubkey: String): String? { + val user = LocalCache.getOrCreateUser(pubkey) + return user.info?.profilePicture() + } +} 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 9acd282a36..72df68b49b 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 @@ -23,21 +23,32 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess 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 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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -82,14 +93,29 @@ 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() var isLoading by remember { mutableStateOf(true) } var loadedGameState by remember { mutableStateOf(null) } var loadAttempted by remember { mutableStateOf(false) } + var showRelaySettings by remember { mutableStateOf(false) } - // Try to load game from cache if not in activeGames - LaunchedEffect(gameId, activeGames) { - val existingState = activeGames[gameId] + // Get relay information + val outboxRelays by accountViewModel.account.outboxRelays.flow + .collectAsState() + val writeRelays = + remember(outboxRelays) { + outboxRelays.map { it.toString() } + } + + // Subscribe to chess events when game screen is visible + // 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 isLoading = false @@ -103,9 +129,9 @@ fun ChessGameScreen( } } - // Update state when activeGames changes (e.g., after refresh loads new moves) - LaunchedEffect(activeGames[gameId]) { - activeGames[gameId]?.let { + // Update state when games change (e.g., after refresh loads new moves) + LaunchedEffect(activeGames[gameId], spectatingGames[gameId]) { + (activeGames[gameId] ?: spectatingGames[gameId])?.let { loadedGameState = it } } @@ -118,21 +144,47 @@ fun ChessGameScreen( } } - val gameState = loadedGameState ?: activeGames[gameId] + val gameState = loadedGameState ?: activeGames[gameId] ?: spectatingGames[gameId] Scaffold( topBar = { - TopAppBar( - title = { Text("Chess Game") }, - navigationIcon = { - IconButton(onClick = { nav.popBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - ) - } - }, - ) + Column { + TopAppBar( + title = { Text("Chess Game") }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + actions = { + IconButton(onClick = { showRelaySettings = true }) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Relay Settings", + ) + } + }, + ) + + // Status banner below top bar + ChessStatusBanner( + status = chessStatus, + onTap = { + when (chessStatus) { + is ChessStatus.MoveFailed -> { + // Could implement retry logic here + } + is ChessStatus.Desynced -> { + chessViewModel.forceRefresh() + } + else -> { } + } + }, + ) + } }, ) { paddingValues -> when { @@ -214,4 +266,112 @@ fun ChessGameScreen( } } } + + // Relay settings bottom sheet + if (showRelaySettings) { + ModalBottomSheet( + onDismissRequest = { showRelaySettings = false }, + sheetState = rememberModalBottomSheetState(), + ) { + RelaySettingsSheet( + writeRelays = writeRelays, + gameId = gameId, + ) + } + } +} + +/** + * Bottom sheet showing relay settings for the chess game + */ +@Composable +private fun RelaySettingsSheet( + writeRelays: List, + gameId: String, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "Chess Game Relays", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Moves are broadcast to these relays:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (writeRelays.isEmpty()) { + Text( + text = "No write relays configured", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(writeRelays) { relay -> + RelayItem(relayUrl = relay, isConnected = true) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + HorizontalDivider() + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Game ID", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = gameId, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +@Composable +private fun RelayItem( + relayUrl: String, + isConnected: Boolean, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Circle, + contentDescription = if (isConnected) "Connected" else "Disconnected", + tint = if (isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + + Text( + text = relayUrl.removePrefix("wss://").removePrefix("ws://"), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + } } 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 d8ed6f2017..00e34eaf2f 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 @@ -31,24 +31,30 @@ 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items 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.Settings import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -71,6 +77,7 @@ import com.vitorpamplona.amethyst.model.Note 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 @@ -92,15 +99,27 @@ fun ChessLobbyScreen( ) val activeGames by chessViewModel.activeGames.collectAsState() + val spectatingGames by chessViewModel.spectatingGames.collectAsState() + 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 selectedGameId by chessViewModel.selectedGameId.collectAsState() val userPubkey = accountViewModel.account.userProfile().pubkeyHex var showNewGameDialog by remember { mutableStateOf(false) } + var showRelaySettings by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() + // Get relay information for settings display + val inboxRelays by accountViewModel.account.notificationRelays.flow + .collectAsState() + val outboxRelays by accountViewModel.account.outboxRelays.flow + .collectAsState() + val globalRelays by accountViewModel.account.defaultGlobalRelays.flow + .collectAsState() + // Subscribe to chess events when screen is visible ChessSubscription(accountViewModel, chessViewModel) @@ -139,6 +158,14 @@ fun ChessLobbyScreen( ) } }, + actions = { + IconButton(onClick = { showRelaySettings = true }) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Relay Settings", + ) + } + }, ) }, floatingActionButton = { @@ -170,6 +197,13 @@ fun ChessLobbyScreen( .fillMaxSize() .padding(horizontal = 16.dp), ) { + // Broadcast status banner + ChessStatusBanner( + status = chessStatus, + onTap = { /* no-op in lobby */ }, + modifier = Modifier.padding(bottom = 8.dp), + ) + // Error display error?.let { errorMsg -> Card( @@ -193,6 +227,8 @@ fun ChessLobbyScreen( ChessLobbyContent( challenges = challenges, activeGames = activeGames, + spectatingGames = spectatingGames, + publicGames = publicGames, userPubkey = userPubkey, accountViewModel = accountViewModel, onAcceptChallenge = { note -> @@ -201,6 +237,10 @@ fun ChessLobbyScreen( onSelectGame = { gameId -> nav.nav(Route.ChessGame(gameId)) }, + onWatchGame = { gameId -> + chessViewModel.loadGameAsSpectator(gameId) + nav.nav(Route.ChessGame(gameId)) + }, ) } } @@ -216,18 +256,41 @@ fun ChessLobbyScreen( }, ) } + + // Relay settings bottom sheet + if (showRelaySettings) { + ModalBottomSheet( + onDismissRequest = { showRelaySettings = false }, + sheetState = rememberModalBottomSheetState(), + ) { + ChessRelaySettingsSheet( + inboxRelays = inboxRelays.map { it.toString() }, + outboxRelays = outboxRelays.map { it.toString() }, + globalRelays = globalRelays.map { it.toString() }, + challengeCount = challenges.size, + publicGameCount = publicGames.size, + ) + } + } } @Composable fun ChessLobbyContent( challenges: List, activeGames: Map, + spectatingGames: Map, + publicGames: List, userPubkey: String, accountViewModel: AccountViewModel, onAcceptChallenge: (Note) -> Unit, onSelectGame: (String) -> Unit, + onWatchGame: (String) -> Unit, ) { - if (activeGames.isEmpty() && challenges.isEmpty()) { + val hasContent = + activeGames.isNotEmpty() || spectatingGames.isNotEmpty() || + publicGames.isNotEmpty() || challenges.isNotEmpty() + + if (!hasContent) { // Empty state Box( modifier = Modifier.fillMaxSize(), @@ -255,18 +318,18 @@ fun ChessLobbyContent( LazyColumn( 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), ) } - items(activeGames.entries.toList(), key = { it.key }) { (gameId, state) -> + items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) -> ActiveGameCard( gameId = gameId, opponentPubkey = state.opponentPubkey, @@ -277,6 +340,28 @@ fun ChessLobbyContent( } } + // 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) -> + SpectatingGameCard( + gameId = gameId, + gameState = state, + accountViewModel = accountViewModel, + onClick = { onSelectGame(gameId) }, + ) + } + } + // Incoming challenges val incomingChallenges = challenges.filter { @@ -294,7 +379,7 @@ fun ChessLobbyContent( ) } - items(incomingChallenges, key = { it.idHex }) { note -> + items(incomingChallenges, key = { "incoming-${it.idHex}" }) { note -> ChallengeCard( note = note, isIncoming = true, @@ -304,31 +389,7 @@ fun ChessLobbyContent( } } - // User's outgoing challenges - 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 = { it.idHex }) { note -> - OutgoingChallengeCard( - note = note, - accountViewModel = accountViewModel, - ) - } - } - - // Open challenges from others + // Open challenges from others (can join) val openChallenges = challenges.filter { val event = it.event as? LiveChessGameChallengeEvent @@ -345,7 +406,7 @@ fun ChessLobbyContent( ) } - items(openChallenges, key = { it.idHex }) { note -> + items(openChallenges, key = { "open-${it.idHex}" }) { note -> ChallengeCard( note = note, isIncoming = false, @@ -355,6 +416,51 @@ fun ChessLobbyContent( } } + // 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 -> + PublicGameCard( + game = game, + accountViewModel = accountViewModel, + 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)) @@ -375,6 +481,12 @@ private fun ActiveGameCard( 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 = @@ -391,14 +503,14 @@ private fun ActiveGameCard( ) { Column(modifier = Modifier.weight(1f)) { Text( - "vs $displayName", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, + gameName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, ) Text( - "Game: ${gameId.take(12)}...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + "vs $displayName", + style = MaterialTheme.typography.bodyMedium, ) } @@ -515,3 +627,277 @@ private fun OutgoingChallengeCard( } } } + +@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 + */ +@Composable +private fun ChessRelaySettingsSheet( + inboxRelays: List, + outboxRelays: List, + globalRelays: List, + challengeCount: Int, + publicGameCount: Int, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "Chess Relay Settings", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Stats section + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "$challengeCount", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "Challenges", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "$publicGameCount", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "Live Games", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + 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})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Personal challenges are fetched from here", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + if (inboxRelays.isEmpty()) { + Text( + text = "No inbox relays configured", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } else { + inboxRelays.take(5).forEach { relay -> + RelayRow(relayUrl = relay) + } + if (inboxRelays.size > 5) { + Text( + text = "+${inboxRelays.size - 5} more", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Global relays (where open challenges are fetched) + Text( + text = "Global Relays (${globalRelays.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Open challenges and public games are fetched from here", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + if (globalRelays.isEmpty()) { + Text( + text = "No global relays configured - open challenges won't load!", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } else { + globalRelays.take(5).forEach { relay -> + RelayRow(relayUrl = relay) + } + if (globalRelays.size > 5) { + Text( + text = "+${globalRelays.size - 5} more", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Outbox relays (where your challenges are published) + Text( + text = "Outbox Relays (${outboxRelays.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Your challenges and moves are published here", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + if (outboxRelays.isEmpty()) { + Text( + text = "No outbox relays configured", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } else { + outboxRelays.take(5).forEach { relay -> + RelayRow(relayUrl = relay) + } + if (outboxRelays.size > 5) { + Text( + text = "+${outboxRelays.size - 5} more", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +@Composable +private fun RelayRow(relayUrl: String) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Connected", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(12.dp), + ) + Text( + text = relayUrl.removePrefix("wss://").removePrefix("ws://"), + style = MaterialTheme.typography.bodySmall, + ) + } +} 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 8dfe9fae96..e0ae5c8ca4 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 @@ -25,75 +25,61 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +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.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient 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.utils.TimeUtils /** - * Default "since" window for challenges (24 hours) - * Challenges older than this are considered expired anyway - */ -private const val CHALLENGE_WINDOW_SECONDS = 24 * 60 * 60L - -/** - * Default "since" window for game events when no EOSE cache exists (7 days) - * This prevents loading ancient game history on first connection - */ -private const val GAME_EVENT_WINDOW_SECONDS = 7 * 24 * 60 * 60L - -/** - * Subscribe to chess events when the Chess tab is active. - * Respects the Global/Follows filter from the discovery top bar. + * 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). */ @Composable fun ChessSubscription( accountViewModel: AccountViewModel, chessViewModel: ChessViewModel, ) { - // Observe the current filter selection (Global, All Follows, etc.) - val filterSelection by accountViewModel.account.settings.defaultDiscoveryFollowList - .collectAsStateWithLifecycle() - - val isGlobal = filterSelection == GLOBAL_FOLLOWS - // Get active game IDs from the view model for game-specific subscriptions val activeGames by chessViewModel.activeGames.collectAsStateWithLifecycle() - val activeGameIds = activeGames.keys + val spectatingGames by chessViewModel.spectatingGames.collectAsStateWithLifecycle() + val activeGameIds = activeGames.keys + spectatingGames.keys + + // Extract opponent pubkeys for move filtering + val opponentPubkeys = + remember(activeGames) { + activeGames.values.map { it.opponentPubkey }.toSet() + } val state = - remember(accountViewModel.account, isGlobal, activeGameIds) { + remember(accountViewModel.account, activeGameIds, opponentPubkeys) { ChessQueryState( userPubkey = accountViewModel.account.userProfile().pubkeyHex, // Use notification inbox relays - where others send events TO us inboxRelays = accountViewModel.account.notificationRelays.flow.value, - // Use proxy relays for global, outbox relays for follows - globalRelays = - if (isGlobal) { - accountViewModel.account.defaultGlobalRelays.flow.value - } else { - accountViewModel.account.followOutboxesOrProxy.flow.value - }, - isGlobal = isGlobal, + // Always use global relays for chess - we want to see ALL games + globalRelays = accountViewModel.account.defaultGlobalRelays.flow.value, + // Always treat as global for chess + isGlobal = true, activeGameIds = activeGameIds, + opponentPubkeys = opponentPubkeys, ) } + // 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 DisposableEffect(state) { - accountViewModel.dataSources().chess.subscribe(state) chessViewModel.refreshChallenges() onDispose { - accountViewModel.dataSources().chess.unsubscribe(state) + // Subscription cleanup handled by ViewModel } } } @@ -107,6 +93,7 @@ data class ChessQueryState( val globalRelays: Set, val isGlobal: Boolean, val activeGameIds: Set = emptySet(), + val opponentPubkeys: Set = emptySet(), ) /** @@ -143,128 +130,33 @@ class ChessFeedFilterSubAssembler( } /** - * Create relay filters for chess events. + * Create relay filters for chess events using the shared [ChessFilterBuilder]. * - * Creates filters based on the Global/Follows setting: - * - Global: Fetch all chess events from global relays - * - Follows: Fetch from followed users' outbox relays + * Following jesterui's approach: + * 1. Fetch ALL challenges from ALL connected relays (no author/tag restriction) + * 2. Filter client-side based on user's preferences (Global/Follows) + * 3. Game-specific subscriptions for active games * - * Also always fetches challenges directed at the user from inbox relays. - * - * Improvements over basic implementation: - * 1. Default "since" timestamps to avoid loading very old events on first connection - * 2. Separate challenge filter (shorter window) from game events (longer window) - * 3. Game-specific subscriptions for active games using #a tag references - * 4. Grouped filters by relay to reduce subscription overhead + * This ensures we see: + * - Open challenges from anyone + * - Challenges directed at us + * - Public games to spectate */ fun filterChessEvents( key: ChessQueryState, since: SincePerRelayMap?, ): List { - val filters = mutableListOf() - val now = TimeUtils.now() - - // Challenge kinds only (for lobby display) - val challengeKinds = - listOf( - LiveChessGameChallengeEvent.KIND, - LiveChessGameAcceptEvent.KIND, + // Convert Android ChessQueryState to shared ChessSubscriptionState + val state = + ChessSubscriptionState( + userPubkey = key.userPubkey, + relays = key.inboxRelays + key.globalRelays, + activeGameIds = key.activeGameIds, + opponentPubkeys = key.opponentPubkeys, ) - // Move/end kinds (for active games) - val gameEventKinds = - listOf( - LiveChessMoveEvent.KIND, - LiveChessGameEndEvent.KIND, - ) - - // All chess kinds - val allChessKinds = challengeKinds + gameEventKinds - - // ======================================== - // Filter 1: Personal challenges from inbox relays - // Uses 24h window for challenges (they expire anyway) - // ======================================== - val inboxFilter = - Filter( - kinds = allChessKinds, - tags = mapOf("p" to listOf(key.userPubkey)), - limit = 100, - ) - - for (relay in key.inboxRelays) { - val sinceTime = since?.get(relay)?.time ?: (now - CHALLENGE_WINDOW_SECONDS) - filters.add( - RelayBasedFilter( - relay = relay, - filter = inboxFilter.copy(since = sinceTime), - ), - ) + // Use shared filter builder for consistent behavior with Desktop + return ChessFilterBuilder.buildAllFilters(state) { relay -> + since?.get(relay)?.time } - - // ======================================== - // Filter 2: General challenges from global/outbox relays - // Uses 24h window to show recent open challenges - // ======================================== - val globalChallengeFilter = - Filter( - kinds = challengeKinds, - limit = 50, - ) - - for (relay in key.globalRelays) { - val sinceTime = since?.get(relay)?.time ?: (now - CHALLENGE_WINDOW_SECONDS) - filters.add( - RelayBasedFilter( - relay = relay, - filter = globalChallengeFilter.copy(since = sinceTime), - ), - ) - } - - // ======================================== - // Filter 3: Active game events - // Uses longer window (7 days) or no window for active games - // Follows jesterui pattern: subscribe to game-specific events via #d tag - // ======================================== - if (key.activeGameIds.isNotEmpty()) { - // Create filters for each active game's events - // Using #d tag to match the game_id in addressable events - val gameSpecificFilter = - Filter( - kinds = gameEventKinds, - tags = mapOf("d" to key.activeGameIds.toList()), - limit = 200, // More moves per game - ) - - // Query all relays for active game events (no since - need full history) - val allRelays = key.inboxRelays + key.globalRelays - for (relay in allRelays) { - filters.add( - RelayBasedFilter( - relay = relay, - filter = gameSpecificFilter, - ), - ) - } - } else { - // No active games - use general game event filter with window - val generalGameFilter = - Filter( - kinds = gameEventKinds, - limit = 100, - ) - - for (relay in key.globalRelays) { - val sinceTime = since?.get(relay)?.time ?: (now - GAME_EVENT_WINDOW_SECONDS) - filters.add( - RelayBasedFilter( - relay = relay, - filter = generalGameFilter.copy(since = sinceTime), - ), - ) - } - } - - return filters } 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 index 033d09bd5c..d727649b6b 100644 --- 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 @@ -29,14 +29,17 @@ 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 @@ -44,7 +47,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import java.util.UUID /** * ViewModel for managing chess game state, challenges, and event publishing @@ -64,11 +66,19 @@ class ChessViewModel( const val CLEANUP_INTERVAL_MS = 5 * 60 * 1000L } - // Active games being played + // Active games being played (user is a participant) private val _activeGames = MutableStateFlow>(emptyMap()) val activeGames: StateFlow> = _activeGames.asStateFlow() - // Pending challenges (incoming and outgoing) + // 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() @@ -88,6 +98,14 @@ class ChessViewModel( 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( @@ -101,6 +119,9 @@ class ChessViewModel( }, ) + // Track games currently being created to prevent race conditions (like Desktop) + private val gamesBeingCreated = mutableSetOf() + init { subscribeToChessEvents() // Start polling - will do initial fetch @@ -201,6 +222,11 @@ class ChessViewModel( * 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) @@ -267,6 +293,14 @@ class ChessViewModel( 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 = @@ -281,8 +315,23 @@ class ChessViewModel( } 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" } } @@ -297,6 +346,11 @@ class ChessViewModel( 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 = @@ -308,6 +362,16 @@ class ChessViewModel( 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() @@ -322,10 +386,16 @@ class ChessViewModel( ) _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}" } } @@ -338,6 +408,13 @@ class ChessViewModel( 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() @@ -353,6 +430,13 @@ class ChessViewModel( 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 @@ -376,6 +460,9 @@ class ChessViewModel( ) _activeGames.value = _activeGames.value + (gameId to gameState) + synchronized(gamesBeingCreated) { + gamesBeingCreated.remove(gameId) + } pollingDelegate.addGameId(gameId) _selectedGameId.value = gameId } @@ -390,12 +477,36 @@ class ChessViewModel( to: String, ) { val gameState = _activeGames.value[gameId] ?: return - val moveResult = gameState.makeMove(from, to) + + // 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) */ @@ -405,6 +516,15 @@ class ChessViewModel( ) { 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, @@ -415,8 +535,33 @@ class ChessViewModel( ) 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}" } } @@ -508,28 +653,241 @@ class ChessViewModel( /** * 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 for chess challenge events + // Query LocalCache.addressables for chess challenge events (kind 30064) + // Chess events are addressable events, not regular notes! val challengeNotes = - LocalCache.notes.filterIntoSet { _, note -> + LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note -> val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false - // Check if challenge is not expired + // Check if challenge is not expired (24h) val createdAt = event.createdAt if ((now - createdAt) >= CHALLENGE_EXPIRY_SECONDS) return@filterIntoSet false - // Include challenges directed at us, or open challenges (not from us) + // 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() - opponentPubkey == userPubkey || (opponentPubkey == null && event.pubKey != userPubkey) || event.pubKey == userPubkey + 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) } } @@ -629,32 +987,31 @@ class ChessViewModel( } /** - * Generate unique game ID + * Generate unique game ID with human-readable component */ - private fun generateGameId(): String { - val timestamp = TimeUtils.now() - val random = UUID.randomUUID().toString().take(8) - return "chess-$timestamp-$random" - } + 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 + // Find moves for this game from addressables (kind 30066) val moveNotes = - LocalCache.notes.filterIntoSet { _, note -> + LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note -> val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false event.gameId() == gameId } - val gameState = _activeGames.value[gameId] - if (gameState != null) { - // Apply any new moves + // 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 @@ -663,9 +1020,29 @@ class ChessViewModel( val san = moveEvent.san() ?: return@forEach val fen = moveEvent.fen() ?: return@forEach val moveNumber = moveEvent.moveNumber() - gameState.applyOpponentMove(san, fen, 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() @@ -675,6 +1052,8 @@ class ChessViewModel( * 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? { @@ -683,17 +1062,17 @@ class ChessViewModel( val userPubkey = account.signer.pubKey - // Find the challenge event for this game + // Find the challenge event for this game from addressables (kind 30064) val challengeNotes = - LocalCache.notes.filterIntoSet { _, note -> + 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 + // Find accept event for this game from addressables (kind 30065) val acceptNotes = - LocalCache.notes.filterIntoSet { _, note -> + LocalCache.addressables.filterIntoSet(LiveChessGameAcceptEvent.KIND) { _, note -> val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false event.gameId() == gameId } @@ -724,9 +1103,10 @@ class ChessViewModel( challengerColor.opposite() to challengerPubkey } challengerPubkey == userPubkey && acceptorPubkey == null -> { - // We created challenge but no one accepted yet - _error.value = "Waiting for opponent to accept challenge" - return 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" @@ -738,9 +1118,9 @@ class ChessViewModel( val engine = ChessEngine() engine.reset() - // Find and apply all moves in order + // Find and apply all moves in order from addressables (kind 30066) val moveNotes = - LocalCache.notes.filterIntoSet { _, note -> + LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note -> val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false event.gameId() == gameId } @@ -767,6 +1147,10 @@ class ChessViewModel( } } + // 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, @@ -774,13 +1158,28 @@ class ChessViewModel( opponentPubkey = opponentPubkey, playerColor = playerColor, engine = engine, + isPendingChallenge = isPendingChallenge, ) // Mark loaded moves as received to prevent re-application during refresh gameState.markMovesAsReceived(loadedMoveNumbers) - // Add to active games - _activeGames.value = _activeGames.value + (gameId to gameState) + // 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) @@ -808,3 +1207,14 @@ data class RetryOperation( 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/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt new file mode 100644 index 0000000000..cedbdb9510 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -0,0 +1,632 @@ +/** + * 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.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.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 ChessEventPublisher { + suspend fun publishChallenge( + gameId: String, + playerColor: Color, + opponentPubkey: String?, + timeControl: String?, + ): Boolean + + suspend fun publishAccept( + gameId: String, + challengeEventId: String, + challengerPubkey: String, + ): Boolean + + suspend fun publishMove(move: ChessMoveEvent): Boolean + + suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean + + suspend fun publishDrawOffer( + gameId: String, + opponentPubkey: String, + message: String?, + ): Boolean + + fun getWriteRelayCount(): Int +} + +/** + * Relay-first fetcher interface. 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 recent challenge events */ + suspend fun fetchChallenges(): List + + /** Fetch recent public game summaries for spectating */ + suspend fun fetchRecentGames(): List +} + +/** + * Summary of a game found on relays (for lobby display / spectating) + */ +data class RelayGameSummary( + val gameId: String, + val whitePubkey: String, + val blackPubkey: String, + val moveCount: Int, + val lastMoveTime: Long, + val isActive: Boolean, +) + +/** + * Shared chess lobby logic — relay-first architecture. + * + * Both Android and Desktop use this identically. + * Platform-specific code only implements: + * - ChessEventPublisher: sign + broadcast events + * - ChessRelayFetcher: one-shot relay queries + * - IUserMetadataProvider: display names / avatars + */ +class ChessLobbyLogic( + private val userPubkey: String, + private val publisher: ChessEventPublisher, + private val fetcher: ChessRelayFetcher, + private val metadataProvider: IUserMetadataProvider, + private val scope: CoroutineScope, + pollingConfig: ChessPollingConfig = ChessPollingDefaults.android, +) { + val state = ChessLobbyState(userPubkey, scope) + + private val pollingDelegate = + ChessPollingDelegate( + config = pollingConfig, + scope = scope, + onRefreshGames = { gameIds -> refreshGames(gameIds) }, + onRefreshChallenges = { refreshChallenges() }, + onCleanup = { cleanupExpiredChallenges() }, + ) + + // ======================================== + // Lifecycle + // ======================================== + + fun startPolling() = pollingDelegate.start() + + fun stopPolling() = pollingDelegate.stop() + + fun forceRefresh() = pollingDelegate.refreshNow() + + // ======================================== + // Incoming event routing (real-time / optimistic) + // ======================================== + + /** + * Route an incoming relay 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) + } + } + + private fun handleChallenge(event: LiveChessGameChallengeEvent) { + val gameId = event.gameId() ?: return + val challengerColor = event.playerColor() ?: Color.WHITE + + val challenge = + ChessChallenge( + eventId = event.id, + gameId = gameId, + challengerPubkey = event.pubKey, + challengerDisplayName = metadataProvider.getDisplayName(event.pubKey), + challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey), + opponentPubkey = event.opponentPubkey(), + challengerColor = challengerColor, + createdAt = event.createdAt, + ) + 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 + val fen = event.fen() ?: return + val moveNumber = event.moveNumber() + + val gameState = state.getGameState(gameId) ?: 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) + } + } + + // ======================================== + // Challenge operations + // ======================================== + + fun createChallenge( + opponentPubkey: String? = null, + playerColor: Color = Color.WHITE, + timeControl: String? = null, + ) { + val gameId = generateGameId() + + scope.launch(Dispatchers.Default) { + state.setBroadcastStatus( + ChessBroadcastStatus.Broadcasting( + san = "Challenge", + successCount = 0, + totalRelays = publisher.getWriteRelayCount(), + ), + ) + + val success = retryWithBackoff { publisher.publishChallenge(gameId, playerColor, opponentPubkey, timeControl) } + + if (success) { + state.setBroadcastStatus( + ChessBroadcastStatus.Success("Challenge", publisher.getWriteRelayCount()), + ) + delay(2000) + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + state.setError(null) + } else { + state.setBroadcastStatus( + ChessBroadcastStatus.Failed("Challenge", "Failed to publish"), + ) + state.setError("Failed to create challenge") + } + } + } + + fun acceptChallenge(challenge: ChessChallenge) { + scope.launch(Dispatchers.Default) { + val success = + retryWithBackoff { + publisher.publishAccept( + gameId = challenge.gameId, + challengeEventId = challenge.eventId, + challengerPubkey = challenge.challengerPubkey, + ) + } + + 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") + } + } + } + + /** + * When we detect our challenge was accepted, load game from relays. + */ + fun handleGameAccepted(gameId: String) { + scope.launch(Dispatchers.Default) { + state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) + + val events = fetcher.fetchGameEvents(gameId) + val result = ChessGameLoader.loadGame(events, userPubkey) + + when (result) { + is LoadGameResult.Success -> { + state.addActiveGame(gameId, result.liveState) + pollingDelegate.addGameId(gameId) + state.selectGame(gameId) + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + state.setError(null) + } + is LoadGameResult.Error -> { + state.setError("Failed to load game: ${result.message}") + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + } + } + } + } + + // ======================================== + // Game operations + // ======================================== + + fun publishMove( + gameId: String, + from: String, + to: String, + ) { + val gameState = state.getGameState(gameId) ?: return + + if (state.isSpectating(gameId)) { + state.setError("Cannot move while spectating") + return + } + + val moveResult = gameState.makeMove(from, to) ?: return + + scope.launch(Dispatchers.Default) { + state.setBroadcastStatus( + ChessBroadcastStatus.Broadcasting( + san = moveResult.san, + successCount = 0, + totalRelays = publisher.getWriteRelayCount(), + ), + ) + + val success = retryWithBackoff { publisher.publishMove(moveResult) } + + if (success) { + state.setBroadcastStatus( + ChessBroadcastStatus.Success(moveResult.san, publisher.getWriteRelayCount()), + ) + delay(3000) + + val currentState = state.getGameState(gameId) + state.setBroadcastStatus( + if (currentState?.isPlayerTurn() == false) { + ChessBroadcastStatus.WaitingForOpponent + } else { + ChessBroadcastStatus.Idle + }, + ) + state.setError(null) + } else { + state.setBroadcastStatus( + ChessBroadcastStatus.Failed(moveResult.san, "Failed to publish move"), + ) + state.setError("Failed to publish move") + } + } + } + + fun resign(gameId: String) { + val gameState = state.getGameState(gameId) ?: return + + if (state.isSpectating(gameId)) { + state.setError("Cannot resign while spectating") + return + } + + scope.launch(Dispatchers.Default) { + val endData = gameState.resign() + val success = retryWithBackoff { publisher.publishGameEnd(endData) } + + if (success) { + state.moveToCompleted(gameId, endData.result.notation, endData.termination.name.lowercase()) + pollingDelegate.removeGameId(gameId) + state.setError(null) + } else { + state.setError("Failed to resign") + } + } + } + + 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 + 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.setError(null) + } else { + state.setError("Failed to claim abandonment victory") + } + } + } + + // ======================================== + // Spectator mode + // ======================================== + + fun loadGameAsSpectator(gameId: String) { + scope.launch(Dispatchers.Default) { + state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) + + val events = fetcher.fetchGameEvents(gameId) + val result = ChessGameLoader.loadGame(events, userPubkey) + + when (result) { + is LoadGameResult.Success -> { + state.addSpectatingGame(gameId, result.liveState) + pollingDelegate.addGameId(gameId) + state.selectGame(gameId) + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + state.setError(null) + } + is LoadGameResult.Error -> { + state.setError("Failed to load game: ${result.message}") + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + } + } + } + } + + fun loadGame(gameId: String) { + scope.launch(Dispatchers.Default) { + if (state.getGameState(gameId) != null) { + state.selectGame(gameId) + return@launch + } + + state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) + + val events = fetcher.fetchGameEvents(gameId) + val result = ChessGameLoader.loadGame(events, userPubkey) + + when (result) { + is LoadGameResult.Success -> { + if (result.liveState.isSpectator) { + state.addSpectatingGame(gameId, result.liveState) + } else { + state.addActiveGame(gameId, result.liveState) + } + pollingDelegate.addGameId(gameId) + state.selectGame(gameId) + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + state.setError(null) + } + is LoadGameResult.Error -> { + state.setError("Failed to load game: ${result.message}") + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + } + } + } + } + + // ======================================== + // 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) + val result = ChessGameLoader.loadGame(events, userPubkey) + + when (result) { + is LoadGameResult.Success -> { + state.replaceGameState(gameId, result.liveState) + } + is LoadGameResult.Error -> { + // Don't overwrite error for periodic refresh failures + } + } + } + + private suspend fun refreshChallenges() { + val challengeEvents = fetcher.fetchChallenges() + + val challenges = + challengeEvents.mapNotNull { event -> + val gameId = event.gameId() ?: return@mapNotNull null + val challengerColor = event.playerColor() ?: Color.WHITE + + ChessChallenge( + eventId = event.id, + gameId = gameId, + challengerPubkey = event.pubKey, + challengerDisplayName = metadataProvider.getDisplayName(event.pubKey), + challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey), + opponentPubkey = event.opponentPubkey(), + challengerColor = challengerColor, + createdAt = event.createdAt, + ) + } + + state.updateChallenges(challenges) + + // Also refresh public games + val recentGames = fetcher.fetchRecentGames() + val publicGames = + recentGames.map { summary -> + PublicGame( + gameId = summary.gameId, + whitePubkey = summary.whitePubkey, + whiteDisplayName = metadataProvider.getDisplayName(summary.whitePubkey), + blackPubkey = summary.blackPubkey, + blackDisplayName = metadataProvider.getDisplayName(summary.blackPubkey), + moveCount = summary.moveCount, + lastMoveTime = summary.lastMoveTime, + isActive = summary.isActive, + ) + } + state.updatePublicGames(publicGames) + } + + private fun cleanupExpiredChallenges() { + val now = TimeUtils.now() + val validChallenges = + state.challenges.value.filter { challenge -> + (now - challenge.createdAt) < CHALLENGE_EXPIRY_SECONDS + } + state.updateChallenges(validChallenges) + } + + // ======================================== + // 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, + action: suspend () -> Boolean, + ): Boolean { + var delayMs = initialDelayMs + repeat(maxRetries) { attempt -> + if (action()) return true + if (attempt < maxRetries - 1) { + delay(delayMs) + delayMs *= 2 + } + } + return false + } + + fun clearError() { + state.setError(null) + } + + fun selectGame(gameId: String?) { + state.selectGame(gameId) + } +} 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 new file mode 100644 index 0000000000..9c7143d798 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt @@ -0,0 +1,367 @@ +/** + * 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.runtime.Immutable +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Challenge expiry: 24 hours + */ +const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L + +/** + * Represents a chess challenge that can be displayed in the lobby + */ +@Immutable +data class ChessChallenge( + val eventId: String, + val gameId: String, + val challengerPubkey: String, + val challengerDisplayName: String?, + val challengerAvatarUrl: String?, + val opponentPubkey: String?, + val challengerColor: Color, + val createdAt: Long, +) { + /** Whether this is an open challenge anyone can accept */ + val isOpen: Boolean get() = opponentPubkey == null + + /** Whether this challenge is directed at a specific user */ + fun isDirectedAt(pubkey: String): Boolean = opponentPubkey == pubkey + + /** Whether this challenge was created by a specific user */ + fun isFrom(pubkey: String): Boolean = challengerPubkey == pubkey +} + +/** + * Represents a public game that can be spectated + */ +@Immutable +data class PublicGame( + val gameId: String, + val whitePubkey: String, + val whiteDisplayName: String?, + val blackPubkey: String, + val blackDisplayName: String?, + val moveCount: Int, + val lastMoveTime: Long, + val isActive: Boolean, +) + +/** + * Represents a completed game for history display + */ +@Immutable +data class CompletedGame( + val gameId: String, + val whitePubkey: String, + val whiteDisplayName: String?, + val blackPubkey: String, + val blackDisplayName: String?, + val result: String, + val termination: String?, + val moveCount: Int, + val completedAt: Long, +) { + /** Whether user won this game */ + fun didUserWin(userPubkey: String): Boolean = + when (result) { + "1-0" -> whitePubkey == userPubkey + "0-1" -> blackPubkey == userPubkey + else -> false + } + + /** Whether this was a draw */ + val isDraw: Boolean get() = result == "1/2-1/2" +} + +/** + * Chess status for UI feedback + */ +@Immutable +sealed class ChessBroadcastStatus { + data object Idle : ChessBroadcastStatus() + + data class Broadcasting( + val san: String, + val successCount: Int, + val totalRelays: Int, + ) : ChessBroadcastStatus() { + val progress: Float get() = if (totalRelays > 0) successCount.toFloat() / totalRelays else 0f + } + + data class Success( + val san: String, + val relayCount: Int, + ) : ChessBroadcastStatus() + + data class Failed( + val san: String, + val error: String, + ) : ChessBroadcastStatus() + + data object WaitingForOpponent : ChessBroadcastStatus() + + data class Syncing( + val progress: Float = 0f, + ) : ChessBroadcastStatus() + + data class Desynced( + val message: String, + ) : ChessBroadcastStatus() +} + +/** + * Shared chess lobby state that can be used by both Android and Desktop + */ +class ChessLobbyState( + private val userPubkey: String, + private val scope: CoroutineScope, +) { + // Active games where user is a participant + private val _activeGames = MutableStateFlow>(emptyMap()) + val activeGames: StateFlow> = _activeGames.asStateFlow() + + // Public games that can be spectated + private val _publicGames = MutableStateFlow>(emptyList()) + val publicGames: StateFlow> = _publicGames.asStateFlow() + + // All challenges (filtered by UI based on type) + private val _challenges = MutableStateFlow>(emptyList()) + val challenges: StateFlow> = _challenges.asStateFlow() + + // Spectating games (user is watching but not playing) + private val _spectatingGames = MutableStateFlow>(emptyMap()) + val spectatingGames: StateFlow> = _spectatingGames.asStateFlow() + + // Completed games history + private val _completedGames = MutableStateFlow>(emptyList()) + val completedGames: StateFlow> = _completedGames.asStateFlow() + + // Broadcast status + private val _broadcastStatus = MutableStateFlow(ChessBroadcastStatus.Idle) + val broadcastStatus: StateFlow = _broadcastStatus.asStateFlow() + + // Error state + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + // Selected game ID for navigation + private val _selectedGameId = MutableStateFlow(null) + val selectedGameId: StateFlow = _selectedGameId.asStateFlow() + + // Badge count (incoming challenges + your turn games) + val badgeCount: Int + get() { + val incomingChallenges = _challenges.value.count { it.isDirectedAt(userPubkey) } + val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() } + return incomingChallenges + yourTurnGames + } + + // ======================================== + // Derived state for UI sections + // ======================================== + + /** Challenges directed at the user */ + fun incomingChallenges(): List = _challenges.value.filter { it.isDirectedAt(userPubkey) } + + /** Challenges created by the user */ + fun outgoingChallenges(): List = _challenges.value.filter { it.isFrom(userPubkey) } + + /** Open challenges from others that user can join */ + fun openChallenges(): List = _challenges.value.filter { it.isOpen && !it.isFrom(userPubkey) } + + // ======================================== + // State updates + // ======================================== + + fun updateChallenges(challenges: List) { + _challenges.value = challenges + } + + fun addChallenge(challenge: ChessChallenge) { + _challenges.update { current -> + if (current.any { it.eventId == challenge.eventId || it.gameId == challenge.gameId }) { + current + } else { + current + challenge + } + } + } + + fun removeChallenge(gameId: String) { + _challenges.update { current -> + current.filter { it.gameId != gameId } + } + } + + fun updatePublicGames(games: List) { + _publicGames.value = games + } + + fun addActiveGame( + gameId: String, + state: LiveChessGameState, + ) { + _activeGames.update { it + (gameId to state) } + // Remove from challenges if present + removeChallenge(gameId) + } + + fun removeActiveGame(gameId: String) { + _activeGames.update { it - gameId } + } + + fun updateActiveGame( + gameId: String, + update: (LiveChessGameState) -> LiveChessGameState, + ) { + _activeGames.update { current -> + current[gameId]?.let { state -> + current + (gameId to update(state)) + } ?: current + } + } + + /** + * Replace game state entirely after full reconstruction from relays. + * Preserves game location (active vs 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) } + } + } + + /** + * Move a game from active/spectating to completed. + * Display names are optional; UI can look them up later if needed. + */ + fun moveToCompleted( + gameId: String, + result: String, + termination: String?, + whiteDisplayName: String? = null, + blackDisplayName: String? = null, + ) { + val existingState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId] + existingState?.let { gameState -> + // Derive white/black pubkeys from player color + val whitePubkey = + if (gameState.playerColor == Color.WHITE) { + gameState.playerPubkey + } else { + gameState.opponentPubkey + } + val blackPubkey = + if (gameState.playerColor == Color.BLACK) { + gameState.playerPubkey + } else { + gameState.opponentPubkey + } + + val completed = + CompletedGame( + gameId = gameId, + whitePubkey = whitePubkey, + whiteDisplayName = whiteDisplayName, + blackPubkey = blackPubkey, + blackDisplayName = blackDisplayName, + result = result, + termination = termination, + moveCount = gameState.moveHistory.value.size, + completedAt = + com.vitorpamplona.quartz.utils.TimeUtils + .now(), + ) + + _completedGames.update { current -> + // Avoid duplicates + if (current.any { it.gameId == gameId }) { + current + } else { + listOf(completed) + current + } + } + + // Remove from active/spectating + _activeGames.update { it - gameId } + _spectatingGames.update { it - gameId } + + // Clear selection if this game was selected + if (_selectedGameId.value == gameId) { + _selectedGameId.value = null + } + } + } + + fun addSpectatingGame( + gameId: String, + state: LiveChessGameState, + ) { + _spectatingGames.update { it + (gameId to state) } + } + + fun removeSpectatingGame(gameId: String) { + _spectatingGames.update { it - gameId } + } + + fun setBroadcastStatus(status: ChessBroadcastStatus) { + _broadcastStatus.value = status + } + + fun setError(error: String?) { + _error.value = error + } + + fun selectGame(gameId: String?) { + _selectedGameId.value = gameId + } + + fun getGameState(gameId: String): LiveChessGameState? = _activeGames.value[gameId] ?: _spectatingGames.value[gameId] + + fun isUserParticipant(gameId: String): Boolean = _activeGames.value.containsKey(gameId) + + fun isSpectating(gameId: String): Boolean = _spectatingGames.value.containsKey(gameId) + + fun clearAll() { + _activeGames.value = emptyMap() + _publicGames.value = emptyList() + _challenges.value = emptyList() + _spectatingGames.value = emptyMap() + _completedGames.value = emptyList() + _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 new file mode 100644 index 0000000000..1e0e3dfda5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt @@ -0,0 +1,93 @@ +/** + * 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.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 kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.ConcurrentHashMap + +/** + * One-shot relay fetch helper for chess events. + * + * Follows the existing INostrClient + IRequestListener + Channel pattern + * from quartz (see NostrClientSingleDownloadExt.kt). + * + * Each fetch opens a subscription, collects events until EOSE from all relays, + * then closes and returns the collected events. The subscription is transient — + * no state is cached between fetches. + */ +class ChessRelayFetchHelper( + private val client: INostrClient, +) { + /** + * 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 + * @return Deduplicated list of events received before timeout/EOSE + */ + suspend fun fetchEvents( + filters: Map>, + timeoutMs: Long = 30_000, + ): List { + if (filters.isEmpty()) return emptyList() + + val events = ConcurrentHashMap() + val relayCount = filters.keys.size + val eoseReceived = ConcurrentHashMap.newKeySet() + val allEose = CompletableDeferred() + val subId = newSubId() + + val listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + events[event.id] = event + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eoseReceived.add(relay) + if (eoseReceived.size >= relayCount) { + allEose.complete(Unit) + } + } + } + + client.openReqSubscription(subId, filters, listener) + withTimeoutOrNull(timeoutMs) { allEose.await() } + client.close(subId) + + return events.values.toList() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/IUserMetadataProvider.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/IUserMetadataProvider.kt new file mode 100644 index 0000000000..3c6da8c01c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/IUserMetadataProvider.kt @@ -0,0 +1,33 @@ +/** + * 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 + +/** + * Platform-specific metadata provider for enriching chess challenges with display info. + * + * Android: wraps LocalCache.users[pubkey].info + * Desktop: wraps UserMetadataCache + */ +interface IUserMetadataProvider { + fun getDisplayName(pubkey: String): String + + fun getPictureUrl(pubkey: String): String? +} 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 9a183ee4d8..0ae312ae62 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 @@ -24,6 +24,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border 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 @@ -31,8 +32,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -69,15 +73,27 @@ fun InteractiveChessBoard( boardSize: Dp = 400.dp, flipped: Boolean = false, playerColor: ChessColor? = null, // null = allow both (for local play) + positionVersion: Int = 0, // External trigger for position refresh (e.g. moveHistory.size) onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> }, ) { - // Track move count to trigger recomposition when board changes - var moveCount by remember { mutableStateOf(0) } - val position = remember(engine, moveCount) { engine.getPosition() } - val sideToMove = remember(engine, moveCount) { engine.getSideToMove() } + // Track local move count + external version to trigger recomposition when board changes + var localMoveCount by remember { mutableStateOf(0) } + val effectiveVersion = localMoveCount + positionVersion + val position = remember(engine, effectiveVersion) { engine.getPosition() } + val sideToMove = remember(engine, effectiveVersion) { engine.getSideToMove() } var selectedSquare by remember { mutableStateOf?>(null) } var legalMoves by remember { mutableStateOf>(emptyList()) } + // Pawn promotion state + var pendingPromotion by remember { mutableStateOf(null) } + + // Clear stale selection when position changes externally (e.g. opponent move) + LaunchedEffect(positionVersion) { + selectedSquare = null + legalMoves = emptyList() + pendingPromotion = null + } + // Can only interact if it's your turn (or playerColor is null for local play) val canInteract = playerColor == null || sideToMove == playerColor @@ -87,66 +103,118 @@ fun InteractiveChessBoard( val rankRange = if (flipped) (0..7) else (7 downTo 0) val fileRange = if (flipped) (7 downTo 0) else (0..7) - Column(modifier = modifier.size(boardSize)) { - for (rank in rankRange) { - Row { - for (file in fileRange) { - val piece = position.pieceAt(file, rank) - val isLightSquare = (rank + file) % 2 == 0 - val square = fileRankToSquare(file, rank) - val isSelected = selectedSquare == (file to rank) - val isLegalMove = legalMoves.contains(square) + Box(modifier = modifier.size(boardSize)) { + Column(modifier = Modifier.fillMaxSize()) { + for (rank in rankRange) { + Row { + for (file in fileRange) { + val piece = position.pieceAt(file, rank) + val isLightSquare = (rank + file) % 2 == 0 + val square = fileRankToSquare(file, rank) + val isSelected = selectedSquare == (file to rank) + val isLegalMove = legalMoves.contains(square) - val showCoord = if (flipped) rank == 7 else rank == 0 + val showCoord = if (flipped) rank == 7 else rank == 0 - InteractiveChessSquare( - piece = piece, - isLight = isLightSquare, - size = squareSize, - isSelected = isSelected, - isLegalMove = isLegalMove, - showCoordinate = showCoord, - file = file, - rank = rank, - onClick = { - if (!canInteract) return@InteractiveChessSquare + InteractiveChessSquare( + piece = piece, + isLight = isLightSquare, + size = squareSize, + isSelected = isSelected, + isLegalMove = isLegalMove, + showCoordinate = showCoord, + file = file, + rank = rank, + onClick = { + if (!canInteract || pendingPromotion != null) return@InteractiveChessSquare - if (selectedSquare != null) { - // Attempt to make move - validate first, don't actually make it - val from = fileRankToSquare(selectedSquare!!.first, selectedSquare!!.second) - val to = square + if (selectedSquare != null) { + // Attempt to make move - validate first, don't actually make it + val from = fileRankToSquare(selectedSquare!!.first, selectedSquare!!.second) + val to = square - if (legalMoves.contains(to)) { - // Valid move - callback will make the actual move - selectedSquare = null - legalMoves = emptyList() - // Pass empty san - callback will get it from makeMove result - onMoveMade(from, to, "") - moveCount++ // Trigger recomposition after move is made + if (legalMoves.contains(to)) { + // Check if this is a pawn promotion move + val fromRank = selectedSquare!!.second + val toRank = rank + val movingPiece = position.pieceAt(selectedSquare!!.first, fromRank) + val isPromotion = + movingPiece?.type == PieceType.PAWN && + (toRank == 7 || toRank == 0) + + if (isPromotion) { + // Show promotion dialog + pendingPromotion = + PendingPromotion( + from = from, + to = to, + file = file, + rank = toRank, + color = sideToMove, + ) + } else { + // Valid move - callback will make the actual move + selectedSquare = null + legalMoves = emptyList() + // Pass empty san - callback will get it from makeMove result + onMoveMade(from, to, "") + localMoveCount++ // Trigger recomposition after move is made + } + } else { + // Not a legal move target - try selecting this square instead + val validColor = playerColor ?: sideToMove + if (piece != null && piece.color == validColor) { + selectedSquare = file to rank + legalMoves = engine.getLegalMovesFrom(square) + } else { + selectedSquare = null + legalMoves = emptyList() + } + } } else { - // Not a legal move target - try selecting this square instead + // Select piece - only allow selecting player's pieces val validColor = playerColor ?: sideToMove if (piece != null && piece.color == validColor) { selectedSquare = file to rank legalMoves = engine.getLegalMovesFrom(square) - } else { - selectedSquare = null - legalMoves = emptyList() } } - } else { - // Select piece - only allow selecting player's pieces - val validColor = playerColor ?: sideToMove - if (piece != null && piece.color == validColor) { - selectedSquare = file to rank - legalMoves = engine.getLegalMovesFrom(square) - } - } - }, - ) + }, + ) + } } } } + + // Promotion dialog overlay + pendingPromotion?.let { promo -> + PromotionPicker( + color = promo.color, + squareSize = squareSize, + onPieceSelected = { pieceType -> + // Make the move with promotion + val promotionSuffix = + when (pieceType) { + PieceType.QUEEN -> "q" + PieceType.ROOK -> "r" + PieceType.BISHOP -> "b" + PieceType.KNIGHT -> "n" + else -> "q" + } + selectedSquare = null + legalMoves = emptyList() + pendingPromotion = null + // Include promotion in the 'to' square for the callback + onMoveMade(promo.from, promo.to + promotionSuffix, "") + localMoveCount++ + }, + onDismiss = { + pendingPromotion = null + selectedSquare = null + legalMoves = emptyList() + }, + ) + } } } @@ -254,3 +322,71 @@ private fun ChessPiece.toImageVector(): ImageVector { PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn } } + +/** + * Data class for pending pawn promotion + */ +private data class PendingPromotion( + val from: String, + val to: String, + val file: Int, + val rank: Int, + val color: ChessColor, +) + +/** + * Promotion piece picker overlay + */ +@Composable +private fun PromotionPicker( + color: ChessColor, + squareSize: Dp, + onPieceSelected: (PieceType) -> Unit, + onDismiss: () -> Unit, +) { + val isWhite = color == ChessColor.WHITE + val promotionPieces = + listOf( + PieceType.QUEEN to if (isWhite) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen, + PieceType.ROOK to if (isWhite) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook, + PieceType.BISHOP to if (isWhite) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop, + PieceType.KNIGHT to if (isWhite) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight, + ) + + // Semi-transparent overlay + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + Card( + shape = RoundedCornerShape(8.dp), + modifier = Modifier.clickable { /* prevent dismiss */ }, + ) { + Row( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + promotionPieces.forEach { (pieceType, icon) -> + Box( + modifier = + Modifier + .size(squareSize) + .background(Color(0xFFF0D9B5), RoundedCornerShape(4.dp)) + .clickable { onPieceSelected(pieceType) }, + contentAlignment = Alignment.Center, + ) { + Image( + imageVector = icon, + contentDescription = pieceType.name, + modifier = Modifier.fillMaxSize().padding(4.dp), + ) + } + } + } + } + } +} 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 b717d13240..0743f66c27 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 @@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.min 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.LiveChessGameState @@ -189,6 +190,9 @@ fun LiveChessGameScreen( val currentPosition by gameState.currentPosition.collectAsState() val moveHistory by gameState.moveHistory.collectAsState() + // Pending challenges and spectators cannot make moves + val canMakeMoves = !gameState.isSpectator && !gameState.isPendingChallenge + BoxWithConstraints( modifier = modifier.fillMaxSize(), ) { @@ -212,15 +216,23 @@ fun LiveChessGameScreen( opponentName = opponentName, playerColor = gameState.playerColor, currentTurn = currentPosition.activeColor, + isSpectator = gameState.isSpectator, + isPendingChallenge = gameState.isPendingChallenge, ) - // Interactive chess board - flip when playing black + // 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.playerColor == Color.BLACK, - onMoveMade = onMoveMade, + flipped = !gameState.isSpectator && gameState.playerColor == Color.BLACK, + positionVersion = moveHistory.size, + onMoveMade = + if (canMakeMoves) { + onMoveMade + } else { + { _, _, _ -> } // No-op for spectators and pending challenges + }, ) // Move history (scrollable) - observed from state flow @@ -228,11 +240,12 @@ fun LiveChessGameScreen( moves = moveHistory, ) - // Game controls - GameControls( - onResign = onResign, - onOfferDraw = onOfferDraw, - ) + // Show appropriate controls based on game state + when { + gameState.isPendingChallenge -> PendingChallengeInfo() + gameState.isSpectator -> SpectatorInfo() + else -> GameControls(onResign = onResign, onOfferDraw = onOfferDraw) + } } } } @@ -291,6 +304,7 @@ fun LiveChessGameScreen( engine = engine, boardSize = boardSize, flipped = playerColor == Color.BLACK, + positionVersion = engine.getMoveHistory().size, onMoveMade = onMoveMade, ) @@ -317,45 +331,147 @@ private fun GameInfoHeader( opponentName: String, playerColor: Color, currentTurn: Color, + isSpectator: Boolean = false, + isPendingChallenge: Boolean = false, ) { + // Extract human-readable game name if available + val gameName = + remember(gameId) { + ChessGameNameGenerator.extractDisplayName(gameId) + } + Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { + // Show readable name prominently if available + if (gameName != null) { + Text( + text = gameName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + Text( - text = "Game: ${gameId.take(8)}...", + text = if (gameName != null) gameId.take(16) else "Game: ${gameId.take(8)}...", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Text( - text = "vs $opponentName", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) + when { + isPendingChallenge -> { + Text( + text = "Challenge Pending", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.secondary, + ) - Text( - text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}", - style = MaterialTheme.typography.bodyMedium, - ) + Text( + text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodyMedium, + ) - val turnText = - if (currentTurn == playerColor) { - "Your turn" - } else { - "Opponent's turn" + Text( + text = if (opponentName.isNotEmpty()) "Waiting for $opponentName" else "Open challenge", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } + isSpectator -> { + Text( + text = "Spectating", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + ) + val turnText = "${if (currentTurn == Color.WHITE) "White" else "Black"}'s turn" + Text( + text = turnText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + else -> { + Text( + text = "vs $opponentName", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodyMedium, + ) + + 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, + ) + } + } + } +} + +/** + * Info banner shown when spectating a game + */ +@Composable +private fun SpectatorInfo() { + Box( + modifier = + Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), + RoundedCornerShape(8.dp), + ).padding(12.dp), + contentAlignment = Alignment.Center, + ) { Text( - text = turnText, + text = "Watching game - spectator mode", style = MaterialTheme.typography.bodyMedium, - color = - if (currentTurn == playerColor) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - fontWeight = if (currentTurn == playerColor) FontWeight.Bold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } +} + +/** + * Info banner shown when viewing a pending challenge + */ +@Composable +private fun PendingChallengeInfo() { + Box( + modifier = + Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), + RoundedCornerShape(8.dp), + ).padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Waiting for opponent to accept challenge", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, ) } } 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 new file mode 100644 index 0000000000..ed6fc93449 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt @@ -0,0 +1,292 @@ +/** + * 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.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.utils.TimeUtils + +/** + * Shared filter builder for chess subscriptions. + * Used by both Android and Desktop to ensure identical subscription behavior. + * + * 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) + */ +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 + + /** + * Build all chess filters for a given subscription state. + * This is the main entry point - platforms call this to get filters. + * + * @param state The current subscription state with user info and active games + * @param sinceForRelay Function to get cached EOSE time for a relay (null if no cache) + * @return List of relay-specific filters to subscribe with + */ + fun buildAllFilters( + state: ChessSubscriptionState, + sinceForRelay: (NormalizedRelayUrl) -> Long?, + ): List { + val filters = mutableListOf() + val now = TimeUtils.now() + + // Filter 1: Personal events (challenges to us, moves in our games) + filters.addAll( + buildPersonalFilters(state.userPubkey, state.relays, sinceForRelay, now), + ) + + // Filter 2: All challenges (for lobby display) + filters.addAll( + buildChallengeFilters(state.relays, sinceForRelay, now), + ) + + // Filter 3: Active game subscriptions (game-specific) + if (state.hasActiveGames) { + filters.addAll( + buildActiveGameFilters( + state.allGameIds, + state.userPubkey, + state.opponentPubkeys, + state.relays, + ), + ) + } + + // Filter 4: Recent game events (for spectating discovery) + filters.addAll( + buildRecentGameFilters(state.relays, sinceForRelay, now), + ) + + return filters + } + + /** + * Personal events - tagged with user's pubkey. + * Catches: challenges directed at us, moves in our games, game ends. + */ + fun buildPersonalFilters( + userPubkey: String, + relays: Set, + sinceForRelay: (NormalizedRelayUrl) -> Long?, + now: Long = TimeUtils.now(), + ): List { + val filter = + Filter( + kinds = ALL_CHESS_KINDS, + tags = mapOf("p" to listOf(userPubkey)), + limit = 100, + ) + + return relays.map { relay -> + val sinceTime = + sinceForRelay(relay) + ?: (now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS) + RelayBasedFilter( + relay = relay, + filter = filter.copy(since = sinceTime), + ) + } + } + + /** + * All challenge events (like jesterui pattern). + * No author/tag restriction - fetch everything, filter client-side. + * This ensures we see open challenges and public games. + */ + fun buildChallengeFilters( + relays: Set, + sinceForRelay: (NormalizedRelayUrl) -> Long?, + now: Long = TimeUtils.now(), + ): List { + val filter = + Filter( + kinds = CHALLENGE_KINDS, + limit = 100, + ) + + return relays.map { relay -> + val sinceTime = + sinceForRelay(relay) + ?: (now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS) + RelayBasedFilter( + relay = relay, + filter = filter.copy(since = sinceTime), + ) + } + } + + /** + * 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. + */ + fun buildActiveGameFilters( + gameIds: 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") + + val filters = mutableListOf() + + // End events: filter by d-tag (gameId) + val endFilter = + Filter( + kinds = listOf(LiveChessGameEndEvent.KIND), + tags = mapOf("d" to gameIds.toList()), + limit = 50, + ) + + // Move events: filter by p-tag (opponent tagged us) + // This catches all moves for games where we're a participant + val moveFilterByTag = + Filter( + kinds = listOf(LiveChessMoveEvent.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)) + } + + // Move events: filter by authors (opponent pubkeys) + // This directly catches moves made by our opponents + if (opponentPubkeys.isNotEmpty()) { + println("[ChessFilterBuilder] Adding author filter for opponents: $opponentPubkeys") + val moveFilterByAuthor = + Filter( + kinds = listOf(LiveChessMoveEvent.KIND), + authors = opponentPubkeys.toList(), + limit = 200, + ) + + relays.forEach { relay -> + filters.add(RelayBasedFilter(relay = relay, filter = moveFilterByAuthor)) + } + } else { + println("[ChessFilterBuilder] WARNING: No opponent pubkeys provided!") + } + + return filters + } + + /** + * Recent game events for spectating discovery. + * Uses longer time window (7 days) to catch ongoing games. + */ + fun buildRecentGameFilters( + relays: Set, + sinceForRelay: (NormalizedRelayUrl) -> Long?, + now: Long = TimeUtils.now(), + ): List { + val filter = + Filter( + kinds = GAME_EVENT_KINDS, + limit = 100, + ) + + return relays.map { relay -> + val sinceTime = + sinceForRelay(relay) + ?: (now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS) + RelayBasedFilter( + relay = relay, + filter = filter.copy(since = sinceTime), + ) + } + } + + // =================================================== + // Simple filters for one-shot fetches (ChessRelayFetcher) + // =================================================== + + /** + * Filter for all events related to a specific game. + * Used for one-shot fetch when loading a game. + */ + fun gameEventsFilter(gameId: String): Filter = + Filter( + kinds = ALL_CHESS_KINDS + listOf(com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent.KIND), + tags = mapOf("d" to listOf(gameId)), + limit = 500, + ) + + /** + * Filter for challenge events in the last 24 hours. + * Used for one-shot fetch to populate lobby. + */ + fun challengesFilter(userPubkey: String): Filter { + val now = TimeUtils.now() + return Filter( + kinds = CHALLENGE_KINDS, + since = now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS, + limit = 100, + ) + } + + /** + * Filter for recent game activity for spectating discovery. + * Fetches move events from the last 7 days. + */ + fun recentGamesFilter(): Filter { + val now = TimeUtils.now() + return Filter( + kinds = ALL_CHESS_KINDS, + since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS, + limit = 200, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionController.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionController.kt new file mode 100644 index 0000000000..e5de7c850e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionController.kt @@ -0,0 +1,69 @@ +/** + * 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.subscription + +import kotlinx.coroutines.flow.StateFlow + +/** + * Interface for platform-specific chess subscription management. + * + * Implementations handle the actual relay subscription mechanics while + * using [ChessFilterBuilder] for consistent filter construction. + * + * Key responsibilities: + * - Subscribe/unsubscribe to chess events on relays + * - Update filters when active games change + * - Track EOSE (End of Stored Events) per relay for efficient re-subscription + */ +interface ChessSubscriptionController { + /** Current subscription state, null if not subscribed */ + val currentState: StateFlow + + /** + * Subscribe to chess events with the given state. + * Replaces any existing subscription. + */ + fun subscribe(state: ChessSubscriptionState) + + /** Unsubscribe from all chess events */ + fun unsubscribe() + + /** + * Update active game IDs and refresh subscription filters. + * This is the key method for dynamic subscription updates. + * + * When a game starts or ends, call this to update the filters + * so moves are properly received for active games. + * + * @param activeGameIds Game IDs the user is actively playing + * @param spectatingGameIds Game IDs the user is watching (optional) + */ + fun updateActiveGames( + activeGameIds: Set, + spectatingGameIds: Set = emptySet(), + ) + + /** + * Force refresh all filters from relays. + * Clears EOSE cache so full history is re-fetched. + */ + fun forceRefresh() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionState.kt new file mode 100644 index 0000000000..623dbe7a62 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionState.kt @@ -0,0 +1,70 @@ +/** + * 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.subscription + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Immutable state for chess relay subscriptions. + * Used to generate subscription filters and track subscription identity. + * + * When any of these values change, a new subscription should be created + * with updated filters. + */ +@Immutable +data class ChessSubscriptionState( + /** User's public key (hex) for filtering personal events */ + val userPubkey: String, + /** Set of relays to subscribe to */ + val relays: Set, + /** Game IDs the user is actively playing */ + val activeGameIds: Set = emptySet(), + /** Game IDs the user is spectating */ + val spectatingGameIds: Set = emptySet(), + /** Opponent pubkeys for active games (to filter their moves) */ + val opponentPubkeys: Set = emptySet(), +) { + /** + * Unique subscription ID that changes when game IDs change. + * Used for: + * - Subscription deduplication + * - EOSE cache keying + * - Detecting when filters need to be refreshed + */ + fun subscriptionId(): String = + buildString { + append("chess-") + append(userPubkey.take(8)) + if (allGameIds.isNotEmpty()) { + append("-g") + append(allGameIds.sorted().hashCode()) + } + } + + /** All game IDs requiring move subscriptions (active + spectating) */ + val allGameIds: Set + get() = activeGameIds + spectatingGameIds + + /** True if user has any active or spectating games */ + val hasActiveGames: Boolean + get() = allGameIds.isNotEmpty() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessTimeWindows.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessTimeWindows.kt new file mode 100644 index 0000000000..35b4c25487 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessTimeWindows.kt @@ -0,0 +1,40 @@ +/** + * 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.subscription + +/** + * Shared time window constants for chess subscriptions. + * Used by both Android and Desktop to ensure consistent behavior. + */ +object ChessTimeWindows { + /** + * 24 hours - challenges older than this are considered expired. + * Used for challenge and accept event filters. + */ + const val CHALLENGE_WINDOW_SECONDS = 24 * 60 * 60L + + /** + * 7 days - default lookback for game events when no EOSE cache exists. + * This prevents loading ancient game history on first connection + * while still allowing recovery of recent games. + */ + const val GAME_EVENT_WINDOW_SECONDS = 7 * 24 * 60 * 60L +} 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 854f7e9997..8d46318f3f 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 @@ -69,9 +69,10 @@ import com.vitorpamplona.amethyst.commons.data.UserMetadataCache import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager -import com.vitorpamplona.amethyst.desktop.subscriptions.createChessSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createChessSubscriptionWithGames 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 /** @@ -91,14 +92,26 @@ fun ChessScreen( val relayStatuses by relayManager.relayStatuses.collectAsState() val refreshKey by viewModel.refreshKey.collectAsState() val isLoading by viewModel.isLoading.collectAsState() + val activeGames by viewModel.activeGames.collectAsState() - // Subscribe to chess events from relays (re-subscribes when refreshKey changes) - rememberSubscription(relayStatuses, account, refreshKey, relayManager = relayManager) { + // Extract opponent pubkeys from active games for move filtering + val opponentPubkeys = + remember(activeGames) { + val pubkeys = activeGames.values.map { it.opponentPubkey }.toSet() + println("[ChessScreen] Active games: ${activeGames.keys}, Opponent pubkeys: $pubkeys") + pubkeys + } + + // 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()) { - createChessSubscription( + createChessSubscriptionWithGames( relays = configuredRelays, userPubkey = account.pubKeyHex, + activeGameIds = activeGames.keys, + opponentPubkeys = opponentPubkeys, onEvent = { event, _, _, _ -> viewModel.handleIncomingEvent(event) }, @@ -128,7 +141,6 @@ fun ChessScreen( } } - val activeGames by viewModel.activeGames.collectAsState() val challenges by viewModel.challenges.collectAsState() val completedGames by viewModel.completedGames.collectAsState() // Observe metadata changes to trigger recomposition @@ -280,7 +292,7 @@ private fun ChessLobby( ) } - items(activeGames.entries.toList(), key = { it.key }) { (gameId, state) -> + items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) -> ActiveGameCard( gameId = gameId, opponentPubkey = state.opponentPubkey, @@ -375,7 +387,10 @@ private fun ChessLobby( ) } - items(completedGames.take(10), key = { it.gameId }) { game -> + items( + completedGames.distinctBy { it.gameId }.take(10), + key = { "completed-${it.gameId}-${it.completedAt}" }, + ) { game -> CompletedGameCard( game = game, userPubkey = userPubkey, @@ -428,6 +443,12 @@ private fun ActiveGameCard( 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 = @@ -453,14 +474,14 @@ private fun ActiveGameCard( ) Column { Text( - "vs $opponentName", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, + gameName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, ) Text( - "Game: ${gameId.take(12)}...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + "vs $opponentName", + style = MaterialTheme.typography.bodyMedium, ) } } @@ -685,6 +706,7 @@ private fun DesktopChessGameLayout( boardSize = 520.dp, flipped = playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK, playerColor = playerColor, + positionVersion = moveHistory.size, onMoveMade = onMoveMade, ) } @@ -698,6 +720,12 @@ private fun DesktopChessGameLayout( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { + // Extract human-readable game name + val gameName = + remember(gameId) { + ChessGameNameGenerator.extractDisplayName(gameId) + } + // Game info card Card( modifier = Modifier.fillMaxWidth(), @@ -706,11 +734,21 @@ private fun DesktopChessGameLayout( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - "Game Info", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) + // 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, + ) + } Row( verticalAlignment = Alignment.CenterVertically, 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 new file mode 100644 index 0000000000..027bb60db0 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt @@ -0,0 +1,432 @@ +/** + * 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.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.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.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 + +/** + * Desktop implementation of ChessEventPublisher. + * Wraps account.signer.sign() + relayManager.broadcastToAll() for event publishing. + */ +class DesktopChessPublisher( + private val account: AccountState.LoggedIn, + private val relayManager: DesktopRelayConnectionManager, +) : ChessEventPublisher { + override suspend fun publishChallenge( + gameId: String, + playerColor: Color, + opponentPubkey: String?, + timeControl: String?, + ): Boolean = + try { + val template = + LiveChessGameChallengeEvent.build( + gameId = gameId, + playerColor = playerColor, + opponentPubkey = opponentPubkey, + timeControl = timeControl, + ) + val signedEvent = account.signer.sign(template) + relayManager.broadcastToAll(signedEvent) + true + } catch (e: Exception) { + false + } + + override suspend fun publishAccept( + gameId: String, + challengeEventId: String, + challengerPubkey: String, + ): Boolean = + 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, + fen = move.fen, + opponentPubkey = move.opponentPubkey, + ) + val signedEvent = account.signer.sign(template) + relayManager.broadcastToAll(signedEvent) + true + } catch (e: Exception) { + false + } + + override suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean = + try { + val template = + LiveChessGameEndEvent.build( + gameId = gameEnd.gameId, + 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 + } catch (e: Exception) { + 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 +} + +/** + * Desktop implementation of ChessRelayFetcher. + * Uses ChessRelayFetchHelper for one-shot relay queries. + */ +class DesktopRelayFetcher( + private val relayManager: DesktopRelayConnectionManager, + private val userPubkey: String, +) : 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) } + + val events = fetchHelper.fetchEvents(relayFilters) + + 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() + + 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() + + 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 } + } + } + + 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() + + 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 } + } + } + + return ChessGameEvents( + challenge = challengeEvent, + accept = acceptEvent, + moves = moveEvents, + end = endEvent, + drawOffers = drawOffers, + ) + } + + override suspend fun fetchChallenges(): List { + val filters = ChessFilterBuilder.challengesFilter(userPubkey) + val relayFilters = relayManager.connectedRelays.value.associateWith { listOf(filters) } + + 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, + ) + } + } + } + + override suspend fun fetchRecentGames(): List { + val filters = ChessFilterBuilder.recentGamesFilter() + val relayFilters = relayManager.connectedRelays.value.associateWith { listOf(filters) } + + val events = fetchHelper.fetchEvents(relayFilters) + + // Group by game ID and create summaries + val gameIds = + 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() + + 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 + + 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 challengerColor = challenge.playerColor() ?: Color.WHITE + val whitePubkey = + if (challengerColor == Color.WHITE) { + challenge.pubKey + } else { + accept?.pubKey ?: return@mapNotNull null + } + val blackPubkey = + if (challengerColor == Color.BLACK) { + challenge.pubKey + } else { + accept?.pubKey ?: return@mapNotNull null + } + + 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 } + + RelayGameSummary( + gameId = gameId, + whitePubkey = whitePubkey, + blackPubkey = blackPubkey, + moveCount = moves.size, + lastMoveTime = lastMove?.createdAt ?: challenge.createdAt, + isActive = endEvent == null, + ) + } + } +} + +/** + * Desktop implementation of IUserMetadataProvider. + * Wraps UserMetadataCache for user metadata lookup. + */ +class DesktopMetadataProvider( + private val metadataCache: UserMetadataCache, +) : IUserMetadataProvider { + override fun getDisplayName(pubkey: String): String = metadataCache.getDisplayName(pubkey) + + override fun getPictureUrl(pubkey: String): String? = metadataCache.getPictureUrl(pubkey) +} 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 index 1588e9ec96..02f6a55a48 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModel.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModel.kt @@ -20,12 +20,16 @@ */ 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 @@ -36,6 +40,7 @@ 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 @@ -44,7 +49,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import java.util.UUID /** * Desktop ViewModel for managing chess game state and event publishing. @@ -106,18 +110,59 @@ class DesktopChessViewModel( private val pendingAccepts = mutableMapOf() // Track event IDs we've already processed to avoid duplicates - private val processedEventIds = mutableSetOf() + // 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 - if (processedEventIds.contains(event.id)) return - processedEventIds.add(event.id) + // 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) { @@ -230,8 +275,7 @@ class DesktopChessViewModel( } private fun handleIncomingMove(event: LiveChessMoveEvent) { - // Don't process our own moves - if (event.pubKey == account.pubKeyHex) return + 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 @@ -241,14 +285,25 @@ class DesktopChessViewModel( val gameState = _activeGames.value[gameId] if (gameState == null) { - // Game doesn't exist yet - buffer the move for later + // 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 } - if (event.pubKey != gameState.opponentPubkey) 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() } @@ -283,9 +338,10 @@ class DesktopChessViewModel( private fun handleGameEnded(event: LiveChessGameEndEvent) { val gameId = event.gameId() ?: return - // Store game in history before removing + // 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) { + if (gameState != null && !alreadyCompleted) { val completedGame = CompletedGame( gameId = gameId, @@ -303,6 +359,7 @@ class DesktopChessViewModel( // Remove from active games if (_activeGames.value.containsKey(gameId)) { _activeGames.value = _activeGames.value - gameId + notifyActiveGamesChanged() } // Clean up challenge references @@ -460,6 +517,7 @@ class DesktopChessViewModel( ) _activeGames.value = _activeGames.value + (gameId to gameState) + notifyActiveGamesChanged() gamesBeingCreated.remove(gameId) _selectedGameId.value = gameId _challenges.value = _challenges.value.filter { it.id != challengeEvent.id } @@ -480,9 +538,18 @@ class DesktopChessViewModel( 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)) return@launch - if (!gamesBeingCreated.add(gameId)) return@launch // Returns false if already present + 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 @@ -514,11 +581,14 @@ class DesktopChessViewModel( 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) } } @@ -527,13 +597,37 @@ class DesktopChessViewModel( gameId: String, gameState: LiveChessGameState, ) { - val moves = pendingMoves.remove(gameId) ?: return + val moves = pendingMoves.remove(gameId) + if (moves == null) { + println("[Chess] No pending moves for game $gameId") + return + } - // Sort by move number if available, then apply in order + 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 } - for ((san, fen, moveNumber) in sortedMoves) { - gameState.applyOpponentMove(san, fen, moveNumber) + 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() @@ -548,12 +642,36 @@ class DesktopChessViewModel( to: String, ) { val gameState = _activeGames.value[gameId] ?: return - val moveResult = gameState.makeMove(from, to) + + // 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, @@ -606,20 +724,24 @@ class DesktopChessViewModel( } if (success) { - // Store in history - 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 + // 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" @@ -685,20 +807,24 @@ class DesktopChessViewModel( } if (success) { - // Store in history and remove from active - 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 + // 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" @@ -754,6 +880,30 @@ class DesktopChessViewModel( _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() } @@ -776,11 +926,7 @@ class DesktopChessViewModel( return false } - private fun generateGameId(): String { - val timestamp = TimeUtils.now() - val random = UUID.randomUUID().toString().take(8) - return "chess-$timestamp-$random" - } + private fun generateGameId(): String = ChessGameNameGenerator.generateGameId(TimeUtils.now()) } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index 4bf0ef3743..4c9aba1e3f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -95,6 +95,19 @@ object FilterBuilders { authors = pubKeyHexList, ) + /** + * Creates a filter for user metadata (kind 0) from multiple authors. + * Alias for userMetadataBatch for API compatibility. + * + * @param pubKeys List of author public keys (hex-encoded, 64 chars each) + * @return Filter for user metadata + */ + fun userMetadataMultiple(pubKeys: List): Filter = + Filter( + kinds = listOf(0), + authors = pubKeys, + ) + /** * Creates a filter for contact list (kind 3) from a specific author. * diff --git a/docs/chess-engine-refactoring.md b/docs/chess-engine-refactoring.md new file mode 100644 index 0000000000..6e773646a2 --- /dev/null +++ b/docs/chess-engine-refactoring.md @@ -0,0 +1,287 @@ +# Chess Engine Refactoring + +## Context + +Chess feature had inconsistent state reconstruction: +- **Android**: Replays moves from `LocalCache.addressables` (can get stale/desync) +- **Desktop**: Buffers `pendingMoves` and syncs via `forceResync(fen)` (fragile) + +Both ViewModels (~1200 + ~956 lines) duplicate logic that now exists in shared components. + +## Completed Work + +### Shared Components (Done) + +| File | Location | Purpose | +|------|----------|---------| +| `ChessStateReconstructor` | `quartz/commonMain/` | Deterministic event→state reconstruction | +| `ChessStateReconstructorTest` | `quartz/jvmAndroidTest/` | 22 integration tests | +| `ChessEventCollector` | `commons/commonMain/` | Thread-safe event aggregation with dedup | +| `ChessGameLoader` | `commons/commonMain/` | Converts ReconstructionResult → LiveChessGameState | +| `ChessLobbyLogic` | `commons/commonMain/` | Shared business logic (challenges, moves, polling) | +| `ChessLobbyState` | `commons/commonMain/` | Shared UI state (StateFlows for games, challenges, status) | +| `ChessBroadcastStatus` | `commons/commonMain/` | Shared broadcast status sealed class | +| `ChessPollingDelegate` | `commons/commonMain/` | Configurable periodic refresh | +| `ChessFilterBuilder` | `commons/commonMain/subscription/` | Shared relay filter construction | +| `ChessSubscriptionController` | `commons/commonMain/subscription/` | Platform subscription interface | +| `ChessStatusBanner` | `amethyst/` (Android only) | Android broadcast status UI | + +### Test Coverage + +- Game lifecycle, move ordering/dedup, game end conditions +- Viewer perspectives, draw offers, castling, determinism + +--- + +## Target Architecture + +``` +BOTH PLATFORMS IDENTICAL: + +VM (~150 lines) → ChessLobbyLogic + │ + ├─ publish: ChessEventPublisher (platform impl) + │ + └─ refresh (periodic): + one-shot REQ to relays + ↓ + transient ChessEventCollector (use-once) + ↓ + ChessStateReconstructor.reconstruct() + ↓ + LiveChessGameState → UI + (collector discarded, no cache) +``` + +**Key principle**: No cache. Relays are the ONLY source of truth. + +Every refresh cycle: +1. One-shot REQ to relays for all game events +2. Transient collector → reconstruct → get state +3. Diff against UI state, update if changed +4. Discard collector + +Real-time subscription events apply moves **optimistically** to `LiveChessGameState`. Periodic full-reconstruction corrects any drift. + +--- + +## Resolved Decisions + +### 1. No LocalCache for Chess + +**Decision**: Chess events must NOT enter LocalCache. Chess is inherently remote-only. + +**How to stop writes**: Remove chess event handlers from `LocalCache.justConsumeInnerInner()` (lines 2956-2960 in `LocalCache.kt`). These are: +```kotlin +is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified) // REMOVE +is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified) // REMOVE +is LiveChessMoveEvent -> consume(event, relay, wasVerified) // REMOVE +is LiveChessGameEndEvent -> consume(event, relay, wasVerified) // REMOVE +``` + +Also remove chess DataSource from `RelaySubscriptionsCoordinator` (line 83: `val chess = ChessFilterAssembler(client)`). Chess manages its own relay subscriptions independently. + +### 2. One-Shot Relay Fetch (in commons) + +**Decision**: Create a shared one-shot fetch helper in commons using existing `IRequestListener` + `Channel` pattern. + +**Existing pattern** (proven in production): +- `quartz/.../accessories/NostrClientSingleDownloadExt.kt` — single event download +- `quartz/.../accessories/NostrClientSendAndWaitExt.kt` — multi-relay wait + +**Design**: The fetcher takes an `INostrClient` (available on both platforms) and uses the existing `IRequestListener` callback → `Channel` → `withTimeoutOrNull` pattern: + +```kotlin +// commons/commonMain - shared one-shot fetch +class ChessRelayFetchHelper(private val client: INostrClient) { + + suspend fun fetchEvents( + filters: Map>, + timeoutMs: Long = 30_000, + ): List { + val events = mutableListOf() + val eoseReceived = CompletableDeferred() + val subId = UUID.randomUUID().toString().take(8) + + val listener = object : IRequestListener { + override fun onEvent(event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?) { + events.add(event) + } + override fun onEose(relay: NormalizedRelayUrl, forFilters: List?) { + eoseReceived.complete(Unit) + } + } + + client.openReqSubscription(subId, filters, listener) + withTimeoutOrNull(timeoutMs) { eoseReceived.await() } + client.close(subId) + + return events + } +} +``` + +Platform adapters inject their `INostrClient`: +- **Android**: `account.client` (or equivalent from relay pool) +- **Desktop**: `relayManager.client` + +### 3. Metadata Provider (interface in commons) + +**Decision**: Shared `IUserMetadataProvider` interface in commons. Platform-specific implementations. + +Android uses `LocalCache.users[pubkey].info`, Desktop uses `UserMetadataCache`. Both implement: + +```kotlin +// commons/commonMain +interface IUserMetadataProvider { + fun getDisplayName(pubkey: String): String + fun getPictureUrl(pubkey: String): String? +} +``` + +`ChessChallenge` gets enriched with display fields by calling provider at construction time. + +### 4. Challenge Type Migration + +**Decision**: Enrich `ChessChallenge` with display fields (displayName, avatarUrl) so it replaces both `Note` (Android) and `LiveChessGameChallengeEvent` (Desktop) in UI code. + +--- + +## Implementation Steps + +### Step 1: Stop chess events entering LocalCache + +**Files:** +- `amethyst/.../model/LocalCache.kt` — remove chess `when` branches (lines ~2956-2960) +- `amethyst/.../service/relayClient/RelaySubscriptionsCoordinator.kt` — remove `val chess` DataSource + +### Step 2: Create one-shot relay fetch helper in commons + +**File:** `commons/src/commonMain/.../chess/ChessRelayFetchHelper.kt` (new) + +Uses `INostrClient` + `IRequestListener` + `Channel` pattern. Shared by both platforms. + +### Step 3: Create IUserMetadataProvider in commons + +**File:** `commons/src/commonMain/.../chess/IUserMetadataProvider.kt` (new) + +### Step 4: Rewrite ChessLobbyLogic (relay-first) + +**File:** `commons/src/commonMain/.../chess/ChessLobbyLogic.kt` + +Replace `ChessEventFetcher` with `ChessRelayFetcher` (wraps `ChessRelayFetchHelper`): + +```kotlin +interface ChessRelayFetcher { + suspend fun fetchGameEvents(gameId: String): ChessGameEvents + suspend fun fetchChallenges(): List + suspend fun fetchRecentGames(): List +} +``` + +Rewrite refresh cycle: +```kotlin +private suspend fun refreshGame(gameId: String) { + val events = relayFetcher.fetchGameEvents(gameId) // one-shot from relays + val result = ChessStateReconstructor.reconstruct(events, userPubkey) + when (result) { + is ReconstructionResult.Success -> + state.replaceGameState(gameId, ChessGameLoader.toLiveGameState(result, userPubkey)) + is ReconstructionResult.Error -> + state.setError("Game $gameId: ${result.message}") + } +} +``` + +Add: +- `handleIncomingEvent(event)` — optimistic real-time event routing +- `handleGameAccepted()` + `startGameFromAcceptance()` +- `acceptDraw()`, `declineDraw()`, `claimAbandonmentVictory()` +- `retryWithBackoff()` for publish operations +- Subscription controller integration + +### Step 5: Enhance ChessLobbyState + +**File:** `commons/src/commonMain/.../chess/ChessLobbyState.kt` + +Add: +- `replaceGameState(gameId, newState)` — for full reconstruction updates +- `completedGames: StateFlow>` +- Enrich `ChessChallenge` with `displayName`, `avatarUrl` + +### Step 6: Platform adapters + +**Android:** `amethyst/.../chess/AndroidChessAdapter.kt` (new) +- `AndroidChessPublisher` — wraps `account.signAndComputeBroadcast()` +- `AndroidRelayFetcher` — wraps `ChessRelayFetchHelper(account.client)` +- `AndroidMetadataProvider` — wraps `LocalCache.users[pubkey].info` + +**Desktop:** `desktopApp/.../chess/DesktopChessAdapter.kt` (new) +- `DesktopChessPublisher` — wraps `account.signer.sign()` + `relayManager.broadcastToAll()` +- `DesktopRelayFetcher` — wraps `ChessRelayFetchHelper(relayManager.client)` +- `DesktopMetadataProvider` — wraps `UserMetadataCache` + +### Step 7: Rewrite Android ChessViewModel + +**File:** `amethyst/.../chess/ChessViewModel.kt` (1220 → ~150 lines) + +Thin wrapper: delegates to `ChessLobbyLogic`, exposes state, routes `LocalCache.live.newEventBundles` for real-time events. + +Delete: `RetryOperation`, `PublicGameInfo`, all LocalCache queries, all handle* methods, `ChessStatus`. + +### Step 8: Rewrite Desktop DesktopChessViewModel + +**File:** `desktopApp/.../chess/DesktopChessViewModel.kt` (956 → ~150 lines) + +Thin wrapper: delegates to `ChessLobbyLogic`, keeps `UserMetadataCache` platform-specific. + +Delete: `pendingMoves`, `pendingAccepts`, `challengesByGameId`, `processedEventIds`, `gamesBeingCreated`, all handle* methods, `applyPendingMoves`, `CompletedGame`. + +### Step 9: Create shared BroadcastBanner + +**File:** `commons/src/commonMain/.../chess/ChessBroadcastBanner.kt` (new) + +Extract from Android's `ChessStatusBanner.kt`, use `ChessBroadcastStatus` from commons. + +### Step 10: Update UI consumers + +**Android:** +- `ChessGameScreen.kt` / `ChessLobbyScreen.kt`: `ChessChallenge` instead of `Note` +- Remove `ChessStatusBanner.kt`, use shared `ChessBroadcastBanner` + +**Desktop:** +- `ChessScreen.kt`: `ChessChallenge` instead of `LiveChessGameChallengeEvent` +- Add `ChessBroadcastBanner` + +--- + +## Files Modified + +| File | Action | Notes | +|------|--------|-------| +| `LocalCache.kt` | Edit | Remove chess event handlers | +| `RelaySubscriptionsCoordinator.kt` | Edit | Remove chess DataSource | +| `commons/.../ChessRelayFetchHelper.kt` | **New** | One-shot relay query helper | +| `commons/.../IUserMetadataProvider.kt` | **New** | Metadata interface | +| `commons/.../ChessLobbyLogic.kt` | Rewrite | Relay-first, event routing, reconstructor | +| `commons/.../ChessLobbyState.kt` | Enhance | replaceGameState, completedGames, enriched ChessChallenge | +| `commons/.../ChessBroadcastBanner.kt` | **New** | Shared broadcast status UI | +| `amethyst/.../AndroidChessAdapter.kt` | **New** | Publisher + fetcher + metadata | +| `amethyst/.../ChessViewModel.kt` | Rewrite | 1220→150 lines | +| `amethyst/.../ChessStatusBanner.kt` | Delete | Replaced by shared | +| `amethyst/.../ChessGameScreen.kt` | Update | Challenge type + banner | +| `amethyst/.../ChessLobbyScreen.kt` | Update | Challenge type | +| `desktopApp/.../DesktopChessAdapter.kt` | **New** | Publisher + fetcher + metadata | +| `desktopApp/.../DesktopChessViewModel.kt` | Rewrite | 956→150 lines | +| `desktopApp/.../ChessScreen.kt` | Update | Challenge type + banner | + +--- + +## Verification + +1. `./gradlew :quartz:build` +2. `./gradlew :commons:build` +3. `./gradlew :amethyst:compileDebugKotlin` +4. `./gradlew :desktopApp:compileKotlin` +5. `./gradlew :quartz:jvmAndroidTest` — ChessStateReconstructor tests +6. `./gradlew :desktopApp:run` — manual test: create challenge, play moves diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt index 1a98e78865..cbbd8e796b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip64Chess import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -48,7 +48,7 @@ class LiveChessGameChallengeEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { companion object { const val KIND = 30064 @@ -100,7 +100,7 @@ class LiveChessGameAcceptEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { companion object { const val KIND = 30065 @@ -148,7 +148,7 @@ class LiveChessMoveEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { companion object { const val KIND = 30066 @@ -162,7 +162,8 @@ class LiveChessMoveEvent( createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, comment, createdAt) { - add(arrayOf("d", gameId)) + add(arrayOf("d", "$gameId-$moveNumber")) + add(arrayOf("game_id", gameId)) add(arrayOf("move_number", moveNumber.toString())) add(arrayOf("san", san)) add(arrayOf("fen", fen)) @@ -172,7 +173,7 @@ class LiveChessMoveEvent( } } - fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "game_id" }?.get(1) fun moveNumber(): Int? = tags @@ -211,7 +212,7 @@ class LiveChessGameEndEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { companion object { const val KIND = 30067 @@ -268,7 +269,7 @@ class LiveChessDrawOfferEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { companion object { const val KIND = 30068 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 647e0b3e99..6d737b8605 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt @@ -40,6 +40,8 @@ class LiveChessGameState( val playerColor: Color, val engine: ChessEngine, val createdAt: Long = TimeUtils.now(), + val isSpectator: Boolean = false, + val isPendingChallenge: Boolean = false, ) { companion object { // Abandon timeout: 24 hours without a move @@ -92,8 +94,9 @@ class LiveChessGameState( /** * Check if it's the player's turn + * Spectators never have a turn */ - fun isPlayerTurn(): Boolean = engine.getSideToMove() == playerColor + fun isPlayerTurn(): Boolean = !isSpectator && engine.getSideToMove() == playerColor /** * Make a move (called when player makes a move on the board) @@ -129,9 +132,12 @@ class LiveChessGameState( 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 ChessMoveEvent( gameId = gameId, - moveNumber = result.position.moveNumber, + moveNumber = _moveHistory.value.size, san = result.san, fen = engine.getFen(), opponentPubkey = opponentPubkey, @@ -403,6 +409,14 @@ class LiveChessGameState( """.trimIndent() } + /** + * Mark game as finished with the given result. + * Used when loading a game from cache that already has an end event. + */ + fun markAsFinished(result: GameResult) { + _gameStatus.value = GameStatus.Finished(result) + } + /** * Reset game to initial position */ 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 9a732cee16..1eea61060d 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.LiveChessDrawOfferEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent @@ -193,6 +194,7 @@ class EventFactory { LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig) LiveChessMoveEvent.KIND -> LiveChessMoveEvent(id, pubKey, createdAt, tags, content, sig) LiveChessGameEndEvent.KIND -> LiveChessGameEndEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessDrawOfferEvent.KIND -> LiveChessDrawOfferEvent(id, pubKey, createdAt, tags, content, sig) ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt index f861909d91..831a694fb9 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt @@ -27,32 +27,38 @@ import com.github.bhlangonijr.chesslib.Square import com.github.bhlangonijr.chesslib.move.Move /** - * JVM/Android implementation of ChessEngine using kchesslib + * JVM/Android implementation of ChessEngine using kchesslib. + * + * Maintains its own SAN history because chesslib's Move.toString() returns + * UCI/coordinate notation (e.g. "e2e4") rather than proper SAN (e.g. "e4"). */ actual class ChessEngine { private val board = Board() + private val sanHistory = mutableListOf() actual fun getFen(): String = board.fen actual fun loadFen(fen: String) { board.loadFromFen(fen) + sanHistory.clear() } actual fun reset() { board.loadFromFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") + sanHistory.clear() } actual fun makeMove(san: String): MoveResult = try { - val move = board.doMove(san) - if (move != null) { + if (board.doMove(san)) { + // Input is already SAN from Nostr events, store directly + sanHistory.add(san) MoveResult( success = true, san = san, position = boardToPosition(), ) } else { - board.undoMove() MoveResult( success = false, error = "Invalid move: $san", @@ -86,8 +92,9 @@ actual class ChessEngine { val move = Move(fromSquare, toSquare, promotionPiece) if (board.legalMoves().contains(move)) { + val san = computeSan(move) board.doMove(move) - val san = move.toString() // kchesslib converts to SAN + sanHistory.add(san) MoveResult( success = true, san = san, @@ -119,8 +126,7 @@ actual class ChessEngine { actual fun isLegalMove(san: String): Boolean = try { - val move = board.doMove(san) - if (move != null) { + if (board.doMove(san)) { board.undoMove() true } else { @@ -162,6 +168,9 @@ actual class ChessEngine { actual fun undoMove() { board.undoMove() + if (sanHistory.isNotEmpty()) { + sanHistory.removeAt(sanHistory.lastIndex) + } } actual fun getPosition(): ChessPosition = boardToPosition() @@ -172,11 +181,115 @@ actual class ChessEngine { Side.BLACK -> Color.BLACK } - actual fun getMoveHistory(): List { - // kchesslib stores move history - return board.backup.mapNotNull { it?.move?.toString() } + actual fun getMoveHistory(): List = sanHistory.toList() + + /** + * Compute proper SAN notation for a move BEFORE it is applied to the board. + * Handles pieces, pawns, castling, captures, promotion, disambiguation, check/checkmate. + */ + private fun computeSan(move: Move): String { + val fromSquare = move.from + val toSquare = move.to + val piece = board.getPiece(fromSquare) + val pt = piece.pieceType ?: return move.toString() + val promotionPiece = move.promotion ?: Piece.NONE + + // Castling + if (pt == com.github.bhlangonijr.chesslib.PieceType.KING) { + val fileDiff = toSquare.file.ordinal - fromSquare.file.ordinal + if (fileDiff == 2) { + val suffix = checkSuffixAfterMove(move) + return "O-O$suffix" + } + if (fileDiff == -2) { + val suffix = checkSuffixAfterMove(move) + return "O-O-O$suffix" + } + } + + val sb = StringBuilder() + val epTarget = board.enPassantTarget + val isCapture = + board.getPiece(toSquare) != Piece.NONE || + ( + pt == com.github.bhlangonijr.chesslib.PieceType.PAWN && + epTarget != null && epTarget != Square.NONE && toSquare == epTarget + ) + + if (pt != com.github.bhlangonijr.chesslib.PieceType.PAWN) { + sb.append(sanSymbol(pt)) + + // Disambiguation: check if other pieces of same type can reach the same square + val ambiguous = + board.legalMoves().filterNotNull().filter { + it.to == toSquare && + board.getPiece(it.from).pieceType == pt && + it.from != fromSquare + } + + if (ambiguous.isNotEmpty()) { + val sameFile = ambiguous.any { it.from.file == fromSquare.file } + val sameRank = ambiguous.any { it.from.rank == fromSquare.rank } + when { + !sameFile -> sb.append(fileChar(fromSquare)) + !sameRank -> sb.append(rankChar(fromSquare)) + else -> { + sb.append(fileChar(fromSquare)) + sb.append(rankChar(fromSquare)) + } + } + } + } else if (isCapture) { + // Pawn captures include the source file + sb.append(fileChar(fromSquare)) + } + + if (isCapture) sb.append('x') + + sb.append(toSquare.toString().lowercase()) + + // Promotion + if (promotionPiece != Piece.NONE) { + sb.append('=') + val promType = promotionPiece.pieceType + if (promType != null) sb.append(sanSymbol(promType)) + } + + // Check/checkmate suffix + sb.append(checkSuffixAfterMove(move)) + + return sb.toString() } + /** + * Temporarily apply a move to check for check/checkmate, then undo. + */ + private fun checkSuffixAfterMove(move: Move): String { + board.doMove(move) + val suffix = + when { + board.isMated -> "#" + board.isKingAttacked -> "+" + else -> "" + } + board.undoMove() + return suffix + } + + private fun sanSymbol(pt: com.github.bhlangonijr.chesslib.PieceType): String = + when (pt) { + com.github.bhlangonijr.chesslib.PieceType.KING -> "K" + com.github.bhlangonijr.chesslib.PieceType.QUEEN -> "Q" + com.github.bhlangonijr.chesslib.PieceType.ROOK -> "R" + com.github.bhlangonijr.chesslib.PieceType.BISHOP -> "B" + com.github.bhlangonijr.chesslib.PieceType.KNIGHT -> "N" + else -> "" + } + + private fun fileChar(square: Square): Char = 'a' + square.file.ordinal + + private fun rankChar(square: Square): Char = '1' + square.rank.ordinal + /** * Convert kchesslib Board to our ChessPosition model */