Merge pull request #1702 from nrobi144/feat/chess-pgn-display-878
chess: live chess game implementation with Jester protocol
This commit is contained in:
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+6
@@ -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) {
|
||||
|
||||
+43
@@ -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())
|
||||
}
|
||||
}
|
||||
+63
@@ -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 })
|
||||
}
|
||||
}
|
||||
+27
@@ -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
|
||||
+28
@@ -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
|
||||
+3
@@ -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) }
|
||||
|
||||
+9
@@ -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(
|
||||
|
||||
+408
@@ -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()
|
||||
}
|
||||
}
|
||||
+430
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+803
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+164
@@ -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
|
||||
}
|
||||
}
|
||||
+41
@@ -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")
|
||||
}
|
||||
}
|
||||
+198
@@ -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()
|
||||
}
|
||||
+53
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+88
@@ -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)
|
||||
}
|
||||
+36
-29
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+76
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -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()
|
||||
|
||||
+58
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+6
@@ -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 =
|
||||
|
||||
+3
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
+57
@@ -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
|
||||
}
|
||||
}
|
||||
+241
@@ -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
|
||||
}
|
||||
+266
@@ -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)
|
||||
}
|
||||
}
|
||||
+327
@@ -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,
|
||||
)
|
||||
+182
@@ -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
|
||||
}
|
||||
+172
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+342
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+845
@@ -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
|
||||
}
|
||||
+518
@@ -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
|
||||
}
|
||||
}
|
||||
+1018
File diff suppressed because it is too large
Load Diff
+134
@@ -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()
|
||||
}
|
||||
}
|
||||
+311
@@ -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
|
||||
}
|
||||
+33
@@ -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?
|
||||
}
|
||||
+394
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+927
@@ -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,
|
||||
)
|
||||
+137
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+310
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+69
@@ -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()
|
||||
}
|
||||
+70
@@ -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()
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
+144
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+192
@@ -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
|
||||
}
|
||||
+46
@@ -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()
|
||||
}
|
||||
+152
@@ -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,
|
||||
|
||||
+823
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+397
@@ -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)
|
||||
}
|
||||
+150
@@ -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()
|
||||
}
|
||||
}
|
||||
+192
@@ -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()
|
||||
}
|
||||
+217
@@ -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)
|
||||
+33
@@ -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,
|
||||
)
|
||||
|
||||
+110
@@ -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).
|
||||
*
|
||||
|
||||
+21
@@ -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,
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+170
-18
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
+234
@@ -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,
|
||||
)
|
||||
+304
@@ -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
|
||||
}
|
||||
+295
@@ -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
|
||||
}
|
||||
+548
@@ -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)
|
||||
|
||||
+288
@@ -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)
|
||||
}
|
||||
}
|
||||
+349
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+808
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user