diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 01d9b07a6..8a0335bf0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler @@ -79,6 +80,7 @@ class RelaySubscriptionsCoordinator( val hashtags = HashtagFilterAssembler(client) val geohashes = GeoHashFilterAssembler(client) val followPacks = FollowPackFeedFilterAssembler(client) + val chess = ChessFilterAssembler(client) // active when sending zaps via NWC val nwc = NWCPaymentFilterAssembler(client) @@ -101,6 +103,7 @@ class RelaySubscriptionsCoordinator( profile, hashtags, geohashes, + chess, nwc, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 7d053fe16..d31f7171b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen @@ -138,6 +139,7 @@ fun AppNavigation( composable { VideoScreen(accountViewModel, nav) } composable { DiscoverScreen(accountViewModel, nav) } composable { NotificationScreen(accountViewModel, nav) } + composable { ChessLobbyScreen(accountViewModel, nav) } composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 259fb88b2..dc0e12fcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -471,6 +471,15 @@ fun ListContent( route = Route.Drafts, ) + NavigationRow( + title = R.string.route_chess, + icon = R.drawable.ic_chess, + iconReference = 1, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Chess, + ) + IconRowRelays( accountViewModel = accountViewModel, onClick = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 8c98e47e5..0147f5b14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -40,6 +40,8 @@ sealed class Route { @Serializable object Notification : Route() + @Serializable object Chess : Route() + @Serializable object Search : Route() @Serializable object SecurityFilters : Route() @@ -316,6 +318,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? { dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 5b698012b..2fff4ccd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -144,7 +144,7 @@ class TopNavFilterState( ), ) - val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow, muteListFollow) + val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow) fun mergePeopleLists( peopleLists: List, @@ -258,7 +258,7 @@ class TopNavFilterState( checkNotInMainThread() emit( listOf( - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), myLivePeopleListsFlow, myLiveKind3FollowsFlow, listOf(muteListFollow), @@ -274,7 +274,7 @@ class TopNavFilterState( checkNotInMainThread() emit( listOf( - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), myLivePeopleListsFlow, listOf(muteListFollow), ).flatten().toImmutableList(), @@ -413,9 +413,6 @@ val DEFAULT_FEED_KINDS = WikiNoteEvent.KIND, NipTextEvent.KIND, InteractiveStoryPrologueEvent.KIND, - ChessGameEvent.KIND, - LiveChessGameChallengeEvent.KIND, - LiveChessGameEndEvent.KIND, ) val DEFAULT_COMMUNITY_FEEDS = 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 7a5a41cf3..9acd282a3 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 @@ -21,17 +21,31 @@ 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -40,6 +54,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState /** * Wrapper screen for live chess game @@ -50,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel * @param accountViewModel Account view model * @param nav Navigation interface */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChessGameScreen( gameId: String, @@ -66,47 +82,136 @@ fun ChessGameScreen( ) val activeGames by chessViewModel.activeGames.collectAsState() - val gameState = activeGames[gameId] + val error by chessViewModel.error.collectAsState() + var isLoading by remember { mutableStateOf(true) } + var loadedGameState by remember { mutableStateOf(null) } + var loadAttempted by remember { mutableStateOf(false) } - Scaffold { paddingValues -> - if (gameState == null) { - // Game not found - show error - Column( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = "Game Not Found", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - ) + // Try to load game from cache if not in activeGames + LaunchedEffect(gameId, activeGames) { + val existingState = activeGames[gameId] + if (existingState != null) { + loadedGameState = existingState + isLoading = false + loadAttempted = true + } else if (!loadAttempted) { + // Try to load from LocalCache + loadAttempted = true + val loaded = chessViewModel.loadGameFromCache(gameId) + loadedGameState = loaded + isLoading = false + } + } - Spacer(modifier = Modifier.height(8.dp)) + // Update state when activeGames changes (e.g., after refresh loads new moves) + LaunchedEffect(activeGames[gameId]) { + activeGames[gameId]?.let { + loadedGameState = it + } + } - Text( - text = "This game may have ended or the ID is incorrect.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + // Start polling when screen is visible + DisposableEffect(Unit) { + chessViewModel.startPolling() + onDispose { + // Don't stop polling - let ViewModel manage it + } + } + + val gameState = loadedGameState ?: activeGames[gameId] + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Chess Game") }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + ) + }, + ) { paddingValues -> + when { + isLoading -> { + // Loading state + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text("Loading game...") + } + } + } + gameState == null -> { + // Game not found - show error with back button + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "Game Not Found", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Show specific error if available + Text( + text = error ?: "This game may have ended or is waiting for opponent.", + style = MaterialTheme.typography.bodyMedium, + color = if (error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Game ID: ${gameId.take(16)}...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.padding(end = 8.dp), + ) + Text("Go Back") + } + } + } + else -> { + // Show game with proper padding for status bar + // Use state-observing version for automatic refresh on polling updates + LiveChessGameScreen( + modifier = Modifier.padding(paddingValues), + gameState = gameState, + opponentName = gameState.opponentPubkey.take(8), // TODO: Resolve to display name + onMoveMade = { from, to, san -> + chessViewModel.publishMove(gameId, from, to) + }, + onResign = { chessViewModel.resign(gameId) }, + onOfferDraw = { chessViewModel.offerDraw(gameId) }, ) } - } else { - // Show game - LiveChessGameScreen( - engine = gameState.engine, - playerColor = gameState.playerColor, - gameId = gameState.gameId, - opponentName = gameState.opponentPubkey.take(8), // TODO: Resolve to display name - onMoveMade = { from, to, san -> - chessViewModel.publishMove(gameId, from, to) - }, - onResign = { chessViewModel.resign(gameId) }, - onOfferDraw = { chessViewModel.offerDraw(gameId) }, - ) } } } 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 new file mode 100644 index 000000000..d8ed6f201 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -0,0 +1,517 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.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.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.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog +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.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Chess lobby screen showing challenges and active games + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChessLobbyScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val chessViewModel: ChessViewModel = + viewModel( + key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}", + factory = ChessViewModelFactory(accountViewModel.account), + ) + + val activeGames by chessViewModel.activeGames.collectAsState() + val challenges by chessViewModel.challenges.collectAsState() + val error by chessViewModel.error.collectAsState() + val selectedGameId by chessViewModel.selectedGameId.collectAsState() + val userPubkey = accountViewModel.account.userProfile().pubkeyHex + + var showNewGameDialog by remember { mutableStateOf(false) } + var isRefreshing by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + // Subscribe to chess events when screen is visible + ChessSubscription(accountViewModel, chessViewModel) + + // Clear any stale game selection on mount + LaunchedEffect(Unit) { + chessViewModel.selectGame(null) + } + + // Auto-navigate when a game is selected (e.g., after accepting a challenge) + LaunchedEffect(selectedGameId, activeGames) { + selectedGameId?.let { gameId -> + if (activeGames.containsKey(gameId)) { + nav.nav(Route.ChessGame(gameId)) + chessViewModel.selectGame(null) + } + } + } + + // Start polling when screen is visible + DisposableEffect(Unit) { + chessViewModel.startPolling() + onDispose { + chessViewModel.stopPolling() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.route_chess)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { showNewGameDialog = true }, + ) { + Icon(Icons.Default.Add, contentDescription = "New Game") + } + }, + ) { paddingValues -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { + scope.launch { + isRefreshing = true + chessViewModel.forceRefresh() + delay(500) // Brief delay for visual feedback + isRefreshing = false + } + }, + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + // Error display + error?.let { errorMsg -> + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(errorMsg, color = MaterialTheme.colorScheme.onErrorContainer) + OutlinedButton(onClick = { chessViewModel.clearError() }) { + Text("Dismiss") + } + } + } + } + + // Main content + ChessLobbyContent( + challenges = challenges, + activeGames = activeGames, + userPubkey = userPubkey, + accountViewModel = accountViewModel, + onAcceptChallenge = { note -> + chessViewModel.acceptChallenge(note) + }, + onSelectGame = { gameId -> + nav.nav(Route.ChessGame(gameId)) + }, + ) + } + } + } + + // New game dialog + if (showNewGameDialog) { + NewChessGameDialog( + onDismiss = { showNewGameDialog = false }, + onCreateGame = { opponentPubkey, color -> + chessViewModel.createChallenge(opponentPubkey, color) + showNewGameDialog = false + }, + ) + } +} + +@Composable +fun ChessLobbyContent( + challenges: List, + activeGames: Map, + userPubkey: String, + accountViewModel: AccountViewModel, + onAcceptChallenge: (Note) -> Unit, + onSelectGame: (String) -> Unit, +) { + if (activeGames.isEmpty() && challenges.isEmpty()) { + // Empty state + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "No games or challenges", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Create a new game to get started", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + return + } + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Active games section + if (activeGames.isNotEmpty()) { + item { + Text( + "Active Games", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(activeGames.entries.toList(), key = { it.key }) { (gameId, state) -> + ActiveGameCard( + gameId = gameId, + opponentPubkey = state.opponentPubkey, + isYourTurn = state.isPlayerTurn(), + accountViewModel = accountViewModel, + onClick = { onSelectGame(gameId) }, + ) + } + } + + // Incoming challenges + val incomingChallenges = + challenges.filter { + val event = it.event as? LiveChessGameChallengeEvent + event?.opponentPubkey() == userPubkey + } + if (incomingChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Incoming Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(incomingChallenges, key = { it.idHex }) { note -> + ChallengeCard( + note = note, + isIncoming = true, + accountViewModel = accountViewModel, + onAccept = { onAcceptChallenge(note) }, + ) + } + } + + // 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 + val openChallenges = + challenges.filter { + val event = it.event as? LiveChessGameChallengeEvent + event?.opponentPubkey() == null && it.author?.pubkeyHex != userPubkey + } + if (openChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Open Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(openChallenges, key = { it.idHex }) { note -> + ChallengeCard( + note = note, + isIncoming = false, + accountViewModel = accountViewModel, + onAccept = { onAcceptChallenge(note) }, + ) + } + } + + // Bottom padding for FAB + item { + Spacer(Modifier.height(80.dp)) + } + } +} + +@Composable +private fun ActiveGameCard( + gameId: String, + opponentPubkey: String, + isYourTurn: Boolean, + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + val displayName = + remember(opponentPubkey) { + accountViewModel.checkGetOrCreateUser(opponentPubkey)?.toBestDisplayName() ?: opponentPubkey.take(8) + } + + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + border = + if (isYourTurn) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "vs $displayName", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "Game: ${gameId.take(12)}...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + if (isYourTurn) "Your turn" else "Waiting...", + style = MaterialTheme.typography.bodyMedium, + color = if (isYourTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, + ) + } + } +} + +@Composable +private fun ChallengeCard( + note: Note, + isIncoming: Boolean, + accountViewModel: AccountViewModel, + onAccept: () -> Unit, +) { + val event = note.event as? LiveChessGameChallengeEvent ?: return + val challengerPubkey = note.author?.pubkeyHex ?: return + val playerColor = event.playerColor() + + val displayName = + remember(challengerPubkey) { + accountViewModel.checkGetOrCreateUser(challengerPubkey)?.toBestDisplayName() ?: challengerPubkey.take(8) + } + + val borderColor = + if (isIncoming) { + Color(0xFFFF9800) // Orange for incoming + } else { + Color(0xFF4CAF50) // Green for open + } + + Card( + modifier = Modifier.fillMaxWidth(), + border = BorderStroke(2.dp, borderColor), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + if (isIncoming) "Challenge from $displayName" else "Open challenge by $displayName", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "Challenger plays ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Button(onClick = onAccept) { + Text("Accept") + } + } + } +} + +@Composable +private fun OutgoingChallengeCard( + note: Note, + accountViewModel: AccountViewModel, +) { + val event = note.event as? LiveChessGameChallengeEvent ?: return + val opponentPubkey = event.opponentPubkey() + val playerColor = event.playerColor() + + val displayName = + remember(opponentPubkey) { + opponentPubkey?.let { + accountViewModel.checkGetOrCreateUser(it)?.toBestDisplayName() ?: it.take(8) + } + } + + // Not clickable - waiting for opponent to accept + Card( + modifier = Modifier.fillMaxWidth(), + border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + if (displayName != null) { + "Challenge to $displayName" + } else { + "Open challenge (awaiting opponent)" + }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + "Waiting...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} 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 new file mode 100644 index 000000000..8dfe9fae9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessSubscription.kt @@ -0,0 +1,270 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess + +import androidx.compose.runtime.Composable +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.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. + */ +@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 state = + remember(accountViewModel.account, isGlobal, activeGameIds) { + 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, + activeGameIds = activeGameIds, + ) + } + + DisposableEffect(state) { + accountViewModel.dataSources().chess.subscribe(state) + chessViewModel.refreshChallenges() + onDispose { + accountViewModel.dataSources().chess.unsubscribe(state) + } + } +} + +/** + * Query state for chess subscription + */ +data class ChessQueryState( + val userPubkey: String, + val inboxRelays: Set, + val globalRelays: Set, + val isGlobal: Boolean, + val activeGameIds: Set = emptySet(), +) + +/** + * Filter assembler for chess events + */ +class ChessFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + ChessFeedFilterSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} + +/** + * Sub-assembler that creates the actual relay filters + */ +class ChessFeedFilterSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: ChessQueryState, + since: SincePerRelayMap?, + ): List = filterChessEvents(key, since) + + override fun id(key: ChessQueryState): String = "chess-${key.userPubkey}-${key.isGlobal}-${key.activeGameIds.hashCode()}" +} + +/** + * Create relay filters for chess events. + * + * Creates filters based on the Global/Follows setting: + * - Global: Fetch all chess events from global relays + * - Follows: Fetch from followed users' outbox relays + * + * 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 + */ +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, + ) + + // 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), + ), + ) + } + + // ======================================== + // 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 a1937e3a7..033d09bd5 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 @@ -22,12 +22,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults +import com.vitorpamplona.amethyst.commons.chess.ChessPollingDelegate import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.quartz.nip64Chess.ChessEngine import com.vitorpamplona.quartz.nip64Chess.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 @@ -35,6 +39,7 @@ import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -47,6 +52,18 @@ import java.util.UUID class ChessViewModel( private val account: Account, ) : ViewModel() { + companion object { + // Challenge expiry: 24 hours + const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L + + // Retry configuration + const val MAX_RETRIES = 3 + const val RETRY_DELAY_MS = 1000L + + // Cleanup interval: check every 5 minutes + const val CLEANUP_INTERVAL_MS = 5 * 60 * 1000L + } + // Active games being played private val _activeGames = MutableStateFlow>(emptyMap()) val activeGames: StateFlow> = _activeGames.asStateFlow() @@ -67,9 +84,78 @@ class ChessViewModel( private val _error = MutableStateFlow(null) val error: StateFlow = _error.asStateFlow() + // Pending retry operations + private val _pendingRetries = MutableStateFlow>(emptyMap()) + val pendingRetries: StateFlow> = _pendingRetries.asStateFlow() + + // Polling delegate for periodic refresh + private val pollingDelegate = + ChessPollingDelegate( + config = ChessPollingDefaults.android, + scope = viewModelScope, + onRefreshGames = { gameIds -> refreshGamesFromCache(gameIds) }, + onRefreshChallenges = { refreshChallenges() }, + onCleanup = { + cleanupExpiredChallenges() + checkAbandonedGames() + }, + ) + init { - refreshChallenges() subscribeToChessEvents() + // Start polling - will do initial fetch + startPolling() + } + + /** + * Start polling for chess events + */ + fun startPolling() { + pollingDelegate.start() + } + + /** + * Stop polling (call when screen is not visible on Android) + */ + fun stopPolling() { + pollingDelegate.stop() + } + + /** + * Force immediate refresh + */ + fun forceRefresh() { + pollingDelegate.refreshNow() + } + + /** + * Remove expired challenges + */ + private fun cleanupExpiredChallenges() { + val now = TimeUtils.now() + val validChallenges = + _challenges.value.filter { note -> + val event = note.event as? LiveChessGameChallengeEvent + val createdAt = event?.createdAt ?: 0 + (now - createdAt) < CHALLENGE_EXPIRY_SECONDS + } + + if (validChallenges.size != _challenges.value.size) { + _challenges.value = validChallenges + updateBadgeCount() + } + } + + /** + * Check for abandoned games and notify + */ + private fun checkAbandonedGames() { + _activeGames.value.forEach { (gameId, state) -> + if (state.isAbandoned()) { + // Game is abandoned, could auto-claim or notify user + _error.value = "Game $gameId appears abandoned. You can claim victory." + } + } } /** @@ -105,8 +191,9 @@ class ChessViewModel( val san = event.san() ?: return val fen = event.fen() ?: return + val moveNumber = event.moveNumber() - gameState.applyOpponentMove(san, fen) + gameState.applyOpponentMove(san, fen, moveNumber) updateBadgeCount() } @@ -129,6 +216,7 @@ class ChessViewModel( // Remove from active games if present if (_activeGames.value.containsKey(gameId)) { _activeGames.value = _activeGames.value - gameId + pollingDelegate.removeGameId(gameId) updateBadgeCount() } } @@ -140,11 +228,26 @@ class ChessViewModel( note: Note, event: LiveChessGameChallengeEvent, ) { - // Add to challenges if directed at us or is open + val gameId = event.gameId() ?: return + + // Skip if this game is already active + if (_activeGames.value.containsKey(gameId)) return + + // Add to challenges if directed at us, from us, or is open val opponentPubkey = event.opponentPubkey() - if (opponentPubkey == null || opponentPubkey == account.signer.pubKey) { + val isFromUs = event.pubKey == account.signer.pubKey + val isDirectedAtUs = opponentPubkey == account.signer.pubKey + val isOpenChallenge = opponentPubkey == null + + if (isFromUs || isDirectedAtUs || isOpenChallenge) { val currentChallenges = _challenges.value.toMutableList() - if (currentChallenges.none { it.idHex == note.idHex }) { + // Deduplicate by both event ID and game ID + val isDuplicateEvent = currentChallenges.any { it.idHex == note.idHex } + val isDuplicateGame = + currentChallenges.any { + (it.event as? LiveChessGameChallengeEvent)?.gameId() == gameId + } + if (!isDuplicateEvent && !isDuplicateGame) { currentChallenges.add(note) _challenges.value = currentChallenges updateBadgeCount() @@ -153,7 +256,7 @@ class ChessViewModel( } /** - * Create a new chess challenge (open or directed) + * Create a new chess challenge (open or directed) with retry */ fun createChallenge( opponentPubkey: String? = null, @@ -161,24 +264,26 @@ class ChessViewModel( timeControl: String? = null, ) { val acc = account + val gameId = generateGameId() viewModelScope.launch(Dispatchers.IO) { - try { - val gameId = generateGameId() + val success = + retryWithBackoff("challenge-$gameId") { + val template = + LiveChessGameChallengeEvent.build( + gameId = gameId, + playerColor = playerColor, + opponentPubkey = opponentPubkey, + timeControl = timeControl, + ) - val template = - LiveChessGameChallengeEvent.build( - gameId = gameId, - playerColor = playerColor, - opponentPubkey = opponentPubkey, - timeControl = timeControl, - ) - - acc.signAndComputeBroadcast(template) + acc.signAndComputeBroadcast(template) + } + if (success) { _error.value = null - } catch (e: Exception) { - _error.value = "Failed to create challenge: ${e.message}" + } else { + _error.value = "Failed to create challenge after $MAX_RETRIES attempts" } } } @@ -217,6 +322,7 @@ class ChessViewModel( ) _activeGames.value = _activeGames.value + (gameId to gameState) + pollingDelegate.addGameId(gameId) _selectedGameId.value = gameId _error.value = null } catch (e: Exception) { @@ -270,6 +376,7 @@ class ChessViewModel( ) _activeGames.value = _activeGames.value + (gameId to gameState) + pollingDelegate.addGameId(gameId) _selectedGameId.value = gameId } } @@ -362,7 +469,7 @@ class ChessViewModel( } /** - * Offer/accept draw + * Offer a draw to opponent - sends a draw offer event that opponent can accept or decline */ fun offerDraw(gameId: String) { val acc = account @@ -370,22 +477,16 @@ class ChessViewModel( viewModelScope.launch(Dispatchers.IO) { try { - val endData = gameState.offerDraw() + val drawOffer = gameState.offerDraw() val template = - LiveChessGameEndEvent.build( - gameId = endData.gameId, - result = endData.result, - termination = endData.termination, - winnerPubkey = endData.winnerPubkey, - opponentPubkey = endData.opponentPubkey, - pgn = endData.pgn ?: "", + LiveChessDrawOfferEvent.build( + gameId = drawOffer.gameId, + opponentPubkey = drawOffer.opponentPubkey, + message = drawOffer.message ?: "", ) acc.signAndComputeBroadcast(template) - - // Remove from active games - _activeGames.value = _activeGames.value - gameId _error.value = null } catch (e: Exception) { _error.value = "Failed to offer draw: ${e.message}" @@ -408,10 +509,26 @@ class ChessViewModel( /** * Refresh challenges from local cache */ - private fun refreshChallenges() { + fun refreshChallenges() { viewModelScope.launch(Dispatchers.IO) { - // TODO: Subscribe to challenge events via relay filter - // For now, this is a placeholder + val userPubkey = account.signer.pubKey + val now = TimeUtils.now() + + // Query LocalCache for chess challenge events + val challengeNotes = + LocalCache.notes.filterIntoSet { _, note -> + val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false + + // Check if challenge is not expired + val createdAt = event.createdAt + if ((now - createdAt) >= CHALLENGE_EXPIRY_SECONDS) return@filterIntoSet false + + // Include challenges directed at us, or open challenges (not from us) + val opponentPubkey = event.opponentPubkey() + opponentPubkey == userPubkey || (opponentPubkey == null && event.pubKey != userPubkey) || event.pubKey == userPubkey + } + + _challenges.value = challengeNotes.toList() updateBadgeCount() } } @@ -441,6 +558,76 @@ class ChessViewModel( _error.value = null } + /** + * Claim victory for an abandoned game + */ + fun claimAbandonmentVictory(gameId: String) { + val gameState = _activeGames.value[gameId] ?: return + + viewModelScope.launch(Dispatchers.IO) { + val endData = gameState.claimAbandonmentVictory() ?: return@launch + + val success = + retryWithBackoff("abandon-$gameId") { + val template = + LiveChessGameEndEvent.build( + gameId = endData.gameId, + result = endData.result, + termination = endData.termination, + winnerPubkey = endData.winnerPubkey, + opponentPubkey = endData.opponentPubkey, + pgn = endData.pgn ?: "", + ) + + account.signAndComputeBroadcast(template) + } + + if (success) { + _activeGames.value = _activeGames.value - gameId + _error.value = null + } else { + _error.value = "Failed to claim victory" + } + } + } + + /** + * Force resync a game to opponent's position + */ + fun forceResync( + gameId: String, + fen: String, + ) { + val gameState = _activeGames.value[gameId] ?: return + gameState.forceResync(fen) + } + + /** + * Retry an operation with exponential backoff + */ + private suspend fun retryWithBackoff( + operationId: String, + operation: suspend () -> Unit, + ): Boolean { + var attempt = 0 + while (attempt < MAX_RETRIES) { + try { + _pendingRetries.value = + _pendingRetries.value + (operationId to RetryOperation(operationId, attempt + 1, MAX_RETRIES)) + operation() + _pendingRetries.value = _pendingRetries.value - operationId + return true + } catch (e: Exception) { + attempt++ + if (attempt < MAX_RETRIES) { + delay(RETRY_DELAY_MS * attempt) + } + } + } + _pendingRetries.value = _pendingRetries.value - operationId + return false + } + /** * Generate unique game ID */ @@ -449,4 +636,175 @@ class ChessViewModel( val random = UUID.randomUUID().toString().take(8) return "chess-$timestamp-$random" } + + /** + * Refresh game state from LocalCache for specific game IDs + * Called periodically by polling delegate + */ + private suspend fun refreshGamesFromCache(gameIds: Set) { + val userPubkey = account.signer.pubKey + + for (gameId in gameIds) { + // Find moves for this game + val moveNotes = + LocalCache.notes.filterIntoSet { _, 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 + moveNotes + .mapNotNull { it.event as? LiveChessMoveEvent } + .filter { it.pubKey != userPubkey } // Only opponent moves + .sortedBy { it.moveNumber() } + .forEach { moveEvent -> + val san = moveEvent.san() ?: return@forEach + val fen = moveEvent.fen() ?: return@forEach + val moveNumber = moveEvent.moveNumber() + gameState.applyOpponentMove(san, fen, moveNumber) + } + } + } + + updateBadgeCount() + } + + /** + * Load or rebuild game state from LocalCache for a specific gameId + * Used when navigating to a game screen + * + * @return GameLoadResult with either the game state or an error reason + */ + fun loadGameFromCache(gameId: String): LiveChessGameState? { + // Check if already loaded + _activeGames.value[gameId]?.let { return it } + + val userPubkey = account.signer.pubKey + + // Find the challenge event for this game + val challengeNotes = + LocalCache.notes.filterIntoSet { _, note -> + val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false + event.gameId() == gameId + } + val challengeNote = challengeNotes.firstOrNull() + + // Find accept event for this game + val acceptNotes = + LocalCache.notes.filterIntoSet { _, note -> + val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false + event.gameId() == gameId + } + val acceptNote = acceptNotes.firstOrNull() + + // Need challenge to understand the game + val challengeEvent = challengeNote?.event as? LiveChessGameChallengeEvent + if (challengeEvent == null) { + _error.value = "Challenge event not found for game $gameId" + return null + } + + val acceptEvent = acceptNote?.event as? LiveChessGameAcceptEvent + + // Determine if we're a participant + val challengerPubkey = challengeEvent.pubKey + val acceptorPubkey = acceptEvent?.pubKey + val challengerColor = challengeEvent.playerColor() ?: Color.WHITE + + val (playerColor, opponentPubkey) = + when { + challengerPubkey == userPubkey && acceptorPubkey != null -> { + // We created the challenge, someone accepted + challengerColor to acceptorPubkey + } + acceptorPubkey == userPubkey -> { + // We accepted someone's challenge + challengerColor.opposite() to challengerPubkey + } + challengerPubkey == userPubkey && acceptorPubkey == null -> { + // We created challenge but no one accepted yet + _error.value = "Waiting for opponent to accept challenge" + return null + } + else -> { + _error.value = "You are not a participant in this game" + return null + } + } + + // Build game state + val engine = ChessEngine() + engine.reset() + + // Find and apply all moves in order + val moveNotes = + LocalCache.notes.filterIntoSet { _, note -> + val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false + event.gameId() == gameId + } + + val sortedMoves = + moveNotes + .mapNotNull { it.event as? LiveChessMoveEvent } + .sortedBy { it.moveNumber() ?: Int.MAX_VALUE } + + // Track move numbers we've loaded + val loadedMoveNumbers = mutableSetOf() + + for (moveEvent in sortedMoves) { + val san = moveEvent.san() ?: continue + val moveNumber = moveEvent.moveNumber() + try { + engine.makeMove(san) + if (moveNumber != null) { + loadedMoveNumbers.add(moveNumber) + } + } catch (e: Exception) { + _error.value = "Error loading move $san: ${e.message}" + // Continue trying to load other moves + } + } + + val gameState = + LiveChessGameState( + gameId = gameId, + playerPubkey = userPubkey, + opponentPubkey = opponentPubkey, + playerColor = playerColor, + engine = engine, + ) + + // 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) + + // Update polling delegate with this game + pollingDelegate.addGameId(gameId) + + // Clear any error since we loaded successfully + _error.value = null + + return gameState + } + + /** + * Called when ViewModel is cleared + */ + override fun onCleared() { + super.onCleared() + pollingDelegate.stop() + } } + +/** + * Represents a pending retry operation for UI feedback + */ +data class RetryOperation( + val id: String, + val currentAttempt: Int, + val maxAttempts: Int, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/NewChessGameButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/NewChessGameButton.kt new file mode 100644 index 000000000..72af11e89 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/NewChessGameButton.kt @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +/** + * Simple floating action button for creating new chess game challenges. + * Just triggers onClick - the caller handles the dialog. + */ +@Composable +fun NewChessGameButton(onClick: () -> Unit) { + FloatingActionButton( + onClick = onClick, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "New Chess Game", + modifier = Size26Modifier, + tint = Color.White, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index a4ec83354..cb02b626c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -123,7 +123,7 @@ fun DiscoverScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val tabs by + val feedTabs by remember(accountViewModel) { mutableStateOf( listOf( @@ -181,7 +181,7 @@ fun DiscoverScreen( ) } - val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size } + val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { feedTabs.size } WatchAccountForDiscoveryScreen( discoveryFollowSetsFeedContentState = discoveryFollowSetsFeedContentState, @@ -204,13 +204,13 @@ fun DiscoverScreen( DiscoveryFilterAssemblerSubscription(accountViewModel.dataSources().discovery, accountViewModel) - DiscoverPages(pagerState, tabs, accountViewModel, nav) + DiscoverPages(pagerState, feedTabs, accountViewModel, nav) } @Composable private fun DiscoverPages( pagerState: PagerState, - tabs: ImmutableList, + feedTabs: ImmutableList, accountViewModel: AccountViewModel, nav: INav, ) { @@ -228,7 +228,7 @@ private fun DiscoverPages( ) { val coroutineScope = rememberCoroutineScope() - tabs.forEachIndexed { index, tab -> + feedTabs.forEachIndexed { index, tab -> Tab( selected = pagerState.currentPage == index, text = { Text(text = stringRes(tab.resource)) }, @@ -241,42 +241,49 @@ private fun DiscoverPages( bottomBar = { AppBottomBar(Route.Discover, accountViewModel) { route -> if (route == Route.Discover) { - tabs[pagerState.currentPage].feedState.sendToTop() + val currentPage = pagerState.currentPage + if (currentPage >= 0 && currentPage < feedTabs.size) { + feedTabs[currentPage].feedState.sendToTop() + } } else { nav.newStack(route) } } }, floatingButton = { - if (tabs[pagerState.currentPage].resource == R.string.discover_marketplace) { + val currentPage = pagerState.currentPage + if (currentPage >= 0 && currentPage < feedTabs.size && feedTabs[currentPage].resource == R.string.discover_marketplace) { NewProductButton(accountViewModel, nav) } }, accountViewModel = accountViewModel, ) { HorizontalPager(state = pagerState, contentPadding = it) { page -> - RefresheableBox(tabs[page].feedState, true) { - if (tabs[page].useGridLayout) { - SaveableGridFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState -> - RenderDiscoverFeed( - feedContentState = tabs[page].feedState, - routeForLastRead = tabs[page].routeForLastRead, - forceEventKind = tabs[page].forceEventKind, - listState = listState, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } else { - SaveableFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState -> - RenderDiscoverFeed( - feedContentState = tabs[page].feedState, - routeForLastRead = tabs[page].routeForLastRead, - forceEventKind = tabs[page].forceEventKind, - listState = listState, - accountViewModel = accountViewModel, - nav = nav, - ) + if (page >= 0 && page < feedTabs.size) { + val tab = feedTabs[page] + RefresheableBox(tab.feedState, true) { + if (tab.useGridLayout) { + SaveableGridFeedContentState(tab.feedState, scrollStateKey = tab.scrollStateKey) { listState -> + RenderDiscoverFeed( + feedContentState = tab.feedState, + routeForLastRead = tab.routeForLastRead, + forceEventKind = tab.forceEventKind, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } else { + SaveableFeedContentState(tab.feedState, scrollStateKey = tab.scrollStateKey) { listState -> + RenderDiscoverFeed( + feedContentState = tab.feedState, + routeForLastRead = tab.routeForLastRead, + forceEventKind = tab.forceEventKind, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } } diff --git a/amethyst/src/main/res/drawable/ic_chess.xml b/amethyst/src/main/res/drawable/ic_chess.xml new file mode 100644 index 000000000..5786fe6c1 --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_chess.xml @@ -0,0 +1,9 @@ + + + diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 67f2a44a8..61532ee54 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1191,6 +1191,7 @@ Notifications Global Shorts + Chess Security Filters New Post diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt index a5b385932..61811c2d1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt @@ -20,23 +20,25 @@ */ package com.vitorpamplona.amethyst.commons.chess +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.quartz.nip64Chess.ChessPiece import com.vitorpamplona.quartz.nip64Chess.ChessPosition import com.vitorpamplona.quartz.nip64Chess.PieceType import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor @@ -103,13 +105,12 @@ private fun ChessSquare( ), contentAlignment = Alignment.Center, ) { - // Display piece using Unicode chess symbols + // Display piece using CBurnett ImageVector icons piece?.let { - Text( - text = it.toUnicode(), - fontSize = (size.value * 0.6).sp, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurface, + Image( + imageVector = it.toImageVector(), + contentDescription = "${it.color} ${it.type}", + modifier = Modifier.fillMaxSize().padding(2.dp), ) } @@ -130,16 +131,16 @@ private fun ChessSquare( } /** - * Extension function to convert ChessPiece to Unicode chess symbol + * Extension function to convert ChessPiece to CBurnett ImageVector */ -private fun com.vitorpamplona.quartz.nip64Chess.ChessPiece.toUnicode(): String { +private fun ChessPiece.toImageVector(): ImageVector { val white = color == ChessColor.WHITE return when (type) { - PieceType.KING -> if (white) "♔" else "♚" - PieceType.QUEEN -> if (white) "♕" else "♛" - PieceType.ROOK -> if (white) "♖" else "♜" - PieceType.BISHOP -> if (white) "♗" else "♝" - PieceType.KNIGHT -> if (white) "♘" else "♞" - PieceType.PAWN -> if (white) "♙" else "♟" + PieceType.KING -> if (white) ChessPieceVectors.WhiteKing else ChessPieceVectors.BlackKing + PieceType.QUEEN -> if (white) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen + PieceType.ROOK -> if (white) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook + PieceType.BISHOP -> if (white) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop + PieceType.KNIGHT -> if (white) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight + PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt new file mode 100644 index 000000000..1568d897a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt @@ -0,0 +1,257 @@ +/** + * 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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Configuration for chess event polling + */ +data class ChessPollingConfig( + /** Interval for polling active games (ms) */ + val activeGamePollInterval: Long = 10_000L, + /** Interval for polling challenges (ms) */ + val challengePollInterval: Long = 30_000L, + /** Whether to continue polling in background */ + val pollInBackground: Boolean = false, + /** Challenge expiry time (seconds) */ + val challengeExpirySeconds: Long = 24 * 60 * 60L, + /** Cleanup interval (ms) */ + val cleanupInterval: Long = 5 * 60 * 1000L, +) + +/** + * Platform-specific defaults + */ +object ChessPollingDefaults { + /** Android: shorter intervals, no background polling */ + val android = + ChessPollingConfig( + activeGamePollInterval = 10_000L, + challengePollInterval = 30_000L, + pollInBackground = false, + ) + + /** Desktop: can poll in background */ + val desktop = + ChessPollingConfig( + activeGamePollInterval = 10_000L, + challengePollInterval = 30_000L, + pollInBackground = true, + ) +} + +/** + * Delegate for managing chess event polling + * + * Usage: + * ``` + * class ChessViewModel(...) : ViewModel() { + * private val pollingDelegate = ChessPollingDelegate( + * config = ChessPollingDefaults.android, + * scope = viewModelScope, + * onRefreshGames = { gameIds -> refreshGamesFromCache(gameIds) }, + * onRefreshChallenges = { refreshChallengesFromCache() }, + * ) + * + * fun startPolling() = pollingDelegate.start() + * fun stopPolling() = pollingDelegate.stop() + * } + * ``` + */ +class ChessPollingDelegate( + private val config: ChessPollingConfig, + private val scope: CoroutineScope, + private val onRefreshGames: suspend (Set) -> Unit, + private val onRefreshChallenges: suspend () -> Unit, + private val onCleanup: suspend () -> Unit = {}, +) { + private var gamePollingJob: Job? = null + private var challengePollingJob: Job? = null + private var cleanupJob: Job? = null + + private val _isPolling = MutableStateFlow(false) + val isPolling: StateFlow = _isPolling.asStateFlow() + + private val activeGameIdsFlow = MutableStateFlow>(emptySet()) + + /** + * Update the set of game IDs to poll for + */ + fun setActiveGameIds(gameIds: Set) { + activeGameIdsFlow.value = gameIds + } + + /** + * Add a game ID to poll for + */ + fun addGameId(gameId: String) { + activeGameIdsFlow.value = activeGameIdsFlow.value + gameId + } + + /** + * Remove a game ID from polling + */ + fun removeGameId(gameId: String) { + activeGameIdsFlow.value = activeGameIdsFlow.value - gameId + } + + /** + * Start polling for chess events + */ + fun start() { + if (_isPolling.value) return + _isPolling.value = true + + // Poll for active games + gamePollingJob = + scope.launch { + while (isActive) { + val gameIds = activeGameIdsFlow.value + if (gameIds.isNotEmpty()) { + onRefreshGames(gameIds) + } + delay(config.activeGamePollInterval) + } + } + + // Poll for challenges + challengePollingJob = + scope.launch { + // Initial fetch + onRefreshChallenges() + + while (isActive) { + delay(config.challengePollInterval) + onRefreshChallenges() + } + } + + // Cleanup job + cleanupJob = + scope.launch { + while (isActive) { + delay(config.cleanupInterval) + onCleanup() + } + } + } + + /** + * Stop polling + */ + fun stop() { + _isPolling.value = false + gamePollingJob?.cancel() + challengePollingJob?.cancel() + cleanupJob?.cancel() + gamePollingJob = null + challengePollingJob = null + cleanupJob = null + } + + /** + * Pause polling (e.g., when app goes to background on Android) + */ + fun pause() { + if (!config.pollInBackground) { + stop() + } + } + + /** + * Resume polling (e.g., when app comes to foreground on Android) + */ + fun resume() { + if (!_isPolling.value) { + start() + } + } + + /** + * Force an immediate refresh + */ + fun refreshNow() { + scope.launch { + onRefreshChallenges() + val gameIds = activeGameIdsFlow.value + if (gameIds.isNotEmpty()) { + onRefreshGames(gameIds) + } + } + } +} + +/** + * Interface for chess event sources that can be refreshed + */ +interface ChessEventSource { + /** + * Fetch/refresh challenges from the event source + */ + suspend fun fetchChallenges(): List + + /** + * Fetch/refresh game state for specific game IDs + */ + suspend fun fetchGameUpdates(gameIds: Set): Map +} + +/** + * Data class representing a chess challenge + */ +data class ChessChallengeData( + val id: String, + val gameId: String, + val challengerPubkey: String, + val opponentPubkey: String?, + val challengerColor: com.vitorpamplona.quartz.nip64Chess.Color, + val createdAt: Long, + val isExpired: Boolean = false, +) + +/** + * Data class representing a game update + */ +data class ChessGameUpdate( + val gameId: String, + val moves: List, + val isEnded: Boolean = false, + val endReason: String? = null, +) + +/** + * Data class representing a chess move + */ +data class ChessMoveData( + val san: String, + val fen: String, + val moveNumber: Int, + val playerPubkey: String, + val timestamp: Long, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPieceVectors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPieceVectors.kt new file mode 100644 index 000000000..52f119caf --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPieceVectors.kt @@ -0,0 +1,1018 @@ +/** + * 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.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +/** + * CBurnett chess piece vectors from Lichess + * License: GPLv2+ + * Original author: Colin M.L. Burnett (Wikimedia user Cburnett) + */ +object ChessPieceVectors { + val WhiteKing: ImageVector by lazy { + ImageVector + .Builder( + name = "WhiteKing", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Cross on top + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(22.5f, 11.63f) + verticalLineTo(6f) + moveTo(20f, 8f) + horizontalLineTo(25f) + } + // Head/crown + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(22.5f, 25f) + reflectiveCurveTo(27f, 17.5f, 25.5f, 14.5f) + curveTo(25.5f, 14.5f, 24.5f, 12f, 22.5f, 12f) + reflectiveCurveTo(19.5f, 14.5f, 19.5f, 14.5f) + curveTo(18f, 17.5f, 22.5f, 25f, 22.5f, 25f) + } + // Body + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11.5f, 37f) + curveTo(17f, 40.5f, 27f, 40.5f, 32.5f, 37f) + verticalLineTo(30f) + reflectiveCurveTo(41.5f, 25.5f, 38.5f, 19.5f) + curveTo(34.5f, 13f, 25f, 16f, 22.5f, 23.5f) + verticalLineTo(27f) + verticalLineTo(23.5f) + curveTo(19f, 16f, 9.5f, 13f, 5.5f, 19.5f) + curveTo(2.5f, 25.5f, 10.5f, 29.5f, 10.5f, 29.5f) + verticalLineTo(37f) + close() + } + // Horizontal lines + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11.5f, 30f) + curveTo(17f, 27f, 27f, 27f, 32.5f, 30f) + moveTo(11.5f, 33.5f) + curveTo(17f, 30.5f, 27f, 30.5f, 32.5f, 33.5f) + moveTo(11.5f, 37f) + curveTo(17f, 34f, 27f, 34f, 32.5f, 37f) + } + }.build() + } + + val BlackKing: ImageVector by lazy { + ImageVector + .Builder( + name = "BlackKing", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Cross on top + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(22.5f, 11.6f) + verticalLineTo(6f) + moveTo(20f, 8f) + horizontalLineTo(25f) + } + // Head/crown + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(22.5f, 25f) + reflectiveCurveTo(27f, 17.5f, 25.5f, 14.5f) + curveTo(25.5f, 14.5f, 24.5f, 12f, 22.5f, 12f) + reflectiveCurveTo(19.5f, 14.5f, 19.5f, 14.5f) + curveTo(18f, 17.5f, 22.5f, 25f, 22.5f, 25f) + } + // Body + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11.5f, 37f) + curveTo(17f, 40.5f, 27f, 40.5f, 32.5f, 37f) + verticalLineTo(30f) + reflectiveCurveTo(41.5f, 25.5f, 38.5f, 19.5f) + curveTo(34.5f, 13f, 25f, 16f, 22.5f, 23.5f) + verticalLineTo(27f) + verticalLineTo(23.5f) + curveTo(19f, 16f, 9.5f, 13f, 5.5f, 19.5f) + curveTo(2.5f, 25.5f, 10.5f, 29.5f, 10.5f, 29.5f) + verticalLineTo(37f) + close() + } + // Inner highlight lines + path( + fill = null, + stroke = SolidColor(Color(0xFFECECEC)), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(32f, 29.5f) + reflectiveCurveTo(40.5f, 25.5f, 38f, 19.8f) + curveTo(34.1f, 14f, 25f, 18f, 22.5f, 24.6f) + verticalLineTo(26.7f) + verticalLineTo(24.6f) + curveTo(20f, 18f, 9.9f, 14f, 7f, 19.9f) + curveTo(4.5f, 25.5f, 11.8f, 28.9f, 11.8f, 28.9f) + } + path( + fill = null, + stroke = SolidColor(Color(0xFFECECEC)), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11.5f, 30f) + curveTo(17f, 27f, 27f, 27f, 32.5f, 30f) + moveTo(11.5f, 33.5f) + curveTo(17f, 30.5f, 27f, 30.5f, 32.5f, 33.5f) + moveTo(11.5f, 37f) + curveTo(17f, 34f, 27f, 34f, 32.5f, 37f) + } + }.build() + } + + val WhiteQueen: ImageVector by lazy { + ImageVector + .Builder( + name = "WhiteQueen", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Crown circles + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + // Left circle + moveTo(8f, 12f) + arcTo(2f, 2f, 0f, true, true, 4f, 12f) + arcTo(2f, 2f, 0f, true, true, 8f, 12f) + close() + // Center-left circle + moveTo(16f, 8.5f) + arcTo(2f, 2f, 0f, true, true, 12f, 8.5f) + arcTo(2f, 2f, 0f, true, true, 16f, 8.5f) + close() + // Center circle + moveTo(24.5f, 7.5f) + arcTo(2f, 2f, 0f, true, true, 20.5f, 7.5f) + arcTo(2f, 2f, 0f, true, true, 24.5f, 7.5f) + close() + // Center-right circle + moveTo(33f, 9f) + arcTo(2f, 2f, 0f, true, true, 29f, 9f) + arcTo(2f, 2f, 0f, true, true, 33f, 9f) + close() + // Right circle + moveTo(41f, 12f) + arcTo(2f, 2f, 0f, true, true, 37f, 12f) + arcTo(2f, 2f, 0f, true, true, 41f, 12f) + close() + } + // Crown body + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 26f) + curveTo(17.5f, 24.5f, 30f, 24.5f, 36f, 26f) + lineTo(38f, 14f) + lineTo(31f, 25f) + verticalLineTo(11f) + lineTo(25.5f, 24.5f) + lineTo(22.5f, 9.5f) + lineTo(19.5f, 24.5f) + lineTo(14f, 11f) + verticalLineTo(25f) + lineTo(7f, 14f) + lineTo(9f, 26f) + close() + } + // Lower body + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 26f) + curveTo(9f, 28f, 10.5f, 28f, 11.5f, 30f) + curveTo(12.5f, 31.5f, 12.5f, 31f, 12f, 33.5f) + curveTo(10.5f, 34.5f, 10.5f, 36f, 10.5f, 36f) + curveTo(9f, 37.5f, 11f, 38.5f, 11f, 38.5f) + curveTo(17.5f, 39.5f, 27.5f, 39.5f, 34f, 38.5f) + curveTo(34f, 38.5f, 35.5f, 37.5f, 34f, 36f) + curveTo(34f, 36f, 34.5f, 34.5f, 33f, 33.5f) + curveTo(32.5f, 31f, 32.5f, 31.5f, 33.5f, 30f) + curveTo(34.5f, 28f, 36f, 28f, 36f, 26f) + curveTo(27.5f, 24.5f, 17.5f, 24.5f, 9f, 26f) + close() + } + // Inner lines + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11.5f, 30f) + curveTo(15f, 29f, 30f, 29f, 33.5f, 30f) + moveTo(12f, 33.5f) + curveTo(18f, 32.5f, 27f, 32.5f, 33f, 33.5f) + } + }.build() + } + + val BlackQueen: ImageVector by lazy { + ImageVector + .Builder( + name = "BlackQueen", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Crown circles (no stroke for fill) + path( + fill = SolidColor(Color.Black), + stroke = null, + ) { + // Circles at the top + moveTo(6f, 12f) + arcTo(2.75f, 2.75f, 0f, true, true, 6f, 12.01f) + close() + moveTo(14f, 9f) + arcTo(2.75f, 2.75f, 0f, true, true, 14f, 9.01f) + close() + moveTo(22.5f, 8f) + arcTo(2.75f, 2.75f, 0f, true, true, 22.5f, 8.01f) + close() + moveTo(31f, 9f) + arcTo(2.75f, 2.75f, 0f, true, true, 31f, 9.01f) + close() + moveTo(39f, 12f) + arcTo(2.75f, 2.75f, 0f, true, true, 39f, 12.01f) + close() + } + // Crown body + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 26f) + curveTo(17.5f, 24.5f, 30f, 24.5f, 36f, 26f) + lineTo(38.5f, 13.5f) + lineTo(31f, 25f) + lineTo(30.7f, 10.9f) + lineTo(25.5f, 24.5f) + lineTo(22.5f, 10f) + lineTo(19.5f, 24.5f) + lineTo(14.3f, 10.9f) + lineTo(14f, 25f) + lineTo(6.5f, 13.5f) + lineTo(9f, 26f) + close() + } + // Lower body + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 26f) + curveTo(9f, 28f, 10.5f, 28f, 11.5f, 30f) + curveTo(12.5f, 31.5f, 12.5f, 31f, 12f, 33.5f) + curveTo(10.5f, 34.5f, 10.5f, 36f, 10.5f, 36f) + curveTo(9f, 37.5f, 11f, 38.5f, 11f, 38.5f) + curveTo(17.5f, 39.5f, 27.5f, 39.5f, 34f, 38.5f) + curveTo(34f, 38.5f, 35.5f, 37.5f, 34f, 36f) + curveTo(34f, 36f, 34.5f, 34.5f, 33f, 33.5f) + curveTo(32.5f, 31f, 32.5f, 31.5f, 33.5f, 30f) + curveTo(34.5f, 28f, 36f, 28f, 36f, 26f) + curveTo(27.5f, 24.5f, 17.5f, 24.5f, 9f, 26f) + close() + } + // Bottom line + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11f, 38.5f) + arcTo(35f, 35f, 0f, false, false, 34f, 38.5f) + } + // Inner highlight lines + path( + fill = null, + stroke = SolidColor(Color(0xFFECECEC)), + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11f, 29f) + arcTo(35f, 35f, 0f, false, true, 34f, 29f) + moveTo(12.5f, 31.5f) + horizontalLineTo(32.5f) + moveTo(11.5f, 34.5f) + arcTo(35f, 35f, 0f, false, false, 33.5f, 34.5f) + moveTo(10.5f, 37.5f) + arcTo(35f, 35f, 0f, false, false, 34.5f, 37.5f) + } + }.build() + } + + val WhiteRook: ImageVector by lazy { + ImageVector + .Builder( + name = "WhiteRook", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Base + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 39f) + horizontalLineTo(36f) + verticalLineTo(36f) + horizontalLineTo(9f) + verticalLineTo(39f) + close() + } + // Lower platform + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(12f, 36f) + verticalLineTo(32f) + horizontalLineTo(33f) + verticalLineTo(36f) + horizontalLineTo(12f) + close() + } + // Top battlements + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11f, 14f) + verticalLineTo(9f) + horizontalLineTo(15f) + verticalLineTo(11f) + horizontalLineTo(20f) + verticalLineTo(9f) + horizontalLineTo(25f) + verticalLineTo(11f) + horizontalLineTo(30f) + verticalLineTo(9f) + horizontalLineTo(34f) + verticalLineTo(14f) + } + // Top slope + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(34f, 14f) + lineTo(31f, 17f) + horizontalLineTo(14f) + lineTo(11f, 14f) + } + // Body + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(31f, 17f) + verticalLineTo(29.5f) + horizontalLineTo(14f) + verticalLineTo(17f) + } + // Bottom slope + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(31f, 29.5f) + lineTo(32.5f, 32f) + horizontalLineTo(12.5f) + lineTo(14f, 29.5f) + } + // Top line + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(11f, 14f) + horizontalLineTo(34f) + } + }.build() + } + + val BlackRook: ImageVector by lazy { + ImageVector + .Builder( + name = "BlackRook", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Base + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 39f) + horizontalLineTo(36f) + verticalLineTo(36f) + horizontalLineTo(9f) + verticalLineTo(39f) + close() + } + // Lower slope + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(12.5f, 32f) + lineTo(14f, 29.5f) + horizontalLineTo(31f) + lineTo(32.5f, 32f) + horizontalLineTo(12.5f) + close() + } + // Lower platform + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(12f, 36f) + verticalLineTo(32f) + horizontalLineTo(33f) + verticalLineTo(36f) + horizontalLineTo(12f) + close() + } + // Body + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(14f, 29.5f) + verticalLineTo(16.5f) + horizontalLineTo(31f) + verticalLineTo(29.5f) + horizontalLineTo(14f) + close() + } + // Top slope + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(14f, 16.5f) + lineTo(11f, 14f) + horizontalLineTo(34f) + lineTo(31f, 16.5f) + horizontalLineTo(14f) + close() + } + // Top battlements + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11f, 14f) + verticalLineTo(9f) + horizontalLineTo(15f) + verticalLineTo(11f) + horizontalLineTo(20f) + verticalLineTo(9f) + horizontalLineTo(25f) + verticalLineTo(11f) + horizontalLineTo(30f) + verticalLineTo(9f) + horizontalLineTo(34f) + verticalLineTo(14f) + horizontalLineTo(11f) + close() + } + // Inner highlight lines + path( + fill = null, + stroke = SolidColor(Color(0xFFECECEC)), + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(12f, 35.5f) + horizontalLineTo(33f) + moveTo(13f, 31.5f) + horizontalLineTo(32f) + moveTo(14f, 29.5f) + horizontalLineTo(31f) + moveTo(14f, 16.5f) + horizontalLineTo(31f) + moveTo(11f, 14f) + horizontalLineTo(34f) + } + }.build() + } + + val WhiteBishop: ImageVector by lazy { + ImageVector + .Builder( + name = "WhiteBishop", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Base + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 36f) + curveTo(12.39f, 35.03f, 19.11f, 36.43f, 22.5f, 34f) + curveTo(25.89f, 36.43f, 32.61f, 35.03f, 36f, 36f) + curveTo(36f, 36f, 37.65f, 36.54f, 39f, 38f) + curveTo(38.32f, 38.97f, 37.35f, 38.99f, 36f, 38.5f) + curveTo(32.61f, 37.53f, 25.89f, 38.96f, 22.5f, 37.5f) + curveTo(19.11f, 38.96f, 12.39f, 37.53f, 9f, 38.5f) + curveTo(7.65f, 38.99f, 6.68f, 38.97f, 6f, 38f) + curveTo(7.35f, 36.06f, 9f, 36f, 9f, 36f) + close() + } + // Body + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(15f, 32f) + curveTo(17.5f, 34.5f, 27.5f, 34.5f, 30f, 32f) + curveTo(30.5f, 30.5f, 30f, 30f, 30f, 30f) + curveTo(30f, 27.5f, 27.5f, 26f, 27.5f, 26f) + curveTo(33f, 24.5f, 33.5f, 14.5f, 22.5f, 10.5f) + curveTo(11.5f, 14.5f, 12f, 24.5f, 17.5f, 26f) + curveTo(17.5f, 26f, 15f, 27.5f, 15f, 30f) + curveTo(15f, 30f, 14.5f, 30.5f, 15f, 32f) + close() + } + // Head circle + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(25f, 8f) + arcTo(2.5f, 2.5f, 0f, true, true, 20f, 8f) + arcTo(2.5f, 2.5f, 0f, true, true, 25f, 8f) + close() + } + // Inner lines + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(17.5f, 26f) + horizontalLineTo(27.5f) + moveTo(15f, 30f) + horizontalLineTo(30f) + moveTo(22.5f, 15.5f) + verticalLineTo(20.5f) + moveTo(20f, 18f) + horizontalLineTo(25f) + } + }.build() + } + + val BlackBishop: ImageVector by lazy { + ImageVector + .Builder( + name = "BlackBishop", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Base + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 36f) + curveTo(12.4f, 35f, 19.1f, 36.4f, 22.5f, 34f) + curveTo(25.9f, 36.4f, 32.6f, 35f, 36f, 36f) + curveTo(36f, 36f, 37.6f, 36.5f, 39f, 38f) + curveTo(38.3f, 39f, 37.4f, 39f, 36f, 38.5f) + curveTo(32.6f, 37.5f, 25.9f, 39f, 22.5f, 37.5f) + curveTo(19.1f, 39f, 12.4f, 37.5f, 9f, 38.5f) + curveTo(7.6f, 39f, 6.7f, 39f, 6f, 38f) + curveTo(7.4f, 36f, 9f, 36f, 9f, 36f) + close() + } + // Body + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(15f, 32f) + curveTo(17.5f, 34.5f, 27.5f, 34.5f, 30f, 32f) + curveTo(30.5f, 30.5f, 30f, 30f, 30f, 30f) + curveTo(30f, 27.5f, 27.5f, 26f, 27.5f, 26f) + curveTo(33f, 24.5f, 33.5f, 14.5f, 22.5f, 10.5f) + curveTo(11.5f, 14.5f, 12f, 24.5f, 17.5f, 26f) + curveTo(17.5f, 26f, 15f, 27.5f, 15f, 30f) + curveTo(15f, 30f, 14.5f, 30.5f, 15f, 32f) + close() + } + // Head circle + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(25f, 8f) + arcTo(2.5f, 2.5f, 0f, true, true, 20f, 8f) + arcTo(2.5f, 2.5f, 0f, true, true, 25f, 8f) + close() + } + // Inner highlight lines + path( + fill = null, + stroke = SolidColor(Color(0xFFECECEC)), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(17.5f, 26f) + horizontalLineTo(27.5f) + moveTo(15f, 30f) + horizontalLineTo(30f) + moveTo(22.5f, 15.5f) + verticalLineTo(20.5f) + moveTo(20f, 18f) + horizontalLineTo(25f) + } + }.build() + } + + val WhiteKnight: ImageVector by lazy { + ImageVector + .Builder( + name = "WhiteKnight", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Body + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(22f, 10f) + curveTo(32.5f, 11f, 38.5f, 18f, 38f, 39f) + horizontalLineTo(15f) + curveTo(15f, 30f, 25f, 32.5f, 23f, 18f) + } + // Head/mane + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(24f, 18f) + curveTo(24.38f, 20.91f, 18.45f, 25.37f, 16f, 27f) + curveTo(13f, 29f, 13.18f, 31.34f, 11f, 31f) + curveTo(9.958f, 30.06f, 12.41f, 27.96f, 11f, 28f) + curveTo(10f, 28f, 11.19f, 29.23f, 10f, 30f) + curveTo(9f, 30f, 5.997f, 31f, 6f, 26f) + curveTo(6f, 24f, 12f, 14f, 12f, 14f) + reflectiveCurveTo(13.89f, 12.1f, 14f, 10.5f) + curveTo(13.27f, 9.506f, 13.5f, 8.5f, 13.5f, 7.5f) + curveTo(14.5f, 6.5f, 16.5f, 10f, 16.5f, 10f) + horizontalLineTo(18.5f) + reflectiveCurveTo(19.28f, 8.008f, 21f, 7f) + curveTo(22f, 7f, 22f, 10f, 22f, 10f) + } + // Eye and nostril + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + // Eye + moveTo(9.5f, 25.5f) + arcTo(0.5f, 0.5f, 0f, true, true, 8.5f, 25.5f) + arcTo(0.5f, 0.5f, 0f, true, true, 9.5f, 25.5f) + close() + // Nostril + moveTo(14.933f, 15.75f) + arcTo(0.5f, 1.5f, 30f, true, true, 14.067f, 15.25f) + arcTo(0.5f, 1.5f, 30f, true, true, 14.933f, 15.75f) + close() + } + }.build() + } + + val BlackKnight: ImageVector by lazy { + ImageVector + .Builder( + name = "BlackKnight", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Body + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(22f, 10f) + curveTo(32.5f, 11f, 38.5f, 18f, 38f, 39f) + horizontalLineTo(15f) + curveTo(15f, 30f, 25f, 32.5f, 23f, 18f) + } + // Head/mane + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(24f, 18f) + curveTo(24.38f, 20.91f, 18.45f, 25.37f, 16f, 27f) + curveTo(13f, 29f, 13.18f, 31.34f, 11f, 31f) + curveTo(9.96f, 30.06f, 12.41f, 27.96f, 11f, 28f) + curveTo(10f, 28f, 11.19f, 29.23f, 10f, 30f) + curveTo(9f, 30f, 6f, 31f, 6f, 26f) + curveTo(6f, 24f, 12f, 14f, 12f, 14f) + reflectiveCurveTo(13.89f, 12.1f, 14f, 10.5f) + curveTo(13.27f, 9.5f, 13.5f, 8.5f, 13.5f, 7.5f) + curveTo(14.5f, 6.5f, 16.5f, 10f, 16.5f, 10f) + horizontalLineTo(18.5f) + reflectiveCurveTo(19.28f, 8f, 21f, 7f) + curveTo(22f, 7f, 22f, 10f, 22f, 10f) + } + // Eye and nostril (highlights) + path( + fill = SolidColor(Color(0xFFECECEC)), + stroke = SolidColor(Color(0xFFECECEC)), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + // Eye + moveTo(9.5f, 25.5f) + arcTo(0.5f, 0.5f, 0f, true, true, 8.5f, 25.5f) + arcTo(0.5f, 0.5f, 0f, true, true, 9.5f, 25.5f) + close() + // Nostril + moveTo(14.93f, 15.75f) + arcTo(0.5f, 1.5f, 30f, true, true, 14.07f, 15.25f) + arcTo(0.5f, 1.5f, 30f, true, true, 14.93f, 15.75f) + close() + } + // Highlight on body edge + path( + fill = SolidColor(Color(0xFFECECEC)), + stroke = null, + ) { + moveTo(24.55f, 10.4f) + lineTo(24.1f, 11.85f) + lineTo(24.6f, 12f) + curveTo(27.75f, 13f, 30.25f, 14.49f, 32.5f, 18.75f) + reflectiveCurveTo(35.75f, 29.06f, 35.25f, 39f) + lineTo(35.2f, 39.5f) + horizontalLineTo(37.45f) + lineTo(37.5f, 39f) + curveTo(38f, 28.94f, 36.62f, 22.15f, 34.25f, 17.66f) + curveTo(31.88f, 13.17f, 28.46f, 11.02f, 25.06f, 10.5f) + lineTo(24.55f, 10.4f) + close() + } + }.build() + } + + val WhitePawn: ImageVector by lazy { + ImageVector + .Builder( + name = "WhitePawn", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(22.5f, 9f) + curveTo(20.29f, 9f, 18.5f, 10.79f, 18.5f, 13f) + curveTo(18.5f, 13.89f, 18.79f, 14.71f, 19.28f, 15.38f) + curveTo(17.33f, 16.5f, 16f, 18.59f, 16f, 21f) + curveTo(16f, 23.03f, 16.94f, 24.84f, 18.41f, 26.03f) + curveTo(15.41f, 27.09f, 11f, 31.58f, 11f, 39.5f) + horizontalLineTo(34f) + curveTo(34f, 31.58f, 29.59f, 27.09f, 26.59f, 26.03f) + curveTo(28.06f, 24.84f, 29f, 23.03f, 29f, 21f) + curveTo(29f, 18.59f, 27.67f, 16.5f, 25.72f, 15.38f) + curveTo(26.21f, 14.71f, 26.5f, 13.89f, 26.5f, 13f) + curveTo(26.5f, 10.79f, 24.71f, 9f, 22.5f, 9f) + close() + } + }.build() + } + + val BlackPawn: ImageVector by lazy { + ImageVector + .Builder( + name = "BlackPawn", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(22.5f, 9f) + arcTo(4f, 4f, 0f, false, false, 19.28f, 15.38f) + arcTo(6.48f, 6.48f, 0f, false, false, 18.41f, 26.03f) + curveTo(15.41f, 27.09f, 11f, 31.58f, 11f, 39.5f) + horizontalLineTo(34f) + curveTo(34f, 31.58f, 29.59f, 27.09f, 26.59f, 26.03f) + arcTo(6.46f, 6.46f, 0f, false, false, 25.72f, 15.38f) + arcTo(4.01f, 4.01f, 0f, false, false, 22.5f, 9f) + close() + } + }.build() + } +} 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 20b3e5e37..9a183ee4d 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 @@ -20,16 +20,17 @@ */ package com.vitorpamplona.amethyst.commons.chess +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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +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.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -40,12 +41,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.quartz.nip64Chess.ChessEngine +import com.vitorpamplona.quartz.nip64Chess.ChessPiece import com.vitorpamplona.quartz.nip64Chess.PieceType import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor @@ -55,55 +57,76 @@ import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor * @param engine Chess engine for move validation and legal moves * @param modifier Modifier for the board * @param boardSize Total size of the board in dp + * @param flipped If true, renders from black's perspective (rank 1 at top) + * @param playerColor The color the player is playing as (only allows moving these pieces) * @param onMoveMade Callback when a valid move is made, receives (from, to, san) + * NOTE: This callback should make the actual move - this component only validates */ @Composable fun InteractiveChessBoard( engine: ChessEngine, modifier: Modifier = Modifier, boardSize: Dp = 400.dp, + flipped: Boolean = false, + playerColor: ChessColor? = null, // null = allow both (for local play) onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> }, ) { - val position = remember(engine) { engine.getPosition() } + // 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() } var selectedSquare by remember { mutableStateOf?>(null) } var legalMoves by remember { mutableStateOf>(emptyList()) } + // Can only interact if it's your turn (or playerColor is null for local play) + val canInteract = playerColor == null || sideToMove == playerColor + val squareSize = boardSize / 8 + // Rank iteration order based on perspective + 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)) { - // Render ranks 8 down to 1 (from white's perspective) - for (rank in 7 downTo 0) { + for (rank in rankRange) { Row { - for (file in 0..7) { + 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 + InteractiveChessSquare( piece = piece, isLight = isLightSquare, size = squareSize, isSelected = isSelected, isLegalMove = isLegalMove, - showCoordinate = rank == 0, + showCoordinate = showCoord, file = file, rank = rank, onClick = { + if (!canInteract) return@InteractiveChessSquare + if (selectedSquare != null) { - // Attempt to make move + // Attempt to make move - validate first, don't actually make it val from = fileRankToSquare(selectedSquare!!.first, selectedSquare!!.second) val to = square - val result = engine.makeMove(from, to) - if (result.success) { - onMoveMade(from, to, result.san ?: "$from$to") + 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 } else { - // If move failed, try selecting this square instead - if (piece != null && piece.color == engine.getSideToMove()) { + // 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 { @@ -112,8 +135,9 @@ fun InteractiveChessBoard( } } } else { - // Select piece - if (piece != null && piece.color == engine.getSideToMove()) { + // 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) } @@ -164,13 +188,12 @@ private fun InteractiveChessSquare( }, contentAlignment = Alignment.Center, ) { - // Display piece using Unicode chess symbols + // Display piece using CBurnett ImageVector icons piece?.let { - Text( - text = it.toUnicode(), - fontSize = (size.value * 0.6).sp, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurface, + Image( + imageVector = it.toImageVector(), + contentDescription = "${it.color} ${it.type}", + modifier = Modifier.fillMaxSize().padding(2.dp), ) } @@ -218,16 +241,16 @@ private fun fileRankToSquare( ): String = "${('a' + file)}${rank + 1}" /** - * Extension function to convert ChessPiece to Unicode chess symbol + * Extension function to convert ChessPiece to CBurnett ImageVector */ -private fun com.vitorpamplona.quartz.nip64Chess.ChessPiece.toUnicode(): String { +private fun ChessPiece.toImageVector(): ImageVector { val white = color == ChessColor.WHITE return when (type) { - PieceType.KING -> if (white) "♔" else "♚" - PieceType.QUEEN -> if (white) "♕" else "♛" - PieceType.ROOK -> if (white) "♖" else "♜" - PieceType.BISHOP -> if (white) "♗" else "♝" - PieceType.KNIGHT -> if (white) "♘" else "♞" - PieceType.PAWN -> if (white) "♙" else "♟" + PieceType.KING -> if (white) ChessPieceVectors.WhiteKing else ChessPieceVectors.BlackKing + PieceType.QUEEN -> if (white) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen + PieceType.ROOK -> if (white) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook + PieceType.BISHOP -> if (white) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop + PieceType.KNIGHT -> if (white) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight + PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn } } 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 75b21ba90..b717d1324 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt @@ -20,14 +20,21 @@ */ package com.vitorpamplona.amethyst.commons.chess +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +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.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme @@ -35,6 +42,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -43,9 +51,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight 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.Color +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState /** * Dialog for creating a new chess game challenge @@ -156,6 +166,81 @@ fun NewChessGameDialog( /** * Complete live chess game UI with board, controls, and game info * + * This version observes LiveChessGameState flows for automatic UI updates + * when polling refreshes the game state. + * + * @param modifier Modifier for the root layout + * @param gameState Live chess game state (observed for updates) + * @param opponentName Opponent's display name + * @param onMoveMade Callback when player makes a move (from, to, san) + * @param onResign Callback when player resigns + * @param onOfferDraw Callback when player offers draw + */ +@Composable +fun LiveChessGameScreen( + modifier: Modifier = Modifier, + gameState: LiveChessGameState, + opponentName: String, + onMoveMade: (from: String, to: String, san: String) -> Unit, + onResign: () -> Unit, + onOfferDraw: () -> Unit, +) { + // Observe state flows for automatic recomposition on updates + val currentPosition by gameState.currentPosition.collectAsState() + val moveHistory by gameState.moveHistory.collectAsState() + + BoxWithConstraints( + modifier = modifier.fillMaxSize(), + ) { + // Calculate board size based on available space + // Leave room for header (~100dp), history (~60dp), controls (~60dp), and padding + val availableWidth = maxWidth - 32.dp // Account for horizontal padding + val availableHeight = maxHeight - 250.dp // Account for other UI elements + val boardSize = min(availableWidth, availableHeight).coerceAtLeast(200.dp) + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Game info - use currentPosition.activeColor for turn display + GameInfoHeader( + gameId = gameState.gameId, + opponentName = opponentName, + playerColor = gameState.playerColor, + currentTurn = currentPosition.activeColor, + ) + + // Interactive chess board - flip when playing black + // Auto-sized to fit available space + InteractiveChessBoard( + engine = gameState.engine, + boardSize = boardSize, + flipped = gameState.playerColor == Color.BLACK, + onMoveMade = onMoveMade, + ) + + // Move history (scrollable) - observed from state flow + MoveHistoryDisplay( + moves = moveHistory, + ) + + // Game controls + GameControls( + onResign = onResign, + onOfferDraw = onOfferDraw, + ) + } + } +} + +/** + * Legacy version that takes engine directly (for backwards compatibility) + * + * @param modifier Modifier for the root layout * @param engine Chess engine instance * @param playerColor Color the player is playing as * @param gameId Unique game identifier @@ -166,6 +251,7 @@ fun NewChessGameDialog( */ @Composable fun LiveChessGameScreen( + modifier: Modifier = Modifier, engine: ChessEngine, playerColor: Color, gameId: String, @@ -174,39 +260,51 @@ fun LiveChessGameScreen( onResign: () -> Unit, onOfferDraw: () -> Unit, ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), + BoxWithConstraints( + modifier = modifier.fillMaxSize(), ) { - // Game info - GameInfoHeader( - gameId = gameId, - opponentName = opponentName, - playerColor = playerColor, - currentTurn = engine.getSideToMove(), - ) + // Calculate board size based on available space + // Leave room for header (~100dp), history (~60dp), controls (~60dp), and padding + val availableWidth = maxWidth - 32.dp // Account for horizontal padding + val availableHeight = maxHeight - 250.dp // Account for other UI elements + val boardSize = min(availableWidth, availableHeight).coerceAtLeast(200.dp) - // Interactive chess board - InteractiveChessBoard( - engine = engine, - boardSize = 400.dp, - onMoveMade = onMoveMade, - ) + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Game info + GameInfoHeader( + gameId = gameId, + opponentName = opponentName, + playerColor = playerColor, + currentTurn = engine.getSideToMove(), + ) - // Move history - MoveHistoryDisplay( - moves = engine.getMoveHistory(), - ) + // Interactive chess board - flip when playing black + // Auto-sized to fit available space + InteractiveChessBoard( + engine = engine, + boardSize = boardSize, + flipped = playerColor == Color.BLACK, + onMoveMade = onMoveMade, + ) - // Game controls - GameControls( - onResign = onResign, - onOfferDraw = onOfferDraw, - ) + // Move history (scrollable) + MoveHistoryDisplay( + moves = engine.getMoveHistory(), + ) + + // Game controls + GameControls( + onResign = onResign, + onOfferDraw = onOfferDraw, + ) + } } } @@ -263,25 +361,91 @@ private fun GameInfoHeader( } /** - * Display move history in SAN notation + * Display move history in SAN notation with move numbers + * Shows in a horizontally scrollable container */ @Composable private fun MoveHistoryDisplay(moves: List) { - if (moves.isNotEmpty()) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = "Moves:", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - ) + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Move History", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 4.dp), + ) - Text( - text = moves.joinToString(" "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Box( + modifier = + Modifier + .fillMaxWidth() + .height(48.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + RoundedCornerShape(8.dp), + ).padding(horizontal = 8.dp, vertical = 4.dp), + ) { + if (moves.isEmpty()) { + Text( + text = "No moves yet", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterStart), + ) + } else { + val scrollState = rememberScrollState() + + Row( + modifier = + Modifier + .horizontalScroll(scrollState) + .align(Alignment.CenterStart), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Group moves into pairs (white, black) + moves.chunked(2).forEachIndexed { index, movePair -> + // Move number + Text( + text = "${index + 1}.", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // White's move + Text( + text = movePair[0], + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .background( + MaterialTheme.colorScheme.surface, + RoundedCornerShape(4.dp), + ).padding(horizontal = 4.dp, vertical = 2.dp), + ) + + // Black's move (if exists) + if (movePair.size > 1) { + Text( + text = movePair[1], + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(4.dp), + ).padding(horizontal = 4.dp, vertical = 2.dp), + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + } + } + } } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/data/UserMetadataCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/data/UserMetadataCache.kt new file mode 100644 index 000000000..df93422ec --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/data/UserMetadataCache.kt @@ -0,0 +1,144 @@ +/** + * 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.data + +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Simple data class for cached user display info + */ +data class UserDisplayInfo( + val pubkey: String, + val displayName: String?, + val pictureUrl: String?, + val nip05: String?, +) + +/** + * Lightweight user metadata cache for UI display. + * Can be used by any feature (chess, chat, etc.) that needs user info. + * + * Thread-safe via StateFlow updates. + */ +class UserMetadataCache { + private val _metadata = MutableStateFlow>(emptyMap()) + val metadata: StateFlow> = _metadata.asStateFlow() + + private val _pubkeysNeeded = MutableStateFlow>(emptySet()) + val pubkeysNeeded: StateFlow> = _pubkeysNeeded.asStateFlow() + + /** + * Add or update metadata for a pubkey + */ + fun put( + pubkey: String, + userMetadata: UserMetadata, + ) { + _metadata.value = _metadata.value + (pubkey to userMetadata) + _pubkeysNeeded.value = _pubkeysNeeded.value - pubkey + } + + /** + * Get metadata for a pubkey (may be null if not cached) + */ + fun get(pubkey: String): UserMetadata? = _metadata.value[pubkey] + + /** + * Check if metadata is cached for a pubkey + */ + fun contains(pubkey: String): Boolean = _metadata.value.containsKey(pubkey) + + /** + * Request metadata for a pubkey (adds to needed set if not cached) + */ + fun request(pubkey: String) { + if (!contains(pubkey) && pubkey !in _pubkeysNeeded.value) { + _pubkeysNeeded.value = _pubkeysNeeded.value + pubkey + } + } + + /** + * Request metadata for multiple pubkeys + */ + fun requestAll(pubkeys: Collection) { + val newNeeded = pubkeys.filter { !contains(it) && it !in _pubkeysNeeded.value } + if (newNeeded.isNotEmpty()) { + _pubkeysNeeded.value = _pubkeysNeeded.value + newNeeded + } + } + + /** + * Get display name for a pubkey, falling back to truncated pubkey + */ + fun getDisplayName(pubkey: String): String { + val meta = get(pubkey) + return meta?.bestName() ?: formatPubkeyShort(pubkey) + } + + /** + * Get profile picture URL for a pubkey + */ + fun getPictureUrl(pubkey: String): String? = get(pubkey)?.profilePicture() + + /** + * Get display info for a pubkey (for UI binding) + */ + fun getDisplayInfo(pubkey: String): UserDisplayInfo { + val meta = get(pubkey) + return UserDisplayInfo( + pubkey = pubkey, + displayName = meta?.bestName(), + pictureUrl = meta?.profilePicture(), + nip05 = meta?.nip05, + ) + } + + /** + * Clear metadata that was requested but hasn't been fetched + * (call when pubkeys are no longer needed) + */ + fun clearNeeded(pubkeys: Collection) { + _pubkeysNeeded.value = _pubkeysNeeded.value - pubkeys.toSet() + } + + /** + * Clear all cached metadata + */ + fun clear() { + _metadata.value = emptyMap() + _pubkeysNeeded.value = emptySet() + } + + companion object { + /** + * Format pubkey for display (npub-style truncation) + */ + fun formatPubkeyShort(pubkey: String): String = + if (pubkey.length > 12) { + "${pubkey.take(8)}...${pubkey.takeLast(4)}" + } else { + pubkey + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 358bdd044..9109f8119 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Article import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Extension import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Person @@ -82,6 +83,7 @@ import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.chess.ChessScreen import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen @@ -120,6 +122,8 @@ sealed class DesktopScreen { object Notifications : DesktopScreen() + object Chess : DesktopScreen() + object MyProfile : DesktopScreen() data class UserProfile( @@ -415,6 +419,13 @@ fun MainContent( onClick = { onScreenChange(DesktopScreen.Notifications) }, ) + NavigationRailItem( + icon = { Icon(Icons.Default.Extension, contentDescription = "Chess") }, + label = { Text("Chess") }, + selected = currentScreen == DesktopScreen.Chess, + onClick = { onScreenChange(DesktopScreen.Chess) }, + ) + NavigationRailItem( icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, label = { Text("Profile") }, @@ -514,6 +525,14 @@ fun MainContent( NotificationsScreen(relayManager, account, subscriptionsCoordinator) } + DesktopScreen.Chess -> { + ChessScreen( + relayManager = relayManager, + account = account, + onBack = { onScreenChange(DesktopScreen.Feed) }, + ) + } + DesktopScreen.MyProfile -> { UserProfileScreen( pubKeyHex = account.pubKeyHex, 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 new file mode 100644 index 000000000..854f7e999 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -0,0 +1,903 @@ +/** + * 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 androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.chess.InteractiveChessBoard +import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog +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.createMetadataListSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent + +/** + * Desktop chess screen with challenge list and game view + */ +@Composable +fun ChessScreen( + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn, + onBack: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val viewModel = + remember(account.pubKeyHex) { + DesktopChessViewModel(account, relayManager, scope) + } + val relayStatuses by relayManager.relayStatuses.collectAsState() + val refreshKey by viewModel.refreshKey.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + // Subscribe to chess events from relays (re-subscribes when refreshKey changes) + rememberSubscription(relayStatuses, account, refreshKey, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty()) { + createChessSubscription( + relays = configuredRelays, + userPubkey = account.pubKeyHex, + onEvent = { event, _, _, _ -> + viewModel.handleIncomingEvent(event) + }, + onEose = { _, _ -> + viewModel.onLoadComplete() + }, + ) + } else { + null + } + } + + // Subscribe to user metadata for pubkeys that need it + val pubkeysNeeded by viewModel.userMetadataCache.pubkeysNeeded.collectAsState() + rememberSubscription(relayStatuses, pubkeysNeeded, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) { + createMetadataListSubscription( + relays = configuredRelays, + pubKeys = pubkeysNeeded.toList(), + onEvent = { event, _, _, _ -> + viewModel.handleIncomingEvent(event) + }, + ) + } else { + null + } + } + + val activeGames by viewModel.activeGames.collectAsState() + val challenges by viewModel.challenges.collectAsState() + val completedGames by viewModel.completedGames.collectAsState() + // Observe metadata changes to trigger recomposition + val userMetadata by viewModel.userMetadataCache.metadata.collectAsState() + val selectedGameId by viewModel.selectedGameId.collectAsState() + val error by viewModel.error.collectAsState() + var showNewGameDialog by remember { mutableStateOf(false) } + + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (selectedGameId != null) { + IconButton(onClick = { viewModel.selectGame(null) }) { + Icon(Icons.Default.ArrowBack, "Back to list") + } + Spacer(Modifier.width(8.dp)) + } + Text( + if (selectedGameId != null) "Live Game" else "Chess", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + + if (selectedGameId == null) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Refresh button + IconButton( + onClick = { viewModel.refresh() }, + enabled = !isLoading, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } else { + Icon(Icons.Default.Refresh, "Refresh") + } + } + + // New Game button + if (!account.isReadOnly) { + Button(onClick = { showNewGameDialog = true }) { + Icon(Icons.Default.Add, "New Game") + Spacer(Modifier.width(8.dp)) + Text("New Game") + } + } + } + } + } + + // Error display + error?.let { errorMsg -> + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(errorMsg, color = MaterialTheme.colorScheme.onErrorContainer) + OutlinedButton(onClick = { viewModel.clearError() }) { + Text("Dismiss") + } + } + } + } + + // Main content + if (selectedGameId != null) { + val gameState = viewModel.getGameState(selectedGameId!!) + if (gameState != null) { + DesktopChessGameLayout( + gameState = gameState, + opponentName = viewModel.userMetadataCache.getDisplayName(gameState.opponentPubkey), + opponentPicture = viewModel.userMetadataCache.getPictureUrl(gameState.opponentPubkey), + onMoveMade = { from, to, _ -> + viewModel.publishMove(gameState.gameId, from, to) + }, + onResign = { viewModel.resign(gameState.gameId) }, + onOfferDraw = { viewModel.offerDraw(gameState.gameId) }, + onAcceptDraw = { viewModel.acceptDraw(gameState.gameId) }, + onDeclineDraw = { viewModel.declineDraw(gameState.gameId) }, + ) + } + } else { + ChessLobby( + challenges = challenges, + activeGames = activeGames, + completedGames = completedGames, + userPubkey = account.pubKeyHex, + isLoading = isLoading, + metadataCache = viewModel.userMetadataCache, + onAcceptChallenge = { viewModel.acceptChallenge(it) }, + onSelectGame = { viewModel.selectGame(it) }, + ) + } + } + + // New game dialog + if (showNewGameDialog) { + NewChessGameDialog( + onDismiss = { showNewGameDialog = false }, + onCreateGame = { opponentPubkey, color -> + viewModel.createChallenge(opponentPubkey, color) + showNewGameDialog = false + }, + ) + } +} + +/** + * Chess lobby showing challenges and active games + */ +@Composable +private fun ChessLobby( + challenges: List, + activeGames: Map, + completedGames: List, + isLoading: Boolean, + userPubkey: String, + metadataCache: UserMetadataCache, + onAcceptChallenge: (LiveChessGameChallengeEvent) -> Unit, + onSelectGame: (String) -> Unit, +) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Active games section + if (activeGames.isNotEmpty()) { + item { + Text( + "Active Games", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(activeGames.entries.toList(), key = { it.key }) { (gameId, state) -> + ActiveGameCard( + gameId = gameId, + opponentPubkey = state.opponentPubkey, + opponentName = metadataCache.getDisplayName(state.opponentPubkey), + opponentPicture = metadataCache.getPictureUrl(state.opponentPubkey), + isYourTurn = state.isPlayerTurn(), + onClick = { onSelectGame(gameId) }, + ) + } + } + + // Incoming challenges + val incomingChallenges = challenges.filter { it.opponentPubkey() == userPubkey } + if (incomingChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Incoming Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(incomingChallenges, key = { it.id }) { challenge -> + ChallengeCard( + challenge = challenge, + challengerName = metadataCache.getDisplayName(challenge.pubKey), + challengerPicture = metadataCache.getPictureUrl(challenge.pubKey), + isIncoming = true, + onAccept = { onAcceptChallenge(challenge) }, + ) + } + } + + // User's outgoing challenges + val outgoingChallenges = challenges.filter { it.pubKey == userPubkey } + 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.id }) { challenge -> + val opponentPubkey = challenge.opponentPubkey() + OutgoingChallengeCard( + challenge = challenge, + opponentName = opponentPubkey?.let { metadataCache.getDisplayName(it) }, + opponentPicture = opponentPubkey?.let { metadataCache.getPictureUrl(it) }, + ) + } + } + + // Open challenges from others + val openChallenges = challenges.filter { it.opponentPubkey() == null && it.pubKey != userPubkey } + if (openChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Open Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(openChallenges, key = { it.id }) { challenge -> + ChallengeCard( + challenge = challenge, + challengerName = metadataCache.getDisplayName(challenge.pubKey), + challengerPicture = metadataCache.getPictureUrl(challenge.pubKey), + isIncoming = false, + onAccept = { onAcceptChallenge(challenge) }, + ) + } + } + + // Completed games history + if (completedGames.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Recent Games", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(completedGames.take(10), key = { it.gameId }) { game -> + CompletedGameCard( + game = game, + userPubkey = userPubkey, + opponentName = metadataCache.getDisplayName(game.opponentPubkey), + opponentPicture = metadataCache.getPictureUrl(game.opponentPubkey), + ) + } + } + + // Empty state or loading + if (activeGames.isEmpty() && challenges.isEmpty()) { + item { + Column( + modifier = Modifier.fillMaxWidth().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (isLoading) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + "Loading games from relays...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + "No games or challenges", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Create a new game or refresh to load from relays", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun ActiveGameCard( + gameId: String, + opponentPubkey: String, + opponentName: String, + opponentPicture: String?, + isYourTurn: Boolean, + onClick: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + border = + if (isYourTurn) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UserAvatar( + userHex = opponentPubkey, + pictureUrl = opponentPicture, + size = 40.dp, + ) + Column { + Text( + "vs $opponentName", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "Game: ${gameId.take(12)}...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Text( + if (isYourTurn) "Your turn" else "Waiting...", + style = MaterialTheme.typography.bodyMedium, + color = if (isYourTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, + ) + } + } +} + +@Composable +private fun OutgoingChallengeCard( + challenge: LiveChessGameChallengeEvent, + opponentName: String?, + opponentPicture: String?, +) { + val opponentPubkey = challenge.opponentPubkey() + val playerColor = challenge.playerColor() + + Card( + modifier = Modifier.fillMaxWidth(), + border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (opponentPubkey != null) { + UserAvatar( + userHex = opponentPubkey, + pictureUrl = opponentPicture, + size = 40.dp, + ) + } + Column { + Text( + if (opponentName != null) { + "Challenge to $opponentName" + } else { + "Open challenge (awaiting opponent)" + }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Text( + "Waiting...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ChallengeCard( + challenge: LiveChessGameChallengeEvent, + challengerName: String, + challengerPicture: String?, + isIncoming: Boolean, + onAccept: () -> Unit, +) { + val borderColor = + if (isIncoming) { + Color(0xFFFF9800) // Orange for incoming + } else { + Color(0xFF4CAF50) // Green for open + } + + Card( + modifier = Modifier.fillMaxWidth(), + border = BorderStroke(2.dp, borderColor), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UserAvatar( + userHex = challenge.pubKey, + pictureUrl = challengerPicture, + size = 40.dp, + ) + Column { + Text( + if (isIncoming) "Challenge from $challengerName" else "Open challenge by $challengerName", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + val playerColor = challenge.playerColor() + Text( + "Challenger plays ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Button(onClick = onAccept) { + Text("Accept") + } + } + } +} + +@Composable +private fun CompletedGameCard( + game: CompletedGame, + userPubkey: String, + opponentName: String, + opponentPicture: String?, +) { + val resultColor = + when (game.didWin(userPubkey)) { + true -> Color(0xFF4CAF50) // Green for win + false -> Color(0xFFF44336) // Red for loss + null -> Color(0xFF9E9E9E) // Gray for draw + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UserAvatar( + userHex = game.opponentPubkey, + pictureUrl = opponentPicture, + size = 40.dp, + ) + Column { + Text( + "vs $opponentName", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "${game.moveCount} moves - ${game.termination}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Text( + game.resultText(userPubkey), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = resultColor, + ) + } + } +} + +/** + * Desktop-optimized chess game layout with board on left, info/controls on right + */ +@Composable +private fun DesktopChessGameLayout( + gameState: com.vitorpamplona.quartz.nip64Chess.LiveChessGameState, + opponentName: String, + opponentPicture: String?, + onMoveMade: (from: String, to: String, san: String) -> Unit, + onResign: () -> Unit, + onOfferDraw: () -> Unit, + onAcceptDraw: () -> Unit, + onDeclineDraw: () -> Unit, +) { + // Collect state flows to trigger recomposition on changes + val currentPosition by gameState.currentPosition.collectAsState() + val moveHistory by gameState.moveHistory.collectAsState() + val pendingDrawOffer by gameState.pendingDrawOffer.collectAsState() + + val engine = gameState.engine + val playerColor = gameState.playerColor + val gameId = gameState.gameId + val opponentPubkey = gameState.opponentPubkey + val hasOpponentDrawOffer = pendingDrawOffer == opponentPubkey + val hasOurDrawOffer = pendingDrawOffer == gameState.playerPubkey + + Row( + modifier = Modifier.fillMaxSize().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Left side: Chess board + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { + InteractiveChessBoard( + engine = engine, + boardSize = 520.dp, + flipped = playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK, + playerColor = playerColor, + onMoveMade = onMoveMade, + ) + } + + // Right side: Game info, moves, controls + Column( + modifier = + Modifier + .width(300.dp) + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Game info card + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Game Info", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UserAvatar( + userHex = opponentPubkey, + pictureUrl = opponentPicture, + size = 48.dp, + ) + Column { + Text( + "vs $opponentName", + style = MaterialTheme.typography.bodyLarge, + ) + Text( + "You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Use currentPosition to derive turn (triggers recomposition on move) + val currentTurn = currentPosition.activeColor + val isYourTurn = currentTurn == playerColor + + Text( + if (isYourTurn) "Your turn" else "Opponent's turn", + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, + color = + if (isYourTurn) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + // Move history card + if (moveHistory.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Move History", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + // Format moves as numbered pairs + val moveText = + moveHistory + .chunked(2) + .mapIndexed { index, pair -> + "${index + 1}. ${pair.joinToString(" ")}" + }.joinToString("\n") + + Text( + moveText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Draw offer notification (if opponent offered) + if (hasOpponentDrawOffer) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = Color(0xFFFF9800).copy(alpha = 0.15f), + ), + border = BorderStroke(2.dp, Color(0xFFFF9800)), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Draw Offered", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color(0xFFFF9800), + ) + Text( + "$opponentName has offered a draw", + style = MaterialTheme.typography.bodyMedium, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = onAcceptDraw, + modifier = Modifier.weight(1f), + ) { + Text("Accept") + } + OutlinedButton( + onClick = onDeclineDraw, + modifier = Modifier.weight(1f), + ) { + Text("Decline") + } + } + } + } + } + + // Our draw offer pending notification + if (hasOurDrawOffer) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + "Draw offer sent", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + "Waiting for $opponentName to respond...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Game controls card + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Actions", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = onOfferDraw, + modifier = Modifier.weight(1f), + enabled = !hasOurDrawOffer && !hasOpponentDrawOffer, + ) { + Text(if (hasOurDrawOffer) "Offer Sent" else "Offer Draw") + } + + OutlinedButton( + onClick = onResign, + modifier = Modifier.weight(1f), + ) { + Text("Resign") + } + } + } + } + + // Game ID (small footer) + Text( + "Game: ${gameId.take(16)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModel.kt new file mode 100644 index 000000000..1588e9ec9 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModel.kt @@ -0,0 +1,813 @@ +/** + * 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.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.ChessMoveEvent +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.nip64Chess.GameTermination +import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.UUID + +/** + * Desktop ViewModel for managing chess game state and event publishing. + * Adapts the Android ChessViewModel patterns for Desktop's relay architecture. + */ +class DesktopChessViewModel( + private val account: AccountState.LoggedIn, + private val relayManager: DesktopRelayConnectionManager, + private val scope: CoroutineScope, +) { + companion object { + const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L + const val MAX_RETRIES = 3 + const val RETRY_DELAY_MS = 1000L + } + + // Active games being played + private val _activeGames = MutableStateFlow>(emptyMap()) + val activeGames: StateFlow> = _activeGames.asStateFlow() + + // Pending challenges + private val _challenges = MutableStateFlow>(emptyList()) + val challenges: StateFlow> = _challenges.asStateFlow() + + // Badge count + private val _badgeCount = MutableStateFlow(0) + val badgeCount: StateFlow = _badgeCount.asStateFlow() + + // Currently selected game + private val _selectedGameId = MutableStateFlow(null) + val selectedGameId: StateFlow = _selectedGameId.asStateFlow() + + // Error state + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + // Loading state + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + // Refresh key - incrementing this triggers re-subscription + private val _refreshKey = MutableStateFlow(0) + val refreshKey: StateFlow = _refreshKey.asStateFlow() + + // Completed games history + private val _completedGames = MutableStateFlow>(emptyList()) + val completedGames: StateFlow> = _completedGames.asStateFlow() + + // Shared user metadata cache + val userMetadataCache = UserMetadataCache() + + // Pending moves waiting for game to be created (gameId -> list of (san, fen, moveNumber)) + private val pendingMoves = mutableMapOf>>() + + // Challenge events indexed by gameId for lookup during accept processing + private val challengesByGameId = mutableMapOf() + + // Pending accept events waiting for challenge to arrive (gameId -> accept event) + private val pendingAccepts = mutableMapOf() + + // Track event IDs we've already processed to avoid duplicates + private val processedEventIds = mutableSetOf() + + // Track games currently being created to prevent race conditions + private val gamesBeingCreated = mutableSetOf() + + /** + * 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) + + // Check by kind since events may not be cast to specific types + when (event.kind) { + MetadataEvent.KIND -> { + handleMetadataEvent(event) + return + } + LiveChessGameChallengeEvent.KIND -> { + if (event is LiveChessGameChallengeEvent) { + handleNewChallenge(event) + } else { + // Manually parse if not already the right type + val challenge = + LiveChessGameChallengeEvent( + event.id, + event.pubKey, + event.createdAt, + event.tags, + event.content, + event.sig, + ) + handleNewChallenge(challenge) + } + } + LiveChessGameAcceptEvent.KIND -> { + if (event is LiveChessGameAcceptEvent) { + handleGameAccepted(event) + } else { + val accept = + LiveChessGameAcceptEvent( + event.id, + event.pubKey, + event.createdAt, + event.tags, + event.content, + event.sig, + ) + handleGameAccepted(accept) + } + } + LiveChessMoveEvent.KIND -> { + if (event is LiveChessMoveEvent) { + handleIncomingMove(event) + } else { + val move = + LiveChessMoveEvent( + event.id, + event.pubKey, + event.createdAt, + event.tags, + event.content, + event.sig, + ) + handleIncomingMove(move) + } + } + LiveChessGameEndEvent.KIND -> { + if (event is LiveChessGameEndEvent) { + handleGameEnded(event) + } else { + val end = + LiveChessGameEndEvent( + event.id, + event.pubKey, + event.createdAt, + event.tags, + event.content, + event.sig, + ) + handleGameEnded(end) + } + } + LiveChessDrawOfferEvent.KIND -> { + if (event is LiveChessDrawOfferEvent) { + handleDrawOffer(event) + } else { + val drawOffer = + LiveChessDrawOfferEvent( + event.id, + event.pubKey, + event.createdAt, + event.tags, + event.content, + event.sig, + ) + handleDrawOffer(drawOffer) + } + } + } + } + + private fun handleMetadataEvent(event: Event) { + try { + val metadataEvent = + MetadataEvent( + event.id, + event.pubKey, + event.createdAt, + event.tags, + event.content, + event.sig, + ) + val metadata = metadataEvent.contactMetaData() + if (metadata != null) { + userMetadataCache.put(event.pubKey, metadata) + } + } catch (e: Exception) { + // Ignore parse errors + } + } + + private fun handleIncomingMove(event: LiveChessMoveEvent) { + // Don't process our own moves + if (event.pubKey == account.pubKeyHex) return + + val gameId = event.gameId() ?: return + val san = event.san() ?: return + val fen = event.fen() ?: return + val moveNumber = event.moveNumber() + + val gameState = _activeGames.value[gameId] + + if (gameState == null) { + // Game doesn't exist yet - buffer the move for later + val moveList = pendingMoves.getOrPut(gameId) { mutableListOf() } + moveList.add(Triple(san, fen, moveNumber)) + return + } + + if (event.pubKey != gameState.opponentPubkey) return + + gameState.applyOpponentMove(san, fen, moveNumber) + updateBadgeCount() + } + + private fun handleGameAccepted(event: LiveChessGameAcceptEvent) { + val gameId = event.gameId() ?: return + + // Skip if game already exists + if (_activeGames.value.containsKey(gameId)) return + + val challengerPubkey = event.challengerPubkey() ?: return + val accepterPubkey = event.pubKey + + // Check if we have the challenge event for color info + val challengeEvent = challengesByGameId[gameId] + if (challengeEvent == null) { + // Challenge hasn't arrived yet - buffer this accept for later + if (challengerPubkey == account.pubKeyHex || accepterPubkey == account.pubKeyHex) { + pendingAccepts[gameId] = event + } + return + } + + // Handle if user is the challenger or accepter + if (challengerPubkey == account.pubKeyHex) { + startGameFromAcceptance(event, isChallenger = true) + } else if (accepterPubkey == account.pubKeyHex) { + startGameFromAcceptance(event, isChallenger = false) + } + } + + private fun handleGameEnded(event: LiveChessGameEndEvent) { + val gameId = event.gameId() ?: return + + // Store game in history before removing + val gameState = _activeGames.value[gameId] + if (gameState != null) { + val completedGame = + CompletedGame( + gameId = gameId, + opponentPubkey = gameState.opponentPubkey, + playerColor = gameState.playerColor, + result = event.result() ?: "?", + termination = event.termination() ?: "unknown", + winnerPubkey = event.winnerPubkey(), + completedAt = event.createdAt, + moveCount = gameState.moveHistory.value.size, + ) + _completedGames.value = listOf(completedGame) + _completedGames.value + } + + // Remove from active games + if (_activeGames.value.containsKey(gameId)) { + _activeGames.value = _activeGames.value - gameId + } + + // Clean up challenge references + _challenges.value = _challenges.value.filter { it.gameId() != gameId } + challengesByGameId.remove(gameId) + pendingAccepts.remove(gameId) + pendingMoves.remove(gameId) + + updateBadgeCount() + } + + private fun handleDrawOffer(event: LiveChessDrawOfferEvent) { + // Only process draw offers from opponent + if (event.pubKey == account.pubKeyHex) return + + val gameId = event.gameId() ?: return + val gameState = _activeGames.value[gameId] ?: return + + // Verify this is from our opponent in this game + if (event.pubKey != gameState.opponentPubkey) return + + // Pass to game state to track + gameState.receiveDrawOffer(event.pubKey) + updateBadgeCount() + } + + private fun handleNewChallenge(event: LiveChessGameChallengeEvent) { + val gameId = event.gameId() ?: return + val opponentPubkey = event.opponentPubkey() + val isUserChallenge = event.pubKey == account.pubKeyHex + val isOpenChallenge = opponentPubkey == null + val isDirectedAtUser = opponentPubkey == account.pubKeyHex + + // Request metadata for challenger + userMetadataCache.request(event.pubKey) + opponentPubkey?.let { userMetadataCache.request(it) } + + // Always store in lookup map for accept event processing (even if game exists) + challengesByGameId[gameId] = event + + // Skip if game already exists (challenge was already accepted) + if (_activeGames.value.containsKey(gameId)) return + + // Check if there's a pending accept waiting for this challenge + val pendingAccept = pendingAccepts.remove(gameId) + if (pendingAccept != null) { + // Now we can properly process the accept + val isChallenger = event.pubKey == account.pubKeyHex + val isAccepter = pendingAccept.pubKey == account.pubKeyHex + if (isChallenger || isAccepter) { + startGameFromAcceptance(pendingAccept, isChallenger = isChallenger) + return // Don't add to challenges list since game is starting + } + } + + // Show challenges that are: + // - Created by user (their outgoing challenges) + // - Open challenges (anyone can accept) + // - Directed at user (incoming challenges) + if (isUserChallenge || isOpenChallenge || isDirectedAtUser) { + val currentChallenges = _challenges.value.toMutableList() + // Deduplicate by both event ID and game ID to prevent duplicates from relay broadcasts + val isDuplicateEvent = currentChallenges.any { it.id == event.id } + val isDuplicateGame = currentChallenges.any { it.gameId() == gameId } + if (!isDuplicateEvent && !isDuplicateGame) { + currentChallenges.add(event) + _challenges.value = currentChallenges + updateBadgeCount() + } + } + } + + /** + * Create a new chess challenge + */ + fun createChallenge( + opponentPubkey: String? = null, + playerColor: Color = Color.WHITE, + timeControl: String? = null, + ) { + val gameId = generateGameId() + + scope.launch(Dispatchers.IO) { + val success = + retryWithBackoff { + val template = + LiveChessGameChallengeEvent.build( + gameId = gameId, + playerColor = playerColor, + opponentPubkey = opponentPubkey, + timeControl = timeControl, + ) + + val signedEvent = account.signer.sign(template) + relayManager.broadcastToAll(signedEvent) + } + + if (success) { + _error.value = null + } else { + _error.value = "Failed to create challenge after $MAX_RETRIES attempts" + } + } + } + + /** + * Accept a chess challenge + */ + fun acceptChallenge(challengeEvent: LiveChessGameChallengeEvent) { + val gameId = challengeEvent.gameId() ?: return + val challengerPubkey = challengeEvent.pubKey + val challengerColor = challengeEvent.playerColor() ?: Color.WHITE + val playerColor = challengerColor.opposite() + + // Store in lookup map for consistent access + challengesByGameId[gameId] = challengeEvent + + // Mark as being created to prevent duplicate from relay echo + if (!gamesBeingCreated.add(gameId)) return // Already being created + + scope.launch(Dispatchers.IO) { + val success = + retryWithBackoff { + val template = + LiveChessGameAcceptEvent.build( + gameId = gameId, + challengeEventId = challengeEvent.id, + challengerPubkey = challengerPubkey, + ) + + val signedEvent = account.signer.sign(template) + relayManager.broadcastToAll(signedEvent) + } + + if (success) { + // Check if game was already created by relay echo while we were broadcasting + if (_activeGames.value.containsKey(gameId)) { + gamesBeingCreated.remove(gameId) + _selectedGameId.value = gameId + _challenges.value = _challenges.value.filter { it.id != challengeEvent.id } + _error.value = null + return@launch + } + + val engine = ChessEngine() + engine.reset() + + val gameState = + LiveChessGameState( + gameId = gameId, + playerPubkey = account.pubKeyHex, + opponentPubkey = challengerPubkey, + playerColor = playerColor, + engine = engine, + ) + + _activeGames.value = _activeGames.value + (gameId to gameState) + gamesBeingCreated.remove(gameId) + _selectedGameId.value = gameId + _challenges.value = _challenges.value.filter { it.id != challengeEvent.id } + _error.value = null + } else { + gamesBeingCreated.remove(gameId) + _error.value = "Failed to accept challenge" + } + } + } + + private fun startGameFromAcceptance( + acceptEvent: LiveChessGameAcceptEvent, + isChallenger: Boolean, + ) { + scope.launch(Dispatchers.IO) { + val gameId = acceptEvent.gameId() ?: return@launch + val challengerPubkey = acceptEvent.challengerPubkey() ?: return@launch + val accepterPubkey = acceptEvent.pubKey + + // 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 + + val opponentPubkey: String + val playerColor: Color + + // Use challengesByGameId for reliable lookup (not affected by UI filtering) + val challengeEvent = challengesByGameId[gameId] + + if (isChallenger) { + // User is challenger, opponent is accepter + opponentPubkey = accepterPubkey + playerColor = challengeEvent?.playerColor() ?: Color.WHITE + } else { + // User is accepter, opponent is challenger + opponentPubkey = challengerPubkey + // Accepter gets opposite color of challenger + val challengerColor = challengeEvent?.playerColor() ?: Color.WHITE + playerColor = challengerColor.opposite() + } + + val engine = ChessEngine() + engine.reset() + + val gameState = + LiveChessGameState( + gameId = gameId, + playerPubkey = account.pubKeyHex, + opponentPubkey = opponentPubkey, + playerColor = playerColor, + engine = engine, + ) + + _activeGames.value = _activeGames.value + (gameId to gameState) + _challenges.value = _challenges.value.filter { it.gameId() != gameId } + gamesBeingCreated.remove(gameId) + + // Apply any pending moves that arrived before the game was created + applyPendingMoves(gameId, gameState) + } + } + + private fun applyPendingMoves( + gameId: String, + gameState: LiveChessGameState, + ) { + val moves = pendingMoves.remove(gameId) ?: return + + // Sort by move number if available, then apply in order + val sortedMoves = moves.sortedBy { it.third ?: Int.MAX_VALUE } + + for ((san, fen, moveNumber) in sortedMoves) { + gameState.applyOpponentMove(san, fen, moveNumber) + } + + updateBadgeCount() + } + + /** + * Make and publish a move + */ + fun publishMove( + gameId: String, + from: String, + to: String, + ) { + val gameState = _activeGames.value[gameId] ?: return + val moveResult = gameState.makeMove(from, to) + if (moveResult != null) { + publishMoveEvent(gameId, moveResult) + } + } + + private fun publishMoveEvent( + gameId: String, + moveEvent: ChessMoveEvent, + ) { + scope.launch(Dispatchers.IO) { + val success = + retryWithBackoff { + val template = + LiveChessMoveEvent.build( + gameId = moveEvent.gameId, + moveNumber = moveEvent.moveNumber, + san = moveEvent.san, + fen = moveEvent.fen, + opponentPubkey = moveEvent.opponentPubkey, + ) + + val signedEvent = account.signer.sign(template) + relayManager.broadcastToAll(signedEvent) + } + + if (!success) { + _error.value = "Failed to publish move" + } + } + } + + /** + * Resign from a game + */ + fun resign(gameId: String) { + val gameState = _activeGames.value[gameId] ?: return + + scope.launch(Dispatchers.IO) { + val endData = gameState.resign() + + val success = + retryWithBackoff { + val template = + LiveChessGameEndEvent.build( + gameId = endData.gameId, + result = endData.result, + termination = endData.termination, + winnerPubkey = endData.winnerPubkey, + opponentPubkey = endData.opponentPubkey, + pgn = endData.pgn ?: "", + ) + + val signedEvent = account.signer.sign(template) + relayManager.broadcastToAll(signedEvent) + } + + if (success) { + // Store in history + 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 + _error.value = null + } else { + _error.value = "Failed to resign" + } + } + } + + /** + * Offer draw - sends a draw offer event that opponent can accept or decline + */ + fun offerDraw(gameId: String) { + val gameState = _activeGames.value[gameId] ?: return + + scope.launch(Dispatchers.IO) { + val drawOffer = gameState.offerDraw() + + val success = + retryWithBackoff { + val template = + LiveChessDrawOfferEvent.build( + gameId = drawOffer.gameId, + opponentPubkey = drawOffer.opponentPubkey, + message = drawOffer.message ?: "", + ) + + val signedEvent = account.signer.sign(template) + relayManager.broadcastToAll(signedEvent) + } + + if (!success) { + _error.value = "Failed to offer draw" + } + } + } + + /** + * Accept opponent's draw offer + */ + fun acceptDraw(gameId: String) { + val gameState = _activeGames.value[gameId] ?: return + + scope.launch(Dispatchers.IO) { + val endData = gameState.acceptDraw() + if (endData == null) { + _error.value = "No draw offer to accept" + return@launch + } + + val success = + retryWithBackoff { + val template = + LiveChessGameEndEvent.build( + gameId = endData.gameId, + result = endData.result, + termination = endData.termination, + winnerPubkey = endData.winnerPubkey, + opponentPubkey = endData.opponentPubkey, + pgn = endData.pgn ?: "", + ) + + val signedEvent = account.signer.sign(template) + relayManager.broadcastToAll(signedEvent) + } + + if (success) { + // Store in history and remove from active + 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 + _error.value = null + } else { + _error.value = "Failed to accept draw" + } + } + } + + /** + * Decline opponent's draw offer + */ + fun declineDraw(gameId: String) { + val gameState = _activeGames.value[gameId] ?: return + gameState.declineDraw() + } + + fun selectGame(gameId: String?) { + _selectedGameId.value = gameId + } + + fun getGameState(gameId: String): LiveChessGameState? = _activeGames.value[gameId] + + fun clearError() { + _error.value = null + } + + /** + * Trigger a refresh of chess data from relays. + * Preserves existing game state (including local moves) while refreshing challenges. + */ + fun refresh() { + _isLoading.value = true + + // Clear challenges - they'll be rebuilt from relay events + _challenges.value = emptyList() + + // Clear event tracking caches to allow re-processing + processedEventIds.clear() + challengesByGameId.clear() + pendingAccepts.clear() + pendingMoves.clear() + gamesBeingCreated.clear() + + // DON'T clear _activeGames - preserve existing game state with local moves + // Incoming events will be deduplicated by LiveChessGameState.receivedMoveNumbers + + _refreshKey.value++ + } + + /** + * Called when EOSE is received from relay, indicating initial load complete + */ + fun onLoadComplete() { + _isLoading.value = false + } + + private fun updateBadgeCount() { + val incomingChallenges = _challenges.value.count { it.opponentPubkey() == account.pubKeyHex } + val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() } + _badgeCount.value = incomingChallenges + yourTurnGames + } + + private suspend fun retryWithBackoff(operation: suspend () -> Unit): Boolean { + var attempt = 0 + while (attempt < MAX_RETRIES) { + try { + operation() + return true + } catch (e: Exception) { + attempt++ + if (attempt < MAX_RETRIES) { + delay(RETRY_DELAY_MS * attempt) + } + } + } + return false + } + + private fun generateGameId(): String { + val timestamp = TimeUtils.now() + val random = UUID.randomUUID().toString().take(8) + return "chess-$timestamp-$random" + } +} + +/** + * Represents a completed chess game for history display + */ +data class CompletedGame( + val gameId: String, + val opponentPubkey: String, + val playerColor: Color, + val result: String, + val termination: String, + val winnerPubkey: String?, + val completedAt: Long, + val moveCount: Int, +) { + fun didWin(playerPubkey: String): Boolean? = + when { + result == "1/2-1/2" -> null // Draw + winnerPubkey == playerPubkey -> true + winnerPubkey != null -> false + else -> null + } + + fun resultText(playerPubkey: String): String = + when (didWin(playerPubkey)) { + true -> "Won" + false -> "Lost" + null -> "Draw" + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt index 8415393da..d4d3bac20 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt @@ -315,3 +315,36 @@ fun createFollowingLongFormFeedSubscription( onEose = onEose, ) } + +/** + * Creates a subscription config for chess events (challenges, moves, etc.). + * Subscribes to: + * - Open challenges (kind 30064) + * - Challenges directed at the user + * - Events in games the user is playing + * + * @param userPubkey The user's public key to filter relevant games + * @param limit Maximum number of events to request + */ +fun createChessSubscription( + relays: Set, + userPubkey: String, + limit: Int = 100, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig = + SubscriptionConfig( + subId = generateSubId("chess-${userPubkey.take(8)}"), + filters = + listOf( + // Open challenges and challenges to user + FilterBuilders.chessOpenChallenges(limit = limit), + // Events where user is tagged (challenges to them, moves in their games) + FilterBuilders.chessEventsForUser(userPubkey, limit = limit), + // User's own events (their challenges, moves) + FilterBuilders.chessEventsByUser(userPubkey, limit = limit), + ), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) 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 b558853c6..4bf0ef374 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 @@ -200,6 +200,103 @@ object FilterBuilders { ids = ids, ) + /** + * Creates a filter for chess game challenges (kind 30064). + * Includes open challenges (no opponent) and challenges directed at a specific user. + * + * @param userPubkey The user's public key to filter challenges for + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @return Filter for chess challenges + */ + fun chessChallengesToUser( + userPubkey: String, + limit: Int? = 50, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(30064), // LiveChessGameChallengeEvent.KIND + tags = mapOf("p" to listOf(userPubkey)), + limit = limit, + since = since, + ) + + /** + * Creates a filter for open chess challenges (kind 30064, no specific opponent). + * + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @return Filter for open chess challenges + */ + fun chessOpenChallenges( + limit: Int? = 50, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(30064), // LiveChessGameChallengeEvent.KIND + limit = limit, + since = since, + ) + + /** + * Creates a filter for all chess events (challenges, accepts, moves, ends, draw offers). + * Useful for loading the chess lobby. + * + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @return Filter for all chess events + */ + fun chessAllEvents( + limit: Int? = 100, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(30064, 30065, 30066, 30067, 30068), + limit = limit, + since = since, + ) + + /** + * Creates a filter for chess events involving a specific user. + * Includes challenges to/from user, moves in their games, draw offers, etc. + * + * @param userPubkey The user's public key + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @return Filter for chess events involving the user + */ + fun chessEventsForUser( + userPubkey: String, + limit: Int? = 100, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(30064, 30065, 30066, 30067, 30068), + tags = mapOf("p" to listOf(userPubkey)), + limit = limit, + since = since, + ) + + /** + * Creates a filter for chess events by the user (their own events). + * + * @param userPubkey The user's public key + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @return Filter for chess events by the user + */ + fun chessEventsByUser( + userPubkey: String, + limit: Int? = 100, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(30064, 30065, 30066, 30067, 30068), + authors = listOf(userPubkey), + limit = limit, + since = since, + ) + /** * Creates a filter for events tagged with specific p-tags (mentioning users). * diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt index 40695162b..a6bdbc908 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt @@ -71,6 +71,27 @@ fun createBatchMetadataSubscription( ) } +/** + * Creates a subscription config for multiple user metadata (kind 0). + * Useful for fetching metadata for a batch of users at once. + */ +fun createMetadataListSubscription( + relays: Set, + pubKeys: List, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (pubKeys.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("meta-batch-${pubKeys.hashCode()}"), + filters = listOf(FilterBuilders.userMetadataMultiple(pubKeys)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + /** * Creates a subscription config for user posts (kind 1). */ diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 224971776..b4268710f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -584,7 +584,8 @@ fun FeedScreen( LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(events, key = { it.id }) { event -> + // Use distinctBy to prevent duplicate key crashes from events with same ID + items(events.distinctBy { it.id }, key = { it.id }) { event -> FeedNoteCard( event = event, relayManager = relayManager, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 6956d0e8e..0fc6ba44a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -226,7 +226,7 @@ fun NotificationsScreen( LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(notifications, key = { it.event.id }) { notification -> + items(notifications.distinctBy { it.event.id }, key = { it.event.id }) { notification -> NotificationCard(notification) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 1d40a2866..d72b914ef 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -423,7 +423,7 @@ fun ThreadScreen( } // Reply notes with level indicators - items(replyEvents, key = { it.id }) { event -> + items(replyEvents.distinctBy { it.id }, key = { it.id }) { event -> val level = calculateLevel(event) Column( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index c239218ae..41824f2d9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -569,7 +569,7 @@ fun UserProfileScreen( LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(events, key = { it.id }) { event -> + items(events.distinctBy { it.id }, key = { it.id }) { event -> FeedNoteCard( event = event, relayManager = relayManager, diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index d5541b3a1..d069a4979 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -124,7 +124,9 @@ kotlin { implementation(libs.okhttpCoroutines) // Chess engine for move validation and legal move generation - implementation("io.github.cvb941:kchesslib:1.0.4") + // NOTE: 1.0.4+ uses Java 21's removeLast() which crashes on Android API < 34 + // TODO: Test if 1.0.0 works, or fork library to fix + implementation("io.github.cvb941:kchesslib:1.0.0") } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt index 5c6e63fc3..5233fde9a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt @@ -112,6 +112,27 @@ data class ChessGameEnd( val pgn: String? = null, ) +/** + * Draw offer event - offer a draw to opponent + * Kind: 30068 + * + * Tags: + * - d: game_id + * - p: opponent pubkey + * + * Content: Optional message + * + * Opponent can: + * - Accept by sending a GameEnd event with DRAW_AGREEMENT + * - Decline implicitly by making their next move + * - Decline explicitly (optional, no event needed) + */ +data class ChessDrawOffer( + val gameId: String, + val opponentPubkey: String, + val message: String? = null, +) + /** * Game termination reason */ @@ -132,4 +153,5 @@ object LiveChessEventKinds { const val GAME_ACCEPT = 30065 const val CHESS_MOVE = 30066 const val GAME_END = 30067 + const val DRAW_OFFER = 30068 } 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 5c154e60e..1a98e7886 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt @@ -247,3 +247,48 @@ class LiveChessGameEndEvent( fun pgn(): String = content } + +/** + * Live Chess Draw Offer Event (Kind 30068) + * + * Offer a draw to opponent. Opponent can accept by sending a game end event + * with DRAW_AGREEMENT termination, or decline/ignore by making their next move. + * + * Tags: + * - d: game_id + * - p: opponent pubkey + * + * Content: Optional message + */ +@Immutable +class LiveChessDrawOfferEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30068 + + fun build( + gameId: String, + opponentPubkey: String, + message: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, message, createdAt) { + add(arrayOf("d", gameId)) + add(arrayOf("p", opponentPubkey)) + alt("Chess draw offer") + initializer() + } + } + + fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun message(): String = content +} 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 b0249daa4..647e0b3e9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip64Chess +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -38,19 +39,57 @@ class LiveChessGameState( val opponentPubkey: String, val playerColor: Color, val engine: ChessEngine, + val createdAt: Long = TimeUtils.now(), ) { + companion object { + // Abandon timeout: 24 hours without a move + const val ABANDON_TIMEOUT_SECONDS = 24 * 60 * 60L + + // Inactivity warning: 1 hour without a move + const val INACTIVITY_WARNING_SECONDS = 60 * 60L + } + private val _gameStatus = MutableStateFlow(GameStatus.InProgress) val gameStatus: StateFlow = _gameStatus.asStateFlow() private val _currentPosition = MutableStateFlow(engine.getPosition()) val currentPosition: StateFlow = _currentPosition.asStateFlow() - private val _moveHistory = MutableStateFlow>(emptyList()) + // Initialize from engine's history so freshly loaded games show all moves + private val _moveHistory = MutableStateFlow(engine.getMoveHistory()) val moveHistory: StateFlow> = _moveHistory.asStateFlow() private val _lastError = MutableStateFlow(null) val lastError: StateFlow = _lastError.asStateFlow() + // Track when last activity occurred (move made or received) + private val _lastActivityAt = MutableStateFlow(createdAt) + val lastActivityAt: StateFlow = _lastActivityAt.asStateFlow() + + // Track received move numbers to detect duplicates and out-of-order + private val receivedMoveNumbers = mutableSetOf() + + // Pending moves waiting for earlier moves (move number -> move data) + private val pendingMoves = mutableMapOf>() + + // Desync detection + private val _isDesynced = MutableStateFlow(false) + val isDesynced: StateFlow = _isDesynced.asStateFlow() + + // Draw offer tracking - pubkey of who offered the draw (null = no pending offer) + private val _pendingDrawOffer = MutableStateFlow(null) + val pendingDrawOffer: StateFlow = _pendingDrawOffer.asStateFlow() + + /** + * Check if there's a pending draw offer from opponent + */ + fun hasOpponentDrawOffer(): Boolean = _pendingDrawOffer.value == opponentPubkey + + /** + * Check if we have an outgoing draw offer + */ + fun hasOurDrawOffer(): Boolean = _pendingDrawOffer.value == playerPubkey + /** * Check if it's the player's turn */ @@ -80,8 +119,12 @@ class LiveChessGameState( if (result.success && result.san != null && result.position != null) { _currentPosition.value = result.position _moveHistory.value = engine.getMoveHistory() + _lastActivityAt.value = TimeUtils.now() _lastError.value = null + // Making a move implicitly declines any pending draw offer + _pendingDrawOffer.value = null + // Check for game end conditions checkGameEnd() @@ -105,7 +148,22 @@ class LiveChessGameState( fun applyOpponentMove( san: String, fen: String, + moveNumber: Int? = null, ): Boolean { + // Check for duplicate move + if (moveNumber != null && receivedMoveNumbers.contains(moveNumber)) { + // Already processed this move, ignore + return true + } + + // Check for out-of-order move + val expectedMoveNumber = _moveHistory.value.size + 1 + if (moveNumber != null && moveNumber > expectedMoveNumber) { + // Store for later processing + pendingMoves[moveNumber] = san to fen + return true + } + if (isPlayerTurn()) { _lastError.value = "Received move but it's not opponent's turn" return false @@ -115,22 +173,37 @@ class LiveChessGameState( val result = engine.makeMove(san) if (result.success) { + // Mark as received + if (moveNumber != null) { + receivedMoveNumbers.add(moveNumber) + } + // Verify the resulting FEN matches what opponent sent val currentFen = engine.getFen() if (currentFen != fen) { - // Positions don't match - possible desync - _lastError.value = "Position mismatch after opponent move" + // Positions don't match - desync detected + _isDesynced.value = true + _lastError.value = "Position mismatch - syncing to opponent's position" // Load the opponent's FEN to stay in sync engine.loadFen(fen) + } else { + _isDesynced.value = false } _currentPosition.value = engine.getPosition() _moveHistory.value = engine.getMoveHistory() + _lastActivityAt.value = TimeUtils.now() _lastError.value = null + // Opponent making a move declines any pending draw offer + _pendingDrawOffer.value = null + // Check for game end conditions checkGameEnd() + // Process any pending moves that are now valid + processPendingMoves() + return true } else { _lastError.value = "Invalid opponent move: $san" @@ -138,6 +211,79 @@ class LiveChessGameState( } } + /** + * Process any pending out-of-order moves + */ + private fun processPendingMoves() { + val nextMoveNumber = _moveHistory.value.size + 1 + val pendingMove = pendingMoves.remove(nextMoveNumber) + if (pendingMove != null) { + val (san, fen) = pendingMove + applyOpponentMove(san, fen, nextMoveNumber) + } + } + + /** + * Force resync to a specific FEN (manual recovery) + */ + fun forceResync(fen: String) { + engine.loadFen(fen) + _currentPosition.value = engine.getPosition() + _moveHistory.value = engine.getMoveHistory() + _isDesynced.value = false + _lastError.value = null + } + + /** + * Mark move numbers as already received. + * Used when loading a game from cache to prevent duplicate move application during refresh. + * + * @param moveNumbers Set of move numbers that have been loaded + */ + fun markMovesAsReceived(moveNumbers: Set) { + receivedMoveNumbers.addAll(moveNumbers) + } + + /** + * Check if game appears abandoned (opponent hasn't moved in a long time) + */ + fun isAbandoned(): Boolean { + if (_gameStatus.value != GameStatus.InProgress) return false + if (isPlayerTurn()) return false // Only check when waiting for opponent + + val elapsed = TimeUtils.now() - _lastActivityAt.value + return elapsed > ABANDON_TIMEOUT_SECONDS + } + + /** + * Check if opponent is inactive (warning threshold) + */ + fun isOpponentInactive(): Boolean { + if (_gameStatus.value != GameStatus.InProgress) return false + if (isPlayerTurn()) return false + + val elapsed = TimeUtils.now() - _lastActivityAt.value + return elapsed > INACTIVITY_WARNING_SECONDS + } + + /** + * Claim victory due to abandonment + */ + fun claimAbandonmentVictory(): ChessGameEnd? { + if (!isAbandoned()) return null + + _gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor)) + + return ChessGameEnd( + gameId = gameId, + result = GameResult.getResultForWinner(playerColor), + termination = GameTermination.ABANDONMENT, + winnerPubkey = playerPubkey, + opponentPubkey = opponentPubkey, + pgn = generatePGN(), + ) + } + /** * Offer resignation (player resigns) */ @@ -155,9 +301,37 @@ class LiveChessGameState( } /** - * Offer or accept draw + * Offer a draw to opponent. + * Returns the draw offer data to be published to Nostr. */ - fun offerDraw(): ChessGameEnd { + fun offerDraw(): ChessDrawOffer { + _pendingDrawOffer.value = playerPubkey + return ChessDrawOffer( + gameId = gameId, + opponentPubkey = opponentPubkey, + ) + } + + /** + * Handle receiving a draw offer from opponent (via Nostr event) + */ + fun receiveDrawOffer(fromPubkey: String): Boolean { + if (fromPubkey != opponentPubkey) return false + _pendingDrawOffer.value = opponentPubkey + return true + } + + /** + * Accept opponent's draw offer. + * Returns game end data to be published to Nostr. + */ + fun acceptDraw(): ChessGameEnd? { + if (_pendingDrawOffer.value != opponentPubkey) { + _lastError.value = "No draw offer to accept" + return null + } + + _pendingDrawOffer.value = null _gameStatus.value = GameStatus.Finished(GameResult.DRAW) return ChessGameEnd( @@ -170,6 +344,13 @@ class LiveChessGameState( ) } + /** + * Decline a draw offer (explicit decline, also happens implicitly on move) + */ + fun declineDraw() { + _pendingDrawOffer.value = null + } + /** * Check if game has ended (checkmate, stalemate, etc.) */ 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 b16c1c81d..f861909d9 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 @@ -112,6 +112,7 @@ actual class ChessEngine { val sq = Square.fromValue(square.uppercase()) return board .legalMoves() + .filterNotNull() .filter { it.from == sq } .map { it.to.toString().lowercase() } } @@ -173,7 +174,7 @@ actual class ChessEngine { actual fun getMoveHistory(): List { // kchesslib stores move history - return board.backup.map { it.move.toString() } + return board.backup.mapNotNull { it?.move?.toString() } } /**