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.
|
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
|
||||||
val AROUND_ME = " Around Me "
|
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
|
@Stable
|
||||||
class AccountSettings(
|
class AccountSettings(
|
||||||
val keyPair: KeyPair,
|
val keyPair: KeyPair,
|
||||||
|
|||||||
@@ -168,6 +168,13 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
|
|||||||
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
|
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
|
||||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
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.nip65RelayList.AdvertisedRelayListEvent
|
||||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||||
@@ -717,6 +724,49 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
wasVerified: Boolean,
|
wasVerified: Boolean,
|
||||||
) = consumeRegularEvent(event, relay, wasVerified)
|
) = 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(
|
fun consume(
|
||||||
event: NipTextEvent,
|
event: NipTextEvent,
|
||||||
relay: NormalizedRelayUrl?,
|
relay: NormalizedRelayUrl?,
|
||||||
@@ -3006,6 +3056,13 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
is GitReplyEvent -> consume(event, relay, wasVerified)
|
is GitReplyEvent -> consume(event, relay, wasVerified)
|
||||||
is GitPatchEvent -> consume(event, relay, wasVerified)
|
is GitPatchEvent -> consume(event, relay, wasVerified)
|
||||||
is GitRepositoryEvent -> 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 HashtagListEvent -> consume(event, relay, wasVerified)
|
||||||
is HighlightEvent -> consume(event, relay, wasVerified)
|
is HighlightEvent -> consume(event, relay, wasVerified)
|
||||||
is IndexerRelayListEvent -> 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_FOLLOWS
|
||||||
import com.vitorpamplona.amethyst.model.ALL_USER_FOLLOWS
|
import com.vitorpamplona.amethyst.model.ALL_USER_FOLLOWS
|
||||||
import com.vitorpamplona.amethyst.model.AROUND_ME
|
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.GLOBAL_FOLLOWS
|
||||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
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.AllUserFollowsFeedFlow
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFollowsFeedFlow
|
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFollowsFeedFlow
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow
|
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.global.GlobalFeedFlow
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow
|
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownFeedFlow
|
import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownFeedFlow
|
||||||
@@ -84,6 +86,10 @@ class FeedTopNavFilterState(
|
|||||||
AroundMeFeedFlow(locationFlow, followsRelays, proxyRelays)
|
AroundMeFeedFlow(locationFlow, followsRelays, proxyRelays)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CHESS -> {
|
||||||
|
ChessFeedFlow(followsRelays, proxyRelays)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val note = LocalCache.checkGetOrCreateAddressableNote(listName)
|
val note = LocalCache.checkGetOrCreateAddressableNote(listName)
|
||||||
if (note != null) {
|
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.privateDM.datasource.ChatroomFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
|
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.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.communities.datasource.CommunityFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
|
||||||
@@ -79,6 +80,7 @@ class RelaySubscriptionsCoordinator(
|
|||||||
val hashtags = HashtagFilterAssembler(client)
|
val hashtags = HashtagFilterAssembler(client)
|
||||||
val geohashes = GeoHashFilterAssembler(client)
|
val geohashes = GeoHashFilterAssembler(client)
|
||||||
val followPacks = FollowPackFeedFilterAssembler(client)
|
val followPacks = FollowPackFeedFilterAssembler(client)
|
||||||
|
val chess = ChessFilterAssembler(client)
|
||||||
|
|
||||||
// active when sending zaps via NWC
|
// active when sending zaps via NWC
|
||||||
val nwc = NWCPaymentFilterAssembler(client)
|
val nwc = NWCPaymentFilterAssembler(client)
|
||||||
@@ -101,6 +103,7 @@ class RelaySubscriptionsCoordinator(
|
|||||||
profile,
|
profile,
|
||||||
hashtags,
|
hashtags,
|
||||||
geohashes,
|
geohashes,
|
||||||
|
chess,
|
||||||
nwc,
|
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.nip28PublicChat.metadata.ChannelMetadataScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen
|
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.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.communities.CommunityScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
|
||||||
@@ -137,6 +139,7 @@ fun AppNavigation(
|
|||||||
composable<Route.Video> { VideoScreen(accountViewModel, nav) }
|
composable<Route.Video> { VideoScreen(accountViewModel, nav) }
|
||||||
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
|
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
|
||||||
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
|
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
|
||||||
|
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
|
||||||
|
|
||||||
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, 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.Note> { ThreadScreen(it.id, accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
|
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.Geohash> { GeoHashScreen(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.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), 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) }
|
composableFromEndArgs<Route.FollowPack> { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||||
|
|||||||
+9
@@ -471,6 +471,15 @@ fun ListContent(
|
|||||||
route = Route.Drafts,
|
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(
|
IconRowRelays(
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object Notification : Route()
|
@Serializable object Notification : Route()
|
||||||
|
|
||||||
|
@Serializable object Chess : Route()
|
||||||
|
|
||||||
@Serializable object Search : Route()
|
@Serializable object Search : Route()
|
||||||
|
|
||||||
@Serializable object SecurityFilters : Route()
|
@Serializable object SecurityFilters : Route()
|
||||||
@@ -137,6 +139,10 @@ sealed class Route {
|
|||||||
val geohash: String,
|
val geohash: String,
|
||||||
) : Route()
|
) : Route()
|
||||||
|
|
||||||
|
@Serializable data class ChessGame(
|
||||||
|
val gameId: String,
|
||||||
|
) : Route()
|
||||||
|
|
||||||
@Serializable data class Community(
|
@Serializable data class Community(
|
||||||
val kind: Int,
|
val kind: Int,
|
||||||
val pubKeyHex: HexKey,
|
val pubKeyHex: HexKey,
|
||||||
@@ -312,6 +318,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
|
|||||||
dest.hasRoute<Route.Video>() -> entry.toRoute<Route.Video>()
|
dest.hasRoute<Route.Video>() -> entry.toRoute<Route.Video>()
|
||||||
dest.hasRoute<Route.Discover>() -> entry.toRoute<Route.Discover>()
|
dest.hasRoute<Route.Discover>() -> entry.toRoute<Route.Discover>()
|
||||||
dest.hasRoute<Route.Notification>() -> entry.toRoute<Route.Notification>()
|
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.Search>() -> entry.toRoute<Route.Search>()
|
||||||
dest.hasRoute<Route.SecurityFilters>() -> entry.toRoute<Route.SecurityFilters>()
|
dest.hasRoute<Route.SecurityFilters>() -> entry.toRoute<Route.SecurityFilters>()
|
||||||
dest.hasRoute<Route.PrivacyOptions>() -> entry.toRoute<Route.PrivacyOptions>()
|
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.RenderChannelMessage
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage
|
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile
|
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.RenderClassifieds
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
|
import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
|
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.RenderInteractiveStory
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage
|
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent
|
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.RenderLongFormContent
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse
|
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status
|
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.nip57Zaps.splits.hasZapSplitSetup
|
||||||
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
|
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
|
||||||
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
|
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.nip65RelayList.AdvertisedRelayListEvent
|
||||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
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 -> {
|
is ClassifiedsEvent -> {
|
||||||
RenderClassifieds(
|
RenderClassifieds(
|
||||||
noteEvent,
|
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.AROUND_ME
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||||
|
import com.vitorpamplona.amethyst.model.CHESS
|
||||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
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.chat.LiveActivitiesChatMessageEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
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.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||||
@@ -127,6 +131,20 @@ class TopNavFilterState(
|
|||||||
unpackList = listOf(MuteListEvent.blockListFor(account.userProfile().pubkeyHex)),
|
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)
|
val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)
|
||||||
|
|
||||||
fun mergePeopleLists(
|
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)
|
||||||
|
}
|
||||||
+24
-17
@@ -123,7 +123,7 @@ fun DiscoverScreen(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
val tabs by
|
val feedTabs by
|
||||||
remember(accountViewModel) {
|
remember(accountViewModel) {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
listOf(
|
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(
|
WatchAccountForDiscoveryScreen(
|
||||||
discoveryFollowSetsFeedContentState = discoveryFollowSetsFeedContentState,
|
discoveryFollowSetsFeedContentState = discoveryFollowSetsFeedContentState,
|
||||||
@@ -204,13 +204,13 @@ fun DiscoverScreen(
|
|||||||
|
|
||||||
DiscoveryFilterAssemblerSubscription(accountViewModel.dataSources().discovery, accountViewModel)
|
DiscoveryFilterAssemblerSubscription(accountViewModel.dataSources().discovery, accountViewModel)
|
||||||
|
|
||||||
DiscoverPages(pagerState, tabs, accountViewModel, nav)
|
DiscoverPages(pagerState, feedTabs, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiscoverPages(
|
private fun DiscoverPages(
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
tabs: ImmutableList<TabItem>,
|
feedTabs: ImmutableList<TabItem>,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
@@ -228,7 +228,7 @@ private fun DiscoverPages(
|
|||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
tabs.forEachIndexed { index, tab ->
|
feedTabs.forEachIndexed { index, tab ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = pagerState.currentPage == index,
|
selected = pagerState.currentPage == index,
|
||||||
text = { Text(text = stringRes(tab.resource)) },
|
text = { Text(text = stringRes(tab.resource)) },
|
||||||
@@ -241,38 +241,44 @@ private fun DiscoverPages(
|
|||||||
bottomBar = {
|
bottomBar = {
|
||||||
AppBottomBar(Route.Discover, accountViewModel) { route ->
|
AppBottomBar(Route.Discover, accountViewModel) { route ->
|
||||||
if (route == Route.Discover) {
|
if (route == Route.Discover) {
|
||||||
tabs[pagerState.currentPage].feedState.sendToTop()
|
val currentPage = pagerState.currentPage
|
||||||
|
if (currentPage >= 0 && currentPage < feedTabs.size) {
|
||||||
|
feedTabs[currentPage].feedState.sendToTop()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
nav.newStack(route)
|
nav.newStack(route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
floatingButton = {
|
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)
|
NewProductButton(accountViewModel, nav)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
) {
|
) {
|
||||||
HorizontalPager(state = pagerState, contentPadding = it) { page ->
|
HorizontalPager(state = pagerState, contentPadding = it) { page ->
|
||||||
RefresheableBox(tabs[page].feedState, true) {
|
if (page >= 0 && page < feedTabs.size) {
|
||||||
if (tabs[page].useGridLayout) {
|
val tab = feedTabs[page]
|
||||||
SaveableGridFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState ->
|
RefresheableBox(tab.feedState, true) {
|
||||||
|
if (tab.useGridLayout) {
|
||||||
|
SaveableGridFeedContentState(tab.feedState, scrollStateKey = tab.scrollStateKey) { listState ->
|
||||||
RenderDiscoverFeed(
|
RenderDiscoverFeed(
|
||||||
feedContentState = tabs[page].feedState,
|
feedContentState = tab.feedState,
|
||||||
routeForLastRead = tabs[page].routeForLastRead,
|
routeForLastRead = tab.routeForLastRead,
|
||||||
forceEventKind = tabs[page].forceEventKind,
|
forceEventKind = tab.forceEventKind,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
SaveableFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState ->
|
SaveableFeedContentState(tab.feedState, scrollStateKey = tab.scrollStateKey) { listState ->
|
||||||
RenderDiscoverFeed(
|
RenderDiscoverFeed(
|
||||||
feedContentState = tabs[page].feedState,
|
feedContentState = tab.feedState,
|
||||||
routeForLastRead = tabs[page].routeForLastRead,
|
routeForLastRead = tab.routeForLastRead,
|
||||||
forceEventKind = tabs[page].forceEventKind,
|
forceEventKind = tab.forceEventKind,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
@@ -282,6 +288,7 @@ private fun DiscoverPages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+11
-2
@@ -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.FeedContentState
|
||||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||||
import com.vitorpamplona.amethyst.model.AROUND_ME
|
import com.vitorpamplona.amethyst.model.AROUND_ME
|
||||||
|
import com.vitorpamplona.amethyst.model.CHESS
|
||||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||||
@@ -234,7 +235,8 @@ fun HomeScreenFloatingButton(
|
|||||||
accountViewModel.account.settings.defaultHomeFollowList
|
accountViewModel.account.settings.defaultHomeFollowList
|
||||||
.collectAsStateWithLifecycle()
|
.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
if (list.value == AROUND_ME) {
|
when (list.value) {
|
||||||
|
AROUND_ME -> {
|
||||||
val location by Amethyst.instance.locationManager.geohashStateFlow
|
val location by Amethyst.instance.locationManager.geohashStateFlow
|
||||||
.collectAsStateWithLifecycle()
|
.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -247,9 +249,16 @@ fun HomeScreenFloatingButton(
|
|||||||
|
|
||||||
is LocationState.LocationResult.Loading -> { }
|
is LocationState.LocationResult.Loading -> { }
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
CHESS -> {
|
||||||
|
NewChessGameButton(accountViewModel, nav)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
NewNoteButton(nav)
|
NewNoteButton(nav)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+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.nip22Comments.CommentEvent
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
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.nip84Highlights.HighlightEvent
|
||||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||||
@@ -55,6 +58,8 @@ class HomeNewThreadFeedFilter(
|
|||||||
WikiNoteEvent.KIND,
|
WikiNoteEvent.KIND,
|
||||||
ClassifiedsEvent.KIND,
|
ClassifiedsEvent.KIND,
|
||||||
LongTextNoteEvent.KIND,
|
LongTextNoteEvent.KIND,
|
||||||
|
LiveChessGameChallengeEvent.KIND,
|
||||||
|
LiveChessGameEndEvent.KIND,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +123,10 @@ class HomeNewThreadFeedFilter(
|
|||||||
noteEvent is CommentEvent ||
|
noteEvent is CommentEvent ||
|
||||||
noteEvent is AudioTrackEvent ||
|
noteEvent is AudioTrackEvent ||
|
||||||
noteEvent is VoiceEvent ||
|
noteEvent is VoiceEvent ||
|
||||||
noteEvent is AudioHeaderEvent
|
noteEvent is AudioHeaderEvent ||
|
||||||
|
noteEvent is ChessGameEvent ||
|
||||||
|
noteEvent is LiveChessGameChallengeEvent ||
|
||||||
|
noteEvent is LiveChessGameEndEvent
|
||||||
) &&
|
) &&
|
||||||
filterParams.match(noteEvent, it.relays) &&
|
filterParams.match(noteEvent, it.relays) &&
|
||||||
it.isNewThread()
|
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.chat.LiveActivitiesChatMessageEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
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.nip84Highlights.HighlightEvent
|
||||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||||
@@ -60,6 +63,9 @@ val HomePostsNewThreadKinds =
|
|||||||
PollNoteEvent.KIND,
|
PollNoteEvent.KIND,
|
||||||
PollEvent.KIND,
|
PollEvent.KIND,
|
||||||
InteractiveStoryPrologueEvent.KIND,
|
InteractiveStoryPrologueEvent.KIND,
|
||||||
|
ChessGameEvent.KIND,
|
||||||
|
LiveChessGameChallengeEvent.KIND,
|
||||||
|
LiveChessGameEndEvent.KIND,
|
||||||
)
|
)
|
||||||
|
|
||||||
val HomePostsConversationKinds =
|
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.User
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
|
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.global.GlobalTopNavPerRelayFilterSet
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
|
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.filterHomePostsByGeohashes
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByGlobal
|
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.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.filterHomePostsByAllCommunities
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||||
@@ -62,6 +64,7 @@ class HomeOutboxEventsEoseManager(
|
|||||||
is AllCommunitiesTopNavPerRelayFilterSet -> filterHomePostsByAllCommunities(feedSettings, since, newThreadSince)
|
is AllCommunitiesTopNavPerRelayFilterSet -> filterHomePostsByAllCommunities(feedSettings, since, newThreadSince)
|
||||||
is AllFollowsTopNavPerRelayFilterSet -> filterHomePostsByAllFollows(feedSettings, since, newThreadSince, repliesSince)
|
is AllFollowsTopNavPerRelayFilterSet -> filterHomePostsByAllFollows(feedSettings, since, newThreadSince, repliesSince)
|
||||||
is AuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(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 GlobalTopNavPerRelayFilterSet -> filterHomePostsByGlobal(feedSettings, since, newThreadSince, repliesSince)
|
||||||
is HashtagTopNavPerRelayFilterSet -> filterHomePostsByHashtags(feedSettings, since, newThreadSince)
|
is HashtagTopNavPerRelayFilterSet -> filterHomePostsByHashtags(feedSettings, since, newThreadSince)
|
||||||
is LocationTopNavPerRelayFilterSet -> filterHomePostsByGeohashes(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_kind3follows_proxy">Follows via Proxy</string>
|
||||||
<string name="follow_list_aroundme">Around Me</string>
|
<string name="follow_list_aroundme">Around Me</string>
|
||||||
<string name="follow_list_global">Global</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_list_mute_list">Mute List</string>
|
||||||
|
|
||||||
<string name="follow_sets">Follow Lists</string>
|
<string name="follow_sets">Follow Lists</string>
|
||||||
@@ -1192,6 +1193,7 @@
|
|||||||
<string name="route_notifications">Notifications</string>
|
<string name="route_notifications">Notifications</string>
|
||||||
<string name="route_global">Global</string>
|
<string name="route_global">Global</string>
|
||||||
<string name="route_video">Shorts</string>
|
<string name="route_video">Shorts</string>
|
||||||
|
<string name="route_chess">Chess</string>
|
||||||
<string name="route_security_filters">Security Filters</string>
|
<string name="route_security_filters">Security Filters</string>
|
||||||
|
|
||||||
<string name="new_post">New Post</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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Article
|
import androidx.compose.material.icons.automirrored.filled.Article
|
||||||
import androidx.compose.material.icons.filled.Email
|
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.Home
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Person
|
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.AccountManager
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
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.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
|
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
|
||||||
@@ -120,6 +122,8 @@ sealed class DesktopScreen {
|
|||||||
|
|
||||||
object Notifications : DesktopScreen()
|
object Notifications : DesktopScreen()
|
||||||
|
|
||||||
|
object Chess : DesktopScreen()
|
||||||
|
|
||||||
object MyProfile : DesktopScreen()
|
object MyProfile : DesktopScreen()
|
||||||
|
|
||||||
data class UserProfile(
|
data class UserProfile(
|
||||||
@@ -415,6 +419,13 @@ fun MainContent(
|
|||||||
onClick = { onScreenChange(DesktopScreen.Notifications) },
|
onClick = { onScreenChange(DesktopScreen.Notifications) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
NavigationRailItem(
|
||||||
|
icon = { Icon(Icons.Default.Extension, contentDescription = "Chess") },
|
||||||
|
label = { Text("Chess") },
|
||||||
|
selected = currentScreen == DesktopScreen.Chess,
|
||||||
|
onClick = { onScreenChange(DesktopScreen.Chess) },
|
||||||
|
)
|
||||||
|
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
|
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
|
||||||
label = { Text("Profile") },
|
label = { Text("Profile") },
|
||||||
@@ -514,6 +525,14 @@ fun MainContent(
|
|||||||
NotificationsScreen(relayManager, account, subscriptionsCoordinator)
|
NotificationsScreen(relayManager, account, subscriptionsCoordinator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DesktopScreen.Chess -> {
|
||||||
|
ChessScreen(
|
||||||
|
relayManager = relayManager,
|
||||||
|
account = account,
|
||||||
|
onBack = { onScreenChange(DesktopScreen.Feed) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
DesktopScreen.MyProfile -> {
|
DesktopScreen.MyProfile -> {
|
||||||
UserProfileScreen(
|
UserProfileScreen(
|
||||||
pubKeyHex = account.pubKeyHex,
|
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,
|
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,
|
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.
|
* Creates a filter for contact list (kind 3) from a specific author.
|
||||||
*
|
*
|
||||||
@@ -200,6 +213,103 @@ object FilterBuilders {
|
|||||||
ids = ids,
|
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).
|
* 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).
|
* Creates a subscription config for user posts (kind 1).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -584,7 +584,8 @@ fun FeedScreen(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
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(
|
FeedNoteCard(
|
||||||
event = event,
|
event = event,
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
|
|||||||
+1
-1
@@ -226,7 +226,7 @@ fun NotificationsScreen(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
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)
|
NotificationCard(notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,7 +423,7 @@ fun ThreadScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reply notes with level indicators
|
// Reply notes with level indicators
|
||||||
items(replyEvents, key = { it.id }) { event ->
|
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
|
||||||
val level = calculateLevel(event)
|
val level = calculateLevel(event)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
+167
-15
@@ -37,8 +37,10 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
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.PersonAdd
|
||||||
import androidx.compose.material.icons.filled.PersonRemove
|
import androidx.compose.material.icons.filled.PersonRemove
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
@@ -46,7 +48,9 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction
|
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.EventCollectionState
|
||||||
import com.vitorpamplona.amethyst.commons.state.FollowState
|
import com.vitorpamplona.amethyst.commons.state.FollowState
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
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.amethyst.desktop.subscriptions.rememberSubscription
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -111,6 +118,13 @@ fun UserProfileScreen(
|
|||||||
var followersCount by remember { mutableStateOf(0) }
|
var followersCount by remember { mutableStateOf(0) }
|
||||||
var followingCount 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()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// User's posts
|
// User's posts
|
||||||
@@ -187,14 +201,26 @@ fun UserProfileScreen(
|
|||||||
relays = configuredRelays,
|
relays = configuredRelays,
|
||||||
pubKeyHex = pubKeyHex,
|
pubKeyHex = pubKeyHex,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
|
if (event is MetadataEvent) {
|
||||||
try {
|
try {
|
||||||
val content = event.content
|
val metadata = event.contactMetaData()
|
||||||
displayName = extractJsonField(content, "display_name") ?: extractJsonField(content, "name")
|
if (metadata != null) {
|
||||||
about = extractJsonField(content, "about")
|
displayName = metadata.displayName ?: metadata.name
|
||||||
picture = extractJsonField(content, "picture")
|
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) {
|
} catch (e: Exception) {
|
||||||
// Ignore parse errors
|
// Ignore parse errors
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -281,6 +307,19 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
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
|
// Header with back button
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
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) {
|
if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
Button(
|
Button(
|
||||||
@@ -569,7 +627,7 @@ fun UserProfileScreen(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(events, key = { it.id }) { event ->
|
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||||
FeedNoteCard(
|
FeedNoteCard(
|
||||||
event = event,
|
event = event,
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
@@ -586,17 +644,52 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Edit Profile Dialog
|
||||||
* Simple JSON field extractor (not production-ready, just for demo).
|
if (showEditDialog && account != null) {
|
||||||
*/
|
AlertDialog(
|
||||||
private fun extractJsonField(
|
onDismissRequest = { showEditDialog = false },
|
||||||
json: String,
|
title = { Text("Edit Profile") },
|
||||||
field: String,
|
text = {
|
||||||
): String? {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
val regex = """"$field"\s*:\s*"([^"]*)"""".toRegex()
|
OutlinedTextField(
|
||||||
return regex.find(json)?.groupValues?.get(1)
|
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")
|
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
|
// Websockets API
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.okhttpCoroutines)
|
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.seals.SealedRumorEvent
|
||||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||||
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
|
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.nip65RelayList.AdvertisedRelayListEvent
|
||||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||||
@@ -185,6 +192,13 @@ class EventFactory {
|
|||||||
CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig)
|
CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig)
|
CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
CalendarRSVPEvent.KIND -> CalendarRSVPEvent(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)
|
ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
ChannelListEvent.KIND -> ChannelListEvent(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