Merge pull request #1702 from nrobi144/feat/chess-pgn-display-878

chess: live chess game implementation with Jester protocol
This commit is contained in:
Vitor Pamplona
2026-02-17 16:12:26 -05:00
committed by GitHub
93 changed files with 18478 additions and 63 deletions
@@ -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,
@@ -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)
@@ -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,
),
)
}
}
@@ -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) {
@@ -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<Set<NormalizedRelayUrl>>,
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedFlowsType {
val default = ChessTopNavFilter(outboxRelays, proxyRelays)
override fun flow() = MutableStateFlow(default)
override fun startValue(): ChessTopNavFilter = default
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
collector.emit(startValue())
}
}
@@ -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<Set<NormalizedRelayUrl>>,
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : 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<ChessTopNavPerRelayFilterSet> =
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 })
}
}
@@ -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
@@ -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<NormalizedRelayUrl, ChessTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
@@ -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,
)
@@ -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<Route.Video> { VideoScreen(accountViewModel, nav) }
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
@@ -174,6 +177,7 @@ fun AppNavigation(
composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.ChessGame> { ChessGameScreen(it.gameId, accountViewModel, nav) }
composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.FollowPack> { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
@@ -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 = {
@@ -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<Route.Video>() -> entry.toRoute<Route.Video>()
dest.hasRoute<Route.Discover>() -> entry.toRoute<Route.Discover>()
dest.hasRoute<Route.Notification>() -> entry.toRoute<Route.Notification>()
dest.hasRoute<Route.Chess>() -> entry.toRoute<Route.Chess>()
dest.hasRoute<Route.Search>() -> entry.toRoute<Route.Search>()
dest.hasRoute<Route.SecurityFilters>() -> entry.toRoute<Route.SecurityFilters>()
dest.hasRoute<Route.PrivacyOptions>() -> entry.toRoute<Route.PrivacyOptions>()
@@ -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,
@@ -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<Color>,
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<Color>,
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<Color>,
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)
}
}
}
}
@@ -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(
@@ -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<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl> =
ChessConfig.CHESS_RELAYS.map {
com.vitorpamplona.quartz.nip01Core.relay.normalizer
.NormalizedRelayUrl(it)
}
override fun getRelayUrls(): List<String> {
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<JesterEvent>()
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<JesterEvent> {
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<JesterEvent>()
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<RelayGameSummary> {
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<String> {
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<String>()
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()
}
}
@@ -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<String>,
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),
)
}
}
@@ -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<ChessChallenge>,
activeGames: Map<String, LiveChessGameState>,
spectatingGames: Map<String, LiveChessGameState>,
publicGames: List<PublicGame>,
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<String>,
outboxRelays: List<String>,
globalRelays: List<String>,
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
},
)
}
}
@@ -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<NormalizedRelayUrl>,
val globalRelays: Set<NormalizedRelayUrl>,
val isGlobal: Boolean,
val activeGameIds: Set<String> = emptySet(),
val opponentPubkeys: Set<String> = emptySet(),
)
/**
* Filter assembler for chess events
*/
class ChessFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<ChessQueryState>() {
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<ChessQueryState>,
) : PerUniqueIdEoseManager<ChessQueryState, String>(client, allKeys) {
override fun updateFilter(
key: ChessQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> = 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<RelayBasedFilter> {
// 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
}
}
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ChessViewModelNew::class.java)) {
return ChessViewModelNew(account) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
@@ -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<Map<String, LiveChessGameState>> = logic.state.activeGames
val spectatingGames: StateFlow<Map<String, LiveChessGameState>> = logic.state.spectatingGames
val challenges: StateFlow<List<ChessChallenge>> = logic.state.challenges
val publicGames: StateFlow<List<PublicGame>> = logic.state.publicGames
val completedGames: StateFlow<List<CompletedGame>> = logic.state.completedGames
val broadcastStatus: StateFlow<ChessBroadcastStatus> = logic.state.broadcastStatus
val error: StateFlow<String?> = logic.state.error
val selectedGameId: StateFlow<String?> = logic.state.selectedGameId
val isRefreshing: StateFlow<Boolean> = logic.state.isRefreshing
val stateVersion: StateFlow<Long> = logic.state.stateVersion
val syncStatus: StateFlow<ChessSyncStatus> = 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<ChessChallenge> = logic.state.incomingChallenges()
fun outgoingChallenges(): List<ChessChallenge> = logic.state.outgoingChallenges()
fun openChallenges(): List<ChessChallenge> = logic.state.openChallenges()
}
@@ -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,
)
}
}
@@ -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<Note>() {
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<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, it ->
acceptableEvent(it, params)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
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<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -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<TabItem>,
feedTabs: ImmutableList<TabItem>,
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,
)
}
}
}
}
@@ -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)
}
}
}
@@ -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
},
)
}
}
@@ -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()
@@ -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<RelayBasedFilter> {
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,
),
)
}
}
@@ -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 =
@@ -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)
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF9C9C9C"
android:pathData="M19,22H5v-2h14v2M17.16,8.26A8.94,8.94 0,0 0,19 3H15.03A5.24,5.24 0,0 1,14.05 4.89L10.08,4.89A5.24,5.24 0,0 1,9.1 3H5A8.94,8.94 0,0 0,6.84 8.26L6.84,18h10.32v-9.74ZM7.5,6c0.28,0 0.5,0.22 0.5,0.5S7.78,7 7.5,7 7,6.78 7,6.5 7.22,6 7.5,6M9,12 L9,9h2v3H9M13,12v-3h2v3H13Z"/>
</vector>
+2
View File
@@ -572,6 +572,7 @@
<string name="follow_list_kind3follows_proxy">Follows via Proxy</string>
<string name="follow_list_aroundme">Around Me</string>
<string name="follow_list_global">Global</string>
<string name="follow_list_chess">Chess</string>
<string name="follow_list_mute_list">Mute List</string>
<string name="follow_sets">Follow Lists</string>
@@ -1192,6 +1193,7 @@
<string name="route_notifications">Notifications</string>
<string name="route_global">Global</string>
<string name="route_video">Shorts</string>
<string name="route_chess">Chess</string>
<string name="route_security_filters">Security Filters</string>
<string name="new_post">New Post</string>
@@ -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<String>()
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<String>) {
synchronized(lock) {
acceptedGameIds.retainAll(keepGameIds)
}
}
}
@@ -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
}
}
@@ -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
}
@@ -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
}
@@ -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<JesterEvent?>(null)
val startEvent: StateFlow<JesterEvent?> = _startEvent.asStateFlow()
// Move events (deduplicated by event ID)
private val moves = ConcurrentHashMap<String, JesterEvent>()
// Track all processed event IDs for fast deduplication
private val processedEventIds = ConcurrentHashMap.newKeySet<String>()
// Flow that emits when any event is added (for reactive updates)
private val _eventCount = MutableStateFlow(0)
val eventCount: StateFlow<Int> = _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<String, ChessEventCollector>()
/**
* 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<String> = 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)
}
}
@@ -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<String>) -> 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<Boolean> = _isPolling.asStateFlow()
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
internal val activeGameIdsFlow = MutableStateFlow<Set<String>>(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<String?>(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<String> {
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<String>) {
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<ChessChallengeData>
/**
* Fetch/refresh game state for specific game IDs
*/
suspend fun fetchGameUpdates(gameIds: Set<String>): Map<String, ChessGameUpdate>
}
/**
* 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<ChessMoveData>,
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,
)
@@ -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
}
@@ -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,
)
}
}
}
@@ -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,
)
}
}
}
@@ -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<JesterEvent>
/** Fetch recent public game summaries for spectating */
suspend fun fetchRecentGames(): List<RelayGameSummary>
/** Fetch game IDs (startEventIds) where user is a participant */
suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)? = null): Set<String>
/** Get the list of relay URLs that will be used for fetching */
fun getRelayUrls(): List<String>
}
/**
* 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<String>) {
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<String, RelaySyncState>()
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 <T> 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<String, PieceType?> {
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
}
@@ -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<RelaySyncState>,
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<RelaySyncState>,
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<RelaySyncState>,
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<Map<String, LiveChessGameState>>(emptyMap())
val activeGames: StateFlow<Map<String, LiveChessGameState>> = _activeGames.asStateFlow()
// Public games that can be spectated
private val _publicGames = MutableStateFlow<List<PublicGame>>(emptyList())
val publicGames: StateFlow<List<PublicGame>> = _publicGames.asStateFlow()
// All challenges (filtered by UI based on type)
private val _challenges = MutableStateFlow<List<ChessChallenge>>(emptyList())
val challenges: StateFlow<List<ChessChallenge>> = _challenges.asStateFlow()
// Spectating games (user is watching but not playing)
private val _spectatingGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap())
val spectatingGames: StateFlow<Map<String, LiveChessGameState>> = _spectatingGames.asStateFlow()
// Completed games history
private val _completedGames = MutableStateFlow<List<CompletedGame>>(emptyList())
val completedGames: StateFlow<List<CompletedGame>> = _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>(ChessBroadcastStatus.Idle)
val broadcastStatus: StateFlow<ChessBroadcastStatus> = _broadcastStatus.asStateFlow()
// Error state
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
// Loading/refreshing state
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
// Sync status for subscription banner
private val _syncStatus = MutableStateFlow<ChessSyncStatus>(ChessSyncStatus.Idle)
val syncStatus: StateFlow<ChessSyncStatus> = _syncStatus.asStateFlow()
// Selected game ID for navigation
private val _selectedGameId = MutableStateFlow<String?>(null)
val selectedGameId: StateFlow<String?> = _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<Long> = _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<ChessChallenge> = _challenges.value.filter { it.isDirectedAt(userPubkey) }
/** Challenges created by the user */
fun outgoingChallenges(): List<ChessChallenge> = _challenges.value.filter { it.isFrom(userPubkey) }
/** Open challenges from others that user can join */
fun openChallenges(): List<ChessChallenge> = _challenges.value.filter { it.isOpen && !it.isFrom(userPubkey) }
// ========================================
// State updates
// ========================================
fun updateChallenges(challenges: List<ChessChallenge>) {
_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<PublicGame>) {
_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
}
}
@@ -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<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = ChessConfig.FETCH_TIMEOUT_MS,
onProgress: ((RelayFetchProgress) -> Unit)? = null,
): List<Event> {
if (filters.isEmpty()) return emptyList()
val events = ConcurrentHashMap<String, Event>()
val relayCount = filters.keys.size
val eoseReceived = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>()
val relayEventCounts = ConcurrentHashMap<NormalizedRelayUrl, Int>()
val allEose = CompletableDeferred<Unit>()
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<Filter>?,
) {
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<Filter>?,
) {
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()
}
}
@@ -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<RelaySyncState>,
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<RelaySyncState> =
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
}
@@ -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?
}
@@ -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<Pair<Int, Int>?>(null) }
var legalMoves by remember { mutableStateOf<List<String>>(emptyList()) }
// Pawn promotion state
var pendingPromotion by remember { mutableStateOf<PendingPromotion?>(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),
)
}
}
}
}
}
}
@@ -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<String>) {
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<A, B, C, D>(
val first: A,
val second: B,
val third: C,
val fourth: D,
)
@@ -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)
},
)
}
}
}
@@ -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,
)
}
}
}
@@ -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<RelayBasedFilter> {
val filters = mutableListOf<RelayBasedFilter>()
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<NormalizedRelayUrl>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
now: Long = TimeUtils.now(),
): List<RelayBasedFilter> {
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<NormalizedRelayUrl>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
now: Long = TimeUtils.now(),
): List<RelayBasedFilter> {
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<String>,
userPubkey: String,
opponentPubkeys: Set<String>,
relays: Set<NormalizedRelayUrl>,
): List<RelayBasedFilter> {
if (startEventIds.isEmpty()) return emptyList()
val filters = mutableListOf<RelayBasedFilter>()
// 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<NormalizedRelayUrl>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
now: Long = TimeUtils.now(),
): List<RelayBasedFilter> {
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,
)
}
}
@@ -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<ChessSubscriptionState?>
/**
* 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<String>,
spectatingGameIds: Set<String> = emptySet(),
)
/**
* Force refresh all filters from relays.
* Clears EOSE cache so full history is re-fetched.
*/
fun forceRefresh()
}
@@ -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<NormalizedRelayUrl>,
/** Game IDs the user is actively playing */
val activeGameIds: Set<String> = emptySet(),
/** Game IDs the user is spectating */
val spectatingGameIds: Set<String> = emptySet(),
/** Opponent pubkeys for active games (to filter their moves) */
val opponentPubkeys: Set<String> = 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<String>
get() = activeGameIds + spectatingGameIds
/** True if user has any active or spectating games */
val hasActiveGames: Boolean
get() = allGameIds.isNotEmpty()
}
@@ -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
}
@@ -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<Map<String, UserMetadata>>(emptyMap())
val metadata: StateFlow<Map<String, UserMetadata>> = _metadata.asStateFlow()
private val _pubkeysNeeded = MutableStateFlow<Set<String>>(emptySet())
val pubkeysNeeded: StateFlow<Set<String>> = _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<String>) {
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<String>) {
_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
}
}
}
@@ -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
}
@@ -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()
}
@@ -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<NormalizedRelayUrl, Boolean>,
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<Filter>?,
) { }
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) { }
}
// 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<NormalizedRelayUrl>,
timeoutMs: Long,
): Set<NormalizedRelayUrl> {
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)
}
}
@@ -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,
@@ -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<ChessChallenge>,
activeGames: Map<String, com.vitorpamplona.quartz.nip64Chess.LiveChessGameState>,
spectatingGames: Map<String, com.vitorpamplona.quartz.nip64Chess.LiveChessGameState>,
publicGames: List<PublicGame>,
completedGames: List<CompletedGame>,
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,
)
}
}
}
}
@@ -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<NormalizedRelayUrl> = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) }
override fun getRelayUrls(): List<String> {
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<JesterEvent> {
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<String> {
val relays = chessRelayUrls()
println("[DesktopRelayFetcher] fetchUserGameIds: querying cache first, then ${relays.size} chess relays")
val gameIds = mutableSetOf<String>()
// 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<RelayGameSummary> {
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)
}
@@ -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<String, JesterEvent>()
// Index start events by event ID (for quick game lookup)
private val startEvents = ConcurrentHashMap<String, JesterEvent>()
// Index move events by startEventId (game ID) for quick game reconstruction
private val movesByGame = ConcurrentHashMap<String, MutableSet<String>>()
/**
* 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<JesterEvent> = startEvents.values.filter { it.isStartEvent() && filter(it) }
/**
* Get all move events for a specific game.
*/
fun getMovesForGame(startEventId: String): List<JesterEvent> {
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<JesterEvent> = events.values.filter(filter)
/**
* Query start events with custom filter.
*/
fun queryStartEvents(filter: (JesterEvent) -> Boolean = { true }): List<JesterEvent> = startEvents.values.filter { it.isStartEvent() && filter(it) }
/**
* Query move events with custom filter.
*/
fun queryMoveEvents(filter: (JesterEvent) -> Boolean = { true }): List<JesterEvent> = events.values.filter { it.isMoveEvent() && filter(it) }
/**
* Get all game IDs (startEventIds) in the cache.
*/
fun getAllGameIds(): Set<String> = 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()
}
}
@@ -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<Map<String, LiveChessGameState>> = logic.state.activeGames
val spectatingGames: StateFlow<Map<String, LiveChessGameState>> = logic.state.spectatingGames
val challenges: StateFlow<List<ChessChallenge>> = logic.state.challenges
val publicGames: StateFlow<List<PublicGame>> = logic.state.publicGames
val completedGames: StateFlow<List<CompletedGame>> = logic.state.completedGames
val broadcastStatus: StateFlow<ChessBroadcastStatus> = logic.state.broadcastStatus
val error: StateFlow<String?> = logic.state.error
val selectedGameId: StateFlow<String?> = logic.state.selectedGameId
val isRefreshing: StateFlow<Boolean> = logic.state.isRefreshing
val syncStatus: StateFlow<ChessSyncStatus> = logic.state.syncStatus
val stateVersion: StateFlow<Long> = 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<ChessChallenge> = logic.state.incomingChallenges()
fun outgoingChallenges(): List<ChessChallenge> = logic.state.outgoingChallenges()
fun openChallenges(): List<ChessChallenge> = logic.state.openChallenges()
}
@@ -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<Filter>?) -> Unit,
private val onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
) : ChessSubscriptionController {
private val _currentState = MutableStateFlow<ChessSubscriptionState?>(null)
override val currentState: StateFlow<ChessSubscriptionState?> = _currentState.asStateFlow()
// EOSE tracking per relay for efficient re-subscription
private val relayEoseTimes = mutableMapOf<NormalizedRelayUrl, Long>()
// 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<Filter>?,
) {
// 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<Filter>?,
) {
// 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<String>,
spectatingGameIds: Set<String>,
) {
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<NormalizedRelayUrl>,
userPubkey: String,
activeGameIds: Set<String> = emptySet(),
opponentPubkeys: Set<String> = emptySet(),
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> 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<NormalizedRelayUrl>,
userPubkey: String,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? = createChessSubscriptionWithGames(relays, userPubkey, emptySet(), emptySet(), onEvent, onEose)
@@ -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<NormalizedRelayUrl>,
userPubkey: String,
limit: Int = 100,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> 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,
)
@@ -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<String>): 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).
*
@@ -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<NormalizedRelayUrl>,
pubKeys: List<String>,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> 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).
*/
@@ -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,
@@ -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)
}
}
@@ -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(
@@ -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>(ProfileBroadcastStatus.Idle) }
var latestMetadataEvent by remember { mutableStateOf<MetadataEvent?>(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"))
}
}
+287
View File
@@ -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<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000,
): List<Event> {
val events = mutableListOf<Event>()
val eoseReceived = CompletableDeferred<Unit>()
val subId = UUID.randomUUID().toString().take(8)
val listener = object : IRequestListener {
override fun onEvent(event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
events.add(event)
}
override fun onEose(relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
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<LiveChessGameChallengeEvent>
suspend fun fetchRecentGames(): List<RelayGameSummary>
}
```
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<List<CompletedGame>>`
- 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
+266
View File
@@ -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<Map<String, LiveChessGameState>>` - Active games by ID
- `challenges: StateFlow<List<Note>>` - Incoming/outgoing challenges
- `badgeCount: StateFlow<Int>` - 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<LiveChessGameChallengeEvent>,
activeGames: List<LiveChessGameState>,
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.
+5
View File
@@ -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")
}
}
@@ -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<String>
/**
* 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<String>
/**
* 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<String>
}
/**
* 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
)
@@ -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<String, String>,
val moves: List<ChessMove>,
val positions: List<ChessPosition>,
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
}
}
@@ -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 <a href="https://github.com/nostr-protocol/nips/blob/master/64.md">NIP-64</a>
*/
@Immutable
class ChessGameEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
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<ChessGameEvent>.() -> 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)
}
@@ -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() }
}
}
}
@@ -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
}
}
@@ -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<Array<ChessPiece?>>,
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<Int, Int> {
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<ChessPiece?>(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,
)
@@ -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<Int>()
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<String>,
val gameStatus: GameStatus,
val isDesynced: Boolean,
val pendingDrawOffer: String?,
val appliedMoveNumbers: Set<Int>,
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
}
@@ -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<String> = 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<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, JesterProtocol.KIND, tags, content, sig) {
private val parsedContent: JesterContent? by lazy {
try {
json.decodeFromString<JesterContent>(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<String> = 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<String> = 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<JesterEvent>.() -> Unit = {},
): EventTemplate<JesterEvent> {
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<JesterEvent>.() -> Unit = {},
): EventTemplate<JesterEvent> {
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<String>,
opponentPubkey: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<JesterEvent>.() -> Unit = {},
): EventTemplate<JesterEvent> {
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<String>,
opponentPubkey: String,
result: GameResult,
termination: GameTermination,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<JesterEvent>.() -> Unit = {},
): EventTemplate<JesterEvent> {
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<JesterContent>(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<JesterContent>(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<JesterEvent>,
) {
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<String> = 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()
}
@@ -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<String>,
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<String>,
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
}
@@ -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<Array<String>>,
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<LiveChessGameChallengeEvent>.() -> 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<Array<String>>,
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<LiveChessGameAcceptEvent>.() -> 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<Array<String>>,
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<LiveChessMoveEvent>.() -> 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<Array<String>>,
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<LiveChessGameEndEvent>.() -> 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<Array<String>>,
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<LiveChessDrawOfferEvent>.() -> 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
}
@@ -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<String> = _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>(GameStatus.InProgress)
val gameStatus: StateFlow<GameStatus> = _gameStatus.asStateFlow()
private val _currentPosition = MutableStateFlow(engine.getPosition())
val currentPosition: StateFlow<ChessPosition> = _currentPosition.asStateFlow()
// Initialize from engine's history so freshly loaded games show all moves
private val _moveHistory = MutableStateFlow(engine.getMoveHistory())
val moveHistory: StateFlow<List<String>> = _moveHistory.asStateFlow()
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError.asStateFlow()
// Track when last activity occurred (move made or received)
private val _lastActivityAt = MutableStateFlow(createdAt)
val lastActivityAt: StateFlow<Long> = _lastActivityAt.asStateFlow()
// Track received move numbers to detect duplicates and out-of-order
private val receivedMoveNumbers = mutableSetOf<Int>()
// Pending moves waiting for earlier moves (move number -> move data)
private val pendingMoves = mutableMapOf<Int, Pair<String, String>>()
// Desync detection
private val _isDesynced = MutableStateFlow(false)
val isDesynced: StateFlow<Boolean> = _isDesynced.asStateFlow()
// Draw offer tracking - pubkey of who offered the draw (null = no pending offer)
private val _pendingDrawOffer = MutableStateFlow<String?>(null)
val pendingDrawOffer: StateFlow<String?> = _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<Int>) {
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
}
@@ -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<ChessGame> =
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<String>): Map<String, String> {
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<List<ChessMove>, 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<ChessMove>()
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<ChessMove>): List<ChessPosition> {
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<String>()
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
}
}
}
@@ -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)
@@ -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])
}
}
@@ -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<String>,
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<String>,
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",
)
}
}
@@ -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)
}
}
@@ -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<String>()
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<String> = board.legalMoves().map { it.toString() }
actual fun getLegalMovesFrom(square: String): List<String> {
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<String> = 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<ChessPiece?>(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,
)
}
}
@@ -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<String>,
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<String>,
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",
)
}
}