diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index d26cc68f1..93e7ec50d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -111,6 +111,9 @@ val KIND3_FOLLOWS = " Main User Follows " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. val AROUND_ME = " Around Me " +// This has spaces to avoid mixing with a potential NIP-51 list with the same name. +val CHESS = " Chess " + @Stable class AccountSettings( val keyPair: KeyPair, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 43414d1ca..531c0a3dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -168,6 +168,13 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -717,6 +724,49 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + // Chess events (NIP-64 live chess) + fun consume( + event: ChessGameEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: JesterEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: LiveChessGameChallengeEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: LiveChessGameAcceptEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: LiveChessMoveEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: LiveChessGameEndEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: LiveChessDrawOfferEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consume( event: NipTextEvent, relay: NormalizedRelayUrl?, @@ -3006,6 +3056,13 @@ object LocalCache : ILocalCache, ICacheProvider { is GitReplyEvent -> consume(event, relay, wasVerified) is GitPatchEvent -> consume(event, relay, wasVerified) is GitRepositoryEvent -> consume(event, relay, wasVerified) + is ChessGameEvent -> consume(event, relay, wasVerified) + is JesterEvent -> consume(event, relay, wasVerified) + is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified) + is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified) + is LiveChessMoveEvent -> consume(event, relay, wasVerified) + is LiveChessGameEndEvent -> consume(event, relay, wasVerified) + is LiveChessDrawOfferEvent -> consume(event, relay, wasVerified) is HashtagListEvent -> consume(event, relay, wasVerified) is HighlightEvent -> consume(event, relay, wasVerified) is IndexerRelayListEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt new file mode 100644 index 000000000..143f6ce8b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt @@ -0,0 +1,146 @@ +/* + * 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.model.nip64Chess + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.nip64Chess.GameTermination +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent + +/** + * Action class for creating and signing live chess events + */ +class ChessAction { + companion object { + /** + * Create a new chess game challenge + * + * @param gameId Unique identifier for the game + * @param playerColor Color the challenger wants to play + * @param opponentPubkey Optional opponent pubkey (null = open challenge) + * @param timeControl Optional time control (e.g., "10+0") + * @param signer Nostr signer + */ + suspend fun createChallenge( + gameId: String, + playerColor: Color, + opponentPubkey: String? = null, + timeControl: String? = null, + signer: NostrSigner, + ): LiveChessGameChallengeEvent = + signer.sign( + LiveChessGameChallengeEvent.build( + gameId = gameId, + playerColor = playerColor, + opponentPubkey = opponentPubkey, + timeControl = timeControl, + ), + ) + + /** + * Accept a chess game challenge + * + * @param gameId Game identifier from the challenge + * @param challengeEventId Event ID of the challenge + * @param challengerPubkey Pubkey of the challenger + * @param signer Nostr signer + */ + suspend fun acceptChallenge( + gameId: String, + challengeEventId: String, + challengerPubkey: String, + signer: NostrSigner, + ): LiveChessGameAcceptEvent = + signer.sign( + LiveChessGameAcceptEvent.build( + gameId = gameId, + challengeEventId = challengeEventId, + challengerPubkey = challengerPubkey, + ), + ) + + /** + * Publish a move in a live chess game + * + * @param gameId Game identifier + * @param moveNumber Move number (1-based) + * @param san Move in Standard Algebraic Notation + * @param fen Resulting position in FEN notation + * @param opponentPubkey Opponent's pubkey + * @param comment Optional move comment + * @param signer Nostr signer + */ + suspend fun publishMove( + gameId: String, + moveNumber: Int, + san: String, + fen: String, + opponentPubkey: String, + comment: String = "", + signer: NostrSigner, + ): LiveChessMoveEvent = + signer.sign( + LiveChessMoveEvent.build( + gameId = gameId, + moveNumber = moveNumber, + san = san, + fen = fen, + opponentPubkey = opponentPubkey, + comment = comment, + ), + ) + + /** + * End a chess game and publish result + * + * @param gameId Game identifier + * @param result Game result (1-0, 0-1, or 1/2-1/2) + * @param termination How the game ended + * @param winnerPubkey Pubkey of winner (if applicable) + * @param opponentPubkey Opponent's pubkey + * @param pgn Optional PGN of complete game + * @param signer Nostr signer + */ + suspend fun endGame( + gameId: String, + result: GameResult, + termination: GameTermination, + winnerPubkey: String? = null, + opponentPubkey: String, + pgn: String = "", + signer: NostrSigner, + ): LiveChessGameEndEvent = + signer.sign( + LiveChessGameEndEvent.build( + gameId = gameId, + result = result, + termination = termination, + winnerPubkey = winnerPubkey, + opponentPubkey = opponentPubkey, + pgn = pgn, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index 746776ac9..03bea20ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds import com.vitorpamplona.amethyst.model.ALL_FOLLOWS import com.vitorpamplona.amethyst.model.ALL_USER_FOLLOWS import com.vitorpamplona.amethyst.model.AROUND_ME +import com.vitorpamplona.amethyst.model.CHESS import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache @@ -32,6 +33,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlo import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFollowsFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownFeedFlow @@ -84,6 +86,10 @@ class FeedTopNavFilterState( AroundMeFeedFlow(locationFlow, followsRelays, proxyRelays) } + CHESS -> { + ChessFeedFlow(followsRelays, proxyRelays) + } + else -> { val note = LocalCache.checkGetOrCreateAddressableNote(listName) if (note != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessFeedFlow.kt new file mode 100644 index 000000000..2d323ad74 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessFeedFlow.kt @@ -0,0 +1,43 @@ +/* + * 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.model.topNavFeeds.chess + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class ChessFeedFlow( + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + val default = ChessTopNavFilter(outboxRelays, proxyRelays) + + override fun flow() = MutableStateFlow(default) + + override fun startValue(): ChessTopNavFilter = default + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavFilter.kt new file mode 100644 index 000000000..de820177d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavFilter.kt @@ -0,0 +1,63 @@ +/* + * 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.model.topNavFeeds.chess + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +@Immutable +class ChessTopNavFilter( + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event) = + noteEvent is ChessGameEvent || + noteEvent is LiveChessGameChallengeEvent || + noteEvent is LiveChessGameEndEvent + + override fun toPerRelayFlow(cache: LocalCache): Flow = + combine(outboxRelays, proxyRelays) { outboxRelays, proxyRelays -> + if (proxyRelays.isNotEmpty()) { + ChessTopNavPerRelayFilterSet(proxyRelays.associateWith { ChessTopNavPerRelayFilter }) + } else { + ChessTopNavPerRelayFilterSet(outboxRelays.associateWith { ChessTopNavPerRelayFilter }) + } + } + + override fun startValue(cache: LocalCache): ChessTopNavPerRelayFilterSet = + if (proxyRelays.value.isNotEmpty()) { + ChessTopNavPerRelayFilterSet(proxyRelays.value.associateWith { ChessTopNavPerRelayFilter }) + } else { + ChessTopNavPerRelayFilterSet(outboxRelays.value.associateWith { ChessTopNavPerRelayFilter }) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavPerRelayFilter.kt new file mode 100644 index 000000000..49da8bb5f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavPerRelayFilter.kt @@ -0,0 +1,27 @@ +/* + * 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.model.topNavFeeds.chess + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter + +@Immutable +object ChessTopNavPerRelayFilter : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavPerRelayFilterSet.kt new file mode 100644 index 000000000..cfc589883 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/* + * 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.model.topNavFeeds.chess + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class ChessTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet 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 78ff36813..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 @@ -74,6 +74,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen 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 @@ -137,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) } @@ -174,6 +177,7 @@ fun AppNavigation( composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } + composableFromEndArgs { ChessGameScreen(it.gameId, accountViewModel, nav) } composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, 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 6cffc431e..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() @@ -137,6 +139,10 @@ sealed class Route { val geohash: String, ) : Route() + @Serializable data class ChessGame( + val gameId: String, + ) : Route() + @Serializable data class Community( val kind: Int, val pubKeyHex: HexKey, @@ -312,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/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index f4ee5b4fd..9564d0d1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -103,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile +import com.vitorpamplona.amethyst.ui.note.types.RenderChessGame import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack @@ -114,6 +115,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderLiveChessChallenge +import com.vitorpamplona.amethyst.ui.note.types.RenderLiveChessGameEnd import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status @@ -210,6 +213,9 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -913,6 +919,33 @@ private fun RenderNoteRow( ) } + is ChessGameEvent -> { + RenderChessGame( + baseNote, + backgroundColor, + accountViewModel, + nav, + ) + } + + is LiveChessGameChallengeEvent -> { + RenderLiveChessChallenge( + baseNote, + backgroundColor, + accountViewModel, + nav, + ) + } + + is LiveChessGameEndEvent -> { + RenderLiveChessGameEnd( + baseNote, + backgroundColor, + accountViewModel, + nav, + ) + } + is ClassifiedsEvent -> { RenderClassifieds( noteEvent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt new file mode 100644 index 000000000..af7b3d472 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -0,0 +1,268 @@ +/* + * 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.note.types + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessGameViewer +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelNew +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor + +/** + * Render NIP-64 Chess Game event (Kind 64) + * + * Displays interactive chess board with PGN game replay functionality. + * Wraps content in sensitivity warning for user-controlled content filtering. + * + * @param note The note containing the ChessGameEvent + * @param backgroundColor Mutable state for background color + * @param accountViewModel Account view model for sensitivity checking + * @param nav Navigation interface + */ +@Composable +fun RenderChessGame( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = (note.event as? ChessGameEvent) ?: return + + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + ChessGameViewer(pgnContent = event.pgn()) + } +} + +/** + * Render Live Chess Challenge event (Kind 30064) + * + * Shows a challenge card with Accept/Decline actions for incoming challenges + * or status for sent challenges + */ +@Composable +fun RenderLiveChessChallenge( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = (note.event as? LiveChessGameChallengeEvent) ?: return + val gameId = event.gameId() ?: return + + val chessViewModel: ChessViewModelNew = + viewModel( + key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", + factory = ChessViewModelFactory(accountViewModel.account), + ) + + val isOpenChallenge = event.opponentPubkey() == null + val isIncomingChallenge = event.opponentPubkey() == accountViewModel.account.userProfile().pubkeyHex + + val borderColor = + when { + isOpenChallenge -> Color(0xFF4CAF50) + + // Green for open + isIncomingChallenge -> Color(0xFFFFA726) + + // Orange for incoming + else -> MaterialTheme.colorScheme.outline // Gray for sent + } + + val icon = + when { + isOpenChallenge -> "🔓" + isIncomingChallenge -> "💌" + else -> "⏳" + } + + val title = + when { + isOpenChallenge -> "Open Challenge" + isIncomingChallenge -> "Challenge from ${note.author?.toBestDisplayName()}" + else -> "Challenge sent to ${event.opponentPubkey()?.take(8)}" + } + + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .border(2.dp, borderColor, MaterialTheme.shapes.medium), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "$icon $title", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = + if (event.playerColor()?.name == "WHITE") "White" else "Black", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + event.timeControl()?.let { + Text( + text = "Time: $it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Action buttons + if (isIncomingChallenge || isOpenChallenge) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton(onClick = { /* TODO: Decline */ }) { + Text("Decline") + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + // Accept challenge and navigate to game + val challengerPubkey = note.author?.pubkeyHex ?: return@Button + + // Create ChessChallenge from Note data + val challenge = + ChessChallenge( + eventId = note.idHex, + gameId = gameId, + challengerPubkey = challengerPubkey, + challengerDisplayName = note.author?.toBestDisplayName(), + challengerAvatarUrl = note.author?.profilePicture(), + opponentPubkey = event.opponentPubkey(), + challengerColor = event.playerColor() ?: ChessColor.WHITE, + createdAt = event.createdAt, + ) + + chessViewModel.acceptChallenge(challenge) + + // Navigate to game + nav.nav(Route.ChessGame(gameId)) + }, + ) { + Text("Accept") + } + } + } + } + } +} + +/** + * Render Live Chess Game End event (Kind 30067) + * + * Shows final game result with PGN viewer + */ +@Composable +fun RenderLiveChessGameEnd( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = (note.event as? LiveChessGameEndEvent) ?: return + + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Result header + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "🏆 Game Ended", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Result: ${event.result()}", + style = MaterialTheme.typography.bodyMedium, + ) + event.termination()?.let { + Text( + text = "By: ${it.replace("_", " ")}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + + // Show PGN if available + event.pgn().takeIf { it.isNotBlank() }?.let { pgn -> + ChessGameViewer(pgnContent = pgn) + } + } + } +} 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 3ce666e5c..d01366fe1 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 @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.ALL_USER_FOLLOWS import com.vitorpamplona.amethyst.model.AROUND_ME import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.CHESS import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -52,6 +53,9 @@ import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -127,6 +131,20 @@ class TopNavFilterState( unpackList = listOf(MuteListEvent.blockListFor(account.userProfile().pubkeyHex)), ) + val chessFollow = + GlobalFeedDefinition( + code = CHESS, + name = ResourceName(R.string.follow_list_chess), + type = CodeNameType.HARDCODED, + kinds = + listOf( + ChessGameEvent.KIND, // Completed games (Kind 64) + LiveChessGameChallengeEvent.KIND, // Challenges (Kind 30064) + LiveChessGameEndEvent.KIND, // Game endings (Kind 30067) + // Note: LiveChessMoveEvent (Kind 30066) intentionally excluded - too noisy + ), + ) + val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow) fun mergePeopleLists( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt new file mode 100644 index 000000000..18c639ec4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt @@ -0,0 +1,408 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess + +import com.vitorpamplona.amethyst.commons.chess.ChessConfig +import com.vitorpamplona.amethyst.commons.chess.ChessEventBroadcaster +import com.vitorpamplona.amethyst.commons.chess.ChessEventPublisher +import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetchHelper +import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetcher +import com.vitorpamplona.amethyst.commons.chess.IUserMetadataProvider +import com.vitorpamplona.amethyst.commons.chess.RelayFetchProgress +import com.vitorpamplona.amethyst.commons.chess.RelayGameSummary +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd +import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.toJesterEvent + +/** + * Android implementation of ChessEventPublisher using Jester protocol. + * Wraps account.signAndComputeBroadcast() for event publishing. + * + * Jester Protocol: + * - All chess events use kind 30 + * - publishStart creates a start event (content.kind=0) + * - publishMove creates a move event (content.kind=1) with full history + * - publishGameEnd creates a move event with result in content + */ +class AndroidChessPublisher( + private val account: Account, +) : ChessEventPublisher { + private val broadcaster = ChessEventBroadcaster(account.client) + + /** + * Broadcast an event to the dedicated chess relays with reliable delivery. + * Uses ChessEventBroadcaster to ensure relay connections before sending. + */ + private suspend fun broadcastToChessRelays(event: com.vitorpamplona.quartz.nip01Core.core.Event): Boolean { + println("[AndroidChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster") + val result = broadcaster.broadcast(event) + println("[AndroidChessPublisher] Broadcast result: ${result.message}") + return result.success + } + + /** + * Publish a game start event (challenge). + * Returns the startEventId (event ID) if successful. + */ + override suspend fun publishStart( + playerColor: Color, + opponentPubkey: String?, + ): String? = + try { + val template = + if (opponentPubkey != null) { + JesterEvent.buildPrivateStart( + opponentPubkey = opponentPubkey, + playerColor = playerColor, + ) + } else { + JesterEvent.buildStart( + playerColor = playerColor, + ) + } + val signedEvent = account.signer.sign(template) + // Broadcast to chess relays with reliable delivery + val success = broadcastToChessRelays(signedEvent) + // Also add to local cache + account.cache.justConsumeMyOwnEvent(signedEvent) + if (success) signedEvent.id else null + } catch (e: Exception) { + println("[AndroidChessPublisher] publishStart failed: ${e.message}") + null + } + + /** + * Publish a move event. + * Returns the move event ID if successful. + */ + override suspend fun publishMove(move: ChessMoveEvent): String? = + try { + val template = + JesterEvent.buildMove( + startEventId = move.startEventId, + headEventId = move.headEventId, + move = move.san, + fen = move.fen, + history = move.history, + opponentPubkey = move.opponentPubkey, + ) + val signedEvent = account.signer.sign(template) + // Broadcast to chess relays with reliable delivery + val success = broadcastToChessRelays(signedEvent) + // Also add to local cache + account.cache.justConsumeMyOwnEvent(signedEvent) + if (success) signedEvent.id else null + } catch (e: Exception) { + println("[AndroidChessPublisher] publishMove failed: ${e.message}") + null + } + + /** + * Publish a game end event (includes result in content). + */ + override suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean = + try { + val template = + JesterEvent.buildEndMove( + startEventId = gameEnd.startEventId, + headEventId = gameEnd.headEventId, + move = gameEnd.lastMove, + fen = gameEnd.fen, + history = gameEnd.history, + opponentPubkey = gameEnd.opponentPubkey, + result = gameEnd.result, + termination = gameEnd.termination, + ) + val signedEvent = account.signer.sign(template) + // Broadcast to chess relays with reliable delivery + val success = broadcastToChessRelays(signedEvent) + // Also add to local cache + account.cache.justConsumeMyOwnEvent(signedEvent) + success + } catch (e: Exception) { + println("[AndroidChessPublisher] publishGameEnd failed: ${e.message}") + false + } + + override fun getWriteRelayCount(): Int = ChessConfig.CHESS_RELAYS.size +} + +/** + * Android implementation of ChessRelayFetcher using Jester protocol. + * + * Uses direct one-shot relay fetching for reliability: + * - fetchGameEvents: fetches all events for a specific game + * - fetchChallenges: fetches start events (challenges) + * - fetchUserGameIds: discovers games where user is a participant + * - fetchRecentGames: fetches recent games for spectating + * + * Each fetch opens a temporary subscription, waits for EOSE, and returns results. + */ +class AndroidRelayFetcher( + private val account: Account, +) : ChessRelayFetcher { + private val fetchHelper = ChessRelayFetchHelper(account.client) + private val userPubkey = account.userProfile().pubkeyHex + + /** + * Get the normalized chess relay URLs. + * Always uses the 3 dedicated chess relays from ChessConfig. + */ + private fun chessRelayUrls(): List = + ChessConfig.CHESS_RELAYS.map { + com.vitorpamplona.quartz.nip01Core.relay.normalizer + .NormalizedRelayUrl(it) + } + + override fun getRelayUrls(): List { + println("[AndroidRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays") + return ChessConfig.CHESS_RELAYS + } + + /** + * Fetch game events directly from relays. + * Uses one-shot fetch for reliability. + * + * Uses TWO filters: + * 1. Fetch start event by ID (start events don't have #e tag to themselves) + * 2. Fetch moves that reference the start event via #e tag + */ + override suspend fun fetchGameEvents(startEventId: String): JesterGameEvents { + println("[AndroidRelayFetcher] fetchGameEvents: fetching from relays for startEventId=$startEventId") + + // Filter 1: Fetch the start event by its ID + val startEventFilter = + com.vitorpamplona.quartz.nip01Core.relay.filters.Filter( + ids = listOf(startEventId), + kinds = listOf(JesterProtocol.KIND), + ) + + // Filter 2: Fetch moves that reference the start event + val movesFilter = ChessFilterBuilder.gameEventsFilter(startEventId) + + // Combine both filters + val relayFilters = chessRelayUrls().associateWith { listOf(startEventFilter, movesFilter) } + val events = fetchHelper.fetchEvents(relayFilters) + + var startEvent: JesterEvent? = null + val moves = mutableListOf() + + events.forEach { event -> + if (event.kind != JesterProtocol.KIND) return@forEach + val jesterEvent = event.toJesterEvent() ?: return@forEach + + if (jesterEvent.isStartEvent() && jesterEvent.id == startEventId) { + startEvent = jesterEvent + } else if (jesterEvent.isMoveEvent() && jesterEvent.startEventId() == startEventId) { + moves.add(jesterEvent) + } + } + + println("[AndroidRelayFetcher] fetchGameEvents: found start=${startEvent != null}, moves=${moves.size}") + + return JesterGameEvents( + startEvent = startEvent, + moves = moves, + ) + } + + /** + * Fetch challenges (start events) directly from relays. + * Uses one-shot fetch for reliability. + */ + override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List { + val relays = chessRelayUrls() + println("[AndroidRelayFetcher] fetchChallenges: fetching from ${relays.size} relays: $relays") + + if (relays.isEmpty()) { + println("[AndroidRelayFetcher] fetchChallenges: WARNING - no connected relays!") + return emptyList() + } + + val filter = ChessFilterBuilder.challengesFilter(userPubkey) + println("[AndroidRelayFetcher] fetchChallenges: filter kinds=${filter.kinds}, tags=${filter.tags}, since=${filter.since}, limit=${filter.limit}") + println("[AndroidRelayFetcher] fetchChallenges: filter JSON=${filter.toJson()}") + + val relayFilters = relays.associateWith { listOf(filter) } + val events = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress) + println("[AndroidRelayFetcher] fetchChallenges: received ${events.size} raw events") + + // Debug: If no events, also try without #e filter to see if relays have ANY kind 30 events + if (events.isEmpty()) { + println("[AndroidRelayFetcher] DEBUG: No events with #e filter, trying without tag filter...") + val debugFilter = ChessFilterBuilder.recentGamesFilter() + val debugFilters = relays.associateWith { listOf(debugFilter) } + val debugEvents = fetchHelper.fetchEvents(debugFilters, timeoutMs = 10_000) + println("[AndroidRelayFetcher] DEBUG: recentGamesFilter (no #e) returned ${debugEvents.size} events") + debugEvents.take(5).forEach { e -> + println("[AndroidRelayFetcher] DEBUG: kind=${e.kind}, id=${e.id.take(8)}, tags=${e.tags.take(3).map { it.toList() }}") + } + } + + val challenges = mutableListOf() + + events.forEach { event -> + if (event.kind != JesterProtocol.KIND) return@forEach + val jesterEvent = event.toJesterEvent() ?: return@forEach + + if (!jesterEvent.isStartEvent()) return@forEach + + // Include challenges that are: + // 1. Open challenges (no specific opponent) + // 2. Directed at us + // 3. Created by us + val isRelevant = + jesterEvent.opponentPubkey() == null || + jesterEvent.opponentPubkey() == userPubkey || + jesterEvent.pubKey == userPubkey + + if (isRelevant) { + challenges.add(jesterEvent) + } + } + + println("[AndroidRelayFetcher] fetchChallenges: found ${challenges.size} challenges from ${events.size} events") + return challenges + } + + /** + * Fetch recent games for spectating. + */ + override suspend fun fetchRecentGames(): List { + val filter = ChessFilterBuilder.recentGamesFilter() + val relayFilters = chessRelayUrls().associateWith { listOf(filter) } + val events = fetchHelper.fetchEvents(relayFilters) + + // Convert to JesterEvents + val jesterEvents = + events + .filter { it.kind == JesterProtocol.KIND } + .mapNotNull { it.toJesterEvent() } + + // Group by game (startEventId for moves, id for start events) + val startEventsById = + jesterEvents + .filter { it.isStartEvent() } + .associateBy { it.id } + + val movesByGameId = + jesterEvents + .filter { it.isMoveEvent() } + .groupBy { it.startEventId() ?: "" } + .filterKeys { it.isNotEmpty() } + + // Get all game IDs (from start events and moves) + val allGameIds = startEventsById.keys + movesByGameId.keys + + return allGameIds.mapNotNull { startEventId -> + val startEvent = startEventsById[startEventId] ?: return@mapNotNull null + val moves = movesByGameId[startEventId] ?: emptyList() + + val challengerColor = startEvent.playerColor() ?: Color.WHITE + + // Determine players from start event and moves + val challengerPubkey = startEvent.pubKey + val opponentFromMoves = moves.firstOrNull { it.pubKey != challengerPubkey }?.pubKey + val opponentPubkey = startEvent.opponentPubkey() ?: opponentFromMoves ?: return@mapNotNull null + + val (whitePubkey, blackPubkey) = + if (challengerColor == Color.WHITE) { + challengerPubkey to opponentPubkey + } else { + opponentPubkey to challengerPubkey + } + + val lastMove = moves.maxByOrNull { it.history().size } + val hasEnded = lastMove?.result() != null + + RelayGameSummary( + startEventId = startEventId, + whitePubkey = whitePubkey, + blackPubkey = blackPubkey, + moveCount = lastMove?.history()?.size ?: 0, + lastMoveTime = lastMove?.createdAt ?: startEvent.createdAt, + isActive = !hasEnded, + ) + } + } + + /** + * Fetch user's game IDs directly from relays. + * Uses one-shot fetch for reliability. + */ + override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set { + println("[AndroidRelayFetcher] fetchUserGameIds: fetching from relays") + + // Fetch events authored by user AND events tagging user + val authoredFilter = ChessFilterBuilder.userGamesFilter(userPubkey) + val taggedFilter = ChessFilterBuilder.userTaggedFilter(userPubkey) + + val relayFilters = + chessRelayUrls().associateWith { + listOf(authoredFilter, taggedFilter) + } + + val events = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress) + + val gameIds = mutableSetOf() + + events.forEach { event -> + if (event.kind != JesterProtocol.KIND) return@forEach + val jesterEvent = event.toJesterEvent() ?: return@forEach + + // Check if user is involved (author or tagged) + val isUserInvolved = jesterEvent.pubKey == userPubkey || jesterEvent.opponentPubkey() == userPubkey + if (!isUserInvolved) return@forEach + + if (jesterEvent.isStartEvent()) { + gameIds.add(jesterEvent.id) + } else if (jesterEvent.isMoveEvent()) { + jesterEvent.startEventId()?.let { gameIds.add(it) } + } + } + + println("[AndroidRelayFetcher] fetchUserGameIds: found ${gameIds.size} game IDs from ${events.size} events") + return gameIds + } +} + +/** + * Android implementation of IUserMetadataProvider. + * Wraps LocalCache.getOrCreateUser() for user metadata lookup. + */ +class AndroidMetadataProvider : IUserMetadataProvider { + override fun getDisplayName(pubkey: String): String { + val user = LocalCache.getOrCreateUser(pubkey) + return user.toBestDisplayName() + } + + override fun getPictureUrl(pubkey: String): String? { + val user = LocalCache.getOrCreateUser(pubkey) + return user.profilePicture() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt new file mode 100644 index 000000000..f5e5c86f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt @@ -0,0 +1,430 @@ +/* + * 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.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastBanner +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus +import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner +import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator + +/** + * Wrapper screen for live chess game + * + * Connects ChessViewModel to LiveChessGameScreen UI component + * + * @param gameId Unique game identifier + * @param accountViewModel Account view model + * @param nav Navigation interface + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChessGameScreen( + gameId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Scope ViewModel to Activity so it's shared between lobby and game screens + val activity = LocalContext.current as FragmentActivity + val chessViewModel: ChessViewModelNew = + viewModel( + viewModelStoreOwner = activity, + key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", + factory = + ChessViewModelFactory( + accountViewModel.account, + ), + ) + + val activeGames by chessViewModel.activeGames.collectAsState() + val spectatingGames by chessViewModel.spectatingGames.collectAsState() + val error by chessViewModel.error.collectAsState() + val broadcastStatus by chessViewModel.broadcastStatus.collectAsState() + val syncStatus by chessViewModel.syncStatus.collectAsState() + // Observe state version to force recomposition when game state changes internally + val stateVersion by chessViewModel.stateVersion.collectAsState() + var isLoading by remember { mutableStateOf(true) } + var loadAttempted by remember { mutableStateOf(false) } + var showRelaySettings by remember { mutableStateOf(false) } + + // Always read directly from maps for auto-refresh support + // stateVersion changes force recomposition, then we get latest from maps + val gameState = activeGames[gameId] ?: spectatingGames[gameId] + + // Get relay information + val outboxRelays by accountViewModel.account.outboxRelays.flow + .collectAsState() + val writeRelays = + remember(outboxRelays) { + outboxRelays.map { it.toString() } + } + + // Subscribe to chess events when game screen is visible + // This is critical - without it, no new events will arrive from relays + ChessSubscription(accountViewModel, chessViewModel) + + // Handle initial game loading + // Once game is in maps, gameState above will always have the latest + LaunchedEffect(gameId, gameState) { + if (gameState != null) { + // Game found in maps - done loading + isLoading = false + loadAttempted = true + } else if (!loadAttempted) { + // Check if this game was accepted locally (prevents incorrect spectator detection) + val wasAccepted = chessViewModel.wasAccepted(gameId) + + // Brief delay to allow acceptChallenge coroutine to complete + // This handles the race condition where navigation happens before + // the game state is fully propagated to StateFlow collectors + kotlinx.coroutines.delay(150) + + // Check again after delay - game may have been added by acceptChallenge + val stateAfterDelay = chessViewModel.getGameState(gameId) + if (stateAfterDelay != null) { + isLoading = false + loadAttempted = true + } else if (wasAccepted) { + // Game was accepted but not yet in StateFlow - keep waiting, don't fetch from relays + // The state will propagate and trigger this LaunchedEffect again + isLoading = true + } else { + // Game not found locally and not accepted, try to load from relays + loadAttempted = true + chessViewModel.loadGame(gameId) + isLoading = false + } + } + } + + // Set focused game mode when screen is visible (only poll this game) + // ViewModel already starts polling in init, so no need to start here + DisposableEffect(gameId) { + chessViewModel.setFocusedGame(gameId) // Focus on just this game + onDispose { + // Don't clear focus here - let lobby handle it when we navigate back + } + } + + // Extract human-readable game name + val gameName = + remember(gameId) { + ChessGameNameGenerator.extractDisplayName(gameId) ?: "Chess Game" + } + + Scaffold( + topBar = { + Column { + TopAppBar( + title = { Text(gameName) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + actions = { + IconButton(onClick = { showRelaySettings = true }) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Relay Settings", + ) + } + }, + ) + + // Sync status banner + ChessSyncBanner( + status = syncStatus, + onRetry = { chessViewModel.forceRefresh() }, + ) + + // Broadcast status banner (shows when publishing moves) + ChessBroadcastBanner( + status = broadcastStatus, + onTap = { + when (broadcastStatus) { + is ChessBroadcastStatus.Failed -> { + // Could implement retry logic here + } + + is ChessBroadcastStatus.Desynced -> { + chessViewModel.forceRefresh() + } + + else -> { } + } + }, + ) + } + }, + ) { 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 -> { + // Resolve opponent display name + val opponentDisplayName = + remember(gameState.opponentPubkey) { + accountViewModel.checkGetOrCreateUser(gameState.opponentPubkey)?.toBestDisplayName() + ?: gameState.opponentPubkey.take(8) + } + + // Determine spectator status: + // 1. If game was accepted locally, user is definitely NOT a spectator + // 2. Otherwise, check which map the game is in + val wasAccepted = chessViewModel.wasAccepted(gameId) + val isSpectating = !wasAccepted && spectatingGames.containsKey(gameId) + + // Show game with proper padding for status bar + // Use state-observing version for automatic refresh on polling updates + LiveChessGameScreen( + modifier = Modifier.padding(paddingValues), + gameState = gameState, + opponentName = opponentDisplayName, + onMoveMade = { from, to, san -> + chessViewModel.publishMove(gameId, from, to) + }, + onResign = { chessViewModel.resign(gameId) }, + isSpectatorOverride = isSpectating, + ) + } + } + } + + // Relay settings bottom sheet + if (showRelaySettings) { + ModalBottomSheet( + onDismissRequest = { showRelaySettings = false }, + sheetState = rememberModalBottomSheetState(), + ) { + RelaySettingsSheet( + writeRelays = writeRelays, + gameId = gameId, + ) + } + } +} + +/** + * Bottom sheet showing relay settings for the chess game + */ +@Composable +private fun RelaySettingsSheet( + writeRelays: List, + gameId: String, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "Chess Game Relays", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Moves are broadcast to these relays:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (writeRelays.isEmpty()) { + Text( + text = "No write relays configured", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(writeRelays) { relay -> + RelayItem(relayUrl = relay, isConnected = true) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + HorizontalDivider() + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Game ID", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = gameId, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +@Composable +private fun RelayItem( + relayUrl: String, + isConnected: Boolean, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Circle, + contentDescription = if (isConnected) "Connected" else "Disconnected", + tint = if (isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + + Text( + text = relayUrl.removePrefix("wss://").removePrefix("ws://"), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt new file mode 100644 index 000000000..2062caca8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -0,0 +1,803 @@ +/* + * 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.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastBanner +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessConfig +import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner +import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog +import com.vitorpamplona.amethyst.commons.chess.PublicGame +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor + +/** + * Chess lobby screen showing challenges and active games + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChessLobbyScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + // Scope ViewModel to Activity so it's shared between lobby and game screens + val activity = LocalContext.current as FragmentActivity + val chessViewModel: ChessViewModelNew = + viewModel( + viewModelStoreOwner = activity, + key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", + factory = ChessViewModelFactory(accountViewModel.account), + ) + + val activeGames by chessViewModel.activeGames.collectAsState() + val spectatingGames by chessViewModel.spectatingGames.collectAsState() + val publicGames by chessViewModel.publicGames.collectAsState() + val challenges by chessViewModel.challenges.collectAsState() + val error by chessViewModel.error.collectAsState() + val broadcastStatus by chessViewModel.broadcastStatus.collectAsState() + val syncStatus by chessViewModel.syncStatus.collectAsState() + val selectedGameId by chessViewModel.selectedGameId.collectAsState() + val userPubkey = accountViewModel.account.userProfile().pubkeyHex + + var showNewGameDialog by remember { mutableStateOf(false) } + var showRelaySettings by remember { mutableStateOf(false) } + var isRefreshing by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + // Get relay information for settings display + val inboxRelays by accountViewModel.account.notificationRelays.flow + .collectAsState() + val outboxRelays by accountViewModel.account.outboxRelays.flow + .collectAsState() + val globalRelays by accountViewModel.account.defaultGlobalRelays.flow + .collectAsState() + + // Subscribe to chess events when screen is visible + ChessSubscription(accountViewModel, chessViewModel) + + // 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) + } + } + } + + // Set lobby mode (poll all games) when screen is visible + // Don't stop polling in onDispose - ViewModel lifecycle handles that + // This prevents the race where lobby's onDispose fires AFTER game screen enters + DisposableEffect(Unit) { + chessViewModel.clearFocusedGame() // Clear any focused game from game screen + chessViewModel.startPolling() + onDispose { + // Don't stop polling here - it causes a race condition: + // 1. Game screen enters and sets focused mode + // 2. Lobby's onDispose fires and stops ALL polling + // ViewModel.onCleared() will stop polling when user leaves chess entirely + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.route_chess)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + actions = { + IconButton(onClick = { showRelaySettings = true }) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Relay Settings", + ) + } + }, + ) + }, + 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), + ) { + // Sync status banner (shows relay progress during loading) + ChessSyncBanner( + status = syncStatus, + onRetry = { chessViewModel.forceRefresh() }, + modifier = Modifier.padding(bottom = 8.dp), + ) + + // Broadcast status banner + ChessBroadcastBanner( + status = broadcastStatus, + onTap = { /* no-op in lobby */ }, + modifier = Modifier.padding(bottom = 8.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, + spectatingGames = spectatingGames, + publicGames = publicGames, + userPubkey = userPubkey, + accountViewModel = accountViewModel, + onAcceptChallenge = { challenge -> + chessViewModel.acceptChallenge(challenge) + }, + onOpenOwnChallenge = { challenge -> + chessViewModel.openOwnChallenge(challenge) + }, + onSelectGame = { gameId -> + nav.nav(Route.ChessGame(gameId)) + }, + onWatchGame = { gameId -> + chessViewModel.loadGameAsSpectator(gameId) + nav.nav(Route.ChessGame(gameId)) + }, + ) + } + } + } + + // New game dialog + if (showNewGameDialog) { + NewChessGameDialog( + onDismiss = { showNewGameDialog = false }, + onCreateGame = { opponentPubkey, color -> + chessViewModel.createChallenge(opponentPubkey, color) + showNewGameDialog = false + }, + ) + } + + // Relay settings bottom sheet + if (showRelaySettings) { + ModalBottomSheet( + onDismissRequest = { showRelaySettings = false }, + sheetState = rememberModalBottomSheetState(), + ) { + ChessRelaySettingsSheet( + inboxRelays = inboxRelays.map { it.toString() }, + outboxRelays = outboxRelays.map { it.toString() }, + globalRelays = globalRelays.map { it.toString() }, + challengeCount = challenges.size, + publicGameCount = publicGames.size, + ) + } + } +} + +@Composable +fun ChessLobbyContent( + challenges: List, + activeGames: Map, + spectatingGames: Map, + publicGames: List, + userPubkey: String, + accountViewModel: AccountViewModel, + onAcceptChallenge: (ChessChallenge) -> Unit, + onOpenOwnChallenge: (ChessChallenge) -> Unit, + onSelectGame: (String) -> Unit, + onWatchGame: (String) -> Unit, +) { + val hasContent = + activeGames.isNotEmpty() || spectatingGames.isNotEmpty() || + publicGames.isNotEmpty() || challenges.isNotEmpty() + + if (!hasContent) { + // Empty state - use LazyColumn so pull-to-refresh works + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + 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, + ) + Spacer(Modifier.height(16.dp)) + Text( + "Pull down to refresh", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + return + } + + // Track outgoing challenges to scroll to top when a new one is created + val outgoingChallengesCount = challenges.count { it.isFrom(userPubkey) } + val listState = rememberLazyListState() + + // Scroll to top when user creates a new challenge + LaunchedEffect(outgoingChallengesCount) { + if (outgoingChallengesCount > 0) { + listState.animateScrollToItem(0) + } + } + + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Active games section (user is participant) + if (activeGames.isNotEmpty()) { + item { + Text( + "Your Games", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) -> + val displayName = + remember(state.opponentPubkey) { + accountViewModel.checkGetOrCreateUser(state.opponentPubkey)?.toBestDisplayName() ?: state.opponentPubkey.take(8) + } + com.vitorpamplona.amethyst.commons.chess.ActiveGameCard( + gameId = gameId, + opponentName = displayName, + isYourTurn = state.isPlayerTurn(), + onClick = { onSelectGame(gameId) }, + ) + } + } + + // User's outgoing challenges (right after active games - user wants to see their new challenges) + val outgoingChallenges = challenges.filter { it.isFrom(userPubkey) } + if (outgoingChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Your Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(outgoingChallenges, key = { "outgoing-${it.eventId}" }) { challenge -> + val opponentName = + challenge.opponentPubkey?.let { pubkey -> + accountViewModel.checkGetOrCreateUser(pubkey)?.toBestDisplayName() ?: pubkey.take(8) + } + com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard( + opponentName = opponentName, + userPlaysWhite = challenge.challengerColor == ChessColor.WHITE, + onClick = { onOpenOwnChallenge(challenge) }, + ) + } + } + + // Games user is spectating + if (spectatingGames.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Watching", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(spectatingGames.entries.toList(), key = { "spectating-${it.key}" }) { (gameId, state) -> + val moveCount = + state.moveHistory + .collectAsState() + .value.size + com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard( + moveCount = moveCount, + onClick = { onSelectGame(gameId) }, + ) + } + } + + // Incoming challenges (directed at user) + val incomingChallenges = challenges.filter { it.isDirectedAt(userPubkey) } + if (incomingChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Incoming Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(incomingChallenges, key = { "incoming-${it.eventId}" }) { challenge -> + val displayName = + remember(challenge.challengerPubkey, challenge.challengerDisplayName) { + challenge.challengerDisplayName + ?: accountViewModel.checkGetOrCreateUser(challenge.challengerPubkey)?.toBestDisplayName() + ?: challenge.challengerPubkey.take(8) + } + com.vitorpamplona.amethyst.commons.chess.ChallengeCard( + challengerName = displayName, + challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, + isIncoming = true, + onAccept = { onAcceptChallenge(challenge) }, + ) + } + } + + // Open challenges from others (can join) + val openChallenges = challenges.filter { it.isOpen && !it.isFrom(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 = { "open-${it.eventId}" }) { challenge -> + val displayName = + remember(challenge.challengerPubkey, challenge.challengerDisplayName) { + challenge.challengerDisplayName + ?: accountViewModel.checkGetOrCreateUser(challenge.challengerPubkey)?.toBestDisplayName() + ?: challenge.challengerPubkey.take(8) + } + com.vitorpamplona.amethyst.commons.chess.ChallengeCard( + challengerName = displayName, + challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, + isIncoming = false, + onAccept = { onAcceptChallenge(challenge) }, + ) + } + } + + // Live games to watch (public games user is not part of) + if (publicGames.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Live Games", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(publicGames, key = { "public-${it.gameId}" }) { game -> + val whiteName = game.whiteDisplayName ?: game.whitePubkey.take(8) + val blackName = game.blackDisplayName ?: game.blackPubkey.take(8) + com.vitorpamplona.amethyst.commons.chess.PublicGameCard( + whiteName = whiteName, + blackName = blackName, + moveCount = game.moveCount, + onWatch = { onWatchGame(game.gameId) }, + ) + } + } + + // Bottom padding for FAB + item { + Spacer(Modifier.height(80.dp)) + } + } +} + +/** + * Bottom sheet showing relay settings and debug info for chess subscriptions + */ +@Composable +private fun ChessRelaySettingsSheet( + inboxRelays: List, + outboxRelays: List, + globalRelays: List, + challengeCount: Int, + publicGameCount: Int, + connectedRelayCount: Int = 0, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = "Chess Relay Settings", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Stats section + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "$challengeCount", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "Challenges", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "$publicGameCount", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "Live Games", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // Chess relay info + Text( + text = "Active Chess Relays", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Chess uses 3 dedicated relays for fast, reliable queries", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + + // Show chess relays and their connection status + ChessConfig.CHESS_RELAY_NAMES.forEach { relay -> + val isConnected = + inboxRelays.any { it.contains(relay) } || + outboxRelays.any { it.contains(relay) } || + globalRelays.any { it.contains(relay) } + ChessRelayRow(relayUrl = "wss://$relay/", isConnected = isConnected) + } + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // Inbox relays (where challenges TO you are fetched) + Text( + text = "Inbox Relays (${inboxRelays.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Personal challenges are fetched from here", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + if (inboxRelays.isEmpty()) { + Text( + text = "No inbox relays configured", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } else { + inboxRelays.take(5).forEach { relay -> + RelayRow(relayUrl = relay) + } + if (inboxRelays.size > 5) { + Text( + text = "+${inboxRelays.size - 5} more", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Global relays (where open challenges are fetched) + Text( + text = "Global Relays (${globalRelays.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Open challenges and public games are fetched from here", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + if (globalRelays.isEmpty()) { + Text( + text = "No global relays configured - open challenges won't load!", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } else { + globalRelays.take(5).forEach { relay -> + RelayRow(relayUrl = relay) + } + if (globalRelays.size > 5) { + Text( + text = "+${globalRelays.size - 5} more", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Outbox relays (where your challenges are published) + Text( + text = "Outbox Relays (${outboxRelays.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Your challenges and moves are published here", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + if (outboxRelays.isEmpty()) { + Text( + text = "No outbox relays configured", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } else { + outboxRelays.take(5).forEach { relay -> + RelayRow(relayUrl = relay) + } + if (outboxRelays.size > 5) { + Text( + text = "+${outboxRelays.size - 5} more", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +@Composable +private fun RelayRow( + relayUrl: String, + isPreferred: Boolean = false, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Connected", + tint = + if (isPreferred) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.primary + }, + modifier = Modifier.size(12.dp), + ) + Text( + text = relayUrl.removePrefix("wss://").removePrefix("ws://"), + style = MaterialTheme.typography.bodySmall, + fontWeight = if (isPreferred) FontWeight.Medium else FontWeight.Normal, + ) + if (isPreferred) { + Text( + text = "preferred", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } +} + +@Composable +private fun ChessRelayRow( + relayUrl: String, + isConnected: Boolean, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = + if (isConnected) { + Icons.Default.CheckCircle + } else { + Icons.Default.Close + }, + contentDescription = if (isConnected) "Connected" else "Not connected", + tint = + if (isConnected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + modifier = Modifier.size(16.dp), + ) + Text( + text = relayUrl.removePrefix("wss://").removePrefix("ws://").removeSuffix("/"), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.weight(1f)) + Text( + text = if (isConnected) "connected" else "not connected", + style = MaterialTheme.typography.labelSmall, + color = + if (isConnected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessSubscription.kt new file mode 100644 index 000000000..9028de195 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessSubscription.kt @@ -0,0 +1,164 @@ +/* + * 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.commons.chess.subscription.ChessFilterBuilder +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionState +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Subscribe to chess events when the Chess screen is active. + * + * Uses Amethyst's subscription system to fetch events into LocalCache, + * then triggers ViewModel refresh to process them. + */ +@Composable +fun ChessSubscription( + accountViewModel: AccountViewModel, + chessViewModel: ChessViewModelNew, +) { + // Get active game IDs from the view model for game-specific subscriptions + val activeGames by chessViewModel.activeGames.collectAsStateWithLifecycle() + val spectatingGames by chessViewModel.spectatingGames.collectAsStateWithLifecycle() + val activeGameIds = activeGames.keys + spectatingGames.keys + + // Extract opponent pubkeys using stable keys (avoid recomposition from LiveChessGameState identity) + val opponentPubkeys = + remember(activeGameIds) { + activeGames.values.map { it.opponentPubkey }.toSet() + } + + val state = + remember(accountViewModel.account, activeGameIds, opponentPubkeys) { + ChessQueryState( + userPubkey = accountViewModel.account.userProfile().pubkeyHex, + // Use notification inbox relays - where others send events TO us + inboxRelays = accountViewModel.account.notificationRelays.flow.value, + // Always use global relays for chess - we want to see ALL games + globalRelays = accountViewModel.account.defaultGlobalRelays.flow.value, + // Always treat as global for chess + isGlobal = true, + activeGameIds = activeGameIds, + opponentPubkeys = opponentPubkeys, + ) + } + + // Register subscription with Amethyst's subscription system + KeyDataSourceSubscription(state, accountViewModel.dataSources().chess) + + // Trigger ViewModel refresh when subscription state changes + // This fetches challenges from LocalCache after events arrive + DisposableEffect(state) { + chessViewModel.forceRefresh() + onDispose { + // Subscription cleanup handled by KeyDataSourceSubscription + } + } +} + +/** + * Query state for chess subscription + */ +data class ChessQueryState( + val userPubkey: String, + val inboxRelays: Set, + val globalRelays: Set, + val isGlobal: Boolean, + val activeGameIds: Set = emptySet(), + val opponentPubkeys: 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 using the shared [ChessFilterBuilder]. + * + * Following jesterui's approach: + * 1. Fetch ALL challenges from ALL connected relays (no author/tag restriction) + * 2. Filter client-side based on user's preferences (Global/Follows) + * 3. Game-specific subscriptions for active games + * + * This ensures we see: + * - Open challenges from anyone + * - Challenges directed at us + * - Public games to spectate + */ +fun filterChessEvents( + key: ChessQueryState, + since: SincePerRelayMap?, +): List { + // Convert Android ChessQueryState to shared ChessSubscriptionState + val state = + ChessSubscriptionState( + userPubkey = key.userPubkey, + relays = key.inboxRelays + key.globalRelays, + activeGameIds = key.activeGameIds, + opponentPubkeys = key.opponentPubkeys, + ) + + // Use shared filter builder for consistent behavior with Desktop + return ChessFilterBuilder.buildAllFilters(state) { relay -> + since?.get(relay)?.time + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt new file mode 100644 index 000000000..b4c33a68d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account + +/** + * Factory for creating ChessViewModelNew instances. + * Uses the slim ViewModel that delegates to shared ChessLobbyLogic. + */ +class ChessViewModelFactory( + private val account: Account, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(ChessViewModelNew::class.java)) { + return ChessViewModelNew(account) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt new file mode 100644 index 000000000..9c0156997 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic +import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults +import com.vitorpamplona.amethyst.commons.chess.ChessSyncStatus +import com.vitorpamplona.amethyst.commons.chess.CompletedGame +import com.vitorpamplona.amethyst.commons.chess.PublicGame +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import kotlinx.coroutines.flow.StateFlow + +/** + * Slim Android ViewModel for chess (~120 lines). + * + * Delegates all business logic to ChessLobbyLogic. + * Only handles Android-specific concerns: + * - ViewModel lifecycle (viewModelScope) + * - Platform adapter creation + * - State exposure to Compose UI + */ +class ChessViewModelNew( + private val account: Account, +) : ViewModel() { + // Instance ID for debugging ViewModel sharing + val instanceId = System.identityHashCode(this) + + init { + println("[ChessViewModelNew] Created instance $instanceId for ${account.userProfile().pubkeyHex.take(8)}") + } + + // Platform adapters + private val publisher = AndroidChessPublisher(account) + private val fetcher = AndroidRelayFetcher(account) + private val metadataProvider = AndroidMetadataProvider() + + // Shared business logic (creates its own ChessLobbyState internally) + private val logic = + ChessLobbyLogic( + userPubkey = account.userProfile().pubkeyHex, + publisher = publisher, + fetcher = fetcher, + metadataProvider = metadataProvider, + scope = viewModelScope, + pollingConfig = ChessPollingDefaults.android, + ) + + // ============================================ + // State exposure (delegated from ChessLobbyLogic.state) + // ============================================ + + val activeGames: StateFlow> = logic.state.activeGames + val spectatingGames: StateFlow> = logic.state.spectatingGames + val challenges: StateFlow> = logic.state.challenges + val publicGames: StateFlow> = logic.state.publicGames + val completedGames: StateFlow> = logic.state.completedGames + val broadcastStatus: StateFlow = logic.state.broadcastStatus + val error: StateFlow = logic.state.error + val selectedGameId: StateFlow = logic.state.selectedGameId + val isRefreshing: StateFlow = logic.state.isRefreshing + val stateVersion: StateFlow = logic.state.stateVersion + val syncStatus: StateFlow = logic.state.syncStatus + + /** Badge count (incoming challenges + your turn games) - computed property */ + val badgeCount: Int get() = logic.state.badgeCount + + // ============================================ + // Lifecycle + // ============================================ + + init { + logic.startPolling() + } + + fun startPolling() = logic.startPolling() + + fun stopPolling() = logic.stopPolling() + + fun forceRefresh() = logic.forceRefresh() + + /** + * Ensure a game ID is being polled for updates. + * Call this when entering a game screen. + */ + fun ensureGamePolling(gameId: String) = logic.ensureGamePolling(gameId) + + /** + * Set focused game mode - only poll this specific game. + * Call this when entering a game screen to avoid refreshing unrelated games. + */ + fun setFocusedGame(gameId: String) = logic.setFocusedGame(gameId) + + /** + * Clear focused game mode - return to lobby mode (poll all games). + * Call this when returning to the lobby screen. + */ + fun clearFocusedGame() = logic.clearFocusedGame() + + override fun onCleared() { + super.onCleared() + logic.stopPolling() + } + + // ============================================ + // Incoming event routing (from relay subscriptions) + // ============================================ + + fun handleIncomingEvent(event: Event) { + if (event.kind != JesterProtocol.KIND) return + val jesterEvent = event.toJesterEvent() ?: return + logic.handleIncomingEvent(jesterEvent) + } + + // ============================================ + // Challenge operations + // ============================================ + + fun createChallenge( + opponentPubkey: String? = null, + playerColor: Color = Color.WHITE, + timeControl: String? = null, + ) = logic.createChallenge(opponentPubkey, playerColor, timeControl) + + fun acceptChallenge(challenge: ChessChallenge) = logic.acceptChallenge(challenge) + + fun openOwnChallenge(challenge: ChessChallenge) = logic.openOwnChallenge(challenge) + + // ============================================ + // Game operations + // ============================================ + + fun selectGame(gameId: String?) = logic.selectGame(gameId) + + fun publishMove( + gameId: String, + from: String, + to: String, + ) = logic.publishMove(gameId, from, to) + + fun resign(gameId: String) = logic.resign(gameId) + + fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId) + + // ============================================ + // Spectator operations + // ============================================ + + fun loadGame(gameId: String) = logic.loadGame(gameId) + + fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId) + + fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId) + + // ============================================ + // Utility + // ============================================ + + fun clearError() = logic.clearError() + + fun getGameState(gameId: String): LiveChessGameState? = logic.state.getGameState(gameId) + + /** Check if a game was accepted (prevents loading as spectator during race) */ + fun wasAccepted(gameId: String): Boolean = logic.state.wasAccepted(gameId) + + /** Helper for derived challenge lists */ + fun incomingChallenges(): List = logic.state.incomingChallenges() + + fun outgoingChallenges(): List = logic.state.outgoingChallenges() + + fun openChallenges(): List = logic.state.openChallenges() +} 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..c0baa2135 --- /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/chess/dal/ChessFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt new file mode 100644 index 000000000..4ffd267a1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt @@ -0,0 +1,88 @@ +/* + * 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.dal + +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.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent + +/** + * Feed filter for NIP-64 Chess Game events (Kind 64) + * + * Filters the local cache to show only chess games, + * respecting user's follow lists and hidden users. + */ +class ChessFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-chess-feed" + + override fun limit() = 200 + + override fun showHiddenKey(): Boolean = + account.settings.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.settings.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + + override fun feed(): List { + val params = buildFilterParams(account) + + val notes = + LocalCache.notes.filterIntoSet { _, it -> + acceptableEvent(it, params) + } + + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + private fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + + return collection.filterTo(HashSet()) { acceptableEvent(it, params) } + } + + fun acceptableEvent( + note: Note, + params: FilterByListParams, + ): Boolean { + val noteEvent = note.event + + return (noteEvent is ChessGameEvent) && + params.match(noteEvent, note.relays) && + (params.isHiddenList || account.isAcceptable(note)) + } + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + followLists = account.liveStoriesFollowLists.value, + hiddenUsers = account.hiddenUsers.flow.value, + ) + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} 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/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 06fca2a82..3b945df3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActiviti import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.model.AROUND_ME +import com.vitorpamplona.amethyst.model.CHESS import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -234,21 +235,29 @@ fun HomeScreenFloatingButton( accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - if (list.value == AROUND_ME) { - val location by Amethyst.instance.locationManager.geohashStateFlow - .collectAsStateWithLifecycle() + when (list.value) { + AROUND_ME -> { + val location by Amethyst.instance.locationManager.geohashStateFlow + .collectAsStateWithLifecycle() - when (val myLocation = location) { - is LocationState.LocationResult.Success -> { - NewGeoPostButton(myLocation.geoHash.toString(), accountViewModel, nav) + when (val myLocation = location) { + is LocationState.LocationResult.Success -> { + NewGeoPostButton(myLocation.geoHash.toString(), accountViewModel, nav) + } + + is LocationState.LocationResult.LackPermission -> { } + + is LocationState.LocationResult.Loading -> { } } - - is LocationState.LocationResult.LackPermission -> { } - - is LocationState.LocationResult.Loading -> { } } - } else { - NewNoteButton(nav) + + CHESS -> { + NewChessGameButton(accountViewModel, nav) + } + + else -> { + NewNoteButton(nav) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt new file mode 100644 index 000000000..ca4bbefb7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt @@ -0,0 +1,76 @@ +/* + * 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.home + +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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelNew + +/** + * Floating action button for creating new chess game challenges + */ +@Composable +fun NewChessGameButton( + accountViewModel: AccountViewModel, + nav: INav, +) { + var showDialog by remember { mutableStateOf(false) } + + val chessViewModel: ChessViewModelNew = + viewModel( + key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", + factory = ChessViewModelFactory(accountViewModel.account), + ) + + FloatingActionButton( + onClick = { showDialog = true }, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "New Chess Game", + ) + } + + if (showDialog) { + NewChessGameDialog( + onDismiss = { showDialog = false }, + onCreateGame = { opponentPubkey, color -> + chessViewModel.createChallenge( + opponentPubkey = opponentPubkey, + playerColor = color, + ) + showDialog = false + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index a179eeb92..fcf5941dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -39,6 +39,9 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -55,6 +58,8 @@ class HomeNewThreadFeedFilter( WikiNoteEvent.KIND, ClassifiedsEvent.KIND, LongTextNoteEvent.KIND, + LiveChessGameChallengeEvent.KIND, + LiveChessGameEndEvent.KIND, ) } @@ -118,7 +123,10 @@ class HomeNewThreadFeedFilter( noteEvent is CommentEvent || noteEvent is AudioTrackEvent || noteEvent is VoiceEvent || - noteEvent is AudioHeaderEvent + noteEvent is AudioHeaderEvent || + noteEvent is ChessGameEvent || + noteEvent is LiveChessGameChallengeEvent || + noteEvent is LiveChessGameEndEvent ) && filterParams.match(noteEvent, it.relays) && it.isNewThread() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip64Chess/FilterHomePostsByChess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip64Chess/FilterHomePostsByChess.kt new file mode 100644 index 000000000..3999badfc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip64Chess/FilterHomePostsByChess.kt @@ -0,0 +1,58 @@ +/* + * 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.home.datasource.nip64Chess + +import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent + +val ChessPostsKinds = + listOf( + ChessGameEvent.KIND, // Completed games (Kind 64) + LiveChessGameChallengeEvent.KIND, // Challenges (Kind 30064) + LiveChessGameEndEvent.KIND, // Game endings (Kind 30067) + ) + +fun filterHomePostsByChess( + relays: ChessTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + newThreadSince: Long?, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.map { + val since = since?.get(it.key)?.time + val relayUrl = it.key + RelayBasedFilter( + relay = relayUrl, + filter = + Filter( + kinds = ChessPostsKinds, + limit = 50, + since = since ?: newThreadSince, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt index 35516310d..fb9ca719e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -39,6 +39,9 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent @@ -60,6 +63,9 @@ val HomePostsNewThreadKinds = PollNoteEvent.KIND, PollEvent.KIND, InteractiveStoryPrologueEvent.KIND, + ChessGameEvent.KIND, + LiveChessGameChallengeEvent.KIND, + LiveChessGameEndEvent.KIND, ) val HomePostsConversationKinds = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index d3d7f35ae..87ea8d662 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follo import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet @@ -35,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQuerySt import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByGeohashes import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByGlobal import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip64Chess.filterHomePostsByChess import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -62,6 +64,7 @@ class HomeOutboxEventsEoseManager( is AllCommunitiesTopNavPerRelayFilterSet -> filterHomePostsByAllCommunities(feedSettings, since, newThreadSince) is AllFollowsTopNavPerRelayFilterSet -> filterHomePostsByAllFollows(feedSettings, since, newThreadSince, repliesSince) is AuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince) + is ChessTopNavPerRelayFilterSet -> filterHomePostsByChess(feedSettings, since, newThreadSince) is GlobalTopNavPerRelayFilterSet -> filterHomePostsByGlobal(feedSettings, since, newThreadSince, repliesSince) is HashtagTopNavPerRelayFilterSet -> filterHomePostsByHashtags(feedSettings, since, newThreadSince) is LocationTopNavPerRelayFilterSet -> filterHomePostsByGeohashes(feedSettings, since, newThreadSince) 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 571ce3cb1..1096a822b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -572,6 +572,7 @@ Follows via Proxy Around Me Global + Chess Mute List Follow Lists @@ -1192,6 +1193,7 @@ Notifications Global Shorts + Chess Security Filters New Post diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt new file mode 100644 index 000000000..eb7b528af --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +/** + * Global registry of accepted game IDs. + * + * This singleton ensures that accepted game state is shared across all ViewModel instances. + * Without this, different ViewModels (e.g., lobby vs game screen) would have separate + * acceptedGameIds sets, causing the game screen to incorrectly load as spectator. + */ +object AcceptedGamesRegistry { + private val acceptedGameIds = mutableSetOf() + private val lock = Any() + + fun markAsAccepted(gameId: String) { + synchronized(lock) { + acceptedGameIds.add(gameId) + } + } + + fun wasAccepted(gameId: String): Boolean = + synchronized(lock) { + acceptedGameIds.contains(gameId) + } + + fun clear() { + synchronized(lock) { + acceptedGameIds.clear() + } + } + + /** Remove old entries - call periodically to prevent memory leak */ + fun clearOldEntries(keepGameIds: Set) { + synchronized(lock) { + acceptedGameIds.retainAll(keepGameIds) + } + } +} 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 new file mode 100644 index 000000000..dc0cec5bb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.foundation.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.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.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 + +/** + * Renders an 8x8 chess board with pieces + * Shared composable for Android and Desktop platforms + * + * @param position The chess position to display + * @param modifier Modifier for the board + * @param boardSize Total size of the board in dp + * @param showCoordinates Whether to show file labels (a-h) on bottom rank + */ +@Composable +fun ChessBoard( + position: ChessPosition, + modifier: Modifier = Modifier, + boardSize: Dp = 320.dp, + showCoordinates: Boolean = true, +) { + val squareSize = boardSize / 8 + + Column(modifier = modifier.size(boardSize)) { + // Render ranks 8 down to 1 (from white's perspective) + for (rank in 7 downTo 0) { + Row { + for (file in 0..7) { + val piece = position.pieceAt(file, rank) + val isLightSquare = (rank + file) % 2 == 0 + + ChessSquare( + piece = piece, + isLight = isLightSquare, + size = squareSize, + coordinate = + if (showCoordinates && rank == 0) { + ('a' + file).toString() + } else { + null + }, + ) + } + } + } + } +} + +/** + * Renders a single square on the chess board + */ +@Composable +private fun ChessSquare( + piece: com.vitorpamplona.quartz.nip64Chess.ChessPiece?, + isLight: Boolean, + size: Dp, + coordinate: String?, +) { + Box( + modifier = + Modifier + .size(size) + .background( + if (isLight) Color(0xFFF0D9B5) else Color(0xFFB58863), + ), + contentAlignment = Alignment.Center, + ) { + // Display piece using CBurnett ImageVector icons + piece?.let { + Image( + imageVector = it.toImageVector(), + contentDescription = "${it.color} ${it.type}", + modifier = Modifier.fillMaxSize().padding(2.dp), + ) + } + + // Show file coordinate (a-h) on bottom rank + coordinate?.let { + Text( + text = it, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = if (isLight) Color(0xFFB58863) else Color(0xFFF0D9B5), + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(2.dp), + ) + } + } +} + +/** + * Extension function to convert ChessPiece to CBurnett ImageVector + */ +private fun ChessPiece.toImageVector(): ImageVector { + val white = color == ChessColor.WHITE + return when (type) { + 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/ChessBroadcastBanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBroadcastBanner.kt new file mode 100644 index 000000000..e876cfe80 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBroadcastBanner.kt @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.HourglassBottom +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * Shared banner showing chess broadcast status - broadcast progress, sync status, etc. + * Works on both Android and Desktop via Compose Multiplatform. + */ +@Composable +fun ChessBroadcastBanner( + status: ChessBroadcastStatus, + onTap: () -> Unit, + modifier: Modifier = Modifier, +) { + val isVisible = status !is ChessBroadcastStatus.Idle + + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(tween(200)), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(tween(150)), + modifier = modifier, + ) { + Surface( + color = getStatusBackgroundColor(status), + tonalElevation = 2.dp, + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onTap), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .animateContentSize(), + ) { + Icon( + imageVector = getStatusIcon(status), + contentDescription = null, + tint = getStatusIconColor(status), + modifier = Modifier.size(18.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = getStatusText(status), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = getStatusDetail(status), + style = MaterialTheme.typography.labelMedium, + color = getStatusDetailColor(status), + ) + } + + // Show progress bar for broadcasting/syncing + val progress = getStatusProgress(status) + if (progress != null) { + Spacer(Modifier.height(4.dp)) + + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween(300), + label = "progress", + ) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxWidth(), + color = getStatusProgressColor(status), + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun getStatusBackgroundColor(status: ChessBroadcastStatus): Color = + when (status) { + is ChessBroadcastStatus.Failed, is ChessBroadcastStatus.Desynced -> { + MaterialTheme.colorScheme.errorContainer + } + + is ChessBroadcastStatus.Success -> { + MaterialTheme.colorScheme.primaryContainer + } + + else -> { + MaterialTheme.colorScheme.surfaceContainer + } + } + +private fun getStatusIcon(status: ChessBroadcastStatus): ImageVector = + when (status) { + is ChessBroadcastStatus.Broadcasting -> Icons.Default.Sync + is ChessBroadcastStatus.Success -> Icons.Default.CheckCircle + is ChessBroadcastStatus.Failed -> Icons.Default.Error + is ChessBroadcastStatus.WaitingForOpponent -> Icons.Default.HourglassBottom + is ChessBroadcastStatus.Syncing -> Icons.Default.CloudSync + is ChessBroadcastStatus.Desynced -> Icons.Default.Error + is ChessBroadcastStatus.Idle -> Icons.Default.CheckCircle + } + +@Composable +private fun getStatusIconColor(status: ChessBroadcastStatus): Color = + when (status) { + is ChessBroadcastStatus.Failed, is ChessBroadcastStatus.Desynced -> { + MaterialTheme.colorScheme.error + } + + is ChessBroadcastStatus.Success -> { + MaterialTheme.colorScheme.primary + } + + is ChessBroadcastStatus.WaitingForOpponent -> { + MaterialTheme.colorScheme.secondary + } + + else -> { + MaterialTheme.colorScheme.primary + } + } + +private fun getStatusText(status: ChessBroadcastStatus): String = + when (status) { + is ChessBroadcastStatus.Broadcasting -> "Broadcasting: ${status.san}" + is ChessBroadcastStatus.Success -> "Sent: ${status.san}" + is ChessBroadcastStatus.Failed -> "Failed: ${status.san}" + is ChessBroadcastStatus.WaitingForOpponent -> "Waiting for opponent's move..." + is ChessBroadcastStatus.Syncing -> "Syncing game state..." + is ChessBroadcastStatus.Desynced -> "Game desynced: ${status.message}" + is ChessBroadcastStatus.Idle -> "" + } + +private fun getStatusDetail(status: ChessBroadcastStatus): String = + when (status) { + is ChessBroadcastStatus.Broadcasting -> "[${status.successCount}/${status.totalRelays}]" + is ChessBroadcastStatus.Success -> "${status.relayCount} relays" + is ChessBroadcastStatus.Failed -> "Tap to retry" + is ChessBroadcastStatus.Syncing -> "${(status.progress * 100).toInt()}%" + is ChessBroadcastStatus.Desynced -> "Tap to resync" + else -> "" + } + +@Composable +private fun getStatusDetailColor(status: ChessBroadcastStatus): Color = + when (status) { + is ChessBroadcastStatus.Failed, is ChessBroadcastStatus.Desynced -> { + MaterialTheme.colorScheme.error + } + + else -> { + MaterialTheme.colorScheme.primary + } + } + +private fun getStatusProgress(status: ChessBroadcastStatus): Float? = + when (status) { + is ChessBroadcastStatus.Broadcasting -> status.progress + is ChessBroadcastStatus.Syncing -> status.progress + else -> null + } + +@Composable +private fun getStatusProgressColor(status: ChessBroadcastStatus): Color = + when (status) { + is ChessBroadcastStatus.Failed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessConfig.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessConfig.kt new file mode 100644 index 000000000..71a25aa4e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessConfig.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +/** + * Global chess configuration shared across Android and Desktop. + * + * These relays are the primary relays used by Jester and other Nostr chess apps. + * Using a small, fixed set ensures fast queries and reliable game discovery. + */ +object ChessConfig { + /** + * The 3 main relays for chess events. + * These are used for both fetching and publishing chess events. + */ + val CHESS_RELAYS = + listOf( + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.primal.net", + ) + + /** + * Display names for the chess relays (without protocol prefix) + */ + val CHESS_RELAY_NAMES = + listOf( + "relay.damus.io", + "nos.lol", + "relay.primal.net", + ) + + /** + * Timeout for relay queries in milliseconds. + * With only 3 relays, we can wait for all of them. + */ + const val FETCH_TIMEOUT_MS = 10_000L +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt new file mode 100644 index 000000000..c15195dbf --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip64Chess.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.ConcurrentHashMap + +/** + * Collects and aggregates Jester chess events for a game from any source. + * + * This class provides a unified way to collect events regardless of whether + * they come from LocalCache (Android), direct relay (Desktop), or test fixtures. + * It handles: + * - Deduplication by event ID + * - Thread-safe concurrent updates + * - Producing JesterGameEvents snapshots for reconstruction + * + * Jester Protocol: + * - All chess events use kind 30 + * - Start events (content.kind=0) reference START_POSITION_HASH via e-tag + * - Move events (content.kind=1) reference [startEventId, headEventId] via e-tags + * - Full move history is included in every move event + * + * Usage: + * ``` + * val collector = ChessEventCollector(startEventId) + * + * // Add events as they arrive from any source + * collector.addEvent(jesterEvent) + * + * // Get current state for reconstruction + * val events = collector.getEvents() + * val result = ChessStateReconstructor.reconstruct(events, viewerPubkey) + * ``` + */ +class ChessEventCollector( + /** The start event ID - this is the game identifier in Jester protocol */ + val startEventId: String, +) { + // Start event (only one per game) + private val _startEvent = MutableStateFlow(null) + val startEvent: StateFlow = _startEvent.asStateFlow() + + // Move events (deduplicated by event ID) + private val moves = ConcurrentHashMap() + + // Track all processed event IDs for fast deduplication + private val processedEventIds = ConcurrentHashMap.newKeySet() + + // Flow that emits when any event is added (for reactive updates) + private val _eventCount = MutableStateFlow(0) + val eventCount: StateFlow = _eventCount.asStateFlow() + + /** + * Check if an event has already been processed. + */ + fun hasEvent(eventId: String): Boolean = processedEventIds.contains(eventId) + + /** + * Add a Jester event for this game. + * Automatically categorizes as start or move based on content.kind. + * + * @return true if the event was added, false if already exists or invalid + */ + fun addEvent(event: JesterEvent): Boolean { + if (processedEventIds.contains(event.id)) return false + + // Check if this event belongs to our game + val eventStartId = event.startEventId() + val isStartEvent = event.isStartEvent() && event.id == startEventId + val isMoveEvent = event.isMoveEvent() && eventStartId == startEventId + + if (!isStartEvent && !isMoveEvent) return false + + return if (isStartEvent) { + addStartEvent(event) + } else { + addMoveEvent(event) + } + } + + /** + * Add a raw Event if it's a valid Jester event for this game. + * + * @return true if the event was added, false if invalid or not for this game + */ + fun addRawEvent(event: Event): Boolean { + if (event.kind != JesterProtocol.KIND) return false + val jesterEvent = event.toJesterEvent() ?: return false + return addEvent(jesterEvent) + } + + /** + * Add the start event for this game. + * Only the first valid start is kept. + * + * @return true if the event was added, false if already exists or invalid + */ + private fun addStartEvent(event: JesterEvent): Boolean { + if (processedEventIds.contains(event.id)) return false + if (!event.isStartEvent()) return false + if (event.id != startEventId) return false + + if (_startEvent.compareAndSet(null, event)) { + processedEventIds.add(event.id) + incrementEventCount() + return true + } + return false + } + + /** + * Add a move event for this game. + * Moves are deduplicated by event ID. + * + * @return true if the event was added, false if already exists or invalid + */ + private fun addMoveEvent(event: JesterEvent): Boolean { + if (processedEventIds.contains(event.id)) return false + if (!event.isMoveEvent()) return false + if (event.startEventId() != startEventId) return false + + if (moves.putIfAbsent(event.id, event) == null) { + processedEventIds.add(event.id) + incrementEventCount() + return true + } + return false + } + + /** + * Get a snapshot of all collected events for reconstruction. + */ + fun getEvents(): JesterGameEvents = + JesterGameEvents( + startEvent = _startEvent.value, + moves = moves.values.toList(), + ) + + /** + * Check if the game has a start event. + */ + fun hasStartEvent(): Boolean = _startEvent.value != null + + /** + * Check if the game has any moves (indicates game is active). + */ + fun hasMoves(): Boolean = moves.isNotEmpty() + + /** + * Check if the game has ended (has a move with result). + */ + fun hasEnded(): Boolean = moves.values.any { it.result() != null } + + /** + * Get the number of moves collected. + */ + fun moveCount(): Int = moves.size + + /** + * Get the latest move (with longest history). + */ + fun latestMove(): JesterEvent? = moves.values.maxByOrNull { it.history().size } + + /** + * Clear all collected events. + */ + fun clear() { + _startEvent.value = null + moves.clear() + processedEventIds.clear() + _eventCount.value = 0 + } + + private fun incrementEventCount() { + _eventCount.value = processedEventIds.size + } +} + +/** + * Manager for multiple game collectors. + * + * This is useful when managing multiple concurrent games, + * such as in a chess lobby or when spectating multiple games. + */ +class ChessEventCollectorManager { + private val collectors = ConcurrentHashMap() + + /** + * Get or create a collector for a game. + * + * @param startEventId The start event ID (game identifier) + */ + fun getOrCreate(startEventId: String): ChessEventCollector = collectors.getOrPut(startEventId) { ChessEventCollector(startEventId) } + + /** + * Get a collector if it exists. + * + * @param startEventId The start event ID (game identifier) + */ + fun get(startEventId: String): ChessEventCollector? = collectors[startEventId] + + /** + * Remove a collector for a game. + * + * @param startEventId The start event ID (game identifier) + */ + fun remove(startEventId: String): ChessEventCollector? = collectors.remove(startEventId) + + /** + * Get all active game IDs (start event IDs). + */ + fun activeGameIds(): Set = collectors.keys.toSet() + + /** + * Clear all collectors. + */ + fun clear() { + collectors.values.forEach { it.clear() } + collectors.clear() + } + + /** + * Add an event to the appropriate collector (auto-routing). + * Creates a collector if needed for start events. + * + * @return true if the event was added to a collector + */ + fun addEvent(event: JesterEvent): Boolean { + // For start events, create collector with event ID + if (event.isStartEvent()) { + val collector = getOrCreate(event.id) + return collector.addEvent(event) + } + + // For move events, find the collector by startEventId + val startId = event.startEventId() ?: return false + val collector = collectors[startId] ?: return false + return collector.addEvent(event) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt new file mode 100644 index 000000000..f73db65d9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt @@ -0,0 +1,327 @@ +/* + * 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: moderate intervals, no background polling */ + val android = + ChessPollingConfig( + activeGamePollInterval = 5_000L, // 5 seconds for responsive gameplay + challengePollInterval = 15_000L, // 15 seconds for challenges + pollInBackground = false, + ) + + /** Desktop: fast polling for responsive gameplay */ + val desktop = + ChessPollingConfig( + activeGamePollInterval = 2_000L, // 2 seconds for fast updates + challengePollInterval = 10_000L, // 10 seconds for challenges + 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 var manualRefreshJob: Job? = null + + private val _isPolling = MutableStateFlow(false) + val isPolling: StateFlow = _isPolling.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + internal val activeGameIdsFlow = MutableStateFlow>(emptySet()) + + /** + * Focused game ID - when set, only this game is polled for updates. + * Used when viewing a specific game screen to avoid refreshing unrelated games. + * When null, all games in activeGameIdsFlow are polled (lobby mode). + */ + private val _focusedGameId = MutableStateFlow(null) + + /** + * Set focused game mode - only poll this specific game. + * Call this when entering a game screen. + * Pass null to return to lobby mode (poll all games). + */ + fun setFocusedGame(gameId: String?) { + _focusedGameId.value = gameId + } + + /** + * Get current focused game ID (null = lobby mode, polls all games) + */ + fun getFocusedGameId(): String? = _focusedGameId.value + + /** + * Get the effective game IDs to poll based on focused mode. + * In focused mode: only the focused game. + * In lobby mode: all active games. + */ + private fun getEffectiveGameIds(): Set { + val focused = _focusedGameId.value + return if (focused != null) { + // Focused mode - only poll this game + setOf(focused) + } else { + // Lobby mode - poll all games + activeGameIdsFlow.value + } + } + + /** + * Update the set of game IDs to poll for + */ + 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 = getEffectiveGameIds() + if (gameIds.isNotEmpty()) { + try { + onRefreshGames(gameIds) + } catch (_: Exception) { + // Error during refresh - continue polling + } + } + delay(config.activeGamePollInterval) + } + } + + // Poll for challenges (only in lobby mode) + challengePollingJob = + scope.launch { + // Initial fetch (only if not in focused mode) + if (_focusedGameId.value == null) { + onRefreshChallenges() + } + + while (isActive) { + delay(config.challengePollInterval) + // Skip challenge polling in focused mode - game screen doesn't need it + if (_focusedGameId.value == null) { + 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 (debounced - ignores calls if refresh already in progress) + */ + fun refreshNow() { + // Debounce: skip if already refreshing + if (_isRefreshing.value) { + return + } + + // Cancel any pending manual refresh + manualRefreshJob?.cancel() + + manualRefreshJob = + scope.launch { + _isRefreshing.value = true + try { + // In focused mode, skip challenge refresh (game screen doesn't need it) + val focusedId = _focusedGameId.value + if (focusedId == null) { + onRefreshChallenges() + } + val gameIds = getEffectiveGameIds() + if (gameIds.isNotEmpty()) { + onRefreshGames(gameIds) + } + } finally { + _isRefreshing.value = false + } + } + } +} + +/** + * 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/ChessGameLoader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt new file mode 100644 index 000000000..f0557e2ab --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import com.vitorpamplona.quartz.nip64Chess.ChessEngine +import com.vitorpamplona.quartz.nip64Chess.ChessStateReconstructor +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import com.vitorpamplona.quartz.nip64Chess.ReconstructedGameState +import com.vitorpamplona.quartz.nip64Chess.ReconstructionResult +import com.vitorpamplona.quartz.nip64Chess.ViewerRole + +/** + * Converts a ReconstructedGameState (from ChessStateReconstructor) into a + * LiveChessGameState (used by ViewModels for reactive updates). + * + * This bridges the gap between the deterministic reconstruction algorithm + * and the stateful game management that ViewModels need. + * + * Usage: + * ``` + * // Collect events from any source + * val collector = ChessEventCollector(startEventId) + * collector.addEvent(startEvent) + * collector.addEvent(moveEvent1) + * collector.addEvent(moveEvent2) + * + * // Reconstruct using shared algorithm + * val result = ChessStateReconstructor.reconstruct(collector.getEvents(), viewerPubkey) + * + * // Convert to LiveChessGameState for ViewModel use + * val gameState = ChessGameLoader.toLiveGameState(result, viewerPubkey) + * ``` + */ +object ChessGameLoader { + /** + * Convert a ReconstructionResult to a LiveChessGameState for ViewModel use. + * + * @param result The result from ChessStateReconstructor + * @param viewerPubkey The pubkey of the user viewing the game + * @return LiveChessGameState or null if reconstruction failed + */ + fun toLiveGameState( + result: ReconstructionResult, + viewerPubkey: String, + ): LiveChessGameState? { + if (result !is ReconstructionResult.Success) return null + + val state = result.state + val engine = result.engine + + val (playerPubkey, opponentPubkey) = + when (state.viewerRole) { + ViewerRole.WHITE_PLAYER -> viewerPubkey to (state.blackPubkey ?: "") + ViewerRole.BLACK_PLAYER -> viewerPubkey to (state.whitePubkey ?: "") + ViewerRole.SPECTATOR -> viewerPubkey to (state.blackPubkey ?: "") + } + + return LiveChessGameState( + startEventId = state.startEventId, + playerPubkey = playerPubkey, + opponentPubkey = opponentPubkey, + playerColor = state.playerColor, + engine = engine, + createdAt = state.challengeCreatedAt, + isSpectator = state.viewerRole == ViewerRole.SPECTATOR, + isPendingChallenge = state.isPendingChallenge, + initialHeadEventId = state.headEventId, + ).also { gameState -> + // Mark all loaded moves as received to prevent re-application during polling + gameState.markMovesAsReceived(state.appliedMoveNumbers) + + // Mark finished if the game has ended + if (state.isFinished() && state.gameStatus is com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished) { + gameState.markAsFinished( + (state.gameStatus as com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished).result, + ) + } + + // Handle pending draw offer + val drawOfferer = state.pendingDrawOffer + if (drawOfferer != null && drawOfferer != viewerPubkey) { + gameState.receiveDrawOffer(drawOfferer) + } + } + } + + /** + * Load a game using the deterministic reconstruction algorithm. + * + * @param events Collected Jester events for the game + * @param viewerPubkey The pubkey of the user viewing the game + * @return Pair of (LiveChessGameState, ReconstructedGameState) or null if failed + */ + fun loadGame( + events: JesterGameEvents, + viewerPubkey: String, + ): LoadGameResult { + val result = ChessStateReconstructor.reconstruct(events, viewerPubkey) + + return when (result) { + is ReconstructionResult.Success -> { + val liveState = toLiveGameState(result, viewerPubkey) + if (liveState != null) { + LoadGameResult.Success(liveState, result.state) + } else { + LoadGameResult.Error("Failed to convert reconstructed state to live state") + } + } + + is ReconstructionResult.Error -> { + LoadGameResult.Error(result.message) + } + } + } + + /** + * Create a new game state without any events (for when accepting a challenge locally). + * + * @param startEventId The start event ID (game identifier) + * @param playerPubkey The player's pubkey + * @param opponentPubkey The opponent's pubkey + * @param playerColor The player's color + * @return A fresh LiveChessGameState + */ + fun createNewGame( + startEventId: String, + playerPubkey: String, + opponentPubkey: String, + playerColor: Color, + isPendingChallenge: Boolean = false, + ): LiveChessGameState { + val engine = ChessEngine() + engine.reset() + + return LiveChessGameState( + startEventId = startEventId, + playerPubkey = playerPubkey, + opponentPubkey = opponentPubkey, + playerColor = playerColor, + engine = engine, + isPendingChallenge = isPendingChallenge, + ) + } +} + +/** + * Result of loading a game. + */ +sealed class LoadGameResult { + data class Success( + val liveState: LiveChessGameState, + val reconstructedState: ReconstructedGameState, + ) : LoadGameResult() + + data class Error( + val message: String, + ) : LoadGameResult() + + fun isSuccess(): Boolean = this is Success + + fun getOrNull(): LiveChessGameState? = (this as? Success)?.liveState +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameViewer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameViewer.kt new file mode 100644 index 000000000..1fcc1ab3c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameViewer.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.vitorpamplona.quartz.nip64Chess.PGNParser + +/** + * Complete chess game viewer with board, metadata, and navigation + * Shared composable for Android and Desktop platforms + * + * Parses PGN content and displays: + * - Game metadata (event, players, result) + * - Interactive chess board + * - Move navigation controls + * + * @param pgnContent PGN format string + * @param modifier Modifier for the viewer + */ +@Composable +fun ChessGameViewer( + pgnContent: String, + modifier: Modifier = Modifier, +) { + val gameResult = + remember(pgnContent) { + PGNParser.parse(pgnContent) + } + + gameResult.fold( + onSuccess = { game -> + ChessGameDisplay(game, modifier) + }, + onFailure = { error -> + ChessGameError( + errorMessage = error.message ?: "Failed to parse PGN", + pgnContent = pgnContent, + modifier = modifier, + ) + }, + ) +} + +/** + * Displays a successfully parsed chess game + */ +@Composable +private fun ChessGameDisplay( + game: com.vitorpamplona.quartz.nip64Chess.ChessGame, + modifier: Modifier = Modifier, +) { + var currentMoveIndex by remember { mutableStateOf(0) } + + Card( + modifier = modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Game metadata header + PGNMetadata(game = game) + + // Chess board + ChessBoard( + position = game.positionAt(currentMoveIndex), + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f), + boardSize = 320.dp, + ) + + // Move navigation + MoveNavigator( + currentMove = currentMoveIndex, + totalMoves = game.moves.size, + onMoveChange = { currentMoveIndex = it }, + ) + } + } +} + +/** + * Displays error state when PGN parsing fails + */ +@Composable +private fun ChessGameError( + errorMessage: String, + pgnContent: String, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Error title + Text( + text = "Invalid Chess Game", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + + // Error message + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + + // PGN content label + Text( + text = "PGN Content:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(top = 4.dp), + ) + + // Raw PGN content + Text( + text = pgnContent, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt new file mode 100644 index 000000000..e9f23f8f7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt @@ -0,0 +1,342 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator + +// Color constants for card borders +private val IncomingChallengeColor = Color(0xFFFF9800) // Orange +private val OpenChallengeColor = Color(0xFF4CAF50) // Green +private val SpectatingColor = Color(0xFF9C27B0) // Purple +private val LiveGameColor = Color(0xFF2196F3) // Blue + +/** + * Card for an active game where the user is a participant + */ +@Composable +fun ActiveGameCard( + gameId: String, + opponentName: String, + isYourTurn: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + avatar: @Composable (() -> Unit)? = null, +) { + val gameName = + remember(gameId) { + ChessGameNameGenerator.extractDisplayName(gameId) ?: gameId.take(12) + } + + Card( + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), + border = + if (isYourTurn) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + avatar?.invoke() + + Column(modifier = Modifier.weight(1f)) { + Text( + gameName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + "vs $opponentName", + style = MaterialTheme.typography.bodyMedium, + ) + } + + Text( + if (isYourTurn) "Your turn" else "Waiting...", + style = MaterialTheme.typography.bodyMedium, + color = if (isYourTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, + ) + } + } +} + +/** + * Card for an incoming or open challenge + */ +@Composable +fun ChallengeCard( + challengerName: String, + challengerPlaysWhite: Boolean, + isIncoming: Boolean, + onAccept: () -> Unit, + modifier: Modifier = Modifier, + avatar: @Composable (() -> Unit)? = null, +) { + val borderColor = if (isIncoming) IncomingChallengeColor else OpenChallengeColor + + Card( + modifier = modifier.fillMaxWidth(), + border = BorderStroke(2.dp, borderColor), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + avatar?.invoke() + + Column(modifier = Modifier.weight(1f)) { + Text( + if (isIncoming) "Challenge from $challengerName" else "Open challenge by $challengerName", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "Challenger plays ${if (challengerPlaysWhite) "White" else "Black"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Button(onClick = onAccept) { + Text("Accept") + } + } + } +} + +/** + * Card for user's outgoing challenge (waiting for acceptance) + * Clickable so user can open the game board and make the first move when ready + */ +@Composable +fun OutgoingChallengeCard( + opponentName: String?, + userPlaysWhite: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + avatar: @Composable (() -> Unit)? = null, +) { + Card( + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), + border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + avatar?.invoke() + + Column(modifier = Modifier.weight(1f)) { + Text( + if (opponentName != null) { + "Challenge to $opponentName" + } else { + "Open challenge (awaiting opponent)" + }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "You play ${if (userPlaysWhite) "White" else "Black"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + "Waiting...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Card for a game the user is spectating + */ +@Composable +fun SpectatingGameCard( + moveCount: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), + border = BorderStroke(1.dp, SpectatingColor), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Watching game", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "$moveCount moves played", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + "Spectating", + style = MaterialTheme.typography.bodyMedium, + color = SpectatingColor, + fontWeight = FontWeight.Medium, + ) + } + } +} + +/** + * Card for a public live game that can be watched + */ +@Composable +fun PublicGameCard( + whiteName: String, + blackName: String, + moveCount: Int, + onWatch: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + border = BorderStroke(1.dp, LiveGameColor), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "$whiteName vs $blackName", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "$moveCount moves", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + OutlinedButton(onClick = onWatch) { + Text("Watch") + } + } + } +} + +/** + * Card for a completed game in history + */ +@Composable +fun CompletedGameCard( + opponentName: String, + result: String, + didUserWin: Boolean, + isDraw: Boolean, + moveCount: Int, + modifier: Modifier = Modifier, + avatar: @Composable (() -> Unit)? = null, +) { + val resultText = + when { + isDraw -> "Draw" + didUserWin -> "Won" + else -> "Lost" + } + + val resultColor = + when { + isDraw -> MaterialTheme.colorScheme.onSurfaceVariant + + didUserWin -> Color(0xFF4CAF50) + + // Green + else -> Color(0xFFF44336) // Red + } + + Card( + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + avatar?.invoke() + + Column(modifier = Modifier.weight(1f)) { + Text( + "vs $opponentName", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "$moveCount moves • $result", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + resultText, + style = MaterialTheme.typography.bodyMedium, + color = resultColor, + fontWeight = FontWeight.Bold, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt new file mode 100644 index 000000000..56ae30b3d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -0,0 +1,845 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd +import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.PieceType +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Interface for platform-specific chess event publishing using Jester protocol. + * + * All chess events use kind 30 with JSON content: + * - Start events: content.kind=0 + * - Move events: content.kind=1 with full history + */ +interface ChessEventPublisher { + /** + * Publish a game start event (challenge). + * Returns the startEventId (event ID) if successful. + */ + suspend fun publishStart( + playerColor: Color, + opponentPubkey: String?, + ): String? + + /** + * Publish a move event. + * Move events include full history and link to previous move. + */ + suspend fun publishMove(move: ChessMoveEvent): String? + + /** + * Publish a game end event (includes result in content). + */ + suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean + + /** + * Get count of write relays for UI feedback. + */ + fun getWriteRelayCount(): Int +} + +/** + * Relay-first fetcher interface for Jester protocol. + * Platforms provide one-shot relay queries. + * + * Every fetch does: one-shot REQ → collect events → EOSE → close. + * No caching — relays are the single source of truth. + */ +interface ChessRelayFetcher { + /** Fetch all events for a specific game by startEventId */ + suspend fun fetchGameEvents(startEventId: String): JesterGameEvents + + /** Fetch recent start/challenge events with optional progress callback */ + suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)? = null): List + + /** Fetch recent public game summaries for spectating */ + suspend fun fetchRecentGames(): List + + /** Fetch game IDs (startEventIds) where user is a participant */ + suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)? = null): Set + + /** Get the list of relay URLs that will be used for fetching */ + fun getRelayUrls(): List +} + +/** + * Summary of a game found on relays (for lobby display / spectating) + */ +data class RelayGameSummary( + val startEventId: String, + val whitePubkey: String, + val blackPubkey: String, + val moveCount: Int, + val lastMoveTime: Long, + val isActive: Boolean, +) { + // Legacy compatibility + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId +} + +/** + * Shared chess lobby logic — relay-first architecture with Jester protocol. + * + * Both Android and Desktop use this identically. + * Platform-specific code only implements: + * - ChessEventPublisher: sign + broadcast events + * - ChessRelayFetcher: one-shot relay queries + * - IUserMetadataProvider: display names / avatars + */ +class ChessLobbyLogic( + private val userPubkey: String, + private val publisher: ChessEventPublisher, + private val fetcher: ChessRelayFetcher, + private val metadataProvider: IUserMetadataProvider, + private val scope: CoroutineScope, + pollingConfig: ChessPollingConfig = ChessPollingDefaults.android, +) { + val state = ChessLobbyState(userPubkey, scope) + + private val pollingDelegate = + ChessPollingDelegate( + config = pollingConfig, + scope = scope, + onRefreshGames = { gameIds -> refreshGames(gameIds) }, + onRefreshChallenges = { refreshChallenges() }, + onCleanup = { cleanupExpiredChallenges() }, + ) + + // ======================================== + // Lifecycle + // ======================================== + + fun startPolling() { + pollingDelegate.start() + } + + fun stopPolling() { + pollingDelegate.stop() + } + + /** + * Ensure a game ID is being polled for updates. + * Call this when entering a game screen to guarantee polling is active for that game. + */ + fun ensureGamePolling(gameId: String) { + pollingDelegate.addGameId(gameId) + } + + /** + * Set focused game mode - only poll this specific game. + * Call this when entering a game screen to avoid refreshing unrelated games. + * Also ensures the game is in the polling set. + */ + fun setFocusedGame(gameId: String) { + pollingDelegate.addGameId(gameId) + pollingDelegate.setFocusedGame(gameId) + } + + /** + * Clear focused game mode - return to lobby mode (poll all games). + * Call this when returning to the lobby screen. + */ + fun clearFocusedGame() { + pollingDelegate.setFocusedGame(null) + } + + fun forceRefresh() { + pollingDelegate.refreshNow() + } + + // ======================================== + // Incoming event routing (real-time / optimistic) + // ======================================== + + /** + * Route an incoming Jester event to the appropriate handler. + * Called by platform subscription callbacks for real-time updates. + */ + fun handleIncomingEvent(event: JesterEvent) { + when { + event.isStartEvent() -> handleStartEvent(event) + event.isMoveEvent() -> handleMoveEvent(event) + } + } + + private fun handleStartEvent(event: JesterEvent) { + val startEventId = event.id + val challengerColor = event.playerColor() ?: Color.WHITE + + val challenge = + ChessChallenge( + eventId = event.id, + gameId = startEventId, // In Jester, gameId = startEventId + challengerPubkey = event.pubKey, + challengerDisplayName = metadataProvider.getDisplayName(event.pubKey), + challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey), + opponentPubkey = event.opponentPubkey(), + challengerColor = challengerColor, + createdAt = event.createdAt, + ) + state.addChallenge(challenge) + } + + private fun handleMoveEvent(event: JesterEvent) { + val startEventId = event.startEventId() ?: return + val san = event.move() ?: return + val fen = event.fen() ?: return + val history = event.history() + val moveNumber = history.size + + // Check if this is our game (we're either the author or tagged as opponent) + val opponentFromTag = event.opponentPubkey() + val isOurGame = event.pubKey == userPubkey || opponentFromTag == userPubkey + + var gameState = state.getGameState(startEventId) + + // If this is our game but we haven't loaded it yet, load it now + // This happens when someone accepts our challenge (makes first move) + if (gameState == null && isOurGame) { + handleGameAccepted(startEventId) + return // handleGameAccepted will load the game and poll for events + } + + if (gameState == null) return + + // Check for game end + val result = event.result() + if (result != null) { + val gameResult = + when (result) { + "1-0" -> com.vitorpamplona.quartz.nip64Chess.GameResult.WHITE_WINS + "0-1" -> com.vitorpamplona.quartz.nip64Chess.GameResult.BLACK_WINS + "1/2-1/2" -> com.vitorpamplona.quartz.nip64Chess.GameResult.DRAW + else -> null + } + if (gameResult != null) { + gameState.markAsFinished(gameResult) + state.moveToCompleted(startEventId, result, event.termination()) + pollingDelegate.removeGameId(startEventId) + return + } + } + + // Only apply opponent moves optimistically + if (event.pubKey != userPubkey) { + gameState.applyOpponentMove(san, fen, moveNumber) + // Update head event ID for move linking + gameState.updateHeadEventId(event.id) + } + } + + // ======================================== + // Challenge operations + // ======================================== + + fun createChallenge( + opponentPubkey: String? = null, + playerColor: Color = Color.WHITE, + timeControl: String? = null, // Not supported in Jester, kept for API compatibility + ) { + scope.launch(Dispatchers.Default) { + state.setBroadcastStatus( + ChessBroadcastStatus.Broadcasting( + san = "Challenge", + successCount = 0, + totalRelays = publisher.getWriteRelayCount(), + ), + ) + + val startEventId = retryWithBackoffResult { publisher.publishStart(playerColor, opponentPubkey) } + + if (startEventId != null) { + // Add challenge to local state - shows in "Your Challenges" section + val challenge = + ChessChallenge( + eventId = startEventId, + gameId = startEventId, + challengerPubkey = userPubkey, + challengerDisplayName = metadataProvider.getDisplayName(userPubkey), + challengerAvatarUrl = metadataProvider.getPictureUrl(userPubkey), + opponentPubkey = opponentPubkey, + challengerColor = playerColor, + createdAt = TimeUtils.now(), + ) + state.addChallenge(challenge) + pollingDelegate.addGameId(startEventId) + + state.setBroadcastStatus( + ChessBroadcastStatus.Success("Challenge", publisher.getWriteRelayCount()), + ) + delay(2000) + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + state.setError(null) + } else { + state.setBroadcastStatus( + ChessBroadcastStatus.Failed("Challenge", "Failed to publish"), + ) + state.setError("Failed to create challenge") + } + } + } + + /** + * Accept a challenge by loading the game and making the first move (if we're black) + * or waiting for opponent's move (if we're white). + * + * In Jester protocol, acceptance is implicit - we just track the game locally. + */ + fun acceptChallenge(challenge: ChessChallenge) { + // Mark as accepted SYNCHRONOUSLY before launching coroutine + // This prevents race where navigation happens before coroutine runs, + // which would cause loadGame() to incorrectly mark as spectator + state.markAsAccepted(challenge.gameId) + + state.removeChallenge(challenge.gameId) + + // Add to polling delegate FIRST - before adding to activeGames + // This prevents race where Compose sees the game but forceRefresh() doesn't include it + pollingDelegate.addGameId(challenge.gameId) + + scope.launch(Dispatchers.Default) { + val playerColor = challenge.challengerColor.opposite() + val gameState = + ChessGameLoader.createNewGame( + startEventId = challenge.gameId, // gameId = startEventId in Jester + playerPubkey = userPubkey, + opponentPubkey = challenge.challengerPubkey, + playerColor = playerColor, + ) + state.addActiveGame(challenge.gameId, gameState) + state.selectGame(challenge.gameId) + state.setError(null) + + // Immediately fetch game from relays to load any existing moves + // This ensures moves are loaded without waiting for polling interval + refreshGame(challenge.gameId) + } + } + + /** + * Open user's own outgoing challenge to view the board and make moves. + * Creates game state and navigates to game view. + */ + fun openOwnChallenge(challenge: ChessChallenge) { + // Add to polling delegate FIRST - before adding to activeGames + pollingDelegate.addGameId(challenge.gameId) + + // Create game state with user's chosen color + val gameState = + ChessGameLoader.createNewGame( + startEventId = challenge.gameId, + playerPubkey = userPubkey, + opponentPubkey = challenge.opponentPubkey ?: "", + playerColor = challenge.challengerColor, + isPendingChallenge = true, + ) + + state.addActiveGame(challenge.gameId, gameState) + state.selectGame(challenge.gameId) + + // Fetch from relays in case opponent has already made moves + scope.launch(Dispatchers.Default) { + refreshGame(challenge.gameId) + } + } + + /** + * When we detect our challenge was accepted (opponent made first move), load game from relays. + */ + fun handleGameAccepted(startEventId: String) { + scope.launch(Dispatchers.Default) { + state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) + + val events = fetcher.fetchGameEvents(startEventId) + val result = ChessGameLoader.loadGame(events, userPubkey) + + when (result) { + is LoadGameResult.Success -> { + state.addActiveGame(startEventId, result.liveState) + pollingDelegate.addGameId(startEventId) + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + state.setError(null) + } + + is LoadGameResult.Error -> { + state.setError("Failed to load game: ${result.message}") + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + } + } + } + } + + // ======================================== + // Game operations + // ======================================== + + fun publishMove( + startEventId: String, + from: String, + to: String, + ) { + val gameState = state.getGameState(startEventId) ?: return + + if (state.isSpectating(startEventId)) { + state.setError("Cannot move while spectating") + return + } + + // Parse promotion suffix from `to` if present (e.g., "e8q" -> "e8" + QUEEN) + val (actualTo, promotion) = parsePromotionFromTo(to) + + val moveResult = gameState.makeMove(from, actualTo, promotion) ?: return + + scope.launch(Dispatchers.Default) { + state.setBroadcastStatus( + ChessBroadcastStatus.Broadcasting( + san = moveResult.san, + successCount = 0, + totalRelays = publisher.getWriteRelayCount(), + ), + ) + + val newEventId = retryWithBackoffResult { publisher.publishMove(moveResult) } + + if (newEventId != null) { + // Update head event ID for next move linking + gameState.updateHeadEventId(newEventId) + + state.setBroadcastStatus( + ChessBroadcastStatus.Success(moveResult.san, publisher.getWriteRelayCount()), + ) + delay(3000) + + val currentState = state.getGameState(startEventId) + state.setBroadcastStatus( + if (currentState?.isPlayerTurn() == false) { + ChessBroadcastStatus.WaitingForOpponent + } else { + ChessBroadcastStatus.Idle + }, + ) + state.setError(null) + } else { + // Revert the move since publishing failed + gameState.undoLastMove() + + state.setBroadcastStatus( + ChessBroadcastStatus.Failed(moveResult.san, "Failed to publish move"), + ) + state.setError("Failed to publish move - move reverted") + } + } + } + + fun resign(startEventId: String) { + val gameState = state.getGameState(startEventId) ?: return + + if (state.isSpectating(startEventId)) { + state.setError("Cannot resign while spectating") + return + } + + scope.launch(Dispatchers.Default) { + val endData = gameState.resign() + val success = retryWithBackoff { publisher.publishGameEnd(endData) } + + if (success) { + state.moveToCompleted(startEventId, endData.result.notation, endData.termination.name.lowercase()) + pollingDelegate.removeGameId(startEventId) + state.setError(null) + } else { + state.setError("Failed to resign") + } + } + } + + fun claimAbandonmentVictory(startEventId: String) { + val gameState = state.getGameState(startEventId) ?: return + val endData = gameState.claimAbandonmentVictory() ?: return + + scope.launch(Dispatchers.Default) { + val success = retryWithBackoff { publisher.publishGameEnd(endData) } + + if (success) { + state.moveToCompleted(startEventId, endData.result.notation, "abandonment") + pollingDelegate.removeGameId(startEventId) + state.setError(null) + } else { + state.setError("Failed to claim abandonment victory") + } + } + } + + // ======================================== + // Spectator mode + // ======================================== + + fun loadGameAsSpectator(startEventId: String) { + scope.launch(Dispatchers.Default) { + state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) + + val events = fetcher.fetchGameEvents(startEventId) + val result = ChessGameLoader.loadGame(events, userPubkey) + + when (result) { + is LoadGameResult.Success -> { + state.addSpectatingGame(startEventId, result.liveState) + pollingDelegate.addGameId(startEventId) + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + state.setError(null) + // Auto-select the game for Desktop (Android uses route navigation) + state.selectGame(startEventId) + } + + is LoadGameResult.Error -> { + state.setError("Failed to load game: ${result.message}") + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + } + } + } + } + + fun loadGame(startEventId: String) { + scope.launch(Dispatchers.Default) { + // Don't load if game already exists or was accepted (acceptChallenge will handle it) + if (state.getGameState(startEventId) != null || state.wasAccepted(startEventId)) { + return@launch + } + + state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) + + val events = fetcher.fetchGameEvents(startEventId) + val result = ChessGameLoader.loadGame(events, userPubkey) + + when (result) { + is LoadGameResult.Success -> { + if (result.liveState.isSpectator) { + state.addSpectatingGame(startEventId, result.liveState) + } else { + state.addActiveGame(startEventId, result.liveState) + } + pollingDelegate.addGameId(startEventId) + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + state.setError(null) + } + + is LoadGameResult.Error -> { + // Check again if game was added while we were fetching + // (e.g., by acceptChallenge completing in parallel) + if (state.getGameState(startEventId) != null) { + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + return@launch + } + state.setError("Failed to load game: ${result.message}") + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + } + } + } + } + + // ======================================== + // Relay-first refresh (periodic reconstruction) + // ======================================== + + private suspend fun refreshGames(gameIds: Set) { + for (gameId in gameIds) { + refreshGame(gameId) + } + } + + private suspend fun refreshGame(startEventId: String) { + val events = fetcher.fetchGameEvents(startEventId) + + val result = ChessGameLoader.loadGame(events, userPubkey) + + when (result) { + is LoadGameResult.Success -> { + state.replaceGameState(startEventId, result.liveState) + } + + is LoadGameResult.Error -> { + // Don't overwrite error for periodic refresh failures + } + } + } + + private suspend fun refreshChallenges() { + state.setRefreshing(true) + try { + refreshChallengesInternal() + } finally { + state.setRefreshing(false) + } + } + + private suspend fun refreshChallengesInternal() { + val relayUrls = fetcher.getRelayUrls() + val relayStatesMap = mutableMapOf() + relayUrls.forEach { url -> + val displayName = url.substringAfter("://").substringBefore("/") + relayStatesMap[url] = RelaySyncState(url, displayName, RelaySyncStatus.CONNECTING, 0) + } + var totalEvents = 0 + + state.setSyncStatus( + ChessSyncStatus.Syncing( + phase = "challenges", + relayStates = relayStatesMap.values.toList(), + totalEventsReceived = 0, + ), + ) + + val startEvents = + fetcher.fetchChallenges { progress -> + val relayUrl = progress.relay.url + val displayName = relayUrl.substringAfter("://").substringBefore("/") + val status = + when (progress.status) { + RelayFetchStatus.WAITING -> RelaySyncStatus.WAITING + RelayFetchStatus.RECEIVING -> RelaySyncStatus.RECEIVING + RelayFetchStatus.EOSE_RECEIVED -> RelaySyncStatus.EOSE_RECEIVED + RelayFetchStatus.TIMEOUT -> RelaySyncStatus.FAILED + } + relayStatesMap[relayUrl] = + RelaySyncState( + relayUrl, + displayName, + status, + progress.eventCount, + ) + totalEvents = relayStatesMap.values.sumOf { it.eventsReceived } + state.setSyncStatus( + ChessSyncStatus.Syncing( + phase = "challenges", + relayStates = relayStatesMap.values.toList(), + totalEventsReceived = totalEvents, + ), + ) + } + + val fetchedChallenges = + startEvents.mapNotNull { event -> + if (!event.isStartEvent()) return@mapNotNull null + val startEventId = event.id + + // Skip challenges that user has already accepted + if (state.wasAccepted(startEventId)) return@mapNotNull null + + val challengerColor = event.playerColor() ?: Color.WHITE + + ChessChallenge( + eventId = event.id, + gameId = startEventId, + challengerPubkey = event.pubKey, + challengerDisplayName = metadataProvider.getDisplayName(event.pubKey), + challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey), + opponentPubkey = event.opponentPubkey(), + challengerColor = challengerColor, + createdAt = event.createdAt, + ) + } + + // Merge only RECENT optimistic challenges (created in last 5 minutes, not yet propagated) + // This prevents stale challenges from accumulating across sessions + // Also exclude challenges that user has accepted (tracked in _acceptedGameIds) + val now = TimeUtils.now() + val recentThreshold = 5 * 60L // 5 minutes + val fetchedGameIds = fetchedChallenges.map { it.gameId }.toSet() + val optimisticChallenges = + state.challenges.value.filter { challenge -> + val isRecent = (now - challenge.createdAt) < recentThreshold + val notFetched = challenge.gameId !in fetchedGameIds + val notAccepted = !state.wasAccepted(challenge.gameId) + isRecent && notFetched && notAccepted + } + val mergedChallenges = fetchedChallenges + optimisticChallenges + + state.updateChallenges(mergedChallenges) + + state.setSyncStatus( + ChessSyncStatus.Syncing( + phase = "games", + relayStates = relayStatesMap.values.toList(), + totalEventsReceived = totalEvents, + ), + ) + + discoverUserGames() + + val recentGames = fetcher.fetchRecentGames() + val publicGames = + recentGames.map { summary -> + PublicGame( + gameId = summary.startEventId, + whitePubkey = summary.whitePubkey, + whiteDisplayName = metadataProvider.getDisplayName(summary.whitePubkey), + blackPubkey = summary.blackPubkey, + blackDisplayName = metadataProvider.getDisplayName(summary.blackPubkey), + moveCount = summary.moveCount, + lastMoveTime = summary.lastMoveTime, + isActive = summary.isActive, + ) + } + state.updatePublicGames(publicGames) + + val failedCount = relayStatesMap.values.count { it.status == RelaySyncStatus.FAILED } + val activeGamesCount = state.activeGames.value.size + + if (failedCount > 0 && failedCount < relayStatesMap.size) { + state.setSyncStatus( + ChessSyncStatus.PartialSync( + relayStates = relayStatesMap.values.toList(), + message = "$failedCount relay(s) timed out", + ), + ) + } else if (failedCount == relayStatesMap.size) { + state.setSyncStatus( + ChessSyncStatus.PartialSync( + relayStates = relayStatesMap.values.toList(), + message = "All relays failed", + ), + ) + } else { + state.setSyncStatus( + ChessSyncStatus.Synced( + relayStates = relayStatesMap.values.toList(), + challengeCount = mergedChallenges.size, + gameCount = activeGamesCount, + totalEventsReceived = totalEvents, + ), + ) + } + } + + private suspend fun discoverUserGames() { + val discoveredGameIds = fetcher.fetchUserGameIds() + val currentActiveIds = state.activeGames.value.keys + val currentSpectatingIds = state.spectatingGames.value.keys + + val newGameIds = discoveredGameIds - currentActiveIds - currentSpectatingIds + + for (startEventId in newGameIds) { + val events = fetcher.fetchGameEvents(startEventId) + val result = ChessGameLoader.loadGame(events, userPubkey) + + when (result) { + is LoadGameResult.Success -> { + if (!result.liveState.isSpectator) { + state.addActiveGame(startEventId, result.liveState) + pollingDelegate.addGameId(startEventId) + } + } + + is LoadGameResult.Error -> { + // Failed to load game - continue with others + } + } + } + } + + private fun cleanupExpiredChallenges() { + val now = TimeUtils.now() + val validChallenges = + state.challenges.value.filter { challenge -> + (now - challenge.createdAt) < CHALLENGE_EXPIRY_SECONDS + } + state.updateChallenges(validChallenges) + } + + // ======================================== + // Utilities + // ======================================== + + private suspend fun retryWithBackoff( + maxRetries: Int = 3, + initialDelayMs: Long = 1000, + action: suspend () -> Boolean, + ): Boolean { + var delayMs = initialDelayMs + repeat(maxRetries) { attempt -> + if (action()) return true + if (attempt < maxRetries - 1) { + delay(delayMs) + delayMs *= 2 + } + } + return false + } + + private suspend fun retryWithBackoffResult( + maxRetries: Int = 3, + initialDelayMs: Long = 1000, + action: suspend () -> T?, + ): T? { + var delayMs = initialDelayMs + repeat(maxRetries) { attempt -> + val result = action() + if (result != null) return result + if (attempt < maxRetries - 1) { + delay(delayMs) + delayMs *= 2 + } + } + return null + } + + fun clearError() { + state.setError(null) + } + + fun selectGame(gameId: String?) { + state.selectGame(gameId) + } +} + +/** + * Parse promotion piece from a "to" square string. + * For example: "e8q" -> Pair("e8", PieceType.QUEEN) + * Regular moves: "e4" -> Pair("e4", null) + */ +private fun parsePromotionFromTo(to: String): Pair { + if (to.length == 3) { + val square = to.take(2) + val promotion = + when (to.last().lowercaseChar()) { + 'q' -> PieceType.QUEEN + 'r' -> PieceType.ROOK + 'b' -> PieceType.BISHOP + 'n' -> PieceType.KNIGHT + else -> null + } + if (promotion != null) { + return square to promotion + } + } + return to to null +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt new file mode 100644 index 000000000..5ebcd39f0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt @@ -0,0 +1,518 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.util.concurrent.atomic.AtomicLong + +/** + * Challenge expiry: 24 hours + */ +const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L + +/** + * Represents a chess challenge that can be displayed in the lobby + */ +@Immutable +data class ChessChallenge( + val eventId: String, + val gameId: String, + val challengerPubkey: String, + val challengerDisplayName: String?, + val challengerAvatarUrl: String?, + val opponentPubkey: String?, + val challengerColor: Color, + val createdAt: Long, +) { + /** Whether this is an open challenge anyone can accept */ + val isOpen: Boolean get() = opponentPubkey == null + + /** Whether this challenge is directed at a specific user */ + fun isDirectedAt(pubkey: String): Boolean = opponentPubkey == pubkey + + /** Whether this challenge was created by a specific user */ + fun isFrom(pubkey: String): Boolean = challengerPubkey == pubkey +} + +/** + * Represents a public game that can be spectated + */ +@Immutable +data class PublicGame( + val gameId: String, + val whitePubkey: String, + val whiteDisplayName: String?, + val blackPubkey: String, + val blackDisplayName: String?, + val moveCount: Int, + val lastMoveTime: Long, + val isActive: Boolean, +) + +/** + * Represents a completed game for history display + */ +@Immutable +data class CompletedGame( + val gameId: String, + val whitePubkey: String, + val whiteDisplayName: String?, + val blackPubkey: String, + val blackDisplayName: String?, + val result: String, + val termination: String?, + val moveCount: Int, + val completedAt: Long, +) { + /** Whether user won this game */ + fun didUserWin(userPubkey: String): Boolean = + when (result) { + "1-0" -> whitePubkey == userPubkey + "0-1" -> blackPubkey == userPubkey + else -> false + } + + /** Whether this was a draw */ + val isDraw: Boolean get() = result == "1/2-1/2" +} + +/** + * Individual relay status during sync + */ +@Immutable +data class RelaySyncState( + val url: String, + val displayName: String, + val status: RelaySyncStatus, + val eventsReceived: Int = 0, +) + +@Immutable +enum class RelaySyncStatus { + CONNECTING, + WAITING, + RECEIVING, + EOSE_RECEIVED, + FAILED, +} + +/** + * Sync status for incoming events (subscription-side) + */ +@Immutable +sealed class ChessSyncStatus { + data object Idle : ChessSyncStatus() + + data class Syncing( + val phase: String, + val relayStates: List, + val totalEventsReceived: Int, + ) : ChessSyncStatus() { + val connectedCount: Int get() = relayStates.count { it.status != RelaySyncStatus.FAILED } + val eoseCount: Int get() = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED } + val totalCount: Int get() = relayStates.size + } + + data class Synced( + val relayStates: List, + val challengeCount: Int, + val gameCount: Int, + val totalEventsReceived: Int, + ) : ChessSyncStatus() { + val successCount: Int get() = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED } + val totalCount: Int get() = relayStates.size + } + + data class PartialSync( + val relayStates: List, + val message: String, + ) : ChessSyncStatus() { + val successCount: Int get() = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED } + val failedCount: Int get() = relayStates.count { it.status == RelaySyncStatus.FAILED } + val totalCount: Int get() = relayStates.size + } +} + +/** + * Chess status for UI feedback + */ +@Immutable +sealed class ChessBroadcastStatus { + data object Idle : ChessBroadcastStatus() + + data class Broadcasting( + val san: String, + val successCount: Int, + val totalRelays: Int, + ) : ChessBroadcastStatus() { + val progress: Float get() = if (totalRelays > 0) successCount.toFloat() / totalRelays else 0f + } + + data class Success( + val san: String, + val relayCount: Int, + ) : ChessBroadcastStatus() + + data class Failed( + val san: String, + val error: String, + ) : ChessBroadcastStatus() + + data object WaitingForOpponent : ChessBroadcastStatus() + + data class Syncing( + val progress: Float = 0f, + ) : ChessBroadcastStatus() + + data class Desynced( + val message: String, + ) : ChessBroadcastStatus() +} + +/** + * Shared chess lobby state that can be used by both Android and Desktop + */ +class ChessLobbyState( + private val userPubkey: String, + private val scope: CoroutineScope, +) { + // Active games where user is a participant + private val _activeGames = MutableStateFlow>(emptyMap()) + val activeGames: StateFlow> = _activeGames.asStateFlow() + + // Public games that can be spectated + private val _publicGames = MutableStateFlow>(emptyList()) + val publicGames: StateFlow> = _publicGames.asStateFlow() + + // All challenges (filtered by UI based on type) + private val _challenges = MutableStateFlow>(emptyList()) + val challenges: StateFlow> = _challenges.asStateFlow() + + // Spectating games (user is watching but not playing) + private val _spectatingGames = MutableStateFlow>(emptyMap()) + val spectatingGames: StateFlow> = _spectatingGames.asStateFlow() + + // Completed games history + private val _completedGames = MutableStateFlow>(emptyList()) + val completedGames: StateFlow> = _completedGames.asStateFlow() + + // Game IDs that user has accepted - uses global singleton to share across ViewModel instances + // This is critical because lobby and game screen may have different ViewModel instances + + // Broadcast status + private val _broadcastStatus = MutableStateFlow(ChessBroadcastStatus.Idle) + val broadcastStatus: StateFlow = _broadcastStatus.asStateFlow() + + // Error state + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + // Loading/refreshing state + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + // Sync status for subscription banner + private val _syncStatus = MutableStateFlow(ChessSyncStatus.Idle) + val syncStatus: StateFlow = _syncStatus.asStateFlow() + + // Selected game ID for navigation + private val _selectedGameId = MutableStateFlow(null) + val selectedGameId: StateFlow = _selectedGameId.asStateFlow() + + // State version counter - increments on every game state update + // UI can observe this to force recomposition when internal state changes + private val stateVersionCounter = AtomicLong(0) + private val _stateVersion = MutableStateFlow(0L) + val stateVersion: StateFlow = _stateVersion.asStateFlow() + + // Badge count (incoming challenges + your turn games) + val badgeCount: Int + get() { + val incomingChallenges = _challenges.value.count { it.isDirectedAt(userPubkey) } + val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() } + return incomingChallenges + yourTurnGames + } + + // ======================================== + // Derived state for UI sections + // ======================================== + + /** Challenges directed at the user */ + fun incomingChallenges(): List = _challenges.value.filter { it.isDirectedAt(userPubkey) } + + /** Challenges created by the user */ + fun outgoingChallenges(): List = _challenges.value.filter { it.isFrom(userPubkey) } + + /** Open challenges from others that user can join */ + fun openChallenges(): List = _challenges.value.filter { it.isOpen && !it.isFrom(userPubkey) } + + // ======================================== + // State updates + // ======================================== + + fun updateChallenges(challenges: List) { + _challenges.value = challenges + } + + fun addChallenge(challenge: ChessChallenge) { + _challenges.update { current -> + if (current.any { it.eventId == challenge.eventId || it.gameId == challenge.gameId }) { + current + } else { + current + challenge + } + } + } + + fun removeChallenge(gameId: String) { + _challenges.update { current -> + current.filter { it.gameId != gameId } + } + } + + fun updatePublicGames(games: List) { + _publicGames.value = games + } + + fun addActiveGame( + gameId: String, + state: LiveChessGameState, + ) { + _activeGames.update { current -> + val existing = current[gameId] + if (existing != null) { + // Only replace if new state has at least as many moves + val existingMoves = existing.moveHistory.value.size + val newMoves = state.moveHistory.value.size + if (newMoves < existingMoves) { + return@update current + } + } + current + (gameId to state) + } + // Track as accepted to prevent refresh from re-adding as optimistic challenge + AcceptedGamesRegistry.markAsAccepted(gameId) + // Remove from challenges if present + removeChallenge(gameId) + } + + fun removeActiveGame(gameId: String) { + _activeGames.update { it - gameId } + } + + fun updateActiveGame( + gameId: String, + update: (LiveChessGameState) -> LiveChessGameState, + ) { + _activeGames.update { current -> + current[gameId]?.let { state -> + current + (gameId to update(state)) + } ?: current + } + } + + /** + * Replace game state entirely after full reconstruction from relays. + * Preserves game location (active vs spectating). + * + * IMPORTANT: Only replaces if new state has >= moves than current state. + * This prevents race conditions where polling refresh could revert + * a user's move before it propagates to relays. + * + * IMPORTANT: Never replace a participant game with a spectator state. + * This prevents the race where relay fetch returns before acceptance propagates, + * which would incorrectly mark an accepted game as spectating. + */ + fun replaceGameState( + gameId: String, + newState: LiveChessGameState, + ) { + val inActiveGames = _activeGames.value.containsKey(gameId) + val inSpectatingGames = _spectatingGames.value.containsKey(gameId) + + val currentState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId] + val currentMoveCount = currentState?.moveHistory?.value?.size ?: 0 + val newMoveCount = newState.moveHistory.value.size + + // Only replace if new state has at least as many moves + // This prevents reverting user's local moves during refresh + if (newMoveCount < currentMoveCount) { + return + } + + // Handle case where new state incorrectly has isSpectator=true but current is participant + // This happens with open challenges where opponent can't be determined from relay events alone + // Solution: Apply moves from new state while preserving participant status from current + val stateToUse = + if (currentState != null && !currentState.isSpectator && newState.isSpectator && newMoveCount > currentMoveCount) { + // Apply opponent's moves to current state's engine + currentState.applyMovesFrom(newState) + currentState // Keep using current state with updated engine + } else if (currentState != null && !currentState.isSpectator && newState.isSpectator) { + return + } else { + newState + } + + if (inActiveGames) { + _activeGames.update { it + (gameId to stateToUse) } + // Increment version to force UI recomposition even if map equals() returns true + val newVersion = stateVersionCounter.incrementAndGet() + _stateVersion.value = newVersion + } else if (inSpectatingGames) { + _spectatingGames.update { it + (gameId to stateToUse) } + val newVersion = stateVersionCounter.incrementAndGet() + _stateVersion.value = newVersion + } + } + + /** + * Move a game from active/spectating to completed. + * Display names are optional; UI can look them up later if needed. + */ + fun moveToCompleted( + gameId: String, + result: String, + termination: String?, + whiteDisplayName: String? = null, + blackDisplayName: String? = null, + ) { + val existingState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId] + existingState?.let { gameState -> + // Derive white/black pubkeys from player color + val whitePubkey = + if (gameState.playerColor == Color.WHITE) { + gameState.playerPubkey + } else { + gameState.opponentPubkey + } + val blackPubkey = + if (gameState.playerColor == Color.BLACK) { + gameState.playerPubkey + } else { + gameState.opponentPubkey + } + + val completed = + CompletedGame( + gameId = gameId, + whitePubkey = whitePubkey, + whiteDisplayName = whiteDisplayName, + blackPubkey = blackPubkey, + blackDisplayName = blackDisplayName, + result = result, + termination = termination, + moveCount = gameState.moveHistory.value.size, + completedAt = + com.vitorpamplona.quartz.utils.TimeUtils + .now(), + ) + + _completedGames.update { current -> + // Avoid duplicates + if (current.any { it.gameId == gameId }) { + current + } else { + listOf(completed) + current + } + } + + // Remove from active/spectating + _activeGames.update { it - gameId } + _spectatingGames.update { it - gameId } + + // Clear selection if this game was selected + if (_selectedGameId.value == gameId) { + _selectedGameId.value = null + } + } + } + + fun addSpectatingGame( + gameId: String, + state: LiveChessGameState, + ) { + // Never add to spectating if this game was accepted - user is a participant + if (AcceptedGamesRegistry.wasAccepted(gameId)) { + // Add to active games instead + _activeGames.update { it + (gameId to state) } + return + } + _spectatingGames.update { it + (gameId to state) } + } + + fun removeSpectatingGame(gameId: String) { + _spectatingGames.update { it - gameId } + } + + fun setBroadcastStatus(status: ChessBroadcastStatus) { + _broadcastStatus.value = status + } + + fun setError(error: String?) { + _error.value = error + } + + fun setRefreshing(refreshing: Boolean) { + _isRefreshing.value = refreshing + } + + fun setSyncStatus(status: ChessSyncStatus) { + _syncStatus.value = status + } + + fun selectGame(gameId: String?) { + _selectedGameId.value = gameId + } + + fun getGameState(gameId: String): LiveChessGameState? = _activeGames.value[gameId] ?: _spectatingGames.value[gameId] + + fun isUserParticipant(gameId: String): Boolean = _activeGames.value.containsKey(gameId) + + fun isSpectating(gameId: String): Boolean = _spectatingGames.value.containsKey(gameId) + + /** Whether a game ID was accepted (prevents refresh from re-adding as challenge) */ + fun wasAccepted(gameId: String): Boolean = AcceptedGamesRegistry.wasAccepted(gameId) + + /** Mark a game as accepted synchronously (call before async game creation) */ + fun markAsAccepted(gameId: String) { + AcceptedGamesRegistry.markAsAccepted(gameId) + } + + fun clearAll() { + _activeGames.value = emptyMap() + _publicGames.value = emptyList() + _challenges.value = emptyList() + _spectatingGames.value = emptyMap() + _completedGames.value = emptyList() + AcceptedGamesRegistry.clear() + _broadcastStatus.value = ChessBroadcastStatus.Idle + _error.value = null + _selectedGameId.value = null + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPieceVectors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPieceVectors.kt new file mode 100644 index 000000000..e5034553d --- /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/ChessRelayFetchHelper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt new file mode 100644 index 000000000..4a713d379 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.ConcurrentHashMap + +/** + * Progress callback for relay fetch operations + */ +data class RelayFetchProgress( + val relay: NormalizedRelayUrl, + val status: RelayFetchStatus, + val eventCount: Int, +) + +enum class RelayFetchStatus { + WAITING, + RECEIVING, + EOSE_RECEIVED, + TIMEOUT, +} + +/** + * One-shot relay fetch helper for chess events. + * + * Follows the existing INostrClient + IRequestListener + Channel pattern + * from quartz (see NostrClientSingleDownloadExt.kt). + * + * Each fetch opens a subscription, collects events until EOSE from all relays, + * then closes and returns the collected events. The subscription is transient — + * no state is cached between fetches. + */ +class ChessRelayFetchHelper( + private val client: INostrClient, +) { + /** + * Fetch events matching filters from relays, waiting for EOSE. + * + * @param filters Map of relay → filter list (same format as INostrClient.openReqSubscription) + * @param timeoutMs Max time to wait for relays to respond (default from ChessConfig) + * @param onProgress Optional callback for progress updates per relay + * @return Deduplicated list of events received before timeout/EOSE + */ + suspend fun fetchEvents( + filters: Map>, + timeoutMs: Long = ChessConfig.FETCH_TIMEOUT_MS, + onProgress: ((RelayFetchProgress) -> Unit)? = null, + ): List { + if (filters.isEmpty()) return emptyList() + + val events = ConcurrentHashMap() + val relayCount = filters.keys.size + val eoseReceived = ConcurrentHashMap.newKeySet() + val relayEventCounts = ConcurrentHashMap() + val allEose = CompletableDeferred() + val subId = newSubId() + + // Initialize all relays as WAITING + filters.keys.forEach { relay -> + relayEventCounts[relay] = 0 + onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.WAITING, 0)) + } + + val listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + events[event.id] = event + val count = relayEventCounts.compute(relay) { _, v -> (v ?: 0) + 1 } ?: 1 + onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.RECEIVING, count)) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eoseReceived.add(relay) + val count = relayEventCounts[relay] ?: 0 + onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.EOSE_RECEIVED, count)) + // Complete when all relays respond + if (eoseReceived.size >= relayCount) { + allEose.complete(Unit) + } + } + } + + client.openReqSubscription(subId, filters, listener) + val eoseResult = withTimeoutOrNull(timeoutMs) { allEose.await() } + + // Mark timed-out relays + if (eoseResult == null) { + filters.keys.forEach { relay -> + if (relay !in eoseReceived) { + val count = relayEventCounts[relay] ?: 0 + onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.TIMEOUT, count)) + } + } + } + + client.close(subId) + + return events.values.toList() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessSyncBanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessSyncBanner.kt new file mode 100644 index 000000000..e449e838c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessSyncBanner.kt @@ -0,0 +1,311 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * Shared banner showing chess sync/subscription status with expandable relay details. + * Shows incoming event progress from relays during refresh. + */ +@Composable +fun ChessSyncBanner( + status: ChessSyncStatus, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + val isVisible = status !is ChessSyncStatus.Idle + var isExpanded by remember { mutableStateOf(false) } + + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(tween(200)), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(tween(150)), + modifier = modifier, + ) { + Surface( + color = getSyncStatusBackgroundColor(status), + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = + Modifier + .animateContentSize() + .clickable { + if (status is ChessSyncStatus.PartialSync) { + onRetry() + } else { + isExpanded = !isExpanded + } + }, + ) { + // Main status row + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Icon( + imageVector = getSyncStatusIcon(status), + contentDescription = null, + tint = getSyncStatusIconColor(status), + modifier = Modifier.size(18.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = getSyncStatusText(status), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = getSyncStatusDetail(status), + style = MaterialTheme.typography.labelMedium, + color = getSyncStatusDetailColor(status), + ) + } + + // Progress bar for syncing + if (status is ChessSyncStatus.Syncing) { + Spacer(Modifier.height(4.dp)) + val progress = status.eoseCount.toFloat() / status.totalCount.coerceAtLeast(1) + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween(300), + label = "syncProgress", + ) + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + + // Expand/collapse icon + if (status !is ChessSyncStatus.Idle && getRelayStates(status).isNotEmpty()) { + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + + // Expandable relay details + AnimatedVisibility( + visible = isExpanded && getRelayStates(status).isNotEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + thickness = 0.5.dp, + ) + RelayStatusList( + relayStates = getRelayStates(status), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } + } + } + } +} + +@Composable +private fun RelayStatusList( + relayStates: List, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + relayStates.forEach { relay -> + RelayStatusRow(relay) + } + } +} + +@Composable +private fun RelayStatusRow(relay: RelaySyncState) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = getRelayStatusIcon(relay.status), + contentDescription = null, + tint = getRelayStatusColor(relay.status), + modifier = Modifier.size(14.dp), + ) + + Text( + text = relay.displayName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Text( + text = "${relay.eventsReceived} events", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } +} + +private fun getRelayStates(status: ChessSyncStatus): List = + when (status) { + is ChessSyncStatus.Syncing -> status.relayStates + is ChessSyncStatus.Synced -> status.relayStates + is ChessSyncStatus.PartialSync -> status.relayStates + is ChessSyncStatus.Idle -> emptyList() + } + +private fun getRelayStatusIcon(status: RelaySyncStatus): ImageVector = + when (status) { + RelaySyncStatus.CONNECTING -> Icons.Default.HourglassEmpty + RelaySyncStatus.WAITING -> Icons.Default.HourglassEmpty + RelaySyncStatus.RECEIVING -> Icons.Default.CloudDownload + RelaySyncStatus.EOSE_RECEIVED -> Icons.Default.CheckCircle + RelaySyncStatus.FAILED -> Icons.Default.Error + } + +@Composable +private fun getRelayStatusColor(status: RelaySyncStatus): Color = + when (status) { + RelaySyncStatus.CONNECTING -> MaterialTheme.colorScheme.secondary + RelaySyncStatus.WAITING -> MaterialTheme.colorScheme.secondary + RelaySyncStatus.RECEIVING -> MaterialTheme.colorScheme.primary + RelaySyncStatus.EOSE_RECEIVED -> MaterialTheme.colorScheme.primary + RelaySyncStatus.FAILED -> MaterialTheme.colorScheme.error + } + +@Composable +private fun getSyncStatusBackgroundColor(status: ChessSyncStatus): Color = + when (status) { + is ChessSyncStatus.PartialSync -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f) + is ChessSyncStatus.Synced -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f) + else -> MaterialTheme.colorScheme.surfaceContainer + } + +private fun getSyncStatusIcon(status: ChessSyncStatus): ImageVector = + when (status) { + is ChessSyncStatus.Syncing -> Icons.Default.CloudDownload + is ChessSyncStatus.Synced -> Icons.Default.CheckCircle + is ChessSyncStatus.PartialSync -> Icons.Default.Warning + is ChessSyncStatus.Idle -> Icons.Default.CheckCircle + } + +@Composable +private fun getSyncStatusIconColor(status: ChessSyncStatus): Color = + when (status) { + is ChessSyncStatus.PartialSync -> MaterialTheme.colorScheme.error + is ChessSyncStatus.Synced -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.primary + } + +private fun getSyncStatusText(status: ChessSyncStatus): String = + when (status) { + is ChessSyncStatus.Syncing -> "Syncing ${status.phase}..." + is ChessSyncStatus.Synced -> "Synced" + is ChessSyncStatus.PartialSync -> status.message + is ChessSyncStatus.Idle -> "" + } + +private fun getSyncStatusDetail(status: ChessSyncStatus): String = + when (status) { + is ChessSyncStatus.Syncing -> "${status.eoseCount}/${status.totalCount} relays • ${status.totalEventsReceived} events" + is ChessSyncStatus.Synced -> "${status.successCount}/${status.totalCount} relays • ${status.challengeCount} challenges • ${status.gameCount} games" + is ChessSyncStatus.PartialSync -> "Tap to retry" + is ChessSyncStatus.Idle -> "" + } + +@Composable +private fun getSyncStatusDetailColor(status: ChessSyncStatus): Color = + when (status) { + is ChessSyncStatus.PartialSync -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/IUserMetadataProvider.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/IUserMetadataProvider.kt new file mode 100644 index 000000000..c10c9a90c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/IUserMetadataProvider.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +/** + * Platform-specific metadata provider for enriching chess challenges with display info. + * + * Android: wraps LocalCache.users[pubkey].info + * Desktop: wraps UserMetadataCache + */ +interface IUserMetadataProvider { + fun getDisplayName(pubkey: String): String + + fun getPictureUrl(pubkey: String): String? +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt new file mode 100644 index 000000000..c91613b26 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt @@ -0,0 +1,394 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +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 + +/** + * Interactive chess board that allows piece selection and moves + * + * @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 isSpectator If true, disables all interaction (spectator/watch mode) + * @param onMoveMade Callback when a valid move is made, receives (from, to, san) + * NOTE: This callback should make the actual move - this component only validates + */ +@Composable +fun InteractiveChessBoard( + engine: ChessEngine, + modifier: Modifier = Modifier, + boardSize: Dp = 400.dp, + flipped: Boolean = false, + playerColor: ChessColor? = null, // null = allow both (for local play) + isSpectator: Boolean = false, + positionVersion: Int = 0, // External trigger for position refresh (e.g. moveHistory.size) + onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> }, +) { + // Track local move count + external version to trigger recomposition when board changes + var localMoveCount by remember { mutableStateOf(0) } + val effectiveVersion = localMoveCount + positionVersion + val position = remember(engine, effectiveVersion) { engine.getPosition() } + val sideToMove = remember(engine, effectiveVersion) { engine.getSideToMove() } + var selectedSquare by remember { mutableStateOf?>(null) } + var legalMoves by remember { mutableStateOf>(emptyList()) } + + // Pawn promotion state + var pendingPromotion by remember { mutableStateOf(null) } + + // Clear stale selection when position changes externally (e.g. opponent move) + LaunchedEffect(positionVersion) { + selectedSquare = null + legalMoves = emptyList() + pendingPromotion = null + } + + // Can only interact if not spectating and it's your turn (or playerColor is null for local play) + val canInteract = !isSpectator && (playerColor == null || sideToMove == playerColor) + + val squareSize = boardSize / 8 + + // 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) + + Box(modifier = modifier.size(boardSize)) { + Column(modifier = Modifier.fillMaxSize()) { + for (rank in rankRange) { + Row { + for (file in fileRange) { + val piece = position.pieceAt(file, rank) + val isLightSquare = (rank + file) % 2 == 0 + val square = fileRankToSquare(file, rank) + val isSelected = selectedSquare == (file to rank) + val isLegalMove = legalMoves.contains(square) + + val showCoord = if (flipped) rank == 7 else rank == 0 + + InteractiveChessSquare( + piece = piece, + isLight = isLightSquare, + size = squareSize, + isSelected = isSelected, + isLegalMove = isLegalMove, + showCoordinate = showCoord, + file = file, + rank = rank, + onClick = { + if (!canInteract || pendingPromotion != null) return@InteractiveChessSquare + + if (selectedSquare != null) { + // Attempt to make move - validate first, don't actually make it + val from = fileRankToSquare(selectedSquare!!.first, selectedSquare!!.second) + val to = square + + if (legalMoves.contains(to)) { + // Check if this is a pawn promotion move + val fromRank = selectedSquare!!.second + val toRank = rank + val movingPiece = position.pieceAt(selectedSquare!!.first, fromRank) + val isPromotion = + movingPiece?.type == PieceType.PAWN && + (toRank == 7 || toRank == 0) + + if (isPromotion) { + // Show promotion dialog + pendingPromotion = + PendingPromotion( + from = from, + to = to, + file = file, + rank = toRank, + color = sideToMove, + ) + } else { + // Valid move - callback will make the actual move + selectedSquare = null + legalMoves = emptyList() + // Pass empty san - callback will get it from makeMove result + onMoveMade(from, to, "") + localMoveCount++ // Trigger recomposition after move is made + } + } else { + // Not a legal move target - try selecting this square instead + val validColor = playerColor ?: sideToMove + if (piece != null && piece.color == validColor) { + selectedSquare = file to rank + legalMoves = engine.getLegalMovesFrom(square) + } else { + selectedSquare = null + legalMoves = emptyList() + } + } + } else { + // Select piece - only allow selecting player's pieces + val validColor = playerColor ?: sideToMove + if (piece != null && piece.color == validColor) { + selectedSquare = file to rank + legalMoves = engine.getLegalMovesFrom(square) + } + } + }, + ) + } + } + } + } + + // Promotion dialog overlay + pendingPromotion?.let { promo -> + PromotionPicker( + color = promo.color, + squareSize = squareSize, + onPieceSelected = { pieceType -> + // Make the move with promotion + val promotionSuffix = + when (pieceType) { + PieceType.QUEEN -> "q" + PieceType.ROOK -> "r" + PieceType.BISHOP -> "b" + PieceType.KNIGHT -> "n" + else -> "q" + } + selectedSquare = null + legalMoves = emptyList() + pendingPromotion = null + // Include promotion in the 'to' square for the callback + onMoveMade(promo.from, promo.to + promotionSuffix, "") + localMoveCount++ + }, + onDismiss = { + pendingPromotion = null + selectedSquare = null + legalMoves = emptyList() + }, + ) + } + } +} + +/** + * Single interactive chess square + */ +@Composable +private fun InteractiveChessSquare( + piece: com.vitorpamplona.quartz.nip64Chess.ChessPiece?, + isLight: Boolean, + size: Dp, + isSelected: Boolean, + isLegalMove: Boolean, + showCoordinate: Boolean, + file: Int, + rank: Int, + onClick: () -> Unit, +) { + val backgroundColor = + when { + isSelected -> Color(0xFF7FA650) + isLegalMove -> Color(0xFFAAC75B).copy(alpha = 0.6f) + isLight -> Color(0xFFF0D9B5) + else -> Color(0xFFB58863) + } + + Box( + modifier = + Modifier + .size(size) + .background(backgroundColor) + .clickable { onClick() } + .let { + if (isSelected) { + it.border(2.dp, Color(0xFF4A7536)) + } else { + it + } + }, + contentAlignment = Alignment.Center, + ) { + // Display piece using CBurnett ImageVector icons + piece?.let { + Image( + imageVector = it.toImageVector(), + contentDescription = "${it.color} ${it.type}", + modifier = Modifier.fillMaxSize().padding(2.dp), + ) + } + + // Show legal move indicator + if (isLegalMove && piece == null) { + Box( + modifier = + Modifier + .size(size * 0.25f) + .background(Color(0xFF7FA650), CircleShape) + .alpha(0.5f), + ) + } else if (isLegalMove && piece != null) { + // Capture indicator - ring around piece + Box( + modifier = + Modifier + .size(size * 0.8f) + .border(3.dp, Color(0xFF7FA650).copy(alpha = 0.5f), CircleShape), + ) + } + + // Show file coordinate (a-h) on bottom rank + if (showCoordinate) { + Text( + text = ('a' + file).toString(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = if (isLight) Color(0xFFB58863) else Color(0xFFF0D9B5), + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(2.dp), + ) + } + } +} + +/** + * Convert file/rank to algebraic notation (e.g., 0,0 -> "a1") + */ +private fun fileRankToSquare( + file: Int, + rank: Int, +): String = "${('a' + file)}${rank + 1}" + +/** + * Extension function to convert ChessPiece to CBurnett ImageVector + */ +private fun ChessPiece.toImageVector(): ImageVector { + val white = color == ChessColor.WHITE + return when (type) { + 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 + } +} + +/** + * Data class for pending pawn promotion + */ +private data class PendingPromotion( + val from: String, + val to: String, + val file: Int, + val rank: Int, + val color: ChessColor, +) + +/** + * Promotion piece picker overlay + */ +@Composable +private fun PromotionPicker( + color: ChessColor, + squareSize: Dp, + onPieceSelected: (PieceType) -> Unit, + onDismiss: () -> Unit, +) { + val isWhite = color == ChessColor.WHITE + val promotionPieces = + listOf( + PieceType.QUEEN to if (isWhite) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen, + PieceType.ROOK to if (isWhite) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook, + PieceType.BISHOP to if (isWhite) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop, + PieceType.KNIGHT to if (isWhite) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight, + ) + + // Semi-transparent overlay + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + Card( + shape = RoundedCornerShape(8.dp), + modifier = Modifier.clickable { /* prevent dismiss */ }, + ) { + Row( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + promotionPieces.forEach { (pieceType, icon) -> + Box( + modifier = + Modifier + .size(squareSize) + .background(Color(0xFFF0D9B5), RoundedCornerShape(4.dp)) + .clickable { onPieceSelected(pieceType) }, + contentAlignment = Alignment.Center, + ) { + Image( + imageVector = icon, + contentDescription = pieceType.name, + modifier = Modifier.fillMaxSize().padding(4.dp), + ) + } + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt new file mode 100644 index 000000000..977c452b6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt @@ -0,0 +1,927 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleIn +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.vitorpamplona.quartz.nip64Chess.ChessEngine +import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.nip64Chess.GameStatus +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import androidx.compose.ui.graphics.Color as ComposeColor + +/** + * Dialog for creating a new chess game challenge + * + * @param onDismiss Callback when dialog is dismissed + * @param onCreateGame Callback when game is created (opponentPubkey, playerColor) + */ +@Composable +fun NewChessGameDialog( + onDismiss: () -> Unit, + onCreateGame: (opponentPubkey: String?, playerColor: Color) -> Unit, +) { + var opponentPubkey by remember { mutableStateOf("") } + var selectedColor by remember { mutableStateOf(Color.WHITE) } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "New Chess Game", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + Text( + text = "Create a new chess game challenge", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = opponentPubkey, + onValueChange = { opponentPubkey = it }, + label = { Text("Opponent npub (optional)") }, + placeholder = { Text("Leave blank for open challenge") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Text( + text = "Your color:", + style = MaterialTheme.typography.labelMedium, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { selectedColor = Color.WHITE }, + modifier = Modifier.weight(1f), + ) { + Text( + text = if (selectedColor == Color.WHITE) "✓ White" else "White", + fontWeight = if (selectedColor == Color.WHITE) FontWeight.Bold else FontWeight.Normal, + ) + } + + OutlinedButton( + onClick = { selectedColor = Color.BLACK }, + modifier = Modifier.weight(1f), + ) { + Text( + text = if (selectedColor == Color.BLACK) "✓ Black" else "Black", + fontWeight = if (selectedColor == Color.BLACK) FontWeight.Bold else FontWeight.Normal, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + onCreateGame( + opponentPubkey.ifBlank { null }, + selectedColor, + ) + }, + ) { + Text("Create Game") + } + } + } + } + } +} + +/** + * 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 isSpectatorOverride If non-null, overrides gameState.isSpectator. Use this when + * spectator status is determined by which map the game is in (activeGames vs spectatingGames) + * rather than the potentially stale isSpectator flag from relay reconstruction. + */ +@Composable +fun LiveChessGameScreen( + modifier: Modifier = Modifier, + gameState: LiveChessGameState, + opponentName: String, + onMoveMade: (from: String, to: String, san: String) -> Unit, + onResign: () -> Unit, + isSpectatorOverride: Boolean? = null, + onGameEndDismiss: (() -> Unit)? = null, +) { + // Observe state flows for automatic recomposition on updates + val currentPosition by gameState.currentPosition.collectAsState() + val moveHistory by gameState.moveHistory.collectAsState() + val gameStatus by gameState.gameStatus.collectAsState() + + // Use override if provided, otherwise fall back to gameState flag + val isSpectator = isSpectatorOverride ?: gameState.isSpectator + + // Pending challenges and spectators cannot make moves + val canMakeMoves = !isSpectator && !gameState.isPendingChallenge + + // Track if game end overlay was dismissed + var gameEndDismissed by remember { mutableStateOf(false) } + + Box(modifier = modifier.fillMaxSize()) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + ) { + // Calculate board size dynamically based on available space + // Use a percentage of available height to leave room for other UI elements + val availableWidth = maxWidth - 32.dp // Account for horizontal padding + // Reserve ~35% of height for header, history, controls, and any banners + val availableHeight = maxHeight * 0.65f + val boardSize = min(availableWidth, availableHeight).coerceIn(150.dp, 520.dp) + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Game info - use currentPosition.activeColor for turn display + GameInfoHeader( + gameId = gameState.gameId, + opponentName = opponentName, + playerColor = gameState.playerColor, + currentTurn = currentPosition.activeColor, + isSpectator = isSpectator, + isPendingChallenge = gameState.isPendingChallenge, + gameStatus = gameStatus, + ) + + // Interactive chess board - flip when playing black (spectators see from white's view) + // Auto-sized to fit available space + InteractiveChessBoard( + engine = gameState.engine, + boardSize = boardSize, + flipped = !isSpectator && gameState.playerColor == Color.BLACK, + playerColor = gameState.playerColor, + isSpectator = !canMakeMoves, + positionVersion = moveHistory.size, + onMoveMade = onMoveMade, + ) + + // Move history (scrollable) - observed from state flow + MoveHistoryDisplay( + moves = moveHistory, + ) + + // Show appropriate controls based on game state + when { + gameStatus is GameStatus.Finished -> { + GameEndInfo( + result = (gameStatus as GameStatus.Finished).result, + playerColor = gameState.playerColor, + isSpectator = isSpectator, + ) + } + + gameState.isPendingChallenge -> { + PendingChallengeInfo() + } + + isSpectator -> { + SpectatorInfo() + } + + else -> { + GameControls(onResign = onResign) + } + } + } + } + + // Game end celebration overlay + if (gameStatus is GameStatus.Finished && !gameEndDismissed) { + GameEndOverlay( + result = (gameStatus as GameStatus.Finished).result, + playerColor = gameState.playerColor, + isSpectator = isSpectator, + onDismiss = { + gameEndDismissed = true + onGameEndDismiss?.invoke() + }, + ) + } + } +} + +/** + * 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 + * @param opponentName Opponent's display name + * @param onMoveMade Callback when player makes a move (from, to, san) + * @param onResign Callback when player resigns + */ +@Composable +fun LiveChessGameScreen( + modifier: Modifier = Modifier, + engine: ChessEngine, + playerColor: Color, + gameId: String, + opponentName: String, + onMoveMade: (from: String, to: String, san: String) -> Unit, + onResign: () -> Unit, +) { + 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 + GameInfoHeader( + gameId = gameId, + opponentName = opponentName, + playerColor = playerColor, + currentTurn = engine.getSideToMove(), + ) + + // Interactive chess board - flip when playing black + // Auto-sized to fit available space + InteractiveChessBoard( + engine = engine, + boardSize = boardSize, + flipped = playerColor == Color.BLACK, + positionVersion = engine.getMoveHistory().size, + onMoveMade = onMoveMade, + ) + + // Move history (scrollable) + MoveHistoryDisplay( + moves = engine.getMoveHistory(), + ) + + // Game controls + GameControls( + onResign = onResign, + ) + } + } +} + +/** + * Game information header showing game ID, opponent, and current turn + */ +@Composable +private fun GameInfoHeader( + gameId: String, + opponentName: String, + playerColor: Color, + currentTurn: Color, + isSpectator: Boolean = false, + isPendingChallenge: Boolean = false, + gameStatus: GameStatus = GameStatus.InProgress, +) { + // Extract human-readable game name if available + val gameName = + remember(gameId) { + ChessGameNameGenerator.extractDisplayName(gameId) + } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Show readable name prominently if available + if (gameName != null) { + Text( + text = gameName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + + Text( + text = if (gameName != null) gameId.take(16) else "Game: ${gameId.take(8)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + when { + isPendingChallenge -> { + Text( + text = "Challenge Pending", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.secondary, + ) + + Text( + text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodyMedium, + ) + + Text( + text = if (opponentName.isNotEmpty()) "Waiting for $opponentName" else "Open challenge", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + isSpectator -> { + Text( + text = "Spectating", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + ) + + val turnText = "${if (currentTurn == Color.WHITE) "White" else "Black"}'s turn" + Text( + text = turnText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + else -> { + Text( + text = "vs $opponentName", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodyMedium, + ) + + // Show turn or game result + when (gameStatus) { + is GameStatus.Finished -> { + val result = (gameStatus as GameStatus.Finished).result + val resultText = + when { + result == GameResult.DRAW -> "Draw" + + (result == GameResult.WHITE_WINS && playerColor == Color.WHITE) || + (result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> "You won!" + + else -> "You lost" + } + val resultColor = + when { + result == GameResult.DRAW -> { + MaterialTheme.colorScheme.secondary + } + + (result == GameResult.WHITE_WINS && playerColor == Color.WHITE) || + (result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> { + ComposeColor(0xFF4CAF50) + } + + else -> { + MaterialTheme.colorScheme.error + } + } + Text( + text = resultText, + style = MaterialTheme.typography.bodyMedium, + color = resultColor, + fontWeight = FontWeight.Bold, + ) + } + + else -> { + val turnText = + if (currentTurn == playerColor) { + "Your turn" + } else { + "Opponent's turn" + } + + Text( + text = turnText, + style = MaterialTheme.typography.bodyMedium, + color = + if (currentTurn == playerColor) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + fontWeight = if (currentTurn == playerColor) FontWeight.Bold else FontWeight.Normal, + ) + } + } + } + } + } +} + +/** + * Info banner shown when spectating a game + */ +@Composable +private fun SpectatorInfo() { + Box( + modifier = + Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), + RoundedCornerShape(8.dp), + ).padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Watching game - spectator mode", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } +} + +/** + * Info banner shown when viewing a pending challenge + */ +@Composable +private fun PendingChallengeInfo() { + Box( + modifier = + Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), + RoundedCornerShape(8.dp), + ).padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Waiting for opponent to accept challenge", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + +/** + * Display move history in SAN notation with move numbers + * Shows in a horizontally scrollable container + */ +@Composable +private fun MoveHistoryDisplay(moves: List) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Move History", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 4.dp), + ) + + 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)) + } + } + } + } + } +} + +/** + * Game control buttons (Resign) + * Note: Draw offers not supported in Jester protocol + */ +@Composable +private fun GameControls(onResign: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + ) { + OutlinedButton(onClick = onResign) { + Text("Resign") + } + } +} + +/** + * Game end celebration overlay + */ +@Composable +private fun GameEndOverlay( + result: GameResult, + playerColor: Color, + isSpectator: Boolean, + onDismiss: () -> Unit, +) { + val playerWon = + when (result) { + GameResult.WHITE_WINS -> playerColor == Color.WHITE + GameResult.BLACK_WINS -> playerColor == Color.BLACK + GameResult.DRAW, GameResult.IN_PROGRESS -> false + } + + val isDraw = result == GameResult.DRAW + + // Determine display based on result + val (emoji, title, subtitle, backgroundColor) = + when { + isSpectator -> { + val winnerText = + when (result) { + GameResult.WHITE_WINS -> "White wins!" + GameResult.BLACK_WINS -> "Black wins!" + GameResult.DRAW -> "Draw!" + GameResult.IN_PROGRESS -> "Game Over" + } + Quadruple("", "Game Over", winnerText, MaterialTheme.colorScheme.surfaceVariant) + } + + playerWon -> { + Quadruple( + "", + "Victory!", + "Congratulations!", + ComposeColor(0xFF4CAF50).copy(alpha = 0.95f), + ) + } + + isDraw -> { + Quadruple( + "", + "Draw", + "Game ended in a draw", + MaterialTheme.colorScheme.surfaceVariant, + ) + } + + else -> { + Quadruple( + "", + "Defeat", + "Better luck next time!", + ComposeColor(0xFFE57373).copy(alpha = 0.95f), + ) + } + } + + // Animated visibility + AnimatedVisibility( + visible = true, + enter = fadeIn(animationSpec = tween(300)) + scaleIn(animationSpec = tween(300)), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(ComposeColor.Black.copy(alpha = 0.7f)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + Card( + modifier = + Modifier + .padding(32.dp) + .clickable { /* prevent dismiss on card click */ }, + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors( + containerColor = backgroundColor, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + Column( + modifier = + Modifier + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Trophy/emoji based on result + val displayEmoji = + when { + isSpectator -> "GG" + playerWon -> "GG" + isDraw -> "=" + else -> "GG" + } + + Box( + modifier = + Modifier + .size(80.dp) + .background( + when { + isSpectator -> MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + playerWon -> ComposeColor(0xFFFFD700).copy(alpha = 0.3f) + isDraw -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f) + else -> MaterialTheme.colorScheme.error.copy(alpha = 0.2f) + }, + CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = displayEmoji, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = + when { + isSpectator -> MaterialTheme.colorScheme.primary + playerWon -> ComposeColor(0xFFFFD700) + isDraw -> MaterialTheme.colorScheme.secondary + else -> MaterialTheme.colorScheme.error + }, + ) + } + + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = + when { + isSpectator -> MaterialTheme.colorScheme.onSurfaceVariant + playerWon -> ComposeColor.White + isDraw -> MaterialTheme.colorScheme.onSurfaceVariant + else -> ComposeColor.White + }, + ) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodyLarge, + color = + when { + isSpectator -> MaterialTheme.colorScheme.onSurfaceVariant + playerWon -> ComposeColor.White.copy(alpha = 0.9f) + isDraw -> MaterialTheme.colorScheme.onSurfaceVariant + else -> ComposeColor.White.copy(alpha = 0.9f) + }, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = onDismiss, + ) { + Text("Continue") + } + } + } + } + } +} + +/** + * Compact game end info shown in controls area + */ +@Composable +private fun GameEndInfo( + result: GameResult, + playerColor: Color, + isSpectator: Boolean, +) { + val resultText = + when { + isSpectator -> { + when (result) { + GameResult.WHITE_WINS -> "White wins" + GameResult.BLACK_WINS -> "Black wins" + GameResult.DRAW -> "Draw" + GameResult.IN_PROGRESS -> "In progress" + } + } + + result == GameResult.DRAW -> { + "Game drawn" + } + + (result == GameResult.WHITE_WINS && playerColor == Color.WHITE) || + (result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> { + "You won!" + } + + else -> { + "You lost" + } + } + + val backgroundColor = + when { + isSpectator -> { + MaterialTheme.colorScheme.surfaceVariant + } + + result == GameResult.DRAW -> { + MaterialTheme.colorScheme.secondaryContainer + } + + (result == GameResult.WHITE_WINS && playerColor == Color.WHITE) || + (result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> { + ComposeColor(0xFF4CAF50).copy(alpha = 0.3f) + } + + else -> { + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f) + } + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(8.dp)) + .padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Game Over - $resultText", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +/** + * Helper data class for quadruple values + */ +private data class Quadruple( + val first: A, + val second: B, + val third: C, + val fourth: D, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt new file mode 100644 index 000000000..f9a1b51c3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.unit.dp + +/** + * Move navigation controls for stepping through chess game positions + * Shared composable for Android and Desktop platforms + * + * @param currentMove Current move index (0 = starting position) + * @param totalMoves Total number of moves in the game + * @param onMoveChange Callback when user navigates to a different move + * @param modifier Modifier for the navigator + */ +@Composable +fun MoveNavigator( + currentMove: Int, + totalMoves: Int, + onMoveChange: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + // First move (starting position) + IconButton( + onClick = { onMoveChange(0) }, + enabled = currentMove > 0, + ) { + Icon( + Icons.Default.SkipPrevious, + contentDescription = "Start position", + tint = + if (currentMove > 0) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + + // Previous move + IconButton( + onClick = { onMoveChange((currentMove - 1).coerceAtLeast(0)) }, + enabled = currentMove > 0, + ) { + Icon( + Icons.Default.KeyboardArrowLeft, + contentDescription = "Previous move", + tint = + if (currentMove > 0) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + + // Current position indicator + Text( + text = "Move $currentMove / $totalMoves", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + // Next move + IconButton( + onClick = { onMoveChange((currentMove + 1).coerceAtMost(totalMoves)) }, + enabled = currentMove < totalMoves, + ) { + Icon( + Icons.Default.KeyboardArrowRight, + contentDescription = "Next move", + tint = + if (currentMove < totalMoves) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + + // Last move (final position) + IconButton( + onClick = { onMoveChange(totalMoves) }, + enabled = currentMove < totalMoves, + ) { + Icon( + Icons.Default.SkipNext, + contentDescription = "Final position", + tint = + if (currentMove < totalMoves) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/PGNMetadata.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/PGNMetadata.kt new file mode 100644 index 000000000..16441b52d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/PGNMetadata.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.quartz.nip64Chess.ChessGame + +/** + * Display PGN game metadata (Event, players, result, etc.) + * Shared composable for Android and Desktop platforms + * + * @param game The chess game with metadata to display + * @param modifier Modifier for the metadata display + */ +@Composable +fun PGNMetadata( + game: ChessGame, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Event name + game.event?.let { event -> + Text( + text = event, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + // Site/location (if available) + game.site?.let { site -> + Text( + text = site, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Players + if (game.white != null || game.black != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + game.white?.let { white -> + Text( + text = white, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Text( + text = "vs", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + game.black?.let { black -> + Text( + text = black, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + + // Date and Result + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + game.date?.let { date -> + Text( + text = date, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Result + Text( + text = "Result: ${game.result.notation}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Round (if available) + game.round?.let { round -> + Text( + text = "Round $round", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt new file mode 100644 index 000000000..90ff1070e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess.subscription + +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Shared filter builder for chess subscriptions using Jester protocol. + * Used by both Android and Desktop to ensure identical subscription behavior. + * + * Jester Protocol (kind 30): + * - All chess events use kind 30 + * - Start events: e-tag references START_POSITION_HASH + * - Move events: e-tags [startEventId, headEventId] + * - Content JSON determines event type (kind: 0=start, 1=move, 2=chat) + * + * Builds 4 types of filters: + * 1. Personal filters - Events tagged with user's pubkey + * 2. Start/Challenge filters - Events referencing START_POSITION_HASH + * 3. Active game filters - Events referencing specific game startEventIds + * 4. Recent game filters - For spectating discovery + */ +object ChessFilterBuilder { + /** Jester protocol kind for all chess events */ + private const val JESTER_KIND = JesterProtocol.KIND + + /** + * Build all chess filters for a given subscription state. + * This is the main entry point - platforms call this to get filters. + * + * @param state The current subscription state with user info and active games + * @param sinceForRelay Function to get cached EOSE time for a relay (null if no cache) + * @return List of relay-specific filters to subscribe with + */ + fun buildAllFilters( + state: ChessSubscriptionState, + sinceForRelay: (NormalizedRelayUrl) -> Long?, + ): List { + val filters = mutableListOf() + val now = TimeUtils.now() + + // Filter 1: Personal events (challenges to us, moves in our games) + filters.addAll( + buildPersonalFilters(state.userPubkey, state.relays, sinceForRelay, now), + ) + + // Filter 2: All start/challenge events (for lobby display) + filters.addAll( + buildStartEventFilters(state.relays, sinceForRelay, now), + ) + + // Filter 3: Active game subscriptions (game-specific) + if (state.hasActiveGames) { + filters.addAll( + buildActiveGameFilters( + state.allGameIds, + state.userPubkey, + state.opponentPubkeys, + state.relays, + ), + ) + } + + // Filter 4: Recent game events (for spectating discovery) + filters.addAll( + buildRecentGameFilters(state.relays, sinceForRelay, now), + ) + + return filters + } + + /** + * Personal events - tagged with user's pubkey. + * Catches: challenges directed at us, moves in our games. + */ + fun buildPersonalFilters( + userPubkey: String, + relays: Set, + sinceForRelay: (NormalizedRelayUrl) -> Long?, + now: Long = TimeUtils.now(), + ): List { + val filter = + Filter( + kinds = listOf(JESTER_KIND), + tags = mapOf("p" to listOf(userPubkey)), + limit = 100, + ) + + return relays.map { relay -> + val sinceTime = + sinceForRelay(relay) + ?: (now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS) + RelayBasedFilter( + relay = relay, + filter = filter.copy(since = sinceTime), + ) + } + } + + /** + * Start/challenge events (Jester pattern). + * In Jester protocol, start events reference the START_POSITION_HASH. + * This fetches all game starts for lobby display. + */ + fun buildStartEventFilters( + relays: Set, + sinceForRelay: (NormalizedRelayUrl) -> Long?, + now: Long = TimeUtils.now(), + ): List { + val filter = + Filter( + kinds = listOf(JESTER_KIND), + tags = mapOf("e" to listOf(JesterProtocol.START_POSITION_HASH)), + limit = 100, + ) + + return relays.map { relay -> + val sinceTime = + sinceForRelay(relay) + ?: (now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS) + RelayBasedFilter( + relay = relay, + filter = filter.copy(since = sinceTime), + ) + } + } + + /** + * Active game events - game-specific subscriptions. + * These filters ensure moves are received for games the user is playing. + * + * In Jester protocol: + * - Move events have e-tags: [startEventId, headEventId] + * - We filter by the first e-tag (startEventId) to get all moves for a game + * - We also filter by opponent authors and p-tag for redundancy + */ + fun buildActiveGameFilters( + startEventIds: Set, + userPubkey: String, + opponentPubkeys: Set, + relays: Set, + ): List { + if (startEventIds.isEmpty()) return emptyList() + + val filters = mutableListOf() + + // Game events: filter by e-tag (startEventId) + // This catches all moves/events for these games + val gameFilter = + Filter( + kinds = listOf(JESTER_KIND), + tags = mapOf("e" to startEventIds.toList()), + limit = 500, + ) + + relays.forEach { relay -> + filters.add(RelayBasedFilter(relay = relay, filter = gameFilter)) + } + + // Also filter by p-tag (opponent tagged us) for redundancy + val tagFilter = + Filter( + kinds = listOf(JESTER_KIND), + tags = mapOf("p" to listOf(userPubkey)), + limit = 200, + ) + + relays.forEach { relay -> + filters.add(RelayBasedFilter(relay = relay, filter = tagFilter)) + } + + // Also filter by authors (opponent pubkeys) for redundancy + if (opponentPubkeys.isNotEmpty()) { + val authorFilter = + Filter( + kinds = listOf(JESTER_KIND), + authors = opponentPubkeys.toList(), + limit = 200, + ) + + relays.forEach { relay -> + filters.add(RelayBasedFilter(relay = relay, filter = authorFilter)) + } + } + + return filters + } + + /** + * Recent game events for spectating discovery. + * Uses longer time window (7 days) to catch ongoing games. + */ + fun buildRecentGameFilters( + relays: Set, + sinceForRelay: (NormalizedRelayUrl) -> Long?, + now: Long = TimeUtils.now(), + ): List { + val filter = + Filter( + kinds = listOf(JESTER_KIND), + limit = 100, + ) + + return relays.map { relay -> + val sinceTime = + sinceForRelay(relay) + ?: (now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS) + RelayBasedFilter( + relay = relay, + filter = filter.copy(since = sinceTime), + ) + } + } + + // =================================================== + // Simple filters for one-shot fetches (ChessRelayFetcher) + // =================================================== + + /** + * Filter for all events related to a specific game. + * Used for one-shot fetch when loading a game. + * + * In Jester protocol, all game events reference the startEventId via e-tag. + */ + fun gameEventsFilter(startEventId: String): Filter = + Filter( + kinds = listOf(JESTER_KIND), + tags = mapOf("e" to listOf(startEventId)), + limit = 500, + ) + + /** + * Filter for start/challenge events in the last 24 hours. + * Used for one-shot fetch to populate lobby. + * + * Start events reference the START_POSITION_HASH. + */ + fun challengesFilter(userPubkey: String? = null): Filter { + val now = TimeUtils.now() + return Filter( + kinds = listOf(JESTER_KIND), + tags = mapOf("e" to listOf(JesterProtocol.START_POSITION_HASH)), + since = now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS, + limit = 100, + ) + } + + /** + * Filter for recent game activity for spectating discovery. + * Fetches events from the last 7 days. + */ + fun recentGamesFilter(): Filter { + val now = TimeUtils.now() + return Filter( + kinds = listOf(JESTER_KIND), + since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS, + limit = 200, + ) + } + + /** + * Filter for user's own chess events (events they authored). + * Used to discover games the user is participating in. + */ + fun userGamesFilter(userPubkey: String): Filter { + val now = TimeUtils.now() + return Filter( + kinds = listOf(JESTER_KIND), + authors = listOf(userPubkey), + since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS, + limit = 200, + ) + } + + /** + * Filter for events tagged with user's pubkey (games they're participating in). + * Complements userGamesFilter to find games where user is the opponent. + */ + fun userTaggedFilter(userPubkey: String): Filter { + val now = TimeUtils.now() + return Filter( + kinds = listOf(JESTER_KIND), + tags = mapOf("p" to listOf(userPubkey)), + since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS, + limit = 200, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionController.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionController.kt new file mode 100644 index 000000000..4539e42a2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionController.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess.subscription + +import kotlinx.coroutines.flow.StateFlow + +/** + * Interface for platform-specific chess subscription management. + * + * Implementations handle the actual relay subscription mechanics while + * using [ChessFilterBuilder] for consistent filter construction. + * + * Key responsibilities: + * - Subscribe/unsubscribe to chess events on relays + * - Update filters when active games change + * - Track EOSE (End of Stored Events) per relay for efficient re-subscription + */ +interface ChessSubscriptionController { + /** Current subscription state, null if not subscribed */ + val currentState: StateFlow + + /** + * Subscribe to chess events with the given state. + * Replaces any existing subscription. + */ + fun subscribe(state: ChessSubscriptionState) + + /** Unsubscribe from all chess events */ + fun unsubscribe() + + /** + * Update active game IDs and refresh subscription filters. + * This is the key method for dynamic subscription updates. + * + * When a game starts or ends, call this to update the filters + * so moves are properly received for active games. + * + * @param activeGameIds Game IDs the user is actively playing + * @param spectatingGameIds Game IDs the user is watching (optional) + */ + fun updateActiveGames( + activeGameIds: Set, + spectatingGameIds: Set = emptySet(), + ) + + /** + * Force refresh all filters from relays. + * Clears EOSE cache so full history is re-fetched. + */ + fun forceRefresh() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionState.kt new file mode 100644 index 000000000..27c43f37c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionState.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess.subscription + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Immutable state for chess relay subscriptions. + * Used to generate subscription filters and track subscription identity. + * + * When any of these values change, a new subscription should be created + * with updated filters. + */ +@Immutable +data class ChessSubscriptionState( + /** User's public key (hex) for filtering personal events */ + val userPubkey: String, + /** Set of relays to subscribe to */ + val relays: Set, + /** Game IDs the user is actively playing */ + val activeGameIds: Set = emptySet(), + /** Game IDs the user is spectating */ + val spectatingGameIds: Set = emptySet(), + /** Opponent pubkeys for active games (to filter their moves) */ + val opponentPubkeys: Set = emptySet(), +) { + /** + * Unique subscription ID that changes when game IDs change. + * Used for: + * - Subscription deduplication + * - EOSE cache keying + * - Detecting when filters need to be refreshed + */ + fun subscriptionId(): String = + buildString { + append("chess-") + append(userPubkey.take(8)) + if (allGameIds.isNotEmpty()) { + append("-g") + append(allGameIds.sorted().hashCode()) + } + } + + /** All game IDs requiring move subscriptions (active + spectating) */ + val allGameIds: Set + get() = activeGameIds + spectatingGameIds + + /** True if user has any active or spectating games */ + val hasActiveGames: Boolean + get() = allGameIds.isNotEmpty() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessTimeWindows.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessTimeWindows.kt new file mode 100644 index 000000000..85267bfff --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessTimeWindows.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess.subscription + +/** + * Shared time window constants for chess subscriptions. + * Used by both Android and Desktop to ensure consistent behavior. + */ +object ChessTimeWindows { + /** + * 24 hours - challenges older than this are considered expired. + * Used for challenge and accept event filters. + */ + const val CHALLENGE_WINDOW_SECONDS = 24 * 60 * 60L + + /** + * 7 days - default lookback for game events when no EOSE cache exists. + * This prevents loading ancient game history on first connection + * while still allowing recovery of recent games. + */ + const val GAME_EVENT_WINDOW_SECONDS = 7 * 24 * 60 * 60L +} diff --git a/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..ac0c4b47f --- /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/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastBanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastBanner.kt new file mode 100644 index 000000000..3009fd10b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastBanner.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.profile + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * Shared banner showing profile metadata broadcast progress. + * Works on both Android and Desktop via Compose Multiplatform. + */ +@Composable +fun ProfileBroadcastBanner( + status: ProfileBroadcastStatus, + onTap: () -> Unit, + modifier: Modifier = Modifier, +) { + val isVisible = status !is ProfileBroadcastStatus.Idle + + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(tween(200)), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(tween(150)), + modifier = modifier, + ) { + Surface( + color = getStatusBackgroundColor(status), + tonalElevation = 2.dp, + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onTap), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(horizontal = 16.dp, vertical = 10.dp) + .animateContentSize(), + ) { + Icon( + imageVector = getStatusIcon(status), + contentDescription = null, + tint = getStatusIconColor(status), + modifier = Modifier.size(18.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = getStatusText(status), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = getStatusDetail(status), + style = MaterialTheme.typography.labelMedium, + color = getStatusDetailColor(status), + ) + } + + // Progress bar for broadcasting + if (status is ProfileBroadcastStatus.Broadcasting) { + Spacer(Modifier.height(4.dp)) + + val animatedProgress by animateFloatAsState( + targetValue = status.progress, + animationSpec = tween(300), + label = "broadcastProgress", + ) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun getStatusBackgroundColor(status: ProfileBroadcastStatus): Color = + when (status) { + is ProfileBroadcastStatus.Failed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.9f) + is ProfileBroadcastStatus.Success -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f) + else -> MaterialTheme.colorScheme.surfaceContainer + } + +private fun getStatusIcon(status: ProfileBroadcastStatus): ImageVector = + when (status) { + is ProfileBroadcastStatus.Broadcasting -> Icons.Default.Sync + is ProfileBroadcastStatus.Success -> Icons.Default.CheckCircle + is ProfileBroadcastStatus.Failed -> Icons.Default.Error + is ProfileBroadcastStatus.Idle -> Icons.Default.CheckCircle + } + +@Composable +private fun getStatusIconColor(status: ProfileBroadcastStatus): Color = + when (status) { + is ProfileBroadcastStatus.Failed -> MaterialTheme.colorScheme.error + is ProfileBroadcastStatus.Success -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.primary + } + +private fun getStatusText(status: ProfileBroadcastStatus): String = + when (status) { + is ProfileBroadcastStatus.Broadcasting -> "Updating ${status.fieldName}..." + is ProfileBroadcastStatus.Success -> "Updated ${status.fieldName}" + is ProfileBroadcastStatus.Failed -> "Failed to update ${status.fieldName}" + is ProfileBroadcastStatus.Idle -> "" + } + +private fun getStatusDetail(status: ProfileBroadcastStatus): String = + when (status) { + is ProfileBroadcastStatus.Broadcasting -> "[${status.successCount}/${status.totalRelays}]" + is ProfileBroadcastStatus.Success -> "${status.relayCount} relays" + is ProfileBroadcastStatus.Failed -> "Tap to retry" + is ProfileBroadcastStatus.Idle -> "" + } + +@Composable +private fun getStatusDetailColor(status: ProfileBroadcastStatus): Color = + when (status) { + is ProfileBroadcastStatus.Failed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastStatus.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastStatus.kt new file mode 100644 index 000000000..c5906088d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastStatus.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.profile + +/** + * Status of profile metadata broadcast to relays. + */ +sealed class ProfileBroadcastStatus { + data object Idle : ProfileBroadcastStatus() + + data class Broadcasting( + val fieldName: String, + val successCount: Int, + val totalRelays: Int, + ) : ProfileBroadcastStatus() { + val progress: Float get() = if (totalRelays > 0) successCount.toFloat() / totalRelays else 0f + } + + data class Success( + val fieldName: String, + val relayCount: Int, + ) : ProfileBroadcastStatus() + + data class Failed( + val fieldName: String, + val error: String, + ) : ProfileBroadcastStatus() +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt new file mode 100644 index 000000000..ac6e7d896 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponse +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import kotlinx.coroutines.delay + +/** + * Result of broadcasting an event + */ +data class BroadcastResult( + val success: Boolean, + val relayResults: Map, + val message: String, +) + +/** + * Helper for broadcasting chess events to relays with reliable delivery. + * + * Uses sendAndWaitForResponse to get actual OK confirmations from relays, + * ensuring the event was actually received and accepted. + */ +class ChessEventBroadcaster( + private val client: INostrClient, +) { + /** + * Broadcast an event to the chess relays with confirmation. + * + * This method: + * 1. Triggers relay connections via a dummy subscription + * 2. Waits for relays to connect + * 3. Sends the event and waits for OK responses + * + * @param event The signed event to broadcast + * @param timeoutSeconds Maximum time to wait for relay responses + * @return BroadcastResult with success status + */ + suspend fun broadcast( + event: Event, + timeoutSeconds: Long = 15L, + ): BroadcastResult { + val targetRelays = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) }.toSet() + val subId = newSubId() + + // Step 1: Check which relays are already connected + val initialConnected = client.connectedRelaysFlow().value + val alreadyConnected = targetRelays.intersect(initialConnected) + val needsConnection = targetRelays - alreadyConnected + + // Step 2: If some relays need connection, open a subscription to trigger it + if (needsConnection.isNotEmpty()) { + // Use a valid filter with recent since timestamp to trigger connection + // without expecting real results + val dummyFilter = + Filter( + kinds = listOf(JesterProtocol.KIND), + since = (System.currentTimeMillis() / 1000) + 3600, // 1 hour in future = no results + limit = 1, + ) + + val listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { } + } + + // Open subscription to all target relays (triggers connection) + val filterMap = targetRelays.associateWith { listOf(dummyFilter) } + client.openReqSubscription(subId, filterMap, listener) + + // Wait for relays to connect (poll with timeout) + waitForRelays(targetRelays, 5000L) + + // Close the dummy subscription + client.close(subId) + } + + // Step 3: Send the event and wait for OK responses + val success = client.sendAndWaitForResponse(event, targetRelays, timeoutSeconds) + + // Note: sendAndWaitForResponse only returns aggregate success (any relay accepted) + // We don't have per-relay results, so relayResults is empty + return BroadcastResult( + success = success, + relayResults = emptyMap(), + message = if (success) "Event accepted by at least one relay" else "No relay accepted the event", + ) + } + + /** + * Wait for target relays to appear in connectedRelaysFlow. + */ + private suspend fun waitForRelays( + targetRelays: Set, + timeoutMs: Long, + ): Set { + val startTime = System.currentTimeMillis() + val pollInterval = 200L + + while (System.currentTimeMillis() - startTime < timeoutMs) { + val connected = client.connectedRelaysFlow().value + val targetConnected = targetRelays.intersect(connected) + + if (targetConnected.size == targetRelays.size) { + return targetConnected + } + + // At least one connected is good enough after half the timeout + if (targetConnected.isNotEmpty() && System.currentTimeMillis() - startTime > timeoutMs / 2) { + return targetConnected + } + + delay(pollInterval) + } + + return client.connectedRelaysFlow().value.intersect(targetRelays) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/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..537cdd2f5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -0,0 +1,823 @@ +/* + * 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.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.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.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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 androidx.compose.ui.unit.min +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastBanner +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessConfig +import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner +import com.vitorpamplona.amethyst.commons.chess.CompletedGame +import com.vitorpamplona.amethyst.commons.chess.InteractiveChessBoard +import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog +import com.vitorpamplona.amethyst.commons.chess.PublicGame +import com.vitorpamplona.amethyst.commons.data.UserMetadataCache +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.createChessSubscriptionWithGames +import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataListSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator +import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor + +/** + * 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) { + DesktopChessViewModelNew(account, relayManager, scope) + } + val relayStatuses by relayManager.relayStatuses.collectAsState() + val broadcastStatus by viewModel.broadcastStatus.collectAsState() + val activeGames by viewModel.activeGames.collectAsState() + // Observe state version to force recomposition when game state changes + val stateVersion by viewModel.stateVersion.collectAsState() + + // Ensure chess relays are added to the relay manager for broadcasting + LaunchedEffect(Unit) { + ChessConfig.CHESS_RELAYS.forEach { relayUrl -> + relayManager.addRelay(relayUrl) + } + println("[ChessScreen] Added ${ChessConfig.CHESS_RELAYS.size} chess relays to relay manager") + } + + // Derive stable keys to avoid recomposition from LiveChessGameState identity changes + val activeGameIds = remember(activeGames.keys) { activeGames.keys.toSet() } + val opponentPubkeys = + remember(activeGameIds) { + activeGames.values.map { it.opponentPubkey }.toSet() + } + + // Subscribe to chess events from dedicated chess relays + // Re-subscribes when active games change + val chessRelays = + remember { + ChessConfig.CHESS_RELAYS + .map { + com.vitorpamplona.quartz.nip01Core.relay.normalizer + .NormalizedRelayUrl(it) + }.toSet() + } + rememberSubscription(chessRelays, account, activeGameIds, opponentPubkeys, relayManager = relayManager) { + createChessSubscriptionWithGames( + relays = chessRelays, + userPubkey = account.pubKeyHex, + activeGameIds = activeGameIds, + opponentPubkeys = opponentPubkeys, + onEvent = { event, _, _, _ -> + viewModel.handleIncomingEvent(event) + }, + onEose = { _, _ -> + // ChessLobbyLogic handles loading state internally + }, + ) + } + + // Subscribe to user metadata for pubkeys that need it + 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 challenges by viewModel.challenges.collectAsState() + val spectatingGames by viewModel.spectatingGames.collectAsState() + val publicGames by viewModel.publicGames.collectAsState() + val completedGames by viewModel.completedGames.collectAsState() + // Observe metadata changes to trigger recomposition + val userMetadata by viewModel.userMetadataCache.metadata.collectAsState() + val selectedGameId by viewModel.selectedGameId.collectAsState() + val error by viewModel.error.collectAsState() + val isRefreshing by viewModel.isRefreshing.collectAsState() + val syncStatus by viewModel.syncStatus.collectAsState() + var showNewGameDialog by remember { mutableStateOf(false) } + + Column(modifier = Modifier.fillMaxSize()) { + // 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.forceRefresh() }) { + 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") + } + } + } + } + } + + // Sync status banner (shown in both lobby and game views) + ChessSyncBanner( + status = syncStatus, + onRetry = { viewModel.forceRefresh() }, + modifier = Modifier.padding(bottom = 8.dp), + ) + + // Broadcast status banner (shows when publishing moves) + ChessBroadcastBanner( + status = broadcastStatus, + onTap = { viewModel.forceRefresh() }, + ) + + // Error display + error?.let { errorMsg -> + Card( + 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) { + // Set focused game mode - only poll this game, not others + LaunchedEffect(selectedGameId) { + viewModel.setFocusedGame(selectedGameId!!) + } + + // Use stateVersion to ensure recomposition when game state changes + val gameState = + remember(selectedGameId, stateVersion) { + viewModel.getGameState(selectedGameId!!) + } + if (gameState != null) { + // Determine spectator status: + // 1. If game was accepted locally, user is definitely NOT a spectator + // 2. Otherwise, check which map the game is in + val wasAccepted = viewModel.wasAccepted(selectedGameId!!) + val isSpectating = !wasAccepted && spectatingGames.containsKey(selectedGameId) + + DesktopChessGameLayout( + gameState = gameState, + opponentName = viewModel.userMetadataCache.getDisplayName(gameState.opponentPubkey), + opponentPicture = viewModel.userMetadataCache.getPictureUrl(gameState.opponentPubkey), + onMoveMade = { from, to, _ -> + viewModel.publishMove(gameState.startEventId, from, to) + }, + onResign = { viewModel.resign(gameState.startEventId) }, + isSpectatorOverride = isSpectating, + ) + } + } else { + // Clear focused game mode when returning to lobby - poll all games + LaunchedEffect(Unit) { + viewModel.clearFocusedGame() + } + + // Track outgoing challenges to scroll to top when a new one is created + val outgoingChallengesCount = challenges.count { it.isFrom(account.pubKeyHex) } + val listState = rememberLazyListState() + + // Scroll to top when user creates a new challenge + LaunchedEffect(outgoingChallengesCount) { + if (outgoingChallengesCount > 0) { + listState.animateScrollToItem(0) + } + } + + ChessLobby( + challenges = challenges, + activeGames = activeGames, + spectatingGames = spectatingGames, + publicGames = publicGames, + completedGames = completedGames, + userPubkey = account.pubKeyHex, + metadataCache = viewModel.userMetadataCache, + onAcceptChallenge = { viewModel.acceptChallenge(it) }, + onOpenOwnChallenge = { viewModel.openOwnChallenge(it) }, + onWatchGame = { viewModel.loadGameAsSpectator(it) }, + onSelectGame = { viewModel.selectGame(it) }, + listState = listState, + ) + } + } + + // 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, + spectatingGames: Map, + publicGames: List, + completedGames: List, + userPubkey: String, + metadataCache: UserMetadataCache, + onAcceptChallenge: (ChessChallenge) -> Unit, + onOpenOwnChallenge: (ChessChallenge) -> Unit, + onWatchGame: (String) -> Unit, + onSelectGame: (String) -> Unit, + listState: LazyListState = rememberLazyListState(), +) { + val hasContent = + activeGames.isNotEmpty() || spectatingGames.isNotEmpty() || + publicGames.isNotEmpty() || challenges.isNotEmpty() || completedGames.isNotEmpty() + + if (!hasContent) { + // Empty state + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "No games or challenges", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Create a new game or refresh to load from relays", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + return + } + + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Active games section (user is participant) + if (activeGames.isNotEmpty()) { + item { + Text( + "Your Games", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) -> + com.vitorpamplona.amethyst.commons.chess.ActiveGameCard( + gameId = gameId, + opponentName = metadataCache.getDisplayName(state.opponentPubkey), + isYourTurn = state.isPlayerTurn(), + onClick = { onSelectGame(gameId) }, + avatar = { + UserAvatar( + userHex = state.opponentPubkey, + pictureUrl = metadataCache.getPictureUrl(state.opponentPubkey), + size = 40.dp, + ) + }, + ) + } + } + + // User's outgoing challenges (right after active games - user wants to see their new challenges) + val outgoingChallenges = challenges.filter { it.isFrom(userPubkey) } + if (outgoingChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Your Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(outgoingChallenges, key = { "outgoing-${it.eventId}" }) { challenge -> + com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard( + opponentName = challenge.opponentPubkey?.let { metadataCache.getDisplayName(it) }, + userPlaysWhite = challenge.challengerColor == ChessColor.WHITE, + onClick = { onOpenOwnChallenge(challenge) }, + avatar = + challenge.opponentPubkey?.let { pubkey -> + { + UserAvatar( + userHex = pubkey, + pictureUrl = metadataCache.getPictureUrl(pubkey), + size = 40.dp, + ) + } + }, + ) + } + } + + // Games user is spectating + if (spectatingGames.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Watching", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(spectatingGames.entries.toList(), key = { "spectating-${it.key}" }) { (gameId, state) -> + val moveCount by state.moveHistory.collectAsState() + com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard( + moveCount = moveCount.size, + onClick = { onSelectGame(gameId) }, + ) + } + } + + // Incoming challenges (directed at user) + val incomingChallenges = challenges.filter { it.isDirectedAt(userPubkey) } + if (incomingChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Incoming Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(incomingChallenges, key = { "incoming-${it.eventId}" }) { challenge -> + com.vitorpamplona.amethyst.commons.chess.ChallengeCard( + challengerName = challenge.challengerDisplayName ?: metadataCache.getDisplayName(challenge.challengerPubkey), + challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, + isIncoming = true, + onAccept = { onAcceptChallenge(challenge) }, + avatar = { + UserAvatar( + userHex = challenge.challengerPubkey, + pictureUrl = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), + size = 40.dp, + ) + }, + ) + } + } + + // Open challenges from others (can join) + val openChallenges = challenges.filter { it.isOpen && !it.isFrom(userPubkey) } + if (openChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Open Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(openChallenges, key = { "open-${it.eventId}" }) { challenge -> + com.vitorpamplona.amethyst.commons.chess.ChallengeCard( + challengerName = challenge.challengerDisplayName ?: metadataCache.getDisplayName(challenge.challengerPubkey), + challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, + isIncoming = false, + onAccept = { onAcceptChallenge(challenge) }, + avatar = { + UserAvatar( + userHex = challenge.challengerPubkey, + pictureUrl = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), + size = 40.dp, + ) + }, + ) + } + } + + // Live games to watch (public games user is not part of) + if (publicGames.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Live Games", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(publicGames, key = { "public-${it.gameId}" }) { game -> + com.vitorpamplona.amethyst.commons.chess.PublicGameCard( + whiteName = game.whiteDisplayName ?: metadataCache.getDisplayName(game.whitePubkey), + blackName = game.blackDisplayName ?: metadataCache.getDisplayName(game.blackPubkey), + moveCount = game.moveCount, + onWatch = { onWatchGame(game.gameId) }, + ) + } + } + + // 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.distinctBy { it.gameId }.take(10), + key = { "completed-${it.gameId}-${it.completedAt}" }, + ) { game -> + // Derive opponent pubkey based on who the user is + val opponentPubkey = + if (game.whitePubkey == userPubkey) game.blackPubkey else game.whitePubkey + com.vitorpamplona.amethyst.commons.chess.CompletedGameCard( + opponentName = game.blackDisplayName ?: game.whiteDisplayName ?: metadataCache.getDisplayName(opponentPubkey), + result = game.result, + didUserWin = game.didUserWin(userPubkey), + isDraw = game.isDraw, + moveCount = game.moveCount, + avatar = { + UserAvatar( + userHex = opponentPubkey, + pictureUrl = metadataCache.getPictureUrl(opponentPubkey), + size = 40.dp, + ) + }, + ) + } + } + + // Bottom padding + item { + Spacer(Modifier.height(16.dp)) + } + } +} + +/** + * Desktop-optimized chess game layout with board on left, info/controls on right + * + * @param isSpectatorOverride If non-null, overrides gameState.isSpectator. Use when spectator + * status is determined by which map the game is in (activeGames vs spectatingGames). + */ +@Composable +private fun DesktopChessGameLayout( + gameState: com.vitorpamplona.quartz.nip64Chess.LiveChessGameState, + opponentName: String, + opponentPicture: String?, + onMoveMade: (from: String, to: String, san: String) -> Unit, + onResign: () -> Unit, + isSpectatorOverride: Boolean? = null, +) { + // Collect state flows to trigger recomposition on changes + val currentPosition by gameState.currentPosition.collectAsState() + val moveHistory by gameState.moveHistory.collectAsState() + + val engine = gameState.engine + val playerColor = gameState.playerColor + val startEventId = gameState.startEventId + val opponentPubkey = gameState.opponentPubkey + val isSpectator = isSpectatorOverride ?: gameState.isSpectator + + BoxWithConstraints( + modifier = Modifier.fillMaxSize().padding(16.dp), + ) { + // Calculate board size based on available space + // Leave room for the info panel (300dp + 24dp spacing) + val infoPanelWidth = 300.dp + 24.dp + val availableWidth = maxWidth - infoPanelWidth + val availableHeight = maxHeight + // Board should fit within available space, maintaining square aspect ratio + val boardSize = min(availableWidth, availableHeight).coerceIn(200.dp, 520.dp) + + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Left side: Chess board + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { + InteractiveChessBoard( + engine = engine, + boardSize = boardSize, + flipped = if (isSpectator) false else playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK, + playerColor = playerColor, + isSpectator = isSpectator, + positionVersion = moveHistory.size, + onMoveMade = onMoveMade, + ) + } + + // Right side: Game info, moves, controls + Column( + modifier = + Modifier + .width(300.dp) + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Extract human-readable game name + val gameName = + remember(startEventId) { + ChessGameNameGenerator.extractDisplayName(startEventId) + } + + // Game info card + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Show readable game name if available + if (gameName != null) { + Text( + gameName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + "Game Info", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UserAvatar( + userHex = opponentPubkey, + pictureUrl = opponentPicture, + size = 48.dp, + ) + Column { + if (isSpectator) { + Text( + "Spectating", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + ) + } else { + Text( + "vs $opponentName", + style = MaterialTheme.typography.bodyLarge, + ) + Text( + "You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Use currentPosition to derive turn (triggers recomposition on move) + val currentTurn = currentPosition.activeColor + + if (isSpectator) { + Text( + "${if (currentTurn == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}'s turn", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + val isYourTurn = currentTurn == playerColor + Text( + if (isYourTurn) "Your turn" else "Opponent's turn", + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, + color = + if (isYourTurn) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + + // Move history card + if (moveHistory.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Move History", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + // Format moves as numbered pairs + val moveText = + moveHistory + .chunked(2) + .mapIndexed { index, pair -> + "${index + 1}. ${pair.joinToString(" ")}" + }.joinToString("\n") + + Text( + moveText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Game controls card (only for participants, not spectators) + if (!isSpectator) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Actions", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + OutlinedButton( + onClick = onResign, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Resign") + } + } + } + } else { + // Spectator info card + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), + ), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Default.Visibility, contentDescription = null) + Text( + "Watching game - spectator mode", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + // Game ID (small footer) + Text( + "Game: ${startEventId.take(16)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt new file mode 100644 index 000000000..f80a3f6a6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt @@ -0,0 +1,397 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.chess + +import com.vitorpamplona.amethyst.commons.chess.ChessConfig +import com.vitorpamplona.amethyst.commons.chess.ChessEventBroadcaster +import com.vitorpamplona.amethyst.commons.chess.ChessEventPublisher +import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetchHelper +import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetcher +import com.vitorpamplona.amethyst.commons.chess.IUserMetadataProvider +import com.vitorpamplona.amethyst.commons.chess.RelayFetchProgress +import com.vitorpamplona.amethyst.commons.chess.RelayGameSummary +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder +import com.vitorpamplona.amethyst.commons.data.UserMetadataCache +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd +import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.toJesterEvent + +/** + * Desktop implementation of ChessEventPublisher using Jester protocol. + * Wraps account.signer.sign() + relayManager.broadcastToAll() for event publishing. + * + * Jester Protocol: + * - All chess events use kind 30 + * - publishStart creates a start event (content.kind=0) + * - publishMove creates a move event (content.kind=1) with full history + * - publishGameEnd creates a move event with result in content + */ +class DesktopChessPublisher( + private val account: AccountState.LoggedIn, + private val relayManager: DesktopRelayConnectionManager, +) : ChessEventPublisher { + private val broadcaster = ChessEventBroadcaster(relayManager.client) + + /** + * Broadcast an event to the dedicated chess relays with reliable delivery. + * Uses ChessEventBroadcaster to ensure relay connections before sending. + */ + private suspend fun broadcastToChessRelays(event: com.vitorpamplona.quartz.nip01Core.core.Event): Boolean { + println("[DesktopChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster") + val result = broadcaster.broadcast(event) + println("[DesktopChessPublisher] Broadcast result: ${result.message}") + return result.success + } + + /** + * Publish a game start event (challenge). + * Returns the startEventId (event ID) if successful. + */ + override suspend fun publishStart( + playerColor: Color, + opponentPubkey: String?, + ): String? = + try { + val template = + if (opponentPubkey != null) { + JesterEvent.buildPrivateStart( + opponentPubkey = opponentPubkey, + playerColor = playerColor, + ) + } else { + JesterEvent.buildStart( + playerColor = playerColor, + ) + } + val signedEvent = account.signer.sign(template) + val success = broadcastToChessRelays(signedEvent) + if (success) signedEvent.id else null + } catch (e: Exception) { + println("[DesktopChessPublisher] publishStart failed: ${e.message}") + null + } + + /** + * Publish a move event. + * Returns the move event ID if successful. + */ + override suspend fun publishMove(move: ChessMoveEvent): String? = + try { + val template = + JesterEvent.buildMove( + startEventId = move.startEventId, + headEventId = move.headEventId, + move = move.san, + fen = move.fen, + history = move.history, + opponentPubkey = move.opponentPubkey, + ) + val signedEvent = account.signer.sign(template) + val success = broadcastToChessRelays(signedEvent) + if (success) signedEvent.id else null + } catch (e: Exception) { + println("[DesktopChessPublisher] publishMove failed: ${e.message}") + null + } + + /** + * Publish a game end event (includes result in content). + */ + override suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean = + try { + val template = + JesterEvent.buildEndMove( + startEventId = gameEnd.startEventId, + headEventId = gameEnd.headEventId, + move = gameEnd.lastMove, + fen = gameEnd.fen, + history = gameEnd.history, + opponentPubkey = gameEnd.opponentPubkey, + result = gameEnd.result, + termination = gameEnd.termination, + ) + val signedEvent = account.signer.sign(template) + broadcastToChessRelays(signedEvent) + } catch (e: Exception) { + println("[DesktopChessPublisher] publishGameEnd failed: ${e.message}") + false + } + + override fun getWriteRelayCount(): Int = ChessConfig.CHESS_RELAYS.size +} + +/** + * Desktop implementation of ChessRelayFetcher using Jester protocol. + * + * Hybrid approach (like Android's LocalCache pattern): + * 1. Query DesktopChessEventCache first (populated by subscription) + * 2. Also do relay queries and merge results for completeness + * + * This solves the "missing games" issue where pure one-shot relay queries + * would miss events due to timing or slow relays. + */ +class DesktopRelayFetcher( + private val relayManager: DesktopRelayConnectionManager, + private val userPubkey: String, +) : ChessRelayFetcher { + private val fetchHelper = ChessRelayFetchHelper(relayManager.client) + + /** + * Get the normalized chess relay URLs. + * Always uses the 3 dedicated chess relays from ChessConfig. + */ + private fun chessRelayUrls(): List = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) } + + override fun getRelayUrls(): List { + println("[DesktopRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays") + return ChessConfig.CHESS_RELAYS + } + + /** + * Fetch game events - queries cache first, supplements with relay query. + * Similar to Android's LocalCache.addressables pattern. + * + * Uses TWO filters: + * 1. Fetch start event by ID (start events don't have #e tag to themselves) + * 2. Fetch moves that reference the start event via #e tag + */ + override suspend fun fetchGameEvents(startEventId: String): JesterGameEvents { + println("[DesktopRelayFetcher] fetchGameEvents: querying cache for startEventId=$startEventId") + + // Query cache first (populated by subscription) + val cachedEvents = DesktopChessEventCache.getGameEvents(startEventId) + println("[DesktopRelayFetcher] fetchGameEvents cache: start=${cachedEvents.startEvent != null}, moves=${cachedEvents.moves.size}") + + // Also do relay query to catch any new events and populate cache + // Filter 1: Fetch the start event by its ID + val startEventFilter = + Filter( + ids = listOf(startEventId), + kinds = listOf(JesterProtocol.KIND), + ) + // Filter 2: Fetch moves that reference the start event + val movesFilter = ChessFilterBuilder.gameEventsFilter(startEventId) + val relayFilters = chessRelayUrls().associateWith { listOf(startEventFilter, movesFilter) } + val relayEvents = fetchHelper.fetchEvents(relayFilters) + + // Add relay events to cache + relayEvents.forEach { DesktopChessEventCache.add(it) } + + // Convert to JesterEvents and merge + val relayJesterEvents = + relayEvents + .filter { it.kind == JesterProtocol.KIND } + .mapNotNull { it.toJesterEvent() } + + val relayStartEvent = + relayJesterEvents + .firstOrNull { it.isStartEvent() && it.id == startEventId } + + val relayMoves = + relayJesterEvents + .filter { it.isMoveEvent() && it.startEventId() == startEventId } + + // Merge: prefer cache start event, merge all moves + val allMoves = (cachedEvents.moves + relayMoves).distinctBy { it.id } + + println("[DesktopRelayFetcher] fetchGameEvents: found start=${cachedEvents.startEvent ?: relayStartEvent != null}, moves=${allMoves.size}") + + return JesterGameEvents( + startEvent = cachedEvents.startEvent ?: relayStartEvent, + moves = allMoves, + ) + } + + /** + * Fetch start events (challenges) - queries cache first. + */ + override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List { + val relays = chessRelayUrls() + println("[DesktopRelayFetcher] fetchChallenges: querying cache first, then ${relays.size} chess relays") + + // Query cache for start events that are: + // 1. Open challenges (no specific opponent) + // 2. Directed at us + // 3. Created by us + val cachedStartEvents = + DesktopChessEventCache.queryStartEvents { event -> + event.opponentPubkey() == null || + event.opponentPubkey() == userPubkey || + event.pubKey == userPubkey + } + + println("[DesktopRelayFetcher] fetchChallenges: found ${cachedStartEvents.size} in cache") + + // Also do relay query to catch any new challenges + val filter = ChessFilterBuilder.challengesFilter(userPubkey) + val relayFilters = relays.associateWith { listOf(filter) } + val relayEvents = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress) + + // Add to cache + relayEvents.forEach { DesktopChessEventCache.add(it) } + + val relayStartEvents = + relayEvents + .filter { it.kind == JesterProtocol.KIND } + .mapNotNull { it.toJesterEvent() } + .filter { it.isStartEvent() } + + // Merge and deduplicate + val allStartEvents = (cachedStartEvents + relayStartEvents).distinctBy { it.id } + println("[DesktopRelayFetcher] fetchChallenges: total ${allStartEvents.size} after merge") + return allStartEvents + } + + /** + * Fetch user game IDs (startEventIds) - queries cache first. + */ + override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set { + val relays = chessRelayUrls() + println("[DesktopRelayFetcher] fetchUserGameIds: querying cache first, then ${relays.size} chess relays") + + val gameIds = mutableSetOf() + + // Query cache for start events created by user or directed at user + DesktopChessEventCache + .queryStartEvents { event -> + event.pubKey == userPubkey || event.opponentPubkey() == userPubkey + }.forEach { gameIds.add(it.id) } + + // Query cache for moves by user or tagged with user + DesktopChessEventCache + .queryMoveEvents { event -> + event.pubKey == userPubkey || event.opponentPubkey() == userPubkey + }.forEach { event -> + event.startEventId()?.let { gameIds.add(it) } + } + + println("[DesktopRelayFetcher] fetchUserGameIds: found ${gameIds.size} in cache") + + // Also do relay query to catch any new game IDs + val userFilter = ChessFilterBuilder.userGamesFilter(userPubkey) + val taggedFilter = ChessFilterBuilder.userTaggedFilter(userPubkey) + val relayFilters = relays.associateWith { listOf(userFilter, taggedFilter) } + val relayEvents = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress) + + // Add to cache + relayEvents.forEach { DesktopChessEventCache.add(it) } + + // Extract game IDs from relay events + relayEvents + .filter { it.kind == JesterProtocol.KIND } + .mapNotNull { it.toJesterEvent() } + .forEach { event -> + if (event.isStartEvent()) { + gameIds.add(event.id) + } else if (event.isMoveEvent()) { + event.startEventId()?.let { gameIds.add(it) } + } + } + + println("[DesktopRelayFetcher] fetchUserGameIds: total ${gameIds.size} after merge") + return gameIds + } + + /** + * Fetch recent games for spectating. + */ + override suspend fun fetchRecentGames(): List { + val filter = ChessFilterBuilder.recentGamesFilter() + val relayFilters = chessRelayUrls().associateWith { listOf(filter) } + + val events = fetchHelper.fetchEvents(relayFilters) + + // Cache events so they're available when watching + events.forEach { DesktopChessEventCache.add(it) } + + // Convert to JesterEvents + val jesterEvents = + events + .filter { it.kind == JesterProtocol.KIND } + .mapNotNull { it.toJesterEvent() } + + // Group by game (startEventId for moves, id for start events) + val startEventsById = + jesterEvents + .filter { it.isStartEvent() } + .associateBy { it.id } + + val movesByGameId = + jesterEvents + .filter { it.isMoveEvent() } + .groupBy { it.startEventId() ?: "" } + .filterKeys { it.isNotEmpty() } + + // Get all game IDs (from start events and moves) + val allGameIds = startEventsById.keys + movesByGameId.keys + + return allGameIds.mapNotNull { startEventId -> + val startEvent = startEventsById[startEventId] ?: return@mapNotNull null + val moves = movesByGameId[startEventId] ?: emptyList() + + val challengerColor = startEvent.playerColor() ?: Color.WHITE + + // Determine players from start event and moves + val challengerPubkey = startEvent.pubKey + val opponentFromMoves = moves.firstOrNull { it.pubKey != challengerPubkey }?.pubKey + val opponentPubkey = startEvent.opponentPubkey() ?: opponentFromMoves ?: return@mapNotNull null + + val (whitePubkey, blackPubkey) = + if (challengerColor == Color.WHITE) { + challengerPubkey to opponentPubkey + } else { + opponentPubkey to challengerPubkey + } + + val lastMove = moves.maxByOrNull { it.history().size } + val hasEnded = lastMove?.result() != null + + RelayGameSummary( + startEventId = startEventId, + whitePubkey = whitePubkey, + blackPubkey = blackPubkey, + moveCount = lastMove?.history()?.size ?: 0, + lastMoveTime = lastMove?.createdAt ?: startEvent.createdAt, + isActive = !hasEnded, + ) + } + } +} + +/** + * Desktop implementation of IUserMetadataProvider. + * Wraps UserMetadataCache for user metadata lookup. + */ +class DesktopMetadataProvider( + private val metadataCache: UserMetadataCache, +) : IUserMetadataProvider { + override fun getDisplayName(pubkey: String): String = metadataCache.getDisplayName(pubkey) + + override fun getPictureUrl(pubkey: String): String? = metadataCache.getPictureUrl(pubkey) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt new file mode 100644 index 000000000..c779afebd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.chess + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip64Chess.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import java.util.concurrent.ConcurrentHashMap + +/** + * Local event cache for Desktop chess events using Jester protocol. + * + * Mimics Android's LocalCache.addressables behavior: + * - Stores events by ID (deduplication) + * - Queryable by kind with custom filters + * - Thread-safe via ConcurrentHashMap + * - Populated from subscription callbacks + * - Persists across fetcher queries (unlike one-shot relay queries) + * + * Jester Protocol Notes: + * - All chess events use kind 30 + * - Start events: content.kind=0, e-tag references START_POSITION_HASH + * - Move events: content.kind=1, e-tags=[startEventId, headEventId] + */ +object DesktopChessEventCache { + // Store all Jester events by ID for deduplication + @PublishedApi + internal val events = ConcurrentHashMap() + + // Index start events by event ID (for quick game lookup) + private val startEvents = ConcurrentHashMap() + + // Index move events by startEventId (game ID) for quick game reconstruction + private val movesByGame = ConcurrentHashMap>() + + /** + * Add an event to the cache. + * Returns true if the event was new, false if it was a duplicate. + */ + fun add(event: Event): Boolean { + if (event.kind != JesterProtocol.KIND) return false + + val jesterEvent = event.toJesterEvent() ?: return false + return addJesterEvent(jesterEvent) + } + + /** + * Add a JesterEvent directly. + */ + fun addJesterEvent(event: JesterEvent): Boolean { + val isNew = events.putIfAbsent(event.id, event) == null + if (isNew) { + if (event.isStartEvent()) { + startEvents[event.id] = event + } else if (event.isMoveEvent()) { + val startId = event.startEventId() + if (startId != null) { + movesByGame.computeIfAbsent(startId) { ConcurrentHashMap.newKeySet() }.add(event.id) + } + } + } + return isNew + } + + /** + * Get an event by ID. + */ + fun get(id: String): JesterEvent? = events[id] + + /** + * Get all start events (challenges). + */ + fun getStartEvents(filter: (JesterEvent) -> Boolean = { true }): List = startEvents.values.filter { it.isStartEvent() && filter(it) } + + /** + * Get all move events for a specific game. + */ + fun getMovesForGame(startEventId: String): List { + val moveIds = movesByGame[startEventId] ?: return emptyList() + return moveIds.mapNotNull { events[it] } + } + + /** + * Get all events for a specific game (start + moves). + */ + fun getGameEvents(startEventId: String): JesterGameEvents { + val startEvent = startEvents[startEventId] + val moves = getMovesForGame(startEventId) + return JesterGameEvents(startEvent, moves) + } + + /** + * Query events with custom filter. + */ + fun query(filter: (JesterEvent) -> Boolean): List = events.values.filter(filter) + + /** + * Query start events with custom filter. + */ + fun queryStartEvents(filter: (JesterEvent) -> Boolean = { true }): List = startEvents.values.filter { it.isStartEvent() && filter(it) } + + /** + * Query move events with custom filter. + */ + fun queryMoveEvents(filter: (JesterEvent) -> Boolean = { true }): List = events.values.filter { it.isMoveEvent() && filter(it) } + + /** + * Get all game IDs (startEventIds) in the cache. + */ + fun getAllGameIds(): Set = startEvents.keys.toSet() + + /** + * Get count of events. + */ + fun size(): Int = events.size + + /** + * Get count of start events. + */ + fun startEventCount(): Int = startEvents.size + + /** + * Clear all cached events. + */ + fun clear() { + events.clear() + startEvents.clear() + movesByGame.clear() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt new file mode 100644 index 000000000..895aed70a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.chess + +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic +import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults +import com.vitorpamplona.amethyst.commons.chess.ChessSyncStatus +import com.vitorpamplona.amethyst.commons.chess.CompletedGame +import com.vitorpamplona.amethyst.commons.chess.PublicGame +import com.vitorpamplona.amethyst.commons.data.UserMetadataCache +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +/** + * Slim Desktop ViewModel for chess (~120 lines). + * + * Delegates all business logic to ChessLobbyLogic. + * Only handles Desktop-specific concerns: + * - Platform adapter creation + * - State exposure to Compose Desktop UI + * - UserMetadataCache for profile display + */ +class DesktopChessViewModelNew( + private val account: AccountState.LoggedIn, + private val relayManager: DesktopRelayConnectionManager, + private val scope: CoroutineScope, +) { + // Desktop-specific metadata cache + val userMetadataCache = UserMetadataCache() + + // Platform adapters + private val publisher = DesktopChessPublisher(account, relayManager) + private val fetcher = DesktopRelayFetcher(relayManager, account.pubKeyHex) + private val metadataProvider = DesktopMetadataProvider(userMetadataCache) + + // Shared business logic (creates its own ChessLobbyState internally) + private val logic = + ChessLobbyLogic( + userPubkey = account.pubKeyHex, + publisher = publisher, + fetcher = fetcher, + metadataProvider = metadataProvider, + scope = scope, + pollingConfig = ChessPollingDefaults.desktop, + ) + + // ============================================ + // State exposure (delegated from ChessLobbyLogic.state) + // ============================================ + + val activeGames: StateFlow> = logic.state.activeGames + val spectatingGames: StateFlow> = logic.state.spectatingGames + val challenges: StateFlow> = logic.state.challenges + val publicGames: StateFlow> = logic.state.publicGames + val completedGames: StateFlow> = logic.state.completedGames + val broadcastStatus: StateFlow = logic.state.broadcastStatus + val error: StateFlow = logic.state.error + val selectedGameId: StateFlow = logic.state.selectedGameId + val isRefreshing: StateFlow = logic.state.isRefreshing + val syncStatus: StateFlow = logic.state.syncStatus + val stateVersion: StateFlow = logic.state.stateVersion + + /** Badge count (incoming challenges + your turn games) - computed property */ + val badgeCount: Int get() = logic.state.badgeCount + + // ============================================ + // Lifecycle + // ============================================ + + init { + logic.startPolling() + } + + fun startPolling() = logic.startPolling() + + fun stopPolling() = logic.stopPolling() + + fun forceRefresh() = logic.forceRefresh() + + /** + * Ensure a game ID is being polled for updates. + * Call this when viewing a game. + */ + fun ensureGamePolling(gameId: String) = logic.ensureGamePolling(gameId) + + /** + * Set focused game mode - only poll this specific game. + * Call this when viewing a game to avoid refreshing unrelated games. + */ + fun setFocusedGame(gameId: String) = logic.setFocusedGame(gameId) + + /** + * Clear focused game mode - return to lobby mode (poll all games). + * Call this when returning to the lobby view. + */ + fun clearFocusedGame() = logic.clearFocusedGame() + + // ============================================ + // Incoming event routing (from relay subscriptions) + // ============================================ + + fun handleIncomingEvent(event: Event) { + if (event.kind != JesterProtocol.KIND) return + val jesterEvent = event.toJesterEvent() ?: return + logic.handleIncomingEvent(jesterEvent) + } + + // ============================================ + // Challenge operations + // ============================================ + + fun createChallenge( + opponentPubkey: String? = null, + playerColor: Color = Color.WHITE, + timeControl: String? = null, + ) = logic.createChallenge(opponentPubkey, playerColor, timeControl) + + fun acceptChallenge(challenge: ChessChallenge) = logic.acceptChallenge(challenge) + + fun openOwnChallenge(challenge: ChessChallenge) = logic.openOwnChallenge(challenge) + + // ============================================ + // Game operations + // ============================================ + + fun selectGame(gameId: String?) = logic.selectGame(gameId) + + fun publishMove( + gameId: String, + from: String, + to: String, + ) = logic.publishMove(gameId, from, to) + + fun resign(gameId: String) = logic.resign(gameId) + + fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId) + + // ============================================ + // Spectator operations + // ============================================ + + fun loadGame(gameId: String) = logic.loadGame(gameId) + + fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId) + + fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId) + + // ============================================ + // Utility + // ============================================ + + fun clearError() = logic.clearError() + + fun getGameState(gameId: String): LiveChessGameState? = logic.state.getGameState(gameId) + + /** Check if a game was accepted (prevents loading as spectator during race) */ + fun wasAccepted(gameId: String): Boolean = logic.state.wasAccepted(gameId) + + /** Helper for derived challenge lists */ + fun incomingChallenges(): List = logic.state.incomingChallenges() + + fun outgoingChallenges(): List = logic.state.outgoingChallenges() + + fun openChallenges(): List = logic.state.openChallenges() +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt new file mode 100644 index 000000000..81d9bbde2 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.subscriptions + +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionController +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionState +import com.vitorpamplona.amethyst.desktop.chess.DesktopChessEventCache +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Chess event kinds for cache filtering */ +private val CHESS_EVENT_KINDS = + setOf( + LiveChessGameChallengeEvent.KIND, + LiveChessGameAcceptEvent.KIND, + LiveChessMoveEvent.KIND, + LiveChessGameEndEvent.KIND, + LiveChessDrawOfferEvent.KIND, + ) + +/** + * Desktop implementation of [ChessSubscriptionController]. + * Uses the shared [ChessFilterBuilder] for consistent filter construction + * with dynamic updates when active games change. + */ +class DesktopChessSubscriptionController( + private val relayManager: RelayConnectionManager, + private val onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + private val onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +) : ChessSubscriptionController { + private val _currentState = MutableStateFlow(null) + override val currentState: StateFlow = _currentState.asStateFlow() + + // EOSE tracking per relay for efficient re-subscription + private val relayEoseTimes = mutableMapOf() + + // Current subscription ID + private var currentSubId: String? = null + + override fun subscribe(state: ChessSubscriptionState) { + // Unsubscribe previous if exists + unsubscribe() + + if (state.relays.isEmpty()) return + + _currentState.value = state + currentSubId = state.subscriptionId() + + // Build filters using shared logic + val relayBasedFilters = + ChessFilterBuilder.buildAllFilters(state) { relay -> + relayEoseTimes[relay] + } + + // Group filters by relay + val filtersByRelay = relayBasedFilters.groupByRelay() + + // Subscribe on each relay with its specific filters + val allFilters = filtersByRelay.values.flatten().distinct() + + relayManager.subscribe( + subId = currentSubId!!, + filters = allFilters, + relays = state.relays, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // Add chess events to local cache for persistence + if (event.kind in CHESS_EVENT_KINDS) { + DesktopChessEventCache.add(event) + } + onEvent(event, isLive, relay, forFilters) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // Track EOSE time for this relay for efficient re-subscription + relayEoseTimes[relay] = TimeUtils.now() + onEose(relay, forFilters) + } + }, + ) + } + + override fun unsubscribe() { + currentSubId?.let { relayManager.unsubscribe(it) } + currentSubId = null + _currentState.value = null + } + + override fun updateActiveGames( + activeGameIds: Set, + spectatingGameIds: Set, + ) { + val current = _currentState.value ?: return + + // Create new state with updated game IDs + val newState = + current.copy( + activeGameIds = activeGameIds, + spectatingGameIds = spectatingGameIds, + ) + + // Only re-subscribe if game IDs actually changed + if (newState.subscriptionId() != current.subscriptionId()) { + subscribe(newState) + } + } + + override fun forceRefresh() { + // Clear EOSE cache to re-fetch full history + relayEoseTimes.clear() + _currentState.value?.let { subscribe(it) } + } +} + +/** + * Creates a subscription config for chess events with support for active game filtering. + * Uses the shared [ChessFilterBuilder] for consistent filter construction. + * + * @param relays Set of relays to subscribe to + * @param userPubkey User's public key for filtering personal events + * @param activeGameIds Set of game IDs the user is actively playing (for game-specific filters) + * @param opponentPubkeys Set of opponent pubkeys for active games (for move filtering) + * @param onEvent Callback for incoming events + * @param onEose Callback for EOSE (End of Stored Events) + */ +fun createChessSubscriptionWithGames( + relays: Set, + userPubkey: String, + activeGameIds: Set = emptySet(), + opponentPubkeys: Set = emptySet(), + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (relays.isEmpty()) return null + + val state = + ChessSubscriptionState( + userPubkey = userPubkey, + relays = relays, + activeGameIds = activeGameIds, + opponentPubkeys = opponentPubkeys, + ) + + val relayBasedFilters = + ChessFilterBuilder.buildAllFilters(state) { null } + + val filters = + relayBasedFilters + .groupByRelay() + .values + .flatten() + .distinct() + + return SubscriptionConfig( + subId = state.subscriptionId(), + filters = filters, + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Legacy function for backward compatibility. + * @deprecated Use createChessSubscriptionWithGames instead + */ +@Deprecated( + "Use createChessSubscriptionWithGames for active game support", + ReplaceWith("createChessSubscriptionWithGames(relays, userPubkey, emptySet(), emptySet(), onEvent, onEose)"), +) +fun createChessSubscription( + relays: Set, + userPubkey: String, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? = createChessSubscriptionWithGames(relays, userPubkey, emptySet(), emptySet(), onEvent, onEose) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/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..4c9aba1e3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -95,6 +95,19 @@ object FilterBuilders { authors = pubKeyHexList, ) + /** + * Creates a filter for user metadata (kind 0) from multiple authors. + * Alias for userMetadataBatch for API compatibility. + * + * @param pubKeys List of author public keys (hex-encoded, 64 chars each) + * @return Filter for user metadata + */ + fun userMetadataMultiple(pubKeys: List): Filter = + Filter( + kinds = listOf(0), + authors = pubKeys, + ) + /** * Creates a filter for contact list (kind 3) from a specific author. * @@ -200,6 +213,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..704a8f03a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -37,8 +37,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonRemove +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -46,7 +48,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -60,6 +64,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction +import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastBanner +import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.state.FollowState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState @@ -76,6 +82,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscript import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import kotlinx.coroutines.Dispatchers @@ -111,6 +118,13 @@ fun UserProfileScreen( var followersCount by remember { mutableStateOf(0) } var followingCount by remember { mutableStateOf(0) } + // Profile editing state (only for own profile) + val isOwnProfile = account != null && pubKeyHex == account.pubKeyHex + var showEditDialog by remember { mutableStateOf(false) } + var editingDisplayName by remember { mutableStateOf("") } + var broadcastStatus by remember { mutableStateOf(ProfileBroadcastStatus.Idle) } + var latestMetadataEvent by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() // User's posts @@ -187,13 +201,25 @@ fun UserProfileScreen( relays = configuredRelays, pubKeyHex = pubKeyHex, onEvent = { event, _, _, _ -> - try { - val content = event.content - displayName = extractJsonField(content, "display_name") ?: extractJsonField(content, "name") - about = extractJsonField(content, "about") - picture = extractJsonField(content, "picture") - } catch (e: Exception) { - // Ignore parse errors + if (event is MetadataEvent) { + try { + val metadata = event.contactMetaData() + if (metadata != null) { + displayName = metadata.displayName ?: metadata.name + about = metadata.about + picture = metadata.picture + } + + // Store MetadataEvent for editing (only for own profile) + if (isOwnProfile) { + val current = latestMetadataEvent + if (current == null || event.createdAt > current.createdAt) { + latestMetadataEvent = event + } + } + } catch (e: Exception) { + // Ignore parse errors + } } }, ) @@ -281,6 +307,19 @@ fun UserProfileScreen( } Column(modifier = Modifier.fillMaxSize()) { + // Broadcast banner for profile updates + ProfileBroadcastBanner( + status = broadcastStatus, + onTap = { + // Clear banner on tap (could add retry logic for failed) + if (broadcastStatus is ProfileBroadcastStatus.Success || + broadcastStatus is ProfileBroadcastStatus.Failed + ) { + broadcastStatus = ProfileBroadcastStatus.Idle + } + }, + ) + // Header with back button Row( modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), @@ -298,6 +337,25 @@ fun UserProfileScreen( ) } + // Edit button for own profile + if (isOwnProfile && account?.isReadOnly == false) { + OutlinedButton( + onClick = { + editingDisplayName = displayName ?: "" + showEditDialog = true + }, + ) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit profile", + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text("Edit Profile") + } + } + + // Follow/Unfollow button for other profiles if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { Column(horizontalAlignment = Alignment.End) { Button( @@ -569,7 +627,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, @@ -586,17 +644,52 @@ fun UserProfileScreen( } } } -} -/** - * Simple JSON field extractor (not production-ready, just for demo). - */ -private fun extractJsonField( - json: String, - field: String, -): String? { - val regex = """"$field"\s*:\s*"([^"]*)"""".toRegex() - return regex.find(json)?.groupValues?.get(1) + // Edit Profile Dialog + if (showEditDialog && account != null) { + AlertDialog( + onDismissRequest = { showEditDialog = false }, + title = { Text("Edit Profile") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = editingDisplayName, + onValueChange = { editingDisplayName = it }, + label = { Text("Display Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button( + onClick = { + showEditDialog = false + scope.launch { + updateProfileDisplayName( + newDisplayName = editingDisplayName, + account = account, + relayManager = relayManager, + latestMetadataEvent = latestMetadataEvent, + currentDisplayName = displayName, + currentAbout = about, + currentPicture = picture, + onStatusUpdate = { broadcastStatus = it }, + onSuccess = { displayName = editingDisplayName }, + ) + } + }, + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = { showEditDialog = false }) { + Text("Cancel") + } + }, + ) + } } /** @@ -648,3 +741,62 @@ private suspend fun unfollowUser( throw IllegalStateException("Cannot unfollow: No contact list available") } } + +/** + * Updates the user's profile display name by creating and broadcasting a new MetadataEvent. + */ +private suspend fun updateProfileDisplayName( + newDisplayName: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + latestMetadataEvent: MetadataEvent?, + currentDisplayName: String?, + currentAbout: String?, + currentPicture: String?, + onStatusUpdate: (ProfileBroadcastStatus) -> Unit, + onSuccess: () -> Unit, +) = withContext(Dispatchers.IO) { + val connectedRelays = relayManager.connectedRelays.value + if (connectedRelays.isEmpty()) { + onStatusUpdate(ProfileBroadcastStatus.Failed("display name", "No connected relays")) + return@withContext + } + + val totalRelays = connectedRelays.size + onStatusUpdate(ProfileBroadcastStatus.Broadcasting("display name", 0, totalRelays)) + + try { + // Create the new MetadataEvent + val template = + if (latestMetadataEvent != null) { + MetadataEvent.updateFromPast( + latest = latestMetadataEvent, + displayName = newDisplayName, + ) + } else { + MetadataEvent.createNew( + name = currentDisplayName, + displayName = newDisplayName, + picture = currentPicture, + about = currentAbout, + ) + } + + // Sign the event + val signedEvent = account.signer.sign(template) + + // Broadcast to all relays + relayManager.broadcastToAll(signedEvent) + + // Update progress (simplified - just show success after broadcast) + // In a full implementation, you'd track OK responses from each relay + onStatusUpdate(ProfileBroadcastStatus.Success("display name", totalRelays)) + onSuccess() + + // Auto-hide banner after delay + delay(3000) + onStatusUpdate(ProfileBroadcastStatus.Idle) + } catch (e: Exception) { + onStatusUpdate(ProfileBroadcastStatus.Failed("display name", e.message ?: "Unknown error")) + } +} diff --git a/docs/chess-engine-refactoring.md b/docs/chess-engine-refactoring.md new file mode 100644 index 000000000..6e773646a --- /dev/null +++ b/docs/chess-engine-refactoring.md @@ -0,0 +1,287 @@ +# Chess Engine Refactoring + +## Context + +Chess feature had inconsistent state reconstruction: +- **Android**: Replays moves from `LocalCache.addressables` (can get stale/desync) +- **Desktop**: Buffers `pendingMoves` and syncs via `forceResync(fen)` (fragile) + +Both ViewModels (~1200 + ~956 lines) duplicate logic that now exists in shared components. + +## Completed Work + +### Shared Components (Done) + +| File | Location | Purpose | +|------|----------|---------| +| `ChessStateReconstructor` | `quartz/commonMain/` | Deterministic event→state reconstruction | +| `ChessStateReconstructorTest` | `quartz/jvmAndroidTest/` | 22 integration tests | +| `ChessEventCollector` | `commons/commonMain/` | Thread-safe event aggregation with dedup | +| `ChessGameLoader` | `commons/commonMain/` | Converts ReconstructionResult → LiveChessGameState | +| `ChessLobbyLogic` | `commons/commonMain/` | Shared business logic (challenges, moves, polling) | +| `ChessLobbyState` | `commons/commonMain/` | Shared UI state (StateFlows for games, challenges, status) | +| `ChessBroadcastStatus` | `commons/commonMain/` | Shared broadcast status sealed class | +| `ChessPollingDelegate` | `commons/commonMain/` | Configurable periodic refresh | +| `ChessFilterBuilder` | `commons/commonMain/subscription/` | Shared relay filter construction | +| `ChessSubscriptionController` | `commons/commonMain/subscription/` | Platform subscription interface | +| `ChessStatusBanner` | `amethyst/` (Android only) | Android broadcast status UI | + +### Test Coverage + +- Game lifecycle, move ordering/dedup, game end conditions +- Viewer perspectives, draw offers, castling, determinism + +--- + +## Target Architecture + +``` +BOTH PLATFORMS IDENTICAL: + +VM (~150 lines) → ChessLobbyLogic + │ + ├─ publish: ChessEventPublisher (platform impl) + │ + └─ refresh (periodic): + one-shot REQ to relays + ↓ + transient ChessEventCollector (use-once) + ↓ + ChessStateReconstructor.reconstruct() + ↓ + LiveChessGameState → UI + (collector discarded, no cache) +``` + +**Key principle**: No cache. Relays are the ONLY source of truth. + +Every refresh cycle: +1. One-shot REQ to relays for all game events +2. Transient collector → reconstruct → get state +3. Diff against UI state, update if changed +4. Discard collector + +Real-time subscription events apply moves **optimistically** to `LiveChessGameState`. Periodic full-reconstruction corrects any drift. + +--- + +## Resolved Decisions + +### 1. No LocalCache for Chess + +**Decision**: Chess events must NOT enter LocalCache. Chess is inherently remote-only. + +**How to stop writes**: Remove chess event handlers from `LocalCache.justConsumeInnerInner()` (lines 2956-2960 in `LocalCache.kt`). These are: +```kotlin +is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified) // REMOVE +is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified) // REMOVE +is LiveChessMoveEvent -> consume(event, relay, wasVerified) // REMOVE +is LiveChessGameEndEvent -> consume(event, relay, wasVerified) // REMOVE +``` + +Also remove chess DataSource from `RelaySubscriptionsCoordinator` (line 83: `val chess = ChessFilterAssembler(client)`). Chess manages its own relay subscriptions independently. + +### 2. One-Shot Relay Fetch (in commons) + +**Decision**: Create a shared one-shot fetch helper in commons using existing `IRequestListener` + `Channel` pattern. + +**Existing pattern** (proven in production): +- `quartz/.../accessories/NostrClientSingleDownloadExt.kt` — single event download +- `quartz/.../accessories/NostrClientSendAndWaitExt.kt` — multi-relay wait + +**Design**: The fetcher takes an `INostrClient` (available on both platforms) and uses the existing `IRequestListener` callback → `Channel` → `withTimeoutOrNull` pattern: + +```kotlin +// commons/commonMain - shared one-shot fetch +class ChessRelayFetchHelper(private val client: INostrClient) { + + suspend fun fetchEvents( + filters: Map>, + timeoutMs: Long = 30_000, + ): List { + val events = mutableListOf() + val eoseReceived = CompletableDeferred() + val subId = UUID.randomUUID().toString().take(8) + + val listener = object : IRequestListener { + override fun onEvent(event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?) { + events.add(event) + } + override fun onEose(relay: NormalizedRelayUrl, forFilters: List?) { + eoseReceived.complete(Unit) + } + } + + client.openReqSubscription(subId, filters, listener) + withTimeoutOrNull(timeoutMs) { eoseReceived.await() } + client.close(subId) + + return events + } +} +``` + +Platform adapters inject their `INostrClient`: +- **Android**: `account.client` (or equivalent from relay pool) +- **Desktop**: `relayManager.client` + +### 3. Metadata Provider (interface in commons) + +**Decision**: Shared `IUserMetadataProvider` interface in commons. Platform-specific implementations. + +Android uses `LocalCache.users[pubkey].info`, Desktop uses `UserMetadataCache`. Both implement: + +```kotlin +// commons/commonMain +interface IUserMetadataProvider { + fun getDisplayName(pubkey: String): String + fun getPictureUrl(pubkey: String): String? +} +``` + +`ChessChallenge` gets enriched with display fields by calling provider at construction time. + +### 4. Challenge Type Migration + +**Decision**: Enrich `ChessChallenge` with display fields (displayName, avatarUrl) so it replaces both `Note` (Android) and `LiveChessGameChallengeEvent` (Desktop) in UI code. + +--- + +## Implementation Steps + +### Step 1: Stop chess events entering LocalCache + +**Files:** +- `amethyst/.../model/LocalCache.kt` — remove chess `when` branches (lines ~2956-2960) +- `amethyst/.../service/relayClient/RelaySubscriptionsCoordinator.kt` — remove `val chess` DataSource + +### Step 2: Create one-shot relay fetch helper in commons + +**File:** `commons/src/commonMain/.../chess/ChessRelayFetchHelper.kt` (new) + +Uses `INostrClient` + `IRequestListener` + `Channel` pattern. Shared by both platforms. + +### Step 3: Create IUserMetadataProvider in commons + +**File:** `commons/src/commonMain/.../chess/IUserMetadataProvider.kt` (new) + +### Step 4: Rewrite ChessLobbyLogic (relay-first) + +**File:** `commons/src/commonMain/.../chess/ChessLobbyLogic.kt` + +Replace `ChessEventFetcher` with `ChessRelayFetcher` (wraps `ChessRelayFetchHelper`): + +```kotlin +interface ChessRelayFetcher { + suspend fun fetchGameEvents(gameId: String): ChessGameEvents + suspend fun fetchChallenges(): List + suspend fun fetchRecentGames(): List +} +``` + +Rewrite refresh cycle: +```kotlin +private suspend fun refreshGame(gameId: String) { + val events = relayFetcher.fetchGameEvents(gameId) // one-shot from relays + val result = ChessStateReconstructor.reconstruct(events, userPubkey) + when (result) { + is ReconstructionResult.Success -> + state.replaceGameState(gameId, ChessGameLoader.toLiveGameState(result, userPubkey)) + is ReconstructionResult.Error -> + state.setError("Game $gameId: ${result.message}") + } +} +``` + +Add: +- `handleIncomingEvent(event)` — optimistic real-time event routing +- `handleGameAccepted()` + `startGameFromAcceptance()` +- `acceptDraw()`, `declineDraw()`, `claimAbandonmentVictory()` +- `retryWithBackoff()` for publish operations +- Subscription controller integration + +### Step 5: Enhance ChessLobbyState + +**File:** `commons/src/commonMain/.../chess/ChessLobbyState.kt` + +Add: +- `replaceGameState(gameId, newState)` — for full reconstruction updates +- `completedGames: StateFlow>` +- Enrich `ChessChallenge` with `displayName`, `avatarUrl` + +### Step 6: Platform adapters + +**Android:** `amethyst/.../chess/AndroidChessAdapter.kt` (new) +- `AndroidChessPublisher` — wraps `account.signAndComputeBroadcast()` +- `AndroidRelayFetcher` — wraps `ChessRelayFetchHelper(account.client)` +- `AndroidMetadataProvider` — wraps `LocalCache.users[pubkey].info` + +**Desktop:** `desktopApp/.../chess/DesktopChessAdapter.kt` (new) +- `DesktopChessPublisher` — wraps `account.signer.sign()` + `relayManager.broadcastToAll()` +- `DesktopRelayFetcher` — wraps `ChessRelayFetchHelper(relayManager.client)` +- `DesktopMetadataProvider` — wraps `UserMetadataCache` + +### Step 7: Rewrite Android ChessViewModel + +**File:** `amethyst/.../chess/ChessViewModel.kt` (1220 → ~150 lines) + +Thin wrapper: delegates to `ChessLobbyLogic`, exposes state, routes `LocalCache.live.newEventBundles` for real-time events. + +Delete: `RetryOperation`, `PublicGameInfo`, all LocalCache queries, all handle* methods, `ChessStatus`. + +### Step 8: Rewrite Desktop DesktopChessViewModel + +**File:** `desktopApp/.../chess/DesktopChessViewModel.kt` (956 → ~150 lines) + +Thin wrapper: delegates to `ChessLobbyLogic`, keeps `UserMetadataCache` platform-specific. + +Delete: `pendingMoves`, `pendingAccepts`, `challengesByGameId`, `processedEventIds`, `gamesBeingCreated`, all handle* methods, `applyPendingMoves`, `CompletedGame`. + +### Step 9: Create shared BroadcastBanner + +**File:** `commons/src/commonMain/.../chess/ChessBroadcastBanner.kt` (new) + +Extract from Android's `ChessStatusBanner.kt`, use `ChessBroadcastStatus` from commons. + +### Step 10: Update UI consumers + +**Android:** +- `ChessGameScreen.kt` / `ChessLobbyScreen.kt`: `ChessChallenge` instead of `Note` +- Remove `ChessStatusBanner.kt`, use shared `ChessBroadcastBanner` + +**Desktop:** +- `ChessScreen.kt`: `ChessChallenge` instead of `LiveChessGameChallengeEvent` +- Add `ChessBroadcastBanner` + +--- + +## Files Modified + +| File | Action | Notes | +|------|--------|-------| +| `LocalCache.kt` | Edit | Remove chess event handlers | +| `RelaySubscriptionsCoordinator.kt` | Edit | Remove chess DataSource | +| `commons/.../ChessRelayFetchHelper.kt` | **New** | One-shot relay query helper | +| `commons/.../IUserMetadataProvider.kt` | **New** | Metadata interface | +| `commons/.../ChessLobbyLogic.kt` | Rewrite | Relay-first, event routing, reconstructor | +| `commons/.../ChessLobbyState.kt` | Enhance | replaceGameState, completedGames, enriched ChessChallenge | +| `commons/.../ChessBroadcastBanner.kt` | **New** | Shared broadcast status UI | +| `amethyst/.../AndroidChessAdapter.kt` | **New** | Publisher + fetcher + metadata | +| `amethyst/.../ChessViewModel.kt` | Rewrite | 1220→150 lines | +| `amethyst/.../ChessStatusBanner.kt` | Delete | Replaced by shared | +| `amethyst/.../ChessGameScreen.kt` | Update | Challenge type + banner | +| `amethyst/.../ChessLobbyScreen.kt` | Update | Challenge type | +| `desktopApp/.../DesktopChessAdapter.kt` | **New** | Publisher + fetcher + metadata | +| `desktopApp/.../DesktopChessViewModel.kt` | Rewrite | 956→150 lines | +| `desktopApp/.../ChessScreen.kt` | Update | Challenge type + banner | + +--- + +## Verification + +1. `./gradlew :quartz:build` +2. `./gradlew :commons:build` +3. `./gradlew :amethyst:compileDebugKotlin` +4. `./gradlew :desktopApp:compileKotlin` +5. `./gradlew :quartz:jvmAndroidTest` — ChessStateReconstructor tests +6. `./gradlew :desktopApp:run` — manual test: create challenge, play moves diff --git a/docs/live-chess-implementation-status.md b/docs/live-chess-implementation-status.md new file mode 100644 index 000000000..e6b942434 --- /dev/null +++ b/docs/live-chess-implementation-status.md @@ -0,0 +1,266 @@ +# Live Chess Implementation Status + +## ✅ Completed Implementation + +### 1. Chess Engine & Move Validation +- **kchesslib dependency** added to `quartz/build.gradle.kts:150` +- **ChessEngine** interface with expect/actual pattern: + - `ChessEngine.kt` (commonMain) - Platform-agnostic API + - `ChessEngine.jvmAndroid.kt` (jvmAndroid) - kchesslib implementation +- **Full move validation**: Legal moves, check/checkmate, castling, en passant, pawn promotion +- **FEN import/export**: Board state serialization +- **Move history tracking**: SAN notation + +### 2. Interactive UI Components (commons/) +- **InteractiveChessBoard.kt**: Click-to-move with visual feedback + - Selected piece highlighting (green) + - Legal move indicators (circles/rings) + - Automatic move validation + - Callback system for move events + +- **LiveChessGame.kt**: Complete game UI + - NewChessGameDialog (challenge creation) + - LiveChessGameScreen (full game interface) + - GameInfoHeader (turn indicator, game status) + - MoveHistoryDisplay (SAN move list) + - GameControls (Resign, Offer Draw) + +### 3. Nostr Event Protocol (quartz/nip64Chess/) +**New Event Kinds:** +| Kind | Event | Purpose | +|------|-------|---------| +| 30064 | LiveChessGameChallengeEvent | Create/accept challenges | +| 30065 | LiveChessGameAcceptEvent | Accept challenge | +| 30066 | LiveChessMoveEvent | Individual moves (SAN + FEN) | +| 30067 | LiveChessGameEndEvent | Game result & termination | + +**Event Factory Integration** (EventFactory.kt:190-193): +- All live chess events registered for automatic parsing + +### 4. Game State Management +- **LiveChessGameState.kt**: Coordinates engine, Nostr, and UI + - Turn validation + - Move synchronization + - Automatic end detection (checkmate/stalemate) + - PGN generation + - Position mismatch recovery + +### 5. ChessViewModel (amethyst/ui/screen/loggedIn/chess/) +- **ChessViewModel.kt**: Full game state management & event publishing + - `activeGames: StateFlow>` - Active games by ID + - `challenges: StateFlow>` - Incoming/outgoing challenges + - `badgeCount: StateFlow` - Notification count + - `createChallenge()` - Create open or directed challenges + - `acceptChallenge()` - Accept challenge and start game + - `publishMove()` - Send moves to relays + - `resign()` / `offerDraw()` - End game actions + - **Relay subscriptions** via `LocalCache.live.newEventBundles` + - Auto-handles incoming moves, acceptances, endings, challenges + +- **ChessViewModelFactory.kt**: ViewModel factory with Account injection +- **ChessGameScreen.kt**: Wrapper connecting ViewModel to LiveChessGameScreen UI + +### 6. Feed Integration +**Chess Feed Filter** (TopNavFilterState.kt:130-142): +- Shows Kind 64 (completed games) +- Shows Kind 30064 (challenges - open & directed) +- Shows Kind 30067 (game endings) +- Excludes Kind 30066 (individual moves - too noisy) + +**Event Rendering** (Chess.kt, NoteCompose.kt): +- `RenderChessGame()` - Completed PGN games (Kind 64) +- `RenderLiveChessChallenge()` - Challenge cards with visual states: + - 🔓 **Open challenges** (green border) + - 💌 **Incoming challenges** (orange border) + - ⏳ **Sent challenges** (gray border) +- `RenderLiveChessGameEnd()` - Game results with PGN + +## 🚧 Remaining Work + +### Priority 1: FAB & Challenge Creation +**Files to modify:** +1. **HomeScreen.kt** or chess-specific screen + ```kotlin + var showNewGameDialog by remember { mutableStateOf(false) } + + Scaffold( + floatingActionButton = { + if (isChessFeed) { + FloatingActionButton( + onClick = { showNewGameDialog = true } + ) { + Icon(Icons.Default.Add, "New Game") + } + } + } + ) + + if (showNewGameDialog) { + NewChessGameDialog( + onDismiss = { showNewGameDialog = false }, + onCreateGame = { opponentPubkey, color -> + viewModel.createChallenge(opponentPubkey, color) + showNewGameDialog = false + } + ) + } + ``` + +### Priority 4: In-App Badges (Optional) +**Files to create:** +1. **BadgeProvider.kt** (commons/) + ```kotlin + object ChessBadgeProvider { + fun getBadgeCount( + challenges: List, + activeGames: List, + userPubkey: String + ): Int { + val incomingChallenges = challenges.count { + it.opponentPubkey() == userPubkey + } + val yourTurnGames = activeGames.count { it.isPlayerTurn() } + return incomingChallenges + yourTurnGames + } + } + ``` + +2. **Badge Display** - Add to chess filter icon: + ```kotlin + BadgedBox(badge = { Badge { Text("$count") } }) { + Icon(/* chess icon */) + } + ``` + +## 📋 Implementation Checklist + +### Phase 1: Basic Playability ✅ COMPLETE +- [x] Create ChessViewModel +- [x] Add navigation route for LiveChessGameScreen (Route.ChessGame exists) +- [x] Create ChessGameScreen wrapper +- [x] Wire up NewGameDialog → ViewModel.createChallenge() +- [x] Implement event publishing (challenges, moves, game end) +- [ ] Test: Create challenge, see it in feed + +### Phase 2: Two-Player Functionality ✅ COMPLETE +- [x] Implement relay subscriptions for game events +- [x] Wire up move synchronization (publish/receive) +- [x] Handle challenge acceptance flow +- [ ] Test: Play complete game between two accounts + +### Phase 3: Polish (IN PROGRESS) +- [ ] Add FAB to chess feed +- [ ] Implement badge counts display in nav +- [x] Add "Accept Challenge" button to incoming challenge cards +- [x] Add "View Game" navigation from challenge/game-end cards +- [ ] Handle edge cases (network errors, position desync, abandoned games) + +### Phase 4: Persistence (Optional) +- [ ] Create Room entities for active games +- [ ] Store games locally for offline viewing +- [ ] Sync on app start +- [ ] Background service for "your turn" notifications + +## 🎯 UX Decisions Implemented + +Based on user choices: + +1. ✅ **Entry Point**: FAB on Chess feed (pending UI implementation) +2. ✅ **Display**: Visual cards in one feed with borders/icons +3. ✅ **Discovery**: Open challenges shown in Chess feed (green border) +4. ✅ **Tap Action**: Navigate directly to full screen (pending nav) +5. ⏳ **Notifications**: In-app badge for counts (pending implementation) +6. ✅ **Filter Scope**: Meaningful events only (completed games, challenges, endings) +7. ⏳ **Storage**: Local cache + relay sync (pending Room/ViewModel) +8. ✅ **Input**: Click-to-move (already implemented) + +## 🔧 Quick Start for Development + +### Test the Chess Engine: +```kotlin +val engine = ChessEngine() +engine.reset() +val result = engine.makeMove("e2", "e4") +println("Move success: ${result.success}, SAN: ${result.san}") +``` + +### Test the Interactive Board: +```kotlin +@Composable +fun TestScreen() { + val engine = remember { ChessEngine().apply { reset() } } + InteractiveChessBoard( + engine = engine, + onMoveMade = { san -> println("Move made: $san") } + ) +} +``` + +### View Chess Feed: +1. Open Amethyst +2. Select "Chess" from home feed dropdown +3. See completed games (Kind 64) +4. See challenges with colored borders (Kind 30064) +5. See game endings with results (Kind 30067) + +## 📁 File Structure + +``` +AmethystMultiplatform/ +├── quartz/ # Protocol layer (KMP) +│ ├── src/commonMain/kotlin/.../nip64Chess/ +│ │ ├── ChessEngine.kt ✅ Engine interface +│ │ ├── ChessPosition.kt ✅ Board state +│ │ ├── ChessMove.kt ✅ Move representation +│ │ ├── ChessGame.kt ✅ Game model +│ │ ├── PGNParser.kt ✅ PGN parsing (31 tests) +│ │ ├── ChessGameEvent.kt ✅ Kind 64 +│ │ ├── LiveChessEvents.kt ✅ Data classes +│ │ ├── LiveChessGameEvents.kt ✅ Kind 30064-30067 +│ │ └── LiveChessGameState.kt ✅ Game coordinator +│ └── src/jvmAndroid/kotlin/.../nip64Chess/ +│ └── ChessEngine.jvmAndroid.kt ✅ kchesslib impl +│ +├── commons/ # Shared UI (Android/Desktop) - KMP +│ └── src/commonMain/kotlin/.../commons/chess/ +│ ├── ChessBoard.kt ✅ Static board +│ ├── InteractiveChessBoard.kt ✅ Click-to-move +│ ├── ChessGameViewer.kt ✅ PGN playback +│ ├── MoveNavigator.kt ✅ Move controls +│ ├── PGNMetadata.kt ✅ Game info +│ └── LiveChessGame.kt ✅ Dialog + screen +│ +├── amethyst/ # Android app +│ └── src/main/java/.../amethyst/ +│ ├── ui/note/types/ +│ │ └── Chess.kt ✅ Event renderers (3 types) +│ ├── ui/note/ +│ │ └── NoteCompose.kt ✅ Event dispatching +│ ├── ui/screen/ +│ │ ├── TopNavFilterState.kt ✅ Chess filter (3 kinds) +│ │ └── loggedIn/chess/ +│ │ ├── ChessViewModel.kt ✅ State & event publishing +│ │ ├── ChessViewModelFactory.kt ✅ ViewModel factory +│ │ ├── ChessGameScreen.kt ✅ Game screen wrapper +│ │ └── dal/ChessFeedFilter.kt ✅ Feed filter +│ └── ui/navigation/routes/ +│ └── Route.ChessGame ✅ Navigation route +│ +└── docs/ + └── live-chess-implementation-status.md ✅ This file +``` + +## 🚀 Next Steps + +**To complete the chess feature**, implement: + +1. ~~**ChessViewModel**~~ ✅ - Game state management & event publishing +2. ~~**Navigation**~~ ✅ - Route to LiveChessGameScreen +3. ~~**Event Subscriptions**~~ ✅ - Listen for opponent moves +4. **FAB** - Add "New Game" button to chess feed +5. **Badge Display** - Show badge count in navigation +6. **Test** - Play a complete game between two test accounts + +--- + +**Status**: Core chess functionality fully implemented. ViewModel and relay subscriptions complete. Ready for FAB/badge polish and end-to-end testing. diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 11852a57c..d069a4979 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -122,6 +122,11 @@ kotlin { // Websockets API implementation(libs.okhttp) implementation(libs.okhttpCoroutines) + + // Chess engine for move validation and legal move generation + // 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/ChessEngine.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.kt new file mode 100644 index 000000000..0ae8c39d4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import androidx.compose.runtime.Immutable + +/** + * Platform-agnostic chess engine interface for move validation and generation + * Implemented using expect/actual for JVM/Android platforms + */ +expect class ChessEngine() { + /** + * Get the current position as FEN string + */ + fun getFen(): String + + /** + * Load a position from FEN notation + */ + fun loadFen(fen: String) + + /** + * Reset to starting position + */ + fun reset() + + /** + * Make a move using Standard Algebraic Notation (SAN) + * @param san Move in SAN format (e.g., "Nf3", "e4", "O-O") + * @return MoveResult with success status and resulting position + */ + fun makeMove(san: String): MoveResult + + /** + * Make a move from source to destination square + * @param from Source square in algebraic notation (e.g., "e2") + * @param to Destination square in algebraic notation (e.g., "e4") + * @param promotion Optional promotion piece type for pawn promotion + * @return MoveResult with success status and resulting position + */ + fun makeMove( + from: String, + to: String, + promotion: PieceType? = null, + ): MoveResult + + /** + * Get all legal moves from the current position + * @return List of legal moves in SAN notation + */ + fun getLegalMoves(): List + + /** + * Get all legal moves for a specific square + * @param square Square in algebraic notation (e.g., "e2") + * @return List of destination squares that are legal + */ + fun getLegalMovesFrom(square: String): List + + /** + * Check if a move is legal in the current position + * @param san Move in SAN format + * @return true if the move is legal + */ + fun isLegalMove(san: String): Boolean + + /** + * Check if a move from-to is legal + */ + fun isLegalMove( + from: String, + to: String, + promotion: PieceType? = null, + ): Boolean + + /** + * Check if current position is checkmate + */ + fun isCheckmate(): Boolean + + /** + * Check if current position is stalemate + */ + fun isStalemate(): Boolean + + /** + * Check if current position is in check + */ + fun isInCheck(): Boolean + + /** + * Undo the last move + */ + fun undoMove() + + /** + * Get the current position as ChessPosition + */ + fun getPosition(): ChessPosition + + /** + * Get side to move + */ + fun getSideToMove(): Color + + /** + * Get move history in SAN notation + */ + fun getMoveHistory(): List +} + +/** + * Result of attempting a chess move + */ +@Immutable +data class MoveResult( + val success: Boolean, + val san: String? = null, // Move in SAN notation if successful + val position: ChessPosition? = null, // Resulting position if successful + val error: String? = null, // Error message if unsuccessful +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGame.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGame.kt new file mode 100644 index 000000000..083ab167a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGame.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import androidx.compose.runtime.Immutable + +/** + * Represents a complete chess game parsed from PGN + * Per NIP-64, content must be valid PGN format + */ +@Immutable +data class ChessGame( + val metadata: Map, + val moves: List, + val positions: List, + val result: GameResult, +) { + init { + require(positions.isNotEmpty()) { "Game must have at least starting position" } + require(positions.size == moves.size + 1) { "Position count must be moves + 1" } + } + + /** + * Standard PGN tag accessors + */ + val event: String? get() = metadata["Event"] + val site: String? get() = metadata["Site"] + val date: String? get() = metadata["Date"] + val round: String? get() = metadata["Round"] + val white: String? get() = metadata["White"] + val black: String? get() = metadata["Black"] + + /** + * Get position after move N (0 = starting position) + */ + fun positionAt(moveIndex: Int): ChessPosition = positions.getOrElse(moveIndex) { positions.last() } + + /** + * Check if game has required metadata + */ + fun hasRequiredMetadata(): Boolean = + metadata.containsKey("Event") && + metadata.containsKey("Site") && + metadata.containsKey("Date") && + metadata.containsKey("Round") && + metadata.containsKey("White") && + metadata.containsKey("Black") && + metadata.containsKey("Result") +} + +/** + * Game result per PGN specification + */ +enum class GameResult( + val notation: String, +) { + WHITE_WINS("1-0"), + BLACK_WINS("0-1"), + DRAW("1/2-1/2"), + IN_PROGRESS("*"), + ; + + companion object { + fun parse(notation: String): GameResult = entries.find { it.notation == notation } ?: IN_PROGRESS + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt new file mode 100644 index 000000000..664c60b0b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-64: Chess Game Event + * + * Kind 64 events contain chess games in PGN (Portable Game Notation) format. + * Per NIP-64 specification: + * - Content must be valid PGN format (PGN-database) + * - Clients should accept "import format" (human-created, flexible) + * - Clients should publish in "export format" (machine-generated, strict) + * - Moves must comply with chess rules + * - Clients should display content as rendered chessboard + * + * @see NIP-64 + */ +@Immutable +class ChessGameEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, // PGN database format + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 64 + const val ALT_DESCRIPTION = "Chess Game" + + /** + * Create a chess game event from PGN content + * Per NIP-64, clients should publish in strict "export format" + * + * @param pgnContent Valid PGN format string + * @param altDescription Alt text for non-supporting clients (NIP-31) + * @param createdAt Event timestamp + * @param initializer Additional tag builder operations + */ + fun build( + pgnContent: String, + altDescription: String = ALT_DESCRIPTION, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, pgnContent, createdAt) { + alt(altDescription) + initializer() + } + } + + /** + * Get PGN content from event + */ + fun pgn(): String = content + + /** + * Get alt text for non-supporting clients (NIP-31) + */ + fun altText(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "alt" }?.get(1) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameNameGenerator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameNameGenerator.kt new file mode 100644 index 000000000..4a36d1bf1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameNameGenerator.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import kotlin.random.Random + +/** + * Generates human-readable chess game names. + * + * Produces memorable names like "Bold Knight", "Swift Rook", etc. + */ +object ChessGameNameGenerator { + // Chess-themed adjectives + private val adjectives = + listOf( + "Bold", + "Swift", + "Cunning", + "Royal", + "Noble", + "Shadow", + "Silent", + "Golden", + "Silver", + "Iron", + "Fierce", + "Wise", + "Dark", + "Bright", + "Ancient", + "Brave", + "Mystic", + "Grand", + "Crimson", + "Azure", + "Ivory", + "Obsidian", + "Emerald", + "Sapphire", + "Ruby", + "Phantom", + "Eternal", + "Secret", + "Hidden", + "Blazing", + ) + + // Chess pieces and related nouns + private val chessNouns = + listOf( + "King", + "Queen", + "Rook", + "Bishop", + "Knight", + "Pawn", + "Castle", + "Crown", + "Throne", + "Gambit", + "Defense", + "Attack", + "Checkmate", + "Fortress", + "Tower", + "Endgame", + "Opening", + "Sacrifice", + "Pin", + "Fork", + "Strategy", + "Victory", + "Battle", + "Duel", + "Match", + "Challenge", + ) + + // Famous chess-related animals/themes + private val animals = + listOf( + "Dragon", + "Phoenix", + "Griffin", + "Eagle", + "Lion", + "Wolf", + "Tiger", + "Falcon", + "Hawk", + "Raven", + "Bear", + "Stallion", + "Serpent", + "Panther", + "Cobra", + ) + + /** + * Generate a random chess game name. + * + * @param includeNumber Whether to append a random number (1-99) + * @return A name like "Bold Knight" or "Swift Dragon 42" + */ + fun generate(includeNumber: Boolean = false): String { + val adjective = adjectives.random() + val noun = if (Random.nextBoolean()) chessNouns.random() else animals.random() + val base = "$adjective $noun" + + return if (includeNumber) { + val num = Random.nextInt(1, 100) + "$base $num" + } else { + base + } + } + + /** + * Generate a name based on two player display names. + * + * Creates a name that incorporates elements from both players + * for a more personalized game name. + * + * @param playerName First player's display name (can be npub/pubkey) + * @param opponentName Second player's display name (can be npub/pubkey, or null for open challenge) + * @return A personalized game name + */ + fun generateFromPlayers( + playerName: String?, + opponentName: String?, + ): String { + // Extract first letter or use adjective if name is a pubkey/npub + val p1Initial = extractInitial(playerName) + val p2Initial = opponentName?.let { extractInitial(it) } + + // If we have good initials, use them in the name + val adjective = + if (p1Initial != null) { + // Try to find an adjective starting with that letter + adjectives.find { it.startsWith(p1Initial, ignoreCase = true) } + ?: adjectives.random() + } else { + adjectives.random() + } + + val noun = + if (p2Initial != null) { + // Try to find a noun starting with opponent's initial + (chessNouns + animals).find { it.startsWith(p2Initial, ignoreCase = true) } + ?: if (Random.nextBoolean()) chessNouns.random() else animals.random() + } else { + // Open challenge - use "Challenge" or similar + listOf("Challenge", "Gambit", "Opening", "Battle", "Duel").random() + } + + return "$adjective $noun" + } + + /** + * Extract a usable initial from a name. + * Returns null if the name looks like a pubkey/npub. + */ + private fun extractInitial(name: String?): Char? { + if (name.isNullOrBlank()) return null + + // Skip if it looks like a hex pubkey or npub + if (name.length > 20 && name.all { it.isLetterOrDigit() }) return null + if (name.startsWith("npub") || name.startsWith("nprofile")) return null + + // Get first letter of the display name + val firstChar = name.firstOrNull { it.isLetter() } + return firstChar?.uppercaseChar() + } + + /** + * Generate a game ID that includes a readable component. + * + * @param timestamp Unix timestamp + * @return Game ID like "chess-1234567890-bold-knight" + */ + fun generateGameId(timestamp: Long): String { + val name = + generate(includeNumber = false) + .lowercase() + .replace(" ", "-") + return "chess-$timestamp-$name" + } + + /** + * Extract the human-readable name from a game ID. + * + * @param gameId Game ID like "chess-1234567890-bold-knight" + * @return Display name like "Bold Knight", or null if not a named game ID + */ + fun extractDisplayName(gameId: String): String? { + // Pattern: chess-timestamp-name-parts + if (!gameId.startsWith("chess-")) return null + + val parts = gameId.removePrefix("chess-").split("-") + if (parts.size < 2) return null + + // First part is timestamp, rest is the name + val nameParts = parts.drop(1) + if (nameParts.isEmpty()) return null + + // Check if it looks like a UUID (8 hex chars) - old format + if (nameParts.size == 1 && nameParts[0].length == 8 && nameParts[0].all { it.isLetterOrDigit() }) { + return null + } + + // Convert to title case + return nameParts.joinToString(" ") { part -> + part.replaceFirstChar { it.uppercaseChar() } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessMove.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessMove.kt new file mode 100644 index 000000000..e715719d5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessMove.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import androidx.compose.runtime.Immutable + +/** + * Represents a single chess move in Standard Algebraic Notation (SAN) + * Per NIP-64, moves must comply with chess rules + */ +@Immutable +data class ChessMove( + val san: String, // Standard Algebraic Notation, e.g., "Nf3", "e4", "O-O", "Qxe5+" + val moveNumber: Int, // Which move in the game (1-based) + val color: Color, // Who made the move + val piece: PieceType = PieceType.PAWN, + val fromSquare: String? = null, // Source square if known + val toSquare: String, // Destination square + val isCapture: Boolean = false, + val isCheck: Boolean = false, + val isCheckmate: Boolean = false, + val isCastling: Boolean = false, + val promotion: PieceType? = null, +) { + override fun toString(): String = san +} + +/** + * Chess piece types per standard notation + */ +enum class PieceType( + val symbol: Char, +) { + KING('K'), + QUEEN('Q'), + ROOK('R'), + BISHOP('B'), + KNIGHT('N'), + PAWN('P'), + ; + + companion object { + fun fromSymbol(c: Char): PieceType? = entries.find { it.symbol == c.uppercaseChar() } + } +} + +/** + * Player color + */ +enum class Color { + WHITE, + BLACK, + ; + + fun opposite(): Color = + when (this) { + WHITE -> BLACK + BLACK -> WHITE + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessPosition.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessPosition.kt new file mode 100644 index 000000000..9f92fddbf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessPosition.kt @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import androidx.compose.runtime.Immutable + +/** + * Represents a chess position using 8x8 board array + * Coordinates: file (a-h) = columns 0-7, rank (1-8) = rows 0-7 + * a1 = [0,0], h1 = [7,0], a8 = [0,7], h8 = [7,7] + */ +@Immutable +data class ChessPosition( + private val board: Array>, + val activeColor: Color, + val moveNumber: Int, + val castlingRights: CastlingRights = CastlingRights(), + val enPassantSquare: String? = null, + val halfMoveClock: Int = 0, +) { + init { + require(board.size == 8) { "Board must be 8x8, got ${board.size} ranks" } + require(board.all { it.size == 8 }) { "Board must be 8x8, some ranks are not 8 files wide" } + } + + /** + * Get piece at square using algebraic notation (e.g., "e4") + */ + operator fun get(square: String): ChessPiece? { + val (file, rank) = parseSquare(square) + return board[rank][file] + } + + /** + * Get piece at coordinates (file: 0-7, rank: 0-7) + */ + fun pieceAt( + file: Int, + rank: Int, + ): ChessPiece? = board.getOrNull(rank)?.getOrNull(file) + + /** + * Create new position with a piece moved + */ + fun makeMove( + from: String, + to: String, + promotion: PieceType? = null, + ): ChessPosition { + val (fromFile, fromRank) = parseSquare(from) + val (toFile, toRank) = parseSquare(to) + + val newBoard = board.map { it.clone() }.toTypedArray() + val piece = newBoard[fromRank][fromFile] ?: throw IllegalArgumentException("No piece at $from") + + // Handle promotion + val finalPiece = + if (promotion != null && piece.type == PieceType.PAWN) { + piece.copy(type = promotion) + } else { + piece + } + + newBoard[toRank][toFile] = finalPiece + newBoard[fromRank][fromFile] = null + + return copy( + board = newBoard, + activeColor = activeColor.opposite(), + moveNumber = if (activeColor == Color.BLACK) moveNumber + 1 else moveNumber, + ) + } + + /** + * Create new position with castling + */ + fun makeCastlingMove(kingSide: Boolean): ChessPosition { + val rank = if (activeColor == Color.WHITE) 0 else 7 + val newBoard = board.map { it.clone() }.toTypedArray() + + if (kingSide) { + // King-side castling (O-O) + val king = newBoard[rank][4] + val rook = newBoard[rank][7] + newBoard[rank][6] = king + newBoard[rank][5] = rook + newBoard[rank][4] = null + newBoard[rank][7] = null + } else { + // Queen-side castling (O-O-O) + val king = newBoard[rank][4] + val rook = newBoard[rank][0] + newBoard[rank][2] = king + newBoard[rank][3] = rook + newBoard[rank][4] = null + newBoard[rank][0] = null + } + + return copy( + board = newBoard, + activeColor = activeColor.opposite(), + moveNumber = if (activeColor == Color.BLACK) moveNumber + 1 else moveNumber, + ) + } + + private fun parseSquare(square: String): Pair { + require(square.length == 2) { "Invalid square notation: $square" } + val file = square[0].lowercaseChar() - 'a' // 0-7 + val rank = square[1] - '1' // 0-7 + require(file in 0..7 && rank in 0..7) { "Square out of bounds: $square" } + return file to rank + } + + companion object { + /** + * Standard starting position per FIDE rules + */ + fun initial(): ChessPosition { + val board = Array(8) { Array(8) { null } } + + // White pieces (ranks 0-1) + board[0] = + arrayOf( + ChessPiece(PieceType.ROOK, Color.WHITE), + ChessPiece(PieceType.KNIGHT, Color.WHITE), + ChessPiece(PieceType.BISHOP, Color.WHITE), + ChessPiece(PieceType.QUEEN, Color.WHITE), + ChessPiece(PieceType.KING, Color.WHITE), + ChessPiece(PieceType.BISHOP, Color.WHITE), + ChessPiece(PieceType.KNIGHT, Color.WHITE), + ChessPiece(PieceType.ROOK, Color.WHITE), + ) + board[1] = Array(8) { ChessPiece(PieceType.PAWN, Color.WHITE) } + + // Black pieces (ranks 6-7) + board[6] = Array(8) { ChessPiece(PieceType.PAWN, Color.BLACK) } + board[7] = + arrayOf( + ChessPiece(PieceType.ROOK, Color.BLACK), + ChessPiece(PieceType.KNIGHT, Color.BLACK), + ChessPiece(PieceType.BISHOP, Color.BLACK), + ChessPiece(PieceType.QUEEN, Color.BLACK), + ChessPiece(PieceType.KING, Color.BLACK), + ChessPiece(PieceType.BISHOP, Color.BLACK), + ChessPiece(PieceType.KNIGHT, Color.BLACK), + ChessPiece(PieceType.ROOK, Color.BLACK), + ) + + return ChessPosition( + board = board, + activeColor = Color.WHITE, + moveNumber = 1, + ) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ChessPosition) return false + return board.contentDeepEquals(other.board) && + activeColor == other.activeColor && + moveNumber == other.moveNumber && + castlingRights == other.castlingRights && + enPassantSquare == other.enPassantSquare && + halfMoveClock == other.halfMoveClock + } + + override fun hashCode(): Int { + var result = board.contentDeepHashCode() + result = 31 * result + activeColor.hashCode() + result = 31 * result + moveNumber + result = 31 * result + castlingRights.hashCode() + result = 31 * result + (enPassantSquare?.hashCode() ?: 0) + result = 31 * result + halfMoveClock + return result + } +} + +/** + * Represents a chess piece + */ +@Immutable +data class ChessPiece( + val type: PieceType, + val color: Color, +) + +/** + * Castling availability + */ +@Immutable +data class CastlingRights( + val whiteKingSide: Boolean = true, + val whiteQueenSide: Boolean = true, + val blackKingSide: Boolean = true, + val blackQueenSide: Boolean = true, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt new file mode 100644 index 000000000..65849aa77 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import androidx.compose.runtime.Immutable + +/** + * Deterministic chess state reconstruction from Jester protocol events. + * + * This is the single source of truth for converting a collection of Jester events + * into a consistent game state. Both Android and Desktop platforms must use this + * algorithm to ensure identical state from the same events. + * + * Jester Protocol Reconstruction: + * 1. Find start event (content.kind=0) → establishes startEventId, challenger color, players + * 2. Find move with longest history → this is the current state + * 3. Replay moves from history to build engine state + * 4. Check for result/termination in latest move → mark finished if present + * + * Key Jester differences: + * - No separate accept event (acceptance is implicit) + * - Full move history in every move event + * - Can reconstruct from any single move (has complete history) + * - startEventId is the game identifier (event ID of start event) + * + * Guarantees: + * - Same events always produce same state (deterministic) + * - Uses longest history for authoritative state + * - Invalid moves are skipped without error + */ +object ChessStateReconstructor { + /** + * Reconstruct game state from a collection of Jester events. + * + * @param events The collected Jester events for a single game + * @param viewerPubkey The pubkey of the user viewing the game (determines perspective) + * @return ReconstructionResult or error if reconstruction fails + */ + fun reconstruct( + events: JesterGameEvents, + viewerPubkey: String, + ): ReconstructionResult { + // Step 1: Get start event (required for player info) + val startEvent = + events.startEvent + ?: return ReconstructionResult.Error("No start event found") + + val startEventId = startEvent.id + val challengerPubkey = startEvent.pubKey + val challengerColor = startEvent.playerColor() ?: Color.WHITE + val challengedPubkey = startEvent.opponentPubkey() + + // In Jester, we determine opponent from the p-tag or from move events + // For open challenges, we need to find who made the first move + val opponentFromMoves = + events.moves + .firstOrNull { it.pubKey != challengerPubkey } + ?.pubKey + + val actualOpponent = challengedPubkey ?: opponentFromMoves + + // Determine players based on challenger's color choice + val (whitePubkey, blackPubkey) = + if (challengerColor == Color.WHITE) { + challengerPubkey to actualOpponent + } else { + actualOpponent to challengerPubkey + } + + // Determine viewer's role + val viewerRole = + when (viewerPubkey) { + whitePubkey -> ViewerRole.WHITE_PLAYER + blackPubkey -> ViewerRole.BLACK_PLAYER + else -> ViewerRole.SPECTATOR + } + + val playerColor = + when (viewerRole) { + ViewerRole.WHITE_PLAYER -> Color.WHITE + ViewerRole.BLACK_PLAYER -> Color.BLACK + ViewerRole.SPECTATOR -> Color.WHITE // Spectators see from white's perspective + } + + val opponentPubkey = + when (viewerRole) { + ViewerRole.WHITE_PLAYER -> blackPubkey ?: "" + ViewerRole.BLACK_PLAYER -> whitePubkey ?: "" + ViewerRole.SPECTATOR -> blackPubkey ?: "" // For spectators, "opponent" is black + } + + // Check if game is pending (no moves yet AND viewer is the challenger) + // If viewer accepted someone else's challenge, the game is NOT pending + val isChallenger = viewerPubkey == startEvent.pubKey + val isPendingChallenge = events.moves.isEmpty() && isChallenger + + // Step 2: Create engine and apply moves from the longest history + val engine = ChessEngine() + val latestMove = events.latestMove() + val history = latestMove?.history() ?: emptyList() + + // Track applied moves + val appliedMoveNumbers = mutableSetOf() + var isDesynced = false + + // Apply moves from history + for ((index, san) in history.withIndex()) { + val moveNumber = index + 1 + val result = engine.makeMove(san) + if (result.success) { + appliedMoveNumbers.add(moveNumber) + } else { + // Move failed - game might be desynced + isDesynced = true + // Try to recover by loading the FEN from latest move if available + latestMove?.fen()?.let { fen -> + engine.loadFen(fen) + } + break + } + } + + // Verify final position matches if we have FEN + latestMove?.fen()?.let { expectedFen -> + val currentFen = engine.getFen() + if (!fenPositionsMatch(currentFen, expectedFen)) { + isDesynced = true + engine.loadFen(expectedFen) + } + } + + // Step 3: Check game end status + val gameResult = latestMove?.result() + val gameStatus = + when { + gameResult != null -> { + val result = parseGameResult(gameResult) + GameStatus.Finished(result) + } + + engine.isCheckmate() -> { + val winner = engine.getSideToMove().opposite() + GameStatus.Finished( + if (winner == Color.WHITE) GameResult.WHITE_WINS else GameResult.BLACK_WINS, + ) + } + + engine.isStalemate() -> { + GameStatus.Finished(GameResult.DRAW) + } + + else -> { + GameStatus.InProgress + } + } + + // Get the head event ID (for linking next move) + val headEventId = latestMove?.id ?: startEventId + + // Build the reconstructed state + val state = + ReconstructedGameState( + startEventId = startEventId, + headEventId = headEventId, + whitePubkey = whitePubkey, + blackPubkey = blackPubkey, + viewerRole = viewerRole, + playerColor = playerColor, + opponentPubkey = opponentPubkey, + isPendingChallenge = isPendingChallenge, + currentPosition = engine.getPosition(), + moveHistory = engine.getMoveHistory(), + gameStatus = gameStatus, + isDesynced = isDesynced, + pendingDrawOffer = null, // Jester doesn't have explicit draw offers + appliedMoveNumbers = appliedMoveNumbers, + challengeCreatedAt = startEvent.createdAt, + timeControl = null, // Not supported in Jester + ) + + return ReconstructionResult.Success(state, engine) + } + + /** + * Compare FEN positions, ignoring halfmove clock and fullmove number. + * This provides a more lenient comparison that focuses on the actual board state. + */ + private fun fenPositionsMatch( + fen1: String, + fen2: String, + ): Boolean { + // FEN format: position activeColor castling enPassant halfmove fullmove + // Compare only first 4 parts (position, activeColor, castling, enPassant) + val parts1 = fen1.split(" ") + val parts2 = fen2.split(" ") + + if (parts1.size < 4 || parts2.size < 4) { + return fen1 == fen2 // Fallback to exact match + } + + return parts1[0] == parts2[0] && // Board position + parts1[1] == parts2[1] && // Active color + parts1[2] == parts2[2] && // Castling rights + parts1[3] == parts2[3] // En passant + } + + private fun parseGameResult(result: String?): GameResult = + when (result) { + "1-0" -> GameResult.WHITE_WINS + "0-1" -> GameResult.BLACK_WINS + "1/2-1/2" -> GameResult.DRAW + else -> GameResult.IN_PROGRESS + } +} + +/** + * The viewer's role in the game. + */ +enum class ViewerRole { + WHITE_PLAYER, + BLACK_PLAYER, + SPECTATOR, +} + +/** + * Result of game state reconstruction. + */ +sealed class ReconstructionResult { + data class Success( + val state: ReconstructedGameState, + val engine: ChessEngine, + ) : ReconstructionResult() + + data class Error( + val message: String, + ) : ReconstructionResult() + + fun isSuccess(): Boolean = this is Success + + fun getOrNull(): ReconstructedGameState? = (this as? Success)?.state + + fun getEngineOrNull(): ChessEngine? = (this as? Success)?.engine +} + +/** + * Reconstructed game state from Jester events. + * This is an immutable snapshot of the game at the time of reconstruction. + */ +@Immutable +data class ReconstructedGameState( + /** The start event ID - this is the game identifier in Jester protocol */ + val startEventId: String, + /** The current head event ID - used for linking the next move */ + val headEventId: String, + val whitePubkey: String?, + val blackPubkey: String?, + val viewerRole: ViewerRole, + val playerColor: Color, + val opponentPubkey: String, + val isPendingChallenge: Boolean, + val currentPosition: ChessPosition, + val moveHistory: List, + val gameStatus: GameStatus, + val isDesynced: Boolean, + val pendingDrawOffer: String?, + val appliedMoveNumbers: Set, + val challengeCreatedAt: Long, + val timeControl: String?, +) { + /** Legacy alias for startEventId */ + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId + + fun isPlayerTurn(): Boolean = + when (viewerRole) { + ViewerRole.SPECTATOR -> false + ViewerRole.WHITE_PLAYER -> currentPosition.activeColor == Color.WHITE + ViewerRole.BLACK_PLAYER -> currentPosition.activeColor == Color.BLACK + } + + fun isFinished(): Boolean = gameStatus is GameStatus.Finished + + fun hasOpponentDrawOffer(playerPubkey: String): Boolean = pendingDrawOffer != null && pendingDrawOffer != playerPubkey + + fun hasOurDrawOffer(playerPubkey: String): Boolean = pendingDrawOffer == playerPubkey +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt new file mode 100644 index 000000000..32ec8c390 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt @@ -0,0 +1,382 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Jester Protocol Implementation + * + * Compatible with jesterui (https://github.com/jesterui/jesterui) + * + * Key differences from previous implementation: + * - Single event kind (30) for all chess messages + * - Content is JSON with: version, kind, fen, move, history, nonce + * - Event linking via e-tags: [startId] or [startId, headId] + * - Full move history included in every move event + * + * Content kind values: + * - 0: Game start (challenge) + * - 1: Move + * - 2: Chat (not implemented) + * + * Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md + */ +object JesterProtocol { + /** Jester uses kind 30 for all chess events */ + const val KIND = 30 + + /** SHA256 of starting FEN position - used as reference for game discovery */ + const val START_POSITION_HASH = "b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879" + + /** Standard starting FEN */ + const val FEN_START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + + /** Content kind for game start */ + const val CONTENT_KIND_START = 0 + + /** Content kind for move */ + const val CONTENT_KIND_MOVE = 1 + + /** Content kind for chat */ + const val CONTENT_KIND_CHAT = 2 +} + +/** + * JSON content structure for Jester events + */ +@Serializable +data class JesterContent( + val version: String = "0", + val kind: Int, + val fen: String = JesterProtocol.FEN_START, + val move: String? = null, + val history: List = emptyList(), + val nonce: String? = null, + // Extended fields for Amethyst (backward compatible - jesterui ignores unknown fields) + val playerColor: String? = null, // "white" or "black" - challenger's color choice + val result: String? = null, // "1-0", "0-1", "1/2-1/2" for game end + val termination: String? = null, // "checkmate", "resignation", "draw_agreement", etc. +) + +private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = false + } + +/** + * Jester Chess Event (Kind 30) + * + * Unified event class for all Jester chess messages. + * The content.kind field determines the event type: + * - 0: Game start/challenge + * - 1: Move + * - 2: Chat + */ +@Immutable +class JesterEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, JesterProtocol.KIND, tags, content, sig) { + private val parsedContent: JesterContent? by lazy { + try { + json.decodeFromString(content) + } catch (e: Exception) { + null + } + } + + /** Get the content kind (0=start, 1=move, 2=chat) */ + fun contentKind(): Int? = parsedContent?.kind + + /** Check if this is a game start event */ + fun isStartEvent(): Boolean = parsedContent?.kind == JesterProtocol.CONTENT_KIND_START + + /** Check if this is a move event */ + fun isMoveEvent(): Boolean = parsedContent?.kind == JesterProtocol.CONTENT_KIND_MOVE + + /** Get the FEN position */ + fun fen(): String? = parsedContent?.fen + + /** Get the latest move (SAN notation) */ + fun move(): String? = parsedContent?.move + + /** Get the full move history */ + fun history(): List = parsedContent?.history ?: emptyList() + + /** Get the nonce (for start events) */ + fun nonce(): String? = parsedContent?.nonce + + /** Get the player color choice (for start events) */ + fun playerColor(): Color? = + parsedContent?.playerColor?.let { + if (it == "white") Color.WHITE else Color.BLACK + } + + /** Get the game result (for end events) */ + fun result(): String? = parsedContent?.result + + /** Get the termination reason (for end events) */ + fun termination(): String? = parsedContent?.termination + + /** Get the start event ID (first e-tag) */ + fun startEventId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) + + /** Get the head/parent move ID (second e-tag, for moves) */ + fun headEventId(): String? = tags.filter { it.size >= 2 && it[0] == "e" }.getOrNull(1)?.get(1) + + /** Get opponent pubkey (p-tag, for private games) */ + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + /** Get all e-tags */ + fun eTags(): List = tags.filter { it.size >= 2 && it[0] == "e" }.map { it[1] } + + companion object { + const val KIND = JesterProtocol.KIND + + /** + * Build a game start event (open challenge) + * + * @param playerColor Color the challenger wants to play + * @param nonce Unique nonce for this game + * @param createdAt Event timestamp + */ + fun buildStart( + playerColor: Color, + nonce: String = generateNonce(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val content = + JesterContent( + kind = JesterProtocol.CONTENT_KIND_START, + fen = JesterProtocol.FEN_START, + history = emptyList(), + nonce = nonce, + playerColor = if (playerColor == Color.WHITE) "white" else "black", + ) + return eventTemplate(KIND, json.encodeToString(content), createdAt) { + // Reference the standard start position for game discovery + add(arrayOf("e", JesterProtocol.START_POSITION_HASH)) + initializer() + } + } + + /** + * Build a private game start event (direct challenge) + * + * @param opponentPubkey Pubkey of the challenged player + * @param playerColor Color the challenger wants to play + * @param nonce Unique nonce for this game + * @param createdAt Event timestamp + */ + fun buildPrivateStart( + opponentPubkey: String, + playerColor: Color, + nonce: String = generateNonce(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val content = + JesterContent( + kind = JesterProtocol.CONTENT_KIND_START, + fen = JesterProtocol.FEN_START, + history = emptyList(), + nonce = nonce, + playerColor = if (playerColor == Color.WHITE) "white" else "black", + ) + return eventTemplate(KIND, json.encodeToString(content), createdAt) { + // Reference the standard start position + add(arrayOf("e", JesterProtocol.START_POSITION_HASH)) + // Tag the opponent for notifications (only if valid pubkey) + if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) { + add(arrayOf("p", opponentPubkey)) + } + initializer() + } + } + + /** + * Build a move event + * + * @param startEventId ID of the game start event + * @param headEventId ID of the previous move (or start event for first move) + * @param move The move in SAN notation (e.g., "e4", "Nf3") + * @param fen Resulting board position in FEN + * @param history Complete move history including this move + * @param opponentPubkey Opponent's pubkey for notifications + * @param createdAt Event timestamp + */ + fun buildMove( + startEventId: String, + headEventId: String, + move: String, + fen: String, + history: List, + opponentPubkey: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val content = + JesterContent( + kind = JesterProtocol.CONTENT_KIND_MOVE, + fen = fen, + move = move, + history = history, + ) + return eventTemplate(KIND, json.encodeToString(content), createdAt) { + // e-tags: [startId, headId] + add(arrayOf("e", startEventId)) + add(arrayOf("e", headEventId)) + // Tag opponent for notifications (only if valid pubkey) + if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) { + add(arrayOf("p", opponentPubkey)) + } + initializer() + } + } + + /** + * Build a game end move (includes result in content) + * + * This is a move event with additional result/termination fields. + * Used when the game ends (checkmate, resignation, draw). + */ + fun buildEndMove( + startEventId: String, + headEventId: String, + move: String?, + fen: String, + history: List, + opponentPubkey: String, + result: GameResult, + termination: GameTermination, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val content = + JesterContent( + kind = JesterProtocol.CONTENT_KIND_MOVE, + fen = fen, + move = move, + history = history, + result = result.notation, + termination = termination.name.lowercase(), + ) + return eventTemplate(KIND, json.encodeToString(content), createdAt) { + add(arrayOf("e", startEventId)) + add(arrayOf("e", headEventId)) + // Tag opponent for notifications (only if valid pubkey) + if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) { + add(arrayOf("p", opponentPubkey)) + } + initializer() + } + } + + private fun generateNonce(): String { + val chars = "abcdefghijklmnopqrstuvwxyz0123456789" + return (1..8).map { chars.random() }.joinToString("") + } + } +} + +/** + * Helper to check if an event is a Jester chess event + */ +fun Event.isJesterEvent(): Boolean = kind == JesterProtocol.KIND + +/** + * Helper to check if an event is a Jester start event + */ +fun Event.isJesterStartEvent(): Boolean { + if (kind != JesterProtocol.KIND) return false + return try { + val content = json.decodeFromString(this.content) + content.kind == JesterProtocol.CONTENT_KIND_START && content.history.isEmpty() + } catch (e: Exception) { + false + } +} + +/** + * Helper to check if an event is a Jester move event + */ +fun Event.isJesterMoveEvent(): Boolean { + if (kind != JesterProtocol.KIND) return false + return try { + val content = json.decodeFromString(this.content) + content.kind == JesterProtocol.CONTENT_KIND_MOVE && + content.history.isNotEmpty() && + tags.count { it.size >= 2 && it[0] == "e" } == 2 + } catch (e: Exception) { + false + } +} + +/** + * Convert a raw Event to JesterEvent if valid + */ +fun Event.toJesterEvent(): JesterEvent? { + if (kind != JesterProtocol.KIND) return null + return JesterEvent(id, pubKey, createdAt, tags, content, sig) +} + +/** + * Game events container for Jester protocol + */ +data class JesterGameEvents( + val startEvent: JesterEvent?, + val moves: List, +) { + companion object { + fun empty() = JesterGameEvents(null, emptyList()) + } + + /** Get the latest move (with longest history) */ + fun latestMove(): JesterEvent? = moves.maxByOrNull { it.history().size } + + /** Get the current FEN position */ + fun currentFen(): String = latestMove()?.fen() ?: startEvent?.fen() ?: JesterProtocol.FEN_START + + /** Get the complete move history */ + fun fullHistory(): List = latestMove()?.history() ?: emptyList() + + /** Check if game has ended */ + fun isEnded(): Boolean = latestMove()?.result() != null + + /** Get the game result if ended */ + fun result(): String? = latestMove()?.result() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt new file mode 100644 index 000000000..0c2119a51 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +/* + * Live chess game state and move events + * + * Uses Jester protocol (kind 30) for compatibility with jesterui. + * See: https://github.com/jesterui/jesterui/blob/devel/FLOW.md + * + * Key features of Jester protocol: + * - Single event kind (30) for all chess messages + * - Content is JSON with: version, kind (0=start, 1=move), fen, move, history + * - Full move history included in every move event + * - Event linking via e-tags: [startId] or [startId, headId] + * + * This enables: + * - Easy game reconstruction from any single move event + * - Tolerance for missing intermediate moves + * - Compatibility with jesterui and other Jester clients + */ + +/** + * Game start/challenge data for publishing + * + * Jester protocol (kind 30) start event: + * - e-tag: reference to standard start position hash + * - p-tag: opponent pubkey (for private/direct challenges) + * - Content JSON with: version, kind=0, fen, history=[], nonce, playerColor + * + * @param opponentPubkey Opponent's pubkey for direct challenge (null = open challenge) + * @param playerColor Color the challenger wants to play + * @param nonce Unique identifier for this game + */ +data class ChessGameChallenge( + val opponentPubkey: String? = null, // null = open challenge + val playerColor: Color, + val nonce: String = generateNonce(), +) { + companion object { + private fun generateNonce(): String { + val chars = "abcdefghijklmnopqrstuvwxyz0123456789" + return (1..8).map { chars.random() }.joinToString("") + } + } + + // Legacy compatibility - gameId is now derived from start event ID after signing + @Deprecated("gameId is determined after event creation", ReplaceWith("startEventId")) + val gameId: String get() = nonce +} + +/** + * Game acceptance data + * + * In Jester protocol, accepting a game is implicit - the opponent + * simply makes the first move (if challenger chose white) or waits + * for challenger's first move (if challenger chose black). + * + * This data class is kept for internal state tracking. + * + * @param startEventId ID of the game start event to accept + * @param challengerPubkey Pubkey of the challenger + */ +data class ChessGameAccept( + val startEventId: String, + val challengerPubkey: String, +) { + // Legacy compatibility + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId + + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val challengeEventId: String get() = startEventId +} + +/** + * Chess move data for publishing + * + * Jester protocol (kind 30) requires: + * - Full move history in every move event + * - e-tags linking: [startEventId, headEventId] + * - Content JSON with: version, kind=1, fen, move, history + * + * @param startEventId ID of the game start event + * @param headEventId ID of the previous move (or startEventId for first move) + * @param san Move in Standard Algebraic Notation (e.g., "e4", "Nf3") + * @param fen Resulting board position in FEN notation + * @param history Complete move history including this move + * @param opponentPubkey Opponent's pubkey for p-tag notification + */ +data class ChessMoveEvent( + val startEventId: String, + val headEventId: String, + val san: String, + val fen: String, + val history: List, + val opponentPubkey: String, +) { + /** Move number (1-based, derived from history length) */ + val moveNumber: Int get() = history.size + + // Legacy compatibility - some code still uses gameId + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId +} + +/** + * Game end data for publishing + * + * In Jester protocol, game end is a move event with result/termination + * fields in the content JSON. + * + * @param startEventId ID of the game start event + * @param headEventId ID of the previous move + * @param lastMove The final move (null for resignation without move) + * @param fen Final board position + * @param history Complete move history + * @param result Game result (1-0, 0-1, 1/2-1/2) + * @param termination How the game ended + * @param opponentPubkey Opponent's pubkey for notification + */ +data class ChessGameEnd( + val startEventId: String, + val headEventId: String, + val lastMove: String?, + val fen: String, + val history: List, + val result: GameResult, + val termination: GameTermination, + val opponentPubkey: String, +) { + // Legacy compatibility + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId + + val winnerPubkey: String? get() = null // Determined from result +} + +/** + * Draw offer data + * + * In Jester protocol, draw offers can be communicated via: + * - Chat message (kind=2 in content) + * - Special field in move content + * + * Opponent can: + * - Accept by sending a game end with DRAW_AGREEMENT + * - Decline implicitly by making their next move + * + * @param startEventId ID of the game start event + * @param headEventId ID of the current head move + * @param opponentPubkey Opponent's pubkey for notification + * @param message Optional message with the draw offer + */ +data class ChessDrawOffer( + val startEventId: String, + val headEventId: String, + val opponentPubkey: String, + val message: String? = null, +) { + // Legacy compatibility + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId +} + +/** + * Game termination reason + */ +enum class GameTermination { + CHECKMATE, + RESIGNATION, + DRAW_AGREEMENT, + STALEMATE, + TIMEOUT, + ABANDONMENT, +} + +/** + * Event kind constants for live chess (Jester protocol) + * + * Jester uses a single kind (30) for all chess events. + * The event type is determined by the content.kind field: + * - 0: Game start/challenge + * - 1: Move + * - 2: Chat + */ +object LiveChessEventKinds { + /** Jester protocol uses kind 30 for all chess events */ + const val JESTER = 30 + + // Legacy constants - deprecated, use JESTER instead + @Deprecated("Jester uses single kind 30", ReplaceWith("JESTER")) + const val GAME_CHALLENGE = 30 + + @Deprecated("Jester uses single kind 30", ReplaceWith("JESTER")) + const val GAME_ACCEPT = 30 + + @Deprecated("Jester uses single kind 30", ReplaceWith("JESTER")) + const val CHESS_MOVE = 30 + + @Deprecated("Jester uses single kind 30", ReplaceWith("JESTER")) + const val GAME_END = 30 + + @Deprecated("Jester uses single kind 30", ReplaceWith("JESTER")) + const val DRAW_OFFER = 30 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt new file mode 100644 index 000000000..4381f832c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Live Chess Game Challenge Event (Kind 30064) + * + * Challenge another player to a chess game or create an open challenge. + * This is a parameterized replaceable event. + * + * Tags: + * - d: game_id (unique identifier for this game) + * - p: opponent pubkey (optional, for direct challenges) + * - player_color: "white" or "black" + * - time_control: optional time control (e.g., "10+0", "5+3") + */ +@Immutable +class LiveChessGameChallengeEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30064 + + fun build( + gameId: String, + playerColor: Color, + opponentPubkey: String? = null, + timeControl: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + add(arrayOf("d", gameId)) + add(arrayOf("player_color", if (playerColor == Color.WHITE) "white" else "black")) + opponentPubkey?.let { add(arrayOf("p", it)) } + timeControl?.let { add(arrayOf("time_control", it)) } + alt("Chess game challenge") + initializer() + } + } + + fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + + fun playerColor(): Color? = + tags + .firstOrNull { it.size >= 2 && it[0] == "player_color" } + ?.get(1) + ?.let { if (it == "white") Color.WHITE else Color.BLACK } + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun timeControl(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "time_control" }?.get(1) +} + +/** + * Live Chess Game Accept Event (Kind 30065) + * + * Accept a chess game challenge + * + * Tags: + * - d: game_id (same as challenge) + * - e: challenge event ID + * - p: challenger pubkey + */ +@Immutable +class LiveChessGameAcceptEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30065 + + fun build( + gameId: String, + challengeEventId: String, + challengerPubkey: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + add(arrayOf("d", gameId)) + add(arrayOf("e", challengeEventId)) + add(arrayOf("p", challengerPubkey)) + alt("Chess game acceptance") + initializer() + } + } + + fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + + fun challengeEventId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) + + fun challengerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) +} + +/** + * Live Chess Move Event (Kind 30066) + * + * Individual move in a live chess game + * + * Tags: + * - d: game_id + * - move_number: move number (1-based) + * - san: move in Standard Algebraic Notation + * - fen: resulting position in FEN notation + * - p: opponent pubkey + * + * Content: Optional move comment + */ +@Immutable +class LiveChessMoveEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30066 + + fun build( + gameId: String, + moveNumber: Int, + san: String, + fen: String, + opponentPubkey: String, + comment: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, comment, createdAt) { + add(arrayOf("d", "$gameId-$moveNumber")) + add(arrayOf("game_id", gameId)) + add(arrayOf("move_number", moveNumber.toString())) + add(arrayOf("san", san)) + add(arrayOf("fen", fen)) + add(arrayOf("p", opponentPubkey)) + alt("Chess move: $san") + initializer() + } + } + + fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "game_id" }?.get(1) + + fun moveNumber(): Int? = + tags + .firstOrNull { it.size >= 2 && it[0] == "move_number" } + ?.get(1) + ?.toIntOrNull() + + fun san(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "san" }?.get(1) + + fun fen(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "fen" }?.get(1) + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun comment(): String = content +} + +/** + * Live Chess Game End Event (Kind 30067) + * + * Game result and termination + * + * Tags: + * - d: game_id + * - result: "1-0"|"0-1"|"1/2-1/2" + * - termination: reason for game end + * - winner: pubkey of winner (if applicable) + * - p: opponent pubkey + * + * Content: Optional PGN of complete game + */ +@Immutable +class LiveChessGameEndEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30067 + + fun build( + gameId: String, + result: GameResult, + termination: GameTermination, + winnerPubkey: String? = null, + opponentPubkey: String, + pgn: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, pgn, createdAt) { + add(arrayOf("d", gameId)) + add(arrayOf("result", result.notation)) + add(arrayOf("termination", termination.name.lowercase())) + winnerPubkey?.let { add(arrayOf("winner", it)) } + add(arrayOf("p", opponentPubkey)) + alt("Chess game ended: ${result.notation}") + initializer() + } + } + + fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + + fun result(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "result" }?.get(1) + + fun termination(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "termination" }?.get(1) + + fun winnerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "winner" }?.get(1) + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + 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, +) : BaseAddressableEvent(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 new file mode 100644 index 000000000..7e0daed77 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt @@ -0,0 +1,548 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Manages state for a live chess game + * + * This class coordinates between: + * - ChessEngine (move validation, board state) + * - Nostr events (publishing moves, receiving opponent moves) + * - UI (game state, turn management) + * + * Jester Protocol Notes: + * - startEventId: ID of the game start event (used as game identifier) + * - headEventId: ID of the current head move (updated after each move) + * - Full move history is included in every published move event + */ +class LiveChessGameState( + /** Start event ID - the game identifier in Jester protocol */ + val startEventId: String, + val playerPubkey: String, + val opponentPubkey: String, + val playerColor: Color, + val engine: ChessEngine, + val createdAt: Long = TimeUtils.now(), + val isSpectator: Boolean = false, + val isPendingChallenge: Boolean = false, + /** Initial head event ID - same as startEventId for new games */ + initialHeadEventId: String? = null, +) { + /** Legacy alias for startEventId */ + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId + + /** Current head event ID - tracks the most recent move for e-tag linking */ + private val _headEventId = MutableStateFlow(initialHeadEventId ?: startEventId) + val headEventId: StateFlow = _headEventId.asStateFlow() + + companion object { + // Abandon timeout: 24 hours without a move + const val ABANDON_TIMEOUT_SECONDS = 24 * 60 * 60L + + // 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() + + // 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 + * Spectators never have a turn + */ + fun isPlayerTurn(): Boolean = !isSpectator && engine.getSideToMove() == playerColor + + /** + * Make a move (called when player makes a move on the board) + * Returns the move event data to be published to Nostr + * + * Jester Protocol: Move events include full history and link to previous event + */ + fun makeMove( + from: String, + to: String, + promotion: PieceType? = null, + ): ChessMoveEvent? { + if (!isPlayerTurn()) { + _lastError.value = "Not your turn" + return null + } + + if (_gameStatus.value != GameStatus.InProgress) { + _lastError.value = "Game is not in progress" + return null + } + + val result = engine.makeMove(from, to, promotion) + + 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() + + // Return move event data with full history for Jester protocol + return ChessMoveEvent( + startEventId = startEventId, + headEventId = _headEventId.value, + san = result.san, + fen = engine.getFen(), + history = _moveHistory.value, // Full history for Jester + opponentPubkey = opponentPubkey, + ) + } else { + _lastError.value = result.error ?: "Invalid move" + return null + } + } + + /** + * Update the head event ID after a move is successfully published. + * Call this after the move event is signed and broadcast. + * + * @param newHeadEventId The ID of the newly published move event + */ + fun updateHeadEventId(newHeadEventId: String) { + _headEventId.value = newHeadEventId + } + + /** + * Undo the last move made. + * Used to revert a move when publishing fails. + * + * @return true if the move was successfully undone, false if there was nothing to undo + */ + fun undoLastMove(): Boolean { + if (_moveHistory.value.isEmpty()) return false + + engine.undoMove() + _currentPosition.value = engine.getPosition() + _moveHistory.value = engine.getMoveHistory() + _lastError.value = null + + // Reset game status in case checkmate was detected + if (_gameStatus.value is GameStatus.Finished) { + _gameStatus.value = GameStatus.InProgress + } + + return true + } + + /** + * Apply opponent's move (called when receiving move event from Nostr) + */ + 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 + } + + // Verify the move is valid by trying to make it + 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 - 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" + return false + } + } + + /** + * 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 + } + + /** + * Apply moves from another game state while preserving this state's participant info. + * This is used when relay reconstruction incorrectly marks us as spectator but has newer moves. + * + * @param other The other game state to take moves from + */ + fun applyMovesFrom(other: LiveChessGameState) { + val currentMoves = _moveHistory.value + val otherMoves = other.moveHistory.value + + if (otherMoves.size <= currentMoves.size) return // Nothing new to apply + + // Apply only the new moves + for (i in currentMoves.size until otherMoves.size) { + val san = otherMoves[i] + val result = engine.makeMove(san) + if (!result.success) { + // Try to resync from the other engine's FEN + val otherFen = other.engine.getFen() + engine.loadFen(otherFen) + break + } + } + + // Update state flows + _currentPosition.value = engine.getPosition() + _moveHistory.value = engine.getMoveHistory() + _lastActivityAt.value = other.lastActivityAt.value + _isDesynced.value = false + _lastError.value = null + + // Update head event ID from other state + _headEventId.value = other.headEventId.value + + // Mark new moves as received + val newMoveNumbers = (currentMoves.size + 1..otherMoves.size).toSet() + receivedMoveNumbers.addAll(newMoveNumbers) + + // Check for game end conditions + checkGameEnd() + } + + /** + * Mark move numbers as already received. + * Used when loading a game from cache to prevent duplicate move application during refresh. + * + * @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( + startEventId = startEventId, + headEventId = _headEventId.value, + lastMove = null, + fen = engine.getFen(), + history = _moveHistory.value, + result = GameResult.getResultForWinner(playerColor), + termination = GameTermination.ABANDONMENT, + opponentPubkey = opponentPubkey, + ) + } + + /** + * Offer resignation (player resigns) + */ + fun resign(): ChessGameEnd { + _gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor.opposite())) + + return ChessGameEnd( + startEventId = startEventId, + headEventId = _headEventId.value, + lastMove = null, + fen = engine.getFen(), + history = _moveHistory.value, + result = GameResult.getResultForWinner(playerColor.opposite()), + termination = GameTermination.RESIGNATION, + opponentPubkey = opponentPubkey, + ) + } + + /** + * Offer a draw to opponent. + * Returns the draw offer data to be published to Nostr. + */ + fun offerDraw(): ChessDrawOffer { + _pendingDrawOffer.value = playerPubkey + return ChessDrawOffer( + startEventId = startEventId, + headEventId = _headEventId.value, + 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( + startEventId = startEventId, + headEventId = _headEventId.value, + lastMove = null, + fen = engine.getFen(), + history = _moveHistory.value, + result = GameResult.DRAW, + termination = GameTermination.DRAW_AGREEMENT, + opponentPubkey = opponentPubkey, + ) + } + + /** + * Decline a draw offer (explicit decline, also happens implicitly on move) + */ + fun declineDraw() { + _pendingDrawOffer.value = null + } + + /** + * Check if game has ended (checkmate, stalemate, etc.) + */ + private fun checkGameEnd() { + when { + engine.isCheckmate() -> { + val winner = engine.getSideToMove().opposite() + _gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(winner)) + // In a real implementation, you'd publish GameEnd event here + } + + engine.isStalemate() -> { + _gameStatus.value = GameStatus.Finished(GameResult.DRAW) + // In a real implementation, you'd publish GameEnd event here + } + } + } + + /** + * Generate PGN for the current game + */ + private fun generatePGN(): String { + val moves = _moveHistory.value + val movePairs = moves.chunked(2) + + val moveText = + movePairs + .mapIndexed { index, pair -> + val moveNum = index + 1 + when (pair.size) { + 2 -> "$moveNum. ${pair[0]} ${pair[1]}" + 1 -> "$moveNum. ${pair[0]}" + else -> "" + } + }.joinToString(" ") + + val result = + when (val status = _gameStatus.value) { + is GameStatus.Finished -> status.result.notation + else -> "*" + } + + return """ + [Event "Live Chess Game"] + [Site "Nostr"] + [White "${if (playerColor == Color.WHITE) playerPubkey else opponentPubkey}"] + [Black "${if (playerColor == Color.BLACK) playerPubkey else opponentPubkey}"] + [Result "$result"] + + $moveText $result + """.trimIndent() + } + + /** + * Mark game as finished with the given result. + * Used when loading a game from cache that already has an end event. + */ + fun markAsFinished(result: GameResult) { + _gameStatus.value = GameStatus.Finished(result) + } + + /** + * Reset game to initial position + */ + fun reset() { + engine.reset() + _currentPosition.value = engine.getPosition() + _moveHistory.value = emptyList() + _gameStatus.value = GameStatus.InProgress + _lastError.value = null + } +} + +/** + * Game status + */ +sealed class GameStatus { + data object InProgress : GameStatus() + + data class Finished( + val result: GameResult, + ) : GameStatus() +} + +/** + * Extension to get result for a winning color + */ +private fun GameResult.Companion.getResultForWinner(winner: Color): GameResult = + when (winner) { + Color.WHITE -> GameResult.WHITE_WINS + Color.BLACK -> GameResult.BLACK_WINS + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParser.kt new file mode 100644 index 000000000..e9793b405 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParser.kt @@ -0,0 +1,325 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +/** + * Parse PGN (Portable Game Notation) format per NIP-64 specification + * + * Accepts "import format" (human-created, flexible) as per PGN spec + * Handles: + * - Tag pairs: [TagName "TagValue"] + * - Move text in Standard Algebraic Notation (SAN) + * - Comments: {...} + * - Variations: (...) + * - Result markers: 1-0, 0-1, 1/2-1/2, * + */ +object PGNParser { + /** + * Parse PGN content into ChessGame + * + * @param pgn PGN format string + * @return Result containing ChessGame or error + */ + fun parse(pgn: String): Result = + runCatching { + val lines = pgn.lines().map { it.trim() } + + // Extract metadata tags [Key "Value"] + val metadata = parseMetadata(lines) + + // Extract move text (everything after metadata) + val moveText = + lines + .dropWhile { it.startsWith("[") || it.isEmpty() } + .joinToString(" ") + + val (moves, result) = parseMoves(moveText) + + // Generate positions by replaying moves + val positions = generatePositions(moves) + + ChessGame( + metadata = metadata, + moves = moves, + positions = positions, + result = result, + ) + } + + /** + * Extract PGN tag pairs from lines + * Format: [TagName "TagValue"] + */ + private fun parseMetadata(lines: List): Map { + val tagRegex = """\[(\w+)\s+"([^"]*)"\]""".toRegex() + return lines + .mapNotNull { tagRegex.matchEntire(it) } + .associate { it.groupValues[1] to it.groupValues[2] } + } + + /** + * Parse move text into list of moves and game result + */ + private fun parseMoves(moveText: String): Pair, GameResult> { + // Remove comments {...} and variations (...) + val cleaned = + moveText + .replace(Regex("""\{[^}]*\}"""), "") // Remove comments + .replace(Regex("""\([^)]*\)"""), "") // Remove variations + .replace(Regex("""\$\d+"""), "") // Remove NAG annotations + .trim() + + // Extract result marker + val result = + when { + cleaned.contains("1-0") -> GameResult.WHITE_WINS + cleaned.contains("0-1") -> GameResult.BLACK_WINS + cleaned.contains("1/2-1/2") -> GameResult.DRAW + else -> GameResult.IN_PROGRESS + } + + // Remove result marker from move text + val movesOnly = + cleaned + .replace("1-0", "") + .replace("0-1", "") + .replace("1/2-1/2", "") + .replace("*", "") + .replace(Regex("""\s+"""), " ") // Normalize whitespace + .trim() + + // Parse move pairs: "1. e4 e5 2. Nf3 Nc6" + val moveRegex = """(\d+)\.\s*([^\s]+)(?:\s+([^\s]+))?""".toRegex() + val moves = mutableListOf() + + moveRegex.findAll(movesOnly).forEach { match -> + val moveNum = match.groupValues[1].toIntOrNull() ?: return@forEach + val whiteMove = match.groupValues[2] + val blackMove = match.groupValues.getOrNull(3)?.takeIf { it.isNotEmpty() } + + // Skip if this is just a result marker + if (!isResultMarker(whiteMove)) { + moves.add(parseMove(whiteMove, moveNum, Color.WHITE)) + } + + blackMove?.let { + if (!isResultMarker(it)) { + moves.add(parseMove(it, moveNum, Color.BLACK)) + } + } + } + + return moves to result + } + + /** + * Check if text is a game result marker + * Valid results: 1-0, 0-1, 1/2-1/2, * + */ + private fun isResultMarker(text: String): Boolean = text == "1-0" || text == "0-1" || text == "1/2-1/2" || text == "*" + + /** + * Parse a single move in Standard Algebraic Notation (SAN) + * + * Examples: + * - e4 (pawn move) + * - Nf3 (knight to f3) + * - Bxe5 (bishop captures on e5) + * - O-O (kingside castling) + * - O-O-O (queenside castling) + * - e8=Q (pawn promotion to queen) + * - Nbd2 (knight from b-file to d2) + * - R1a3 (rook from rank 1 to a3) + * - Qh4+ (queen to h4 with check) + * - Qh4# (queen to h4 with checkmate) + */ + private fun parseMove( + san: String, + moveNumber: Int, + color: Color, + ): ChessMove { + // Clean up annotations + var text = san.replace(Regex("[!?]+"), "").trim() + + val isCheck = text.contains("+") + val isCheckmate = text.contains("#") + val isCapture = text.contains("x") + + // Remove check/checkmate markers + text = text.replace("+", "").replace("#", "") + + // Castling + if (text == "O-O" || text == "0-0") { + return ChessMove( + san = san, + moveNumber = moveNumber, + color = color, + piece = PieceType.KING, + toSquare = if (color == Color.WHITE) "g1" else "g8", + isCheck = isCheck, + isCheckmate = isCheckmate, + isCastling = true, + ) + } + + if (text == "O-O-O" || text == "0-0-0") { + return ChessMove( + san = san, + moveNumber = moveNumber, + color = color, + piece = PieceType.KING, + toSquare = if (color == Color.WHITE) "c1" else "c8", + isCheck = isCheck, + isCheckmate = isCheckmate, + isCastling = true, + ) + } + + // Remove capture marker + text = text.replace("x", "") + + // Check for promotion (e.g., e8=Q) + val promotionRegex = """([a-h][18])=([QRBN])""".toRegex() + val promotionMatch = promotionRegex.find(text) + val promotion = promotionMatch?.groupValues?.get(2)?.let { PieceType.fromSymbol(it[0]) } + if (promotionMatch != null) { + text = text.replace(promotionRegex, promotionMatch.groupValues[1]) + } + + // Determine piece type (first char if uppercase, otherwise pawn) + val piece = + if (text.isNotEmpty() && text[0].isUpperCase()) { + PieceType.fromSymbol(text[0]) ?: PieceType.PAWN + } else { + PieceType.PAWN + } + + // Remove piece symbol if present + if (piece != PieceType.PAWN && text.isNotEmpty()) { + text = text.substring(1) + } + + // Extract destination square (last 2 chars should be file+rank) + val squareRegex = """([a-h][1-8])$""".toRegex() + val squareMatch = squareRegex.find(text) + val toSquare = squareMatch?.value ?: "" + + // Extract disambiguation (file or rank between piece and destination) + val fromSquare = text.replace(toSquare, "").takeIf { it.isNotEmpty() } + + return ChessMove( + san = san, + moveNumber = moveNumber, + color = color, + piece = piece, + fromSquare = fromSquare, + toSquare = toSquare, + isCapture = isCapture, + isCheck = isCheck, + isCheckmate = isCheckmate, + promotion = promotion, + ) + } + + /** + * Generate list of positions by replaying moves from starting position + * + * Note: This implementation uses simplified move application. + * Full legal move validation would require complete chess engine logic. + */ + private fun generatePositions(moves: List): List { + val positions = mutableListOf(ChessPosition.initial()) + var current = ChessPosition.initial() + + moves.forEach { move -> + try { + current = + when { + move.isCastling -> { + // Castling + val kingSide = move.toSquare.contains("g") + current.makeCastlingMove(kingSide) + } + + move.fromSquare != null -> { + // Move with disambiguation (we can infer full source) + // For now, just use simplified move + makeSimplifiedMove(current, move) + } + + else -> { + // Regular move + makeSimplifiedMove(current, move) + } + } + positions.add(current) + } catch (e: Exception) { + // If move application fails, keep previous position + positions.add(current) + } + } + + return positions + } + + /** + * Simplified move application without full legal move validation + * Finds piece that can move to destination and creates new position + */ + private fun makeSimplifiedMove( + position: ChessPosition, + move: ChessMove, + ): ChessPosition { + // For MVP: Try to find a piece of the right type that could move to destination + // This is simplified and doesn't validate full chess rules + val targetSquare = move.toSquare + + // Find all pieces of the moving color and type + val candidates = mutableListOf() + for (rank in 0..7) { + for (file in 0..7) { + val piece = position.pieceAt(file, rank) + if (piece != null && + piece.color == move.color && + piece.type == move.piece + ) { + val square = "${'a' + file}${rank + 1}" + candidates.add(square) + } + } + } + + // Use disambiguation if provided + val fromSquare = + if (move.fromSquare != null) { + candidates.firstOrNull { it.contains(move.fromSquare) } + } else { + candidates.firstOrNull() + } ?: "" + + return if (fromSquare.isNotEmpty()) { + position.makeMove(fromSquare, targetSquare, move.promotion) + } else { + // If we can't find source, return current position unchanged + position + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 4aae539b6..cbd2924ec 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -112,6 +112,13 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -185,6 +192,13 @@ class EventFactory { CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) + ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig) + JesterEvent.KIND -> JesterEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessGameChallengeEvent.KIND -> LiveChessGameChallengeEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessMoveEvent.KIND -> LiveChessMoveEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessGameEndEvent.KIND -> LiveChessGameEndEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessDrawOfferEvent.KIND -> LiveChessDrawOfferEvent(id, pubKey, createdAt, tags, content, sig) ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEventTest.kt new file mode 100644 index 000000000..e470989d5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEventTest.kt @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Tests for ChessGameEvent (NIP-64 Kind 64 event) + * + * Verifies: + * - Event kind is 64 + * - PGN content storage + * - Alt text support (NIP-31) + * - Event structure compliance + */ +class ChessGameEventTest { + private val samplePGN = + """ + [Event "Test Game"] + [Site "Internet"] + [Date "2024.12.28"] + [Round "1"] + [White "Alice"] + [Black "Bob"] + [Result "1-0"] + + 1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0 + """.trimIndent() + + @Test + fun `verify event kind is 64`() { + assertEquals(64, ChessGameEvent.KIND, "Chess game event should be kind 64") + } + + @Test + fun `pgn content is accessible`() { + // Create a mock event manually for testing + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Chess Game")), + content = samplePGN, + sig = "test_sig", + ) + + assertEquals(samplePGN, testEvent.pgn(), "PGN content should be accessible via pgn()") + assertEquals(samplePGN, testEvent.content, "PGN should be in content field") + } + + @Test + fun `alt text is accessible when present`() { + val customAltText = "Scholar's Mate Example" + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", customAltText)), + content = samplePGN, + sig = "test_sig", + ) + + assertEquals(customAltText, testEvent.altText(), "Alt text should be extractable from tags") + } + + @Test + fun `alt text returns null when not present`() { + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = emptyArray(), + content = samplePGN, + sig = "test_sig", + ) + + assertEquals(null, testEvent.altText(), "Should return null when no alt tag present") + } + + @Test + fun `event can store complete game with metadata`() { + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Chess Game")), + content = samplePGN, + sig = "test_sig", + ) + + // Parse the PGN to verify it's valid + val gameResult = PGNParser.parse(testEvent.pgn()) + assertTrue(gameResult.isSuccess, "Event should contain valid PGN") + + val game = gameResult.getOrThrow() + assertEquals("Test Game", game.event) + assertEquals("Alice", game.white) + assertEquals("Bob", game.black) + assertEquals(GameResult.WHITE_WINS, game.result) + } + + @Test + fun `event can store minimal PGN`() { + val minimalPGN = "1. e4 *" + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Chess Game")), + content = minimalPGN, + sig = "test_sig", + ) + + val gameResult = PGNParser.parse(testEvent.pgn()) + assertTrue(gameResult.isSuccess, "Should handle minimal PGN") + + val game = gameResult.getOrThrow() + assertEquals(1, game.moves.size) + assertEquals(GameResult.IN_PROGRESS, game.result) + } + + @Test + fun `event can store game in progress`() { + val inProgressPGN = + """ + [Event "Live Game"] + [Result "*"] + + 1. e4 e5 2. Nf3 Nc6 3. Bb5 * + """.trimIndent() + + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Live Chess Game")), + content = inProgressPGN, + sig = "test_sig", + ) + + val gameResult = PGNParser.parse(testEvent.pgn()) + assertTrue(gameResult.isSuccess) + + val game = gameResult.getOrThrow() + assertEquals(GameResult.IN_PROGRESS, game.result) + assertEquals("*", game.metadata["Result"]) + } + + @Test + fun `event preserves PGN formatting`() { + val formattedPGN = + """ + [Event "Formatted Game"] + [White "Player 1"] + [Black "Player 2"] + + 1. e4 e5 + 2. Nf3 Nc6 + 3. Bb5 a6 + * + """.trimIndent() + + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Chess Game")), + content = formattedPGN, + sig = "test_sig", + ) + + // Content should be preserved exactly as provided + assertEquals(formattedPGN, testEvent.pgn()) + } + + @Test + fun `default alt text is Chess Game`() { + assertEquals("Chess Game", ChessGameEvent.ALT_DESCRIPTION) + } + + @Test + fun `event inherits from Event base class`() { + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = emptyArray(), + content = "1. e4 *", + sig = "test_sig", + ) + + // Verify base Event properties + assertEquals("test_id", testEvent.id) + assertEquals("test_pubkey", testEvent.pubKey) + assertEquals(1000L, testEvent.createdAt) + assertEquals(64, testEvent.kind) + assertEquals("test_sig", testEvent.sig) + } + + @Test + fun `event can contain long tournament game`() { + val longPGN = + """ + [Event "Tournament Game"] + [Site "Online"] + [Date "2024.12.28"] + [Round "5"] + [White "GM Player"] + [Black "IM Player"] + [Result "1/2-1/2"] + + 1. d4 Nf6 2. c4 g6 3. Nc3 Bg7 4. e4 d6 5. Nf3 O-O 6. Be2 e5 + 7. O-O Nc6 8. d5 Ne7 9. Ne1 Nd7 10. Nd3 f5 11. Bd2 Nf6 12. f3 f4 + 13. Rc1 g5 14. Nb5 Ng6 15. c5 Rf7 16. Qa4 h5 17. Rfe1 Bf8 + 18. cxd6 cxd6 19. Rc6 Bd7 20. Rec1 Bxc6 21. Rxc6 Qd7 22. Rc1 Rc8 + 23. Rxc8 Qxc8 24. Qa6 Qc2 25. Qxb7 Qxb2 26. Qxa7 Qxa2 27. Qb7 Qa1+ + 28. Kf2 Qb2 29. Qa7 Ra7 30. Qa4 Qa2 31. Qa8 Qb2 32. Qa4 Qa2 1/2-1/2 + """.trimIndent() + + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Long Tournament Game")), + content = longPGN, + sig = "test_sig", + ) + + val gameResult = PGNParser.parse(testEvent.pgn()) + assertTrue(gameResult.isSuccess) + + val game = gameResult.getOrThrow() + assertTrue(game.moves.size > 50, "Should handle long games") + assertEquals(GameResult.DRAW, game.result) + } + + @Test + fun `verify tags array structure`() { + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = + arrayOf( + arrayOf("alt", "Test Alt Text"), + arrayOf("t", "chess"), + arrayOf("t", "game"), + ), + content = "1. e4 *", + sig = "test_sig", + ) + + // Verify tags structure + assertTrue(testEvent.tags.size >= 1) + assertEquals("alt", testEvent.tags[0][0]) + assertEquals("Test Alt Text", testEvent.tags[0][1]) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEventTest.kt new file mode 100644 index 000000000..1c96c2e92 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEventTest.kt @@ -0,0 +1,573 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for JesterEvent (Jester Protocol kind 30 events) + * + * Verifies: + * - Event kind is 30 + * - JSON content parsing (version, kind, fen, move, history) + * - e-tag structure for event linking + * - p-tag for opponent tagging + * - Start vs Move event detection + * - Compatibility with jesterui protocol + * + * Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md + */ +class JesterEventTest { + private val testPubkey = "abc123def456" + private val opponentPubkey = "opponent789xyz" + private val startEventId = "start-event-id-001" + + // ========================================================================== + // PROTOCOL CONSTANTS + // ========================================================================== + + @Test + fun `verify event kind is 30`() { + assertEquals(30, JesterProtocol.KIND, "Jester protocol uses kind 30") + assertEquals(30, JesterEvent.KIND, "JesterEvent.KIND should be 30") + } + + @Test + fun `verify start position hash constant`() { + assertEquals( + "b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879", + JesterProtocol.START_POSITION_HASH, + "Should match jesterui's START_POSITION_HASH", + ) + } + + @Test + fun `verify starting FEN constant`() { + assertEquals( + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + JesterProtocol.FEN_START, + "Standard chess starting position", + ) + } + + @Test + fun `verify content kind constants`() { + assertEquals(0, JesterProtocol.CONTENT_KIND_START, "Start event kind should be 0") + assertEquals(1, JesterProtocol.CONTENT_KIND_MOVE, "Move event kind should be 1") + assertEquals(2, JesterProtocol.CONTENT_KIND_CHAT, "Chat event kind should be 2") + } + + // ========================================================================== + // START EVENT TESTS + // ========================================================================== + + @Test + fun `parse start event - open challenge`() { + val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"abc12345","playerColor":"white"}""" + val event = + JesterEvent( + id = startEventId, + pubKey = testPubkey, + createdAt = 1000L, + tags = + arrayOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + ), + content = content, + sig = "test_sig", + ) + + assertEquals(30, event.kind) + assertTrue(event.isStartEvent(), "Should be detected as start event") + assertFalse(event.isMoveEvent(), "Should not be a move event") + assertEquals(0, event.contentKind()) + assertEquals(JesterProtocol.FEN_START, event.fen()) + assertEquals(Color.WHITE, event.playerColor()) + assertEquals("abc12345", event.nonce()) + assertTrue(event.history().isEmpty(), "Start event has no history") + assertNull(event.opponentPubkey(), "Open challenge has no opponent") + } + + @Test + fun `parse start event - private challenge`() { + val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"xyz98765","playerColor":"black"}""" + val event = + JesterEvent( + id = startEventId, + pubKey = testPubkey, + createdAt = 1000L, + tags = + arrayOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertTrue(event.isStartEvent()) + assertEquals(Color.BLACK, event.playerColor()) + assertEquals(opponentPubkey, event.opponentPubkey(), "Private challenge should have opponent") + } + + @Test + fun `start event e-tag references START_POSITION_HASH`() { + val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[]}""" + val event = + JesterEvent( + id = startEventId, + pubKey = testPubkey, + createdAt = 1000L, + tags = + arrayOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + ), + content = content, + sig = "test_sig", + ) + + val eTags = event.eTags() + assertEquals(1, eTags.size) + assertEquals(JesterProtocol.START_POSITION_HASH, eTags[0], "Start event should reference START_POSITION_HASH") + } + + // ========================================================================== + // MOVE EVENT TESTS + // ========================================================================== + + @Test + fun `parse move event - first move e4`() { + val content = """{"version":"0","kind":1,"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1","move":"e4","history":["e4"]}""" + val event = + JesterEvent( + id = "move-001", + pubKey = testPubkey, + createdAt = 2000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", startEventId), // For first move, head is also start + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertFalse(event.isStartEvent(), "Should not be a start event") + assertTrue(event.isMoveEvent(), "Should be detected as move event") + assertEquals(1, event.contentKind()) + assertEquals("e4", event.move()) + assertEquals(listOf("e4"), event.history()) + assertEquals("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", event.fen()) + assertEquals(opponentPubkey, event.opponentPubkey()) + } + + @Test + fun `parse move event - multiple moves in history`() { + val content = """{"version":"0","kind":1,"fen":"rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2","move":"Nf3","history":["e4","e5","Nf3"]}""" + val event = + JesterEvent( + id = "move-003", + pubKey = testPubkey, + createdAt = 4000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "move-002"), // Previous move + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertTrue(event.isMoveEvent()) + assertEquals("Nf3", event.move()) + assertEquals(listOf("e4", "e5", "Nf3"), event.history()) + assertEquals(3, event.history().size) + } + + @Test + fun `move event e-tags structure - startEventId and headEventId`() { + val content = """{"version":"0","kind":1,"fen":"test","move":"e4","history":["e4"]}""" + val headEventId = "move-002" + val event = + JesterEvent( + id = "move-003", + pubKey = testPubkey, + createdAt = 3000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", headEventId), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertEquals(startEventId, event.startEventId(), "First e-tag should be startEventId") + assertEquals(headEventId, event.headEventId(), "Second e-tag should be headEventId") + + val eTags = event.eTags() + assertEquals(2, eTags.size) + assertEquals(startEventId, eTags[0]) + assertEquals(headEventId, eTags[1]) + } + + // ========================================================================== + // GAME END EVENT TESTS + // ========================================================================== + + @Test + fun `parse game end event - checkmate`() { + val content = """{"version":"0","kind":1,"fen":"r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4","move":"Qxf7#","history":["e4","e5","Qh5","Nc6","Bc4","Nf6","Qxf7#"],"result":"1-0","termination":"checkmate"}""" + val event = + JesterEvent( + id = "move-007", + pubKey = testPubkey, + createdAt = 8000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "move-006"), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertTrue(event.isMoveEvent(), "End event is still a move event") + assertEquals("1-0", event.result(), "Should have result") + assertEquals("checkmate", event.termination(), "Should have termination reason") + assertEquals(7, event.history().size) + } + + @Test + fun `parse game end event - resignation`() { + val content = """{"version":"0","kind":1,"fen":"test","move":"e5","history":["e4","e5"],"result":"0-1","termination":"resignation"}""" + val event = + JesterEvent( + id = "move-002", + pubKey = opponentPubkey, + createdAt = 3000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "move-001"), + arrayOf("p", testPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertEquals("0-1", event.result(), "Black wins") + assertEquals("resignation", event.termination()) + } + + @Test + fun `parse game end event - draw`() { + val content = """{"version":"0","kind":1,"fen":"test","move":"Kf1","history":["e4","e5","Kf1"],"result":"1/2-1/2","termination":"draw_agreement"}""" + val event = + JesterEvent( + id = "move-003", + pubKey = testPubkey, + createdAt = 4000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "move-002"), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertEquals("1/2-1/2", event.result(), "Draw") + assertEquals("draw_agreement", event.termination()) + } + + // ========================================================================== + // EDGE CASES AND ERROR HANDLING + // ========================================================================== + + @Test + fun `handle malformed JSON content gracefully`() { + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = "invalid json {{{}", + sig = "test_sig", + ) + + assertNull(event.contentKind(), "Should return null for invalid JSON") + assertFalse(event.isStartEvent(), "Should not crash on invalid content") + assertFalse(event.isMoveEvent(), "Should not crash on invalid content") + assertNull(event.fen()) + assertNull(event.move()) + assertTrue(event.history().isEmpty()) + } + + @Test + fun `handle empty content`() { + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = "", + sig = "test_sig", + ) + + assertNull(event.contentKind()) + assertFalse(event.isStartEvent()) + assertFalse(event.isMoveEvent()) + } + + @Test + fun `handle missing optional fields`() { + // Minimal valid content with only required fields + val content = """{"kind":1}""" + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = content, + sig = "test_sig", + ) + + assertEquals(1, event.contentKind()) + assertNull(event.move()) + assertTrue(event.history().isEmpty()) + assertNull(event.result()) + assertNull(event.termination()) + assertNull(event.playerColor()) + } + + @Test + fun `handle event with no e-tags`() { + val content = """{"version":"0","kind":0}""" + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = content, + sig = "test_sig", + ) + + assertNull(event.startEventId(), "No e-tags means no startEventId") + assertNull(event.headEventId(), "No e-tags means no headEventId") + assertTrue(event.eTags().isEmpty()) + } + + @Test + fun `handle event with only one e-tag`() { + val content = """{"version":"0","kind":1}""" + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = + arrayOf( + arrayOf("e", startEventId), + ), + content = content, + sig = "test_sig", + ) + + assertEquals(startEventId, event.startEventId()) + assertNull(event.headEventId(), "Only one e-tag means no headEventId") + } + + // ========================================================================== + // JESTER GAME EVENTS CONTAINER TESTS + // ========================================================================== + + @Test + fun `JesterGameEvents - empty returns correct values`() { + val events = JesterGameEvents.empty() + + assertNull(events.startEvent) + assertTrue(events.moves.isEmpty()) + assertNull(events.latestMove()) + assertEquals(JesterProtocol.FEN_START, events.currentFen()) + assertTrue(events.fullHistory().isEmpty()) + assertFalse(events.isEnded()) + assertNull(events.result()) + } + + @Test + fun `JesterGameEvents - latestMove returns move with longest history`() { + val move1 = createTestMoveEvent("move-001", listOf("e4")) + val move2 = createTestMoveEvent("move-002", listOf("e4", "e5")) + val move3 = createTestMoveEvent("move-003", listOf("e4", "e5", "Nf3")) + + // Provide moves in random order + val events = + JesterGameEvents( + startEvent = null, + moves = listOf(move2, move3, move1), + ) + + val latest = events.latestMove() + assertNotNull(latest) + assertEquals("move-003", latest.id, "Should return move with longest history") + assertEquals(3, latest.history().size) + } + + @Test + fun `JesterGameEvents - currentFen returns FEN from latest move`() { + val move1 = createTestMoveEvent("move-001", listOf("e4"), fen = "fen-after-e4") + val move2 = createTestMoveEvent("move-002", listOf("e4", "e5"), fen = "fen-after-e5") + + val events = + JesterGameEvents( + startEvent = null, + moves = listOf(move1, move2), + ) + + assertEquals("fen-after-e5", events.currentFen()) + } + + @Test + fun `JesterGameEvents - fullHistory returns history from latest move`() { + val move1 = createTestMoveEvent("move-001", listOf("e4")) + val move2 = createTestMoveEvent("move-002", listOf("e4", "e5")) + + val events = + JesterGameEvents( + startEvent = null, + moves = listOf(move1, move2), + ) + + assertEquals(listOf("e4", "e5"), events.fullHistory()) + } + + @Test + fun `JesterGameEvents - isEnded detects result in latest move`() { + val normalMove = createTestMoveEvent("move-001", listOf("e4")) + val endMove = createTestMoveEventWithResult("move-002", listOf("e4", "Qxf7#"), result = "1-0") + + val ongoingGame = JesterGameEvents(startEvent = null, moves = listOf(normalMove)) + val finishedGame = JesterGameEvents(startEvent = null, moves = listOf(normalMove, endMove)) + + assertFalse(ongoingGame.isEnded()) + assertTrue(finishedGame.isEnded()) + assertEquals("1-0", finishedGame.result()) + } + + // ========================================================================== + // EXTENSION FUNCTION TESTS + // ========================================================================== + + @Test + fun `isJesterEvent extension detects kind 30`() { + val jesterEvent = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = "{}", + sig = "sig", + ) + + assertTrue(jesterEvent.isJesterEvent()) + } + + @Test + fun `toJesterEvent converts valid Event`() { + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = """{"kind":0}""", + sig = "sig", + ) + + val jesterEvent = event.toJesterEvent() + assertNotNull(jesterEvent) + assertEquals("test", jesterEvent.id) + } + + // ========================================================================== + // HELPER FUNCTIONS + // ========================================================================== + + private fun createTestMoveEvent( + id: String, + history: List, + fen: String = "test-fen", + ): JesterEvent { + val historyJson = history.joinToString(",") { "\"$it\"" } + val content = """{"version":"0","kind":1,"fen":"$fen","move":"${history.last()}","history":[$historyJson]}""" + return JesterEvent( + id = id, + pubKey = testPubkey, + createdAt = 1000L + history.size * 1000, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "prev-move"), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "sig", + ) + } + + private fun createTestMoveEventWithResult( + id: String, + history: List, + result: String, + ): JesterEvent { + val historyJson = history.joinToString(",") { "\"$it\"" } + val content = """{"version":"0","kind":1,"fen":"test-fen","move":"${history.last()}","history":[$historyJson],"result":"$result","termination":"checkmate"}""" + return JesterEvent( + id = id, + pubKey = testPubkey, + createdAt = 1000L + history.size * 1000, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "prev-move"), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "sig", + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParserTest.kt new file mode 100644 index 000000000..fb1d55ded --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParserTest.kt @@ -0,0 +1,454 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Comprehensive tests for PGN parsing per NIP-64 specification + * + * Tests cover: + * - PGN metadata extraction + * - Move parsing in Standard Algebraic Notation + * - Game result parsing + * - Comments and variations handling + * - Edge cases and error handling + */ +class PGNParserTest { + // Test 1: Parse minimal PGN (NIP-64 requirement: accept import format) + @Test + fun `parse minimal PGN with single move`() { + val pgn = "1. e4 *" + val result = PGNParser.parse(pgn) + + assertTrue(result.isSuccess, "Should successfully parse minimal PGN") + val game = result.getOrThrow() + assertEquals(1, game.moves.size, "Should have 1 move") + assertEquals("e4", game.moves[0].san) + assertEquals(GameResult.IN_PROGRESS, game.result) + } + + // Test 2: Parse complete game with metadata (NIP-64 requirement) + @Test + fun `parse PGN with full metadata tags`() { + val pgn = + """ + [Event "F/S Return Match"] + [Site "Belgrade, Serbia JUG"] + [Date "1992.11.04"] + [Round "29"] + [White "Fischer, Robert J."] + [Black "Spassky, Boris V."] + [Result "1/2-1/2"] + + 1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 1/2-1/2 + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Verify metadata extraction + assertEquals("F/S Return Match", game.event) + assertEquals("Belgrade, Serbia JUG", game.site) + assertEquals("1992.11.04", game.date) + assertEquals("29", game.round) + assertEquals("Fischer, Robert J.", game.white) + assertEquals("Spassky, Boris V.", game.black) + assertEquals(GameResult.DRAW, game.result) + + // Verify moves + assertEquals(6, game.moves.size) + assertEquals("e4", game.moves[0].san) + assertEquals("Nf3", game.moves[2].san) + } + + // Test 3: Scholar's Mate (4 move checkmate) + @Test + fun `parse scholars mate with checkmate notation`() { + val pgn = + """ + [Event "Scholar's Mate"] + [White "Alice"] + [Black "Bob"] + [Result "1-0"] + + 1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6 4. Qxf7# 1-0 + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals(7, game.moves.size) + assertEquals(GameResult.WHITE_WINS, game.result) + + // Verify last move has checkmate marker + val lastMove = game.moves.last() + assertEquals("Qxf7#", lastMove.san) + assertTrue(lastMove.isCheckmate, "Last move should be checkmate") + assertTrue(lastMove.isCapture, "Last move should be capture") + } + + // Test 4: Fool's Mate (2 move checkmate) + @Test + fun `parse fools mate shortest checkmate`() { + val pgn = + """ + 1. f3 e5 2. g4 Qh4# 0-1 + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals(4, game.moves.size) + assertEquals(GameResult.BLACK_WINS, game.result) + + val lastMove = game.moves.last() + assertEquals("Qh4#", lastMove.san) + assertTrue(lastMove.isCheckmate) + } + + // Test 5: Castling notation + @Test + fun `parse castling moves kingside and queenside`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O O-O 5. d3 d6 6. c3 a6 7. a4 a5 8. O-O-O *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Find castling moves + val kingsideCastling = game.moves.filter { it.san == "O-O" } + val queensideCastling = game.moves.filter { it.san == "O-O-O" } + + assertEquals(2, kingsideCastling.size, "Should have 2 kingside castling moves") + assertEquals(1, queensideCastling.size, "Should have 1 queenside castling move") + + kingsideCastling.forEach { move -> + assertTrue(move.isCastling, "O-O should be marked as castling") + assertEquals(PieceType.KING, move.piece) + } + + queensideCastling.forEach { move -> + assertTrue(move.isCastling, "O-O-O should be marked as castling") + assertEquals(PieceType.KING, move.piece) + } + } + + // Test 6: Pawn promotion + @Test + fun `parse pawn promotion to queen`() { + val pgn = + """ + [Event "Promotion Example"] + + 1. e4 d5 2. exd5 Qxd5 3. Nc3 Qa5 4. d4 c6 5. Nf3 Bg4 6. Bf4 e6 + 7. h3 Bxf3 8. Qxf3 Bb4 9. Be2 Nd7 10. a3 O-O-O 11. axb4 Qxa1+ + 12. Kd2 Qxh1 13. Qxh1 a6 14. c4 f6 15. b5 axb5 16. cxb5 c5 + 17. b6 Ne7 18. Qh2 h6 19. dxc5 Nxc5 20. b3 Kc8 21. Qg3 Ncd7 + 22. Bd6 Nf5 23. Qf4 Nxd6 24. Qxd6 Nb8 25. Kc3 Rh7 26. Kb4 Rd7 + 27. Qc5+ Kd8 28. Bf3 Ke8 29. Bd5 exd5 30. Nxd5 Kf7 31. b7 Kg6 + 32. b8=Q * + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Find promotion move + val promotionMove = game.moves.firstOrNull { it.promotion != null } + assertNotNull(promotionMove, "Should have promotion move") + assertEquals(PieceType.QUEEN, promotionMove.promotion) + assertTrue(promotionMove.san.contains("=Q"), "Promotion move should contain =Q") + } + + // Test 7: Captures + @Test + fun `parse capture notation`() { + val pgn = "1. e4 d5 2. exd5 Qxd5 3. Nc3 Qxd4 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + val captures = game.moves.filter { it.isCapture } + assertEquals(3, captures.size, "Should have 3 capture moves") + + captures.forEach { move -> + assertTrue(move.san.contains("x"), "Capture moves should contain 'x'") + } + } + + // Test 8: Check notation + @Test + fun `parse check and checkmate markers`() { + val pgn = "1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + val checkMoves = game.moves.filter { it.isCheck } + val checkmateMoves = game.moves.filter { it.isCheckmate } + + assertTrue(checkmateMoves.isNotEmpty(), "Should have checkmate moves") + checkmateMoves.forEach { move -> + assertTrue(move.san.contains("#"), "Checkmate moves should contain #") + } + } + + // Test 9: Comments and variations (NIP-64: should handle PGN comments) + @Test + fun `parse PGN with comments and variations stripped`() { + val pgn = + """ + 1. e4 {Best by test} e5 (1...c5 2. Nf3) 2. Nf3 Nc6 {Developing} 3. Bb5 * + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Comments and variations should be stripped + assertEquals(5, game.moves.size) + assertEquals("e4", game.moves[0].san) + assertEquals("e5", game.moves[1].san) + assertEquals("Nf3", game.moves[2].san) + } + + // Test 10: NAG annotations (Numeric Annotation Glyphs) + @Test + fun `parse PGN with NAG annotations`() { + val pgn = "1. e4$1 e5$6 2. Nf3$10 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals(3, game.moves.size) + } + + // Test 11: Disambiguating moves + @Test + fun `parse moves with disambiguation`() { + val pgn = + """ + 1. Nf3 Nf6 2. Nc3 Nc6 3. d4 d5 4. Bf4 Bf5 5. e3 e6 + 6. Nbd2 Nbd7 7. Bd3 Bd6 * + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Check for disambiguated moves (Nbd2, Nbd7) + val disambiguatedMoves = game.moves.filter { it.fromSquare != null } + assertTrue(disambiguatedMoves.isNotEmpty(), "Should have disambiguated moves") + } + + // Test 12: All possible game results + @Test + fun `parse all game result notations`() { + val whiteWins = "1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0" + val blackWins = "1. f3 e5 2. g4 Qh4# 0-1" + val draw = "1. e4 e5 2. Nf3 Nc6 1/2-1/2" + val inProgress = "1. e4 e5 *" + + assertEquals(GameResult.WHITE_WINS, PGNParser.parse(whiteWins).getOrThrow().result) + assertEquals(GameResult.BLACK_WINS, PGNParser.parse(blackWins).getOrThrow().result) + assertEquals(GameResult.DRAW, PGNParser.parse(draw).getOrThrow().result) + assertEquals(GameResult.IN_PROGRESS, PGNParser.parse(inProgress).getOrThrow().result) + } + + // Test 13: Empty/invalid PGN handling + @Test + fun `handle empty PGN gracefully`() { + val emptyPgn = "" + val result = PGNParser.parse(emptyPgn) + + // Should not crash, might return empty game + assertTrue(result.isSuccess || result.isFailure) + } + + // Test 14: Position generation + @Test + fun `generate positions for each move`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Should have starting position + one per move + assertEquals(game.moves.size + 1, game.positions.size) + + // First position should be starting position + val startPos = game.positions[0] + assertEquals(Color.WHITE, startPos.activeColor) + assertEquals(1, startPos.moveNumber) + } + + // Test 15: Verify required metadata (NIP-64: PGN should have standard tags) + @Test + fun `detect presence of required PGN metadata tags`() { + val fullPgn = + """ + [Event "FIDE World Championship"] + [Site "London"] + [Date "2018.11.28"] + [Round "12"] + [White "Carlsen, Magnus"] + [Black "Caruana, Fabiano"] + [Result "1-0"] + + 1. e4 * + """.trimIndent() + + val minimalPgn = "1. e4 *" + + val fullGame = PGNParser.parse(fullPgn).getOrThrow() + val minimalGame = PGNParser.parse(minimalPgn).getOrThrow() + + assertTrue(fullGame.hasRequiredMetadata(), "Full PGN should have required metadata") + assertFalse(minimalGame.hasRequiredMetadata(), "Minimal PGN should not have required metadata") + } + + // Test 16: Long tournament game + @Test + fun `parse realistic tournament game`() { + val pgn = + """ + [Event "Wch"] + [Site "New York"] + [Date "1886.??.??"] + [Round "1"] + [White "Zukertort, Johannes"] + [Black "Steinitz, William"] + [Result "0-1"] + + 1. d4 d5 2. c4 e6 3. Nc3 Nf6 4. e3 c5 5. Nf3 Nc6 6. a3 dxc4 + 7. Bxc4 cxd4 8. exd4 Be7 9. O-O O-O 10. Qd3 Bd7 11. Qe2 Qb8 + 12. Rd1 Rd8 13. Be3 Be8 14. Ne5 Nxe5 15. dxe5 Rxd1+ 16. Rxd1 Nd7 + 17. f4 Nc5 18. Qf2 Rc8 19. b4 Na6 20. Bd3 Nb8 21. Ne4 Nc6 + 22. Nd6 Bxd6 23. exd6 Qxd6 24. Bxh7+ Kh8 25. Bf5 Qc7 26. Bxc8 Qxc8 + 27. Qd2 Bg6 28. Qd7 Qxd7 29. Rxd7 b6 30. Bc1 Nd8 31. Rxd8+ 0-1 + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals("Zukertort, Johannes", game.white) + assertEquals("Steinitz, William", game.black) + assertEquals(GameResult.BLACK_WINS, game.result) + assertTrue(game.moves.size > 50, "Tournament game should have many moves") + } + + // Test 17: Alternative castling notation (0-0 instead of O-O) + @Test + fun `parse alternative castling notation with zeros`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. 0-0 0-0 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + val castlingMoves = game.moves.filter { it.isCastling } + assertEquals(2, castlingMoves.size) + } + + // Test 18: Move count verification + @Test + fun `verify move numbers are correct`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals(6, game.moves.size) + + // Verify move numbers + assertEquals(1, game.moves[0].moveNumber) // e4 + assertEquals(1, game.moves[1].moveNumber) // e5 + assertEquals(2, game.moves[2].moveNumber) // Nf3 + assertEquals(2, game.moves[3].moveNumber) // Nc6 + assertEquals(3, game.moves[4].moveNumber) // Bb5 + assertEquals(3, game.moves[5].moveNumber) // a6 + } + + // Test 19: Move colors are correct + @Test + fun `verify move colors alternate correctly`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals(Color.WHITE, game.moves[0].color) + assertEquals(Color.BLACK, game.moves[1].color) + assertEquals(Color.WHITE, game.moves[2].color) + assertEquals(Color.BLACK, game.moves[3].color) + assertEquals(Color.WHITE, game.moves[4].color) + assertEquals(Color.BLACK, game.moves[5].color) + } + + // Test 20: Piece type detection + @Test + fun `detect piece types from SAN notation`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 Bc5 4. Qa4 Qf6 5. Ke2 Ke7 6. Ra3 Ra6 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // e4, e5 - pawns + assertEquals(PieceType.PAWN, game.moves[0].piece) + assertEquals(PieceType.PAWN, game.moves[1].piece) + + // Nf3, Nc6 - knights + assertEquals(PieceType.KNIGHT, game.moves[2].piece) + assertEquals(PieceType.KNIGHT, game.moves[3].piece) + + // Bb5, Bc5 - bishops + assertEquals(PieceType.BISHOP, game.moves[4].piece) + assertEquals(PieceType.BISHOP, game.moves[5].piece) + + // Qa4, Qf6 - queens + assertEquals(PieceType.QUEEN, game.moves[6].piece) + assertEquals(PieceType.QUEEN, game.moves[7].piece) + + // Ke2, Ke7 - kings + assertEquals(PieceType.KING, game.moves[8].piece) + assertEquals(PieceType.KING, game.moves[9].piece) + + // Ra3, Ra6 - rooks + assertEquals(PieceType.ROOK, game.moves[10].piece) + assertEquals(PieceType.ROOK, game.moves[11].piece) + } +} 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 new file mode 100644 index 000000000..42f5dd891 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt @@ -0,0 +1,349 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import com.github.bhlangonijr.chesslib.Board +import com.github.bhlangonijr.chesslib.Piece +import com.github.bhlangonijr.chesslib.Side +import com.github.bhlangonijr.chesslib.Square +import com.github.bhlangonijr.chesslib.move.Move + +/** + * JVM/Android implementation of ChessEngine using kchesslib. + * + * Maintains its own SAN history because chesslib's Move.toString() returns + * UCI/coordinate notation (e.g. "e2e4") rather than proper SAN (e.g. "e4"). + */ +actual class ChessEngine { + private val board = Board() + private val sanHistory = mutableListOf() + + actual fun getFen(): String = board.fen + + actual fun loadFen(fen: String) { + board.loadFromFen(fen) + sanHistory.clear() + } + + actual fun reset() { + board.loadFromFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") + sanHistory.clear() + } + + actual fun makeMove(san: String): MoveResult = + try { + if (board.doMove(san)) { + // Input is already SAN from Nostr events, store directly + sanHistory.add(san) + MoveResult( + success = true, + san = san, + position = boardToPosition(), + ) + } else { + MoveResult( + success = false, + error = "Invalid move: $san", + ) + } + } catch (e: Exception) { + MoveResult( + success = false, + error = e.message ?: "Unknown error making move $san", + ) + } + + actual fun makeMove( + from: String, + to: String, + promotion: PieceType?, + ): MoveResult = + try { + val fromSquare = Square.fromValue(from.uppercase()) + val toSquare = Square.fromValue(to.uppercase()) + + val promotionPiece = + when (promotion) { + PieceType.QUEEN -> if (board.sideToMove == Side.WHITE) Piece.WHITE_QUEEN else Piece.BLACK_QUEEN + PieceType.ROOK -> if (board.sideToMove == Side.WHITE) Piece.WHITE_ROOK else Piece.BLACK_ROOK + PieceType.BISHOP -> if (board.sideToMove == Side.WHITE) Piece.WHITE_BISHOP else Piece.BLACK_BISHOP + PieceType.KNIGHT -> if (board.sideToMove == Side.WHITE) Piece.WHITE_KNIGHT else Piece.BLACK_KNIGHT + else -> Piece.NONE + } + + val move = Move(fromSquare, toSquare, promotionPiece) + + if (board.legalMoves().contains(move)) { + val san = computeSan(move) + board.doMove(move) + sanHistory.add(san) + MoveResult( + success = true, + san = san, + position = boardToPosition(), + ) + } else { + MoveResult( + success = false, + error = "Illegal move: $from to $to", + ) + } + } catch (e: Exception) { + MoveResult( + success = false, + error = e.message ?: "Unknown error making move $from to $to", + ) + } + + actual fun getLegalMoves(): List = board.legalMoves().map { it.toString() } + + actual fun getLegalMovesFrom(square: String): List { + val sq = Square.fromValue(square.uppercase()) + return board + .legalMoves() + .filterNotNull() + .filter { it.from == sq } + .map { it.to.toString().lowercase() } + } + + actual fun isLegalMove(san: String): Boolean = + try { + if (board.doMove(san)) { + board.undoMove() + true + } else { + false + } + } catch (e: Exception) { + false + } + + actual fun isLegalMove( + from: String, + to: String, + promotion: PieceType?, + ): Boolean = + try { + val fromSquare = Square.fromValue(from.uppercase()) + val toSquare = Square.fromValue(to.uppercase()) + + val promotionPiece = + when (promotion) { + PieceType.QUEEN -> if (board.sideToMove == Side.WHITE) Piece.WHITE_QUEEN else Piece.BLACK_QUEEN + PieceType.ROOK -> if (board.sideToMove == Side.WHITE) Piece.WHITE_ROOK else Piece.BLACK_ROOK + PieceType.BISHOP -> if (board.sideToMove == Side.WHITE) Piece.WHITE_BISHOP else Piece.BLACK_BISHOP + PieceType.KNIGHT -> if (board.sideToMove == Side.WHITE) Piece.WHITE_KNIGHT else Piece.BLACK_KNIGHT + else -> Piece.NONE + } + + val move = Move(fromSquare, toSquare, promotionPiece) + board.legalMoves().contains(move) + } catch (e: Exception) { + false + } + + actual fun isCheckmate(): Boolean = board.isMated + + actual fun isStalemate(): Boolean = board.isStaleMate + + actual fun isInCheck(): Boolean = board.isKingAttacked + + actual fun undoMove() { + board.undoMove() + if (sanHistory.isNotEmpty()) { + sanHistory.removeAt(sanHistory.lastIndex) + } + } + + actual fun getPosition(): ChessPosition = boardToPosition() + + actual fun getSideToMove(): Color = + when (board.sideToMove) { + Side.WHITE -> Color.WHITE + Side.BLACK -> Color.BLACK + } + + actual fun getMoveHistory(): List = sanHistory.toList() + + /** + * Compute proper SAN notation for a move BEFORE it is applied to the board. + * Handles pieces, pawns, castling, captures, promotion, disambiguation, check/checkmate. + */ + private fun computeSan(move: Move): String { + val fromSquare = move.from + val toSquare = move.to + val piece = board.getPiece(fromSquare) + val pt = piece.pieceType ?: return move.toString() + val promotionPiece = move.promotion ?: Piece.NONE + + // Castling + if (pt == com.github.bhlangonijr.chesslib.PieceType.KING) { + val fileDiff = toSquare.file.ordinal - fromSquare.file.ordinal + if (fileDiff == 2) { + val suffix = checkSuffixAfterMove(move) + return "O-O$suffix" + } + if (fileDiff == -2) { + val suffix = checkSuffixAfterMove(move) + return "O-O-O$suffix" + } + } + + val sb = StringBuilder() + val epTarget = board.enPassantTarget + val isCapture = + board.getPiece(toSquare) != Piece.NONE || + ( + pt == com.github.bhlangonijr.chesslib.PieceType.PAWN && + epTarget != null && epTarget != Square.NONE && toSquare == epTarget + ) + + if (pt != com.github.bhlangonijr.chesslib.PieceType.PAWN) { + sb.append(sanSymbol(pt)) + + // Disambiguation: check if other pieces of same type can reach the same square + val ambiguous = + board.legalMoves().filterNotNull().filter { + it.to == toSquare && + board.getPiece(it.from).pieceType == pt && + it.from != fromSquare + } + + if (ambiguous.isNotEmpty()) { + val sameFile = ambiguous.any { it.from.file == fromSquare.file } + val sameRank = ambiguous.any { it.from.rank == fromSquare.rank } + when { + !sameFile -> { + sb.append(fileChar(fromSquare)) + } + + !sameRank -> { + sb.append(rankChar(fromSquare)) + } + + else -> { + sb.append(fileChar(fromSquare)) + sb.append(rankChar(fromSquare)) + } + } + } + } else if (isCapture) { + // Pawn captures include the source file + sb.append(fileChar(fromSquare)) + } + + if (isCapture) sb.append('x') + + sb.append(toSquare.toString().lowercase()) + + // Promotion + if (promotionPiece != Piece.NONE) { + sb.append('=') + val promType = promotionPiece.pieceType + if (promType != null) sb.append(sanSymbol(promType)) + } + + // Check/checkmate suffix + sb.append(checkSuffixAfterMove(move)) + + return sb.toString() + } + + /** + * Temporarily apply a move to check for check/checkmate, then undo. + */ + private fun checkSuffixAfterMove(move: Move): String { + board.doMove(move) + val suffix = + when { + board.isMated -> "#" + board.isKingAttacked -> "+" + else -> "" + } + board.undoMove() + return suffix + } + + private fun sanSymbol(pt: com.github.bhlangonijr.chesslib.PieceType): String = + when (pt) { + com.github.bhlangonijr.chesslib.PieceType.KING -> "K" + com.github.bhlangonijr.chesslib.PieceType.QUEEN -> "Q" + com.github.bhlangonijr.chesslib.PieceType.ROOK -> "R" + com.github.bhlangonijr.chesslib.PieceType.BISHOP -> "B" + com.github.bhlangonijr.chesslib.PieceType.KNIGHT -> "N" + else -> "" + } + + private fun fileChar(square: Square): Char = 'a' + square.file.ordinal + + private fun rankChar(square: Square): Char = '1' + square.rank.ordinal + + /** + * Convert kchesslib Board to our ChessPosition model + */ + private fun boardToPosition(): ChessPosition { + val positionArray = Array(8) { Array(8) { null } } + + for (rank in 0..7) { + for (file in 0..7) { + val square = Square.squareAt(rank * 8 + file) + val piece = board.getPiece(square) + + if (piece != Piece.NONE) { + val pieceType = + when (piece.pieceType) { + com.github.bhlangonijr.chesslib.PieceType.KING -> PieceType.KING + com.github.bhlangonijr.chesslib.PieceType.QUEEN -> PieceType.QUEEN + com.github.bhlangonijr.chesslib.PieceType.ROOK -> PieceType.ROOK + com.github.bhlangonijr.chesslib.PieceType.BISHOP -> PieceType.BISHOP + com.github.bhlangonijr.chesslib.PieceType.KNIGHT -> PieceType.KNIGHT + com.github.bhlangonijr.chesslib.PieceType.PAWN -> PieceType.PAWN + else -> PieceType.PAWN + } + + val color = + when (piece.pieceSide) { + Side.WHITE -> Color.WHITE + Side.BLACK -> Color.BLACK + else -> Color.WHITE + } + + positionArray[rank][file] = ChessPiece(pieceType, color) + } + } + } + + return ChessPosition( + board = positionArray, + activeColor = getSideToMove(), + moveNumber = board.moveCounter, + castlingRights = + CastlingRights( + whiteKingSide = board.castleRight.toString().contains("K"), + whiteQueenSide = board.castleRight.toString().contains("Q"), + blackKingSide = board.castleRight.toString().contains("k"), + blackQueenSide = board.castleRight.toString().contains("q"), + ), + enPassantSquare = board.enPassantTarget?.let { it.toString().lowercase() }, + halfMoveClock = board.halfMoveCounter, + ) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructorTest.kt new file mode 100644 index 000000000..2b2e0326f --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructorTest.kt @@ -0,0 +1,808 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Integration tests for ChessStateReconstructor with Jester protocol. + * + * These tests verify that the reconstruction algorithm produces correct, + * deterministic game states from Jester events (kind 30). + * + * Jester Protocol: + * - Single event kind (30) for all chess messages + * - Content is JSON with: version, kind, fen, move, history + * - Start events have content.kind=0 and reference START_POSITION_HASH via e-tag + * - Move events have content.kind=1 and reference [startEventId, headEventId] via e-tags + * - Full move history is included in every move event + * - No separate accept event - acceptance is implicit when opponent makes first move + * + * Test Categories: + * 1. Basic game lifecycle (challenge, moves, end) + * 2. Move ordering and reconstruction from history + * 3. Game end conditions (checkmate, resignation) + * 4. Viewer perspective (white player, black player, spectator) + * 5. Determinism tests + */ +class ChessStateReconstructorTest { + // Test pubkeys + private val whitePubkey = "white_pubkey_abc123" + private val blackPubkey = "black_pubkey_def456" + private val spectatorPubkey = "spectator_pubkey_xyz789" + private val startEventId = "start-event-001" + + // ========================================================================== + // 1. BASIC GAME LIFECYCLE TESTS + // ========================================================================== + + @Test + fun `reconstruct pending challenge - no moves yet`() { + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = emptyList(), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess(), "Should reconstruct pending challenge") + + val state = result.getOrNull()!! + assertTrue(state.isPendingChallenge, "Game should be pending") + assertEquals(startEventId, state.startEventId) + assertEquals(whitePubkey, state.whitePubkey) + assertEquals(ViewerRole.WHITE_PLAYER, state.viewerRole) + assertEquals(Color.WHITE, state.playerColor) + assertTrue(state.moveHistory.isEmpty(), "No moves yet") + assertEquals(GameStatus.InProgress, state.gameStatus) + } + + @Test + fun `reconstruct game with initial moves - e4 e5`() { + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move1, move2), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(2, state.moveHistory.size) + assertEquals("e4", state.moveHistory[0]) + assertEquals("e5", state.moveHistory[1]) + assertTrue(state.isPlayerTurn(), "White's turn after e4 e5") + assertEquals(Color.WHITE, state.currentPosition.activeColor) + } + + @Test + fun `reconstruct game - acceptance is implicit via first opponent move`() { + // In Jester, there's no separate accept event + // Game is considered accepted when opponent makes their first move + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move1), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertFalse(state.isPendingChallenge, "Game is active with moves") + assertEquals(blackPubkey, state.blackPubkey) + assertEquals(Color.BLACK, state.currentPosition.activeColor, "Black's turn after e4") + } + + // ========================================================================== + // 2. MOVE RECONSTRUCTION FROM HISTORY + // ========================================================================== + + @Test + fun `reconstruct from latest move with full history`() { + // In Jester, each move contains the full history + // Reconstruction uses the move with the longest history + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + createdAt = 1000, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + createdAt = 2000, + ) + val move3 = + createMoveEvent( + id = "move-003", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = "move-002", + move = "Nf3", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2", + history = listOf("e4", "e5", "Nf3"), + opponentPubkey = blackPubkey, + createdAt = 3000, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move1, move2, move3), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(3, state.moveHistory.size) + assertEquals(listOf("e4", "e5", "Nf3"), state.moveHistory) + assertEquals("move-003", state.headEventId, "Head should be latest move") + } + + @Test + fun `reconstruct with out-of-order moves - uses longest history`() { + // Events might arrive out of order, but we use the longest history + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + + // Events arrive in reverse order + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move2, move1), // Reverse order + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(2, state.moveHistory.size) + assertEquals("e4", state.moveHistory[0], "First move should be e4") + assertEquals("e5", state.moveHistory[1], "Second move should be e5") + } + + @Test + fun `reconstruct with duplicate moves - uses longest history`() { + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move1Duplicate = + createMoveEvent( + id = "move-001-dup", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move1, move1Duplicate, move2), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + // Move2 has the longest history (2 moves), so that's what we reconstruct + assertEquals(2, state.moveHistory.size, "Should have 2 moves from longest history") + } + + // ========================================================================== + // 3. GAME END CONDITIONS TESTS + // ========================================================================== + + @Test + fun `reconstruct scholar's mate - checkmate by engine detection`() { + // Scholar's mate: 1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# + val finalMove = + createMoveEvent( + id = "move-007", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = "move-006", + move = "Qxf7#", + fen = "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4", + history = listOf("e4", "e5", "Qh5", "Nc6", "Bc4", "Nf6", "Qxf7#"), + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(finalMove), // Only need latest move with full history + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(7, state.moveHistory.size) + + val engine = result.getEngineOrNull()!! + assertTrue(engine.isCheckmate(), "Engine should detect checkmate") + + assertTrue(state.gameStatus is GameStatus.Finished) + assertEquals( + GameResult.WHITE_WINS, + (state.gameStatus as GameStatus.Finished).result, + "White wins by checkmate", + ) + } + + @Test + fun `reconstruct game with resignation - result in move content`() { + // In Jester, resignation is indicated by result field in move content + val resignationMove = + createMoveEventWithResult( + id = "move-002", + pubKey = blackPubkey, // Black resigns + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + result = "1-0", // White wins by resignation + termination = "resignation", + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(resignationMove), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertTrue(state.gameStatus is GameStatus.Finished) + assertEquals( + GameResult.WHITE_WINS, + (state.gameStatus as GameStatus.Finished).result, + "White wins by resignation", + ) + } + + @Test + fun `reconstruct fool's mate - 4 move checkmate for black`() { + // 1. f3 e5 2. g4 Qh4# + val finalMove = + createMoveEvent( + id = "move-004", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-003", + move = "Qh4#", + fen = "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3", + history = listOf("f3", "e5", "g4", "Qh4#"), + opponentPubkey = whitePubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(finalMove), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertTrue(state.gameStatus is GameStatus.Finished) + assertEquals( + GameResult.BLACK_WINS, + (state.gameStatus as GameStatus.Finished).result, + "Black wins by fool's mate", + ) + } + + // ========================================================================== + // 4. VIEWER PERSPECTIVE TESTS + // ========================================================================== + + @Test + fun `reconstruct as white player - correct perspective`() { + val events = createBasicGameEvents() + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(ViewerRole.WHITE_PLAYER, state.viewerRole) + assertEquals(Color.WHITE, state.playerColor) + assertEquals(blackPubkey, state.opponentPubkey) + } + + @Test + fun `reconstruct as black player - correct perspective`() { + val events = createBasicGameEvents() + + val result = ChessStateReconstructor.reconstruct(events, blackPubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(ViewerRole.BLACK_PLAYER, state.viewerRole) + assertEquals(Color.BLACK, state.playerColor) + assertEquals(whitePubkey, state.opponentPubkey) + } + + @Test + fun `reconstruct as spectator - white perspective, no turn`() { + val events = createBasicGameEvents() + + val result = ChessStateReconstructor.reconstruct(events, spectatorPubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(ViewerRole.SPECTATOR, state.viewerRole) + assertEquals(Color.WHITE, state.playerColor, "Spectators see from white's perspective") + assertFalse(state.isPlayerTurn(), "Spectators never have a turn") + } + + @Test + fun `challenger is black - roles reversed correctly`() { + // Challenger chooses black + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, // Opponent (white) makes first move + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, blackPubkey, Color.BLACK, whitePubkey), + moves = listOf(move1), + ) + + val whiteResult = ChessStateReconstructor.reconstruct(events, whitePubkey) + val blackResult = ChessStateReconstructor.reconstruct(events, blackPubkey) + + assertTrue(whiteResult.isSuccess()) + assertTrue(blackResult.isSuccess()) + + val whiteState = whiteResult.getOrNull()!! + val blackState = blackResult.getOrNull()!! + + assertEquals(whitePubkey, whiteState.whitePubkey) + assertEquals(blackPubkey, whiteState.blackPubkey) + assertEquals(ViewerRole.WHITE_PLAYER, whiteState.viewerRole) + assertEquals(ViewerRole.BLACK_PLAYER, blackState.viewerRole) + } + + // ========================================================================== + // 5. ERROR HANDLING TESTS + // ========================================================================== + + @Test + fun `reconstruct without start event - should fail`() { + val events = JesterGameEvents(startEvent = null, moves = emptyList()) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result is ReconstructionResult.Error) + assertEquals("No start event found", (result as ReconstructionResult.Error).message) + } + + @Test + fun `reconstruct with empty events - should fail`() { + val events = JesterGameEvents.empty() + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result is ReconstructionResult.Error) + } + + // ========================================================================== + // 6. CASTLING AND SPECIAL MOVES TESTS + // ========================================================================== + + @Test + fun `reconstruct game with kingside castling`() { + // 1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O + val finalMove = + createMoveEvent( + id = "move-007", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = "move-006", + move = "O-O", + fen = "r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQ1RK1 b kq - 5 4", + history = listOf("e4", "e5", "Nf3", "Nc6", "Bc4", "Bc5", "O-O"), + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(finalMove), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(7, state.moveHistory.size) + assertEquals("O-O", state.moveHistory[6]) + assertEquals(Color.BLACK, state.currentPosition.activeColor, "Black's turn after white castled") + } + + // ========================================================================== + // 7. DETERMINISM TESTS + // ========================================================================== + + @Test + fun `same events always produce same state`() { + val events = createBasicGameEvents() + + // Reconstruct multiple times + val results = + (1..5).map { + ChessStateReconstructor.reconstruct(events, whitePubkey) + } + + // All should succeed + assertTrue(results.all { it.isSuccess() }) + + // All states should be identical + val states = results.map { it.getOrNull()!! } + val first = states.first() + + states.forEach { state -> + assertEquals(first.startEventId, state.startEventId) + assertEquals(first.moveHistory, state.moveHistory) + assertEquals(first.gameStatus, state.gameStatus) + assertEquals(first.currentPosition.activeColor, state.currentPosition.activeColor) + assertEquals(first.currentPosition.moveNumber, state.currentPosition.moveNumber) + assertEquals(first.appliedMoveNumbers, state.appliedMoveNumbers) + } + } + + @Test + fun `different move list order produces same state`() { + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + val move3 = + createMoveEvent( + id = "move-003", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = "move-002", + move = "Nf3", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2", + history = listOf("e4", "e5", "Nf3"), + opponentPubkey = blackPubkey, + ) + + val startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey) + + // Order 1: moves in order + val events1 = JesterGameEvents(startEvent, listOf(move1, move2, move3)) + + // Order 2: moves reversed (but move3 has longest history, so result is same) + val events2 = JesterGameEvents(startEvent, listOf(move3, move1, move2)) + + val result1 = ChessStateReconstructor.reconstruct(events1, whitePubkey) + val result2 = ChessStateReconstructor.reconstruct(events2, whitePubkey) + + assertTrue(result1.isSuccess()) + assertTrue(result2.isSuccess()) + + val state1 = result1.getOrNull()!! + val state2 = result2.getOrNull()!! + + assertEquals(state1.moveHistory, state2.moveHistory, "Move history should be identical") + assertEquals(state1.appliedMoveNumbers, state2.appliedMoveNumbers) + } + + // ========================================================================== + // 8. OPEN CHALLENGE TESTS + // ========================================================================== + + @Test + fun `reconstruct open challenge - no specific opponent`() { + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, null), // Open challenge + moves = emptyList(), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertTrue(state.isPendingChallenge) + assertEquals(whitePubkey, state.whitePubkey) + assertEquals(null, state.blackPubkey, "No opponent assigned yet for open challenge") + } + + @Test + fun `reconstruct open challenge with first opponent move`() { + // When an unknown player makes the first move, they become the opponent + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, // Now we know the opponent + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, null), // Open challenge + moves = listOf(move1), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertFalse(state.isPendingChallenge) + assertEquals(whitePubkey, state.whitePubkey) + // Opponent is determined from move events when not specified in start + } + + // ========================================================================== + // HELPER FUNCTIONS + // ========================================================================== + + private fun createBasicGameEvents(): JesterGameEvents { + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + + return JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move1, move2), + ) + } + + private fun createStartEvent( + id: String, + challengerPubkey: String, + challengerColor: Color, + opponentPubkey: String?, + createdAt: Long = 1000, + ): JesterEvent { + val tags = + mutableListOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + ) + opponentPubkey?.let { tags.add(arrayOf("p", it)) } + + val colorString = if (challengerColor == Color.WHITE) "white" else "black" + val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"test123","playerColor":"$colorString"}""" + + return JesterEvent( + id = id, + pubKey = challengerPubkey, + createdAt = createdAt, + tags = tags.toTypedArray(), + content = content, + sig = "sig-start", + ) + } + + private fun createMoveEvent( + id: String, + pubKey: String, + startEventId: String, + headEventId: String, + move: String, + fen: String, + history: List, + opponentPubkey: String, + createdAt: Long = 2000, + ): JesterEvent { + val historyJson = history.joinToString(",") { "\"$it\"" } + val content = """{"version":"0","kind":1,"fen":"$fen","move":"$move","history":[$historyJson]}""" + + return JesterEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", headEventId), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "sig-move", + ) + } + + private fun createMoveEventWithResult( + id: String, + pubKey: String, + startEventId: String, + headEventId: String, + move: String, + fen: String, + history: List, + opponentPubkey: String, + result: String, + termination: String, + createdAt: Long = 2000, + ): JesterEvent { + val historyJson = history.joinToString(",") { "\"$it\"" } + val content = """{"version":"0","kind":1,"fen":"$fen","move":"$move","history":[$historyJson],"result":"$result","termination":"$termination"}""" + + return JesterEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", headEventId), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "sig-move", + ) + } +}