feat(chess): add platform adapters and enhance lobby state (steps 5-6)
- Add ChessLobbyState with completedGames, replaceGameState(), moveToCompleted() - Add challengerAvatarUrl to ChessChallenge - Add CompletedGame data class for game history Platform Adapters: - AndroidChessAdapter: AndroidChessPublisher, AndroidRelayFetcher, AndroidMetadataProvider - DesktopChessAdapter: DesktopChessPublisher, DesktopRelayFetcher, DesktopMetadataProvider Shared Infrastructure: - ChessRelayFetchHelper for one-shot relay queries - IUserMetadataProvider interface for platform-specific metadata - ChessFilterBuilder with simple filter methods for fetchers - ChessSubscriptionController interface for subscription management Fix ChessSubscription.kt to remove broken dataSources().chess reference (subscriptions now managed by ChessLobbyLogic) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -168,11 +168,6 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
@@ -604,36 +599,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: ChessGameEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LiveChessGameChallengeEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LiveChessGameAcceptEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LiveChessMoveEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LiveChessGameEndEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consumeRegularEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -2965,11 +2930,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
is CalendarDateSlotEvent -> consume(event, relay, wasVerified)
|
||||
is CalendarTimeSlotEvent -> consume(event, relay, wasVerified)
|
||||
is CalendarRSVPEvent -> consume(event, relay, wasVerified)
|
||||
is ChessGameEvent -> 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 ChannelCreateEvent -> consume(event, relay, wasVerified)
|
||||
is ChannelListEvent -> consume(event, relay, wasVerified)
|
||||
is ChannelHideMessageEvent -> consume(event, relay, wasVerified)
|
||||
|
||||
-3
@@ -31,7 +31,6 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
|
||||
@@ -80,7 +79,6 @@ class RelaySubscriptionsCoordinator(
|
||||
val hashtags = HashtagFilterAssembler(client)
|
||||
val geohashes = GeoHashFilterAssembler(client)
|
||||
val followPacks = FollowPackFeedFilterAssembler(client)
|
||||
val chess = ChessFilterAssembler(client)
|
||||
|
||||
// active when sending zaps via NWC
|
||||
val nwc = NWCPaymentFilterAssembler(client)
|
||||
@@ -103,7 +101,6 @@ class RelaySubscriptionsCoordinator(
|
||||
profile,
|
||||
hashtags,
|
||||
geohashes,
|
||||
chess,
|
||||
nwc,
|
||||
)
|
||||
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT 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.ChessEventPublisher
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetcher
|
||||
import com.vitorpamplona.amethyst.commons.chess.IUserMetadataProvider
|
||||
import com.vitorpamplona.amethyst.commons.chess.RelayGameSummary
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvents
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
|
||||
|
||||
/**
|
||||
* Android implementation of ChessEventPublisher.
|
||||
* Wraps account.signAndComputeBroadcast() for event publishing.
|
||||
*/
|
||||
class AndroidChessPublisher(
|
||||
private val account: Account,
|
||||
) : ChessEventPublisher {
|
||||
override suspend fun publishChallenge(
|
||||
gameId: String,
|
||||
playerColor: Color,
|
||||
opponentPubkey: String?,
|
||||
timeControl: String?,
|
||||
): Boolean =
|
||||
try {
|
||||
val template =
|
||||
LiveChessGameChallengeEvent.build(
|
||||
gameId = gameId,
|
||||
playerColor = playerColor,
|
||||
opponentPubkey = opponentPubkey,
|
||||
timeControl = timeControl,
|
||||
)
|
||||
account.signAndComputeBroadcast(template)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
override suspend fun publishAccept(
|
||||
gameId: String,
|
||||
challengeEventId: String,
|
||||
challengerPubkey: String,
|
||||
): Boolean =
|
||||
try {
|
||||
val template =
|
||||
LiveChessGameAcceptEvent.build(
|
||||
gameId = gameId,
|
||||
challengeEventId = challengeEventId,
|
||||
challengerPubkey = challengerPubkey,
|
||||
)
|
||||
account.signAndComputeBroadcast(template)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
override suspend fun publishMove(move: ChessMoveEvent): Boolean =
|
||||
try {
|
||||
val template =
|
||||
LiveChessMoveEvent.build(
|
||||
gameId = move.gameId,
|
||||
moveNumber = move.moveNumber,
|
||||
san = move.san,
|
||||
fen = move.fen,
|
||||
opponentPubkey = move.opponentPubkey,
|
||||
)
|
||||
account.signAndComputeBroadcast(template)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
override suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean =
|
||||
try {
|
||||
val template =
|
||||
LiveChessGameEndEvent.build(
|
||||
gameId = gameEnd.gameId,
|
||||
result = gameEnd.result,
|
||||
termination = gameEnd.termination,
|
||||
winnerPubkey = gameEnd.winnerPubkey,
|
||||
opponentPubkey = gameEnd.opponentPubkey,
|
||||
pgn = gameEnd.pgn ?: "",
|
||||
)
|
||||
account.signAndComputeBroadcast(template)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
override suspend fun publishDrawOffer(
|
||||
gameId: String,
|
||||
opponentPubkey: String,
|
||||
message: String?,
|
||||
): Boolean =
|
||||
try {
|
||||
val template =
|
||||
LiveChessDrawOfferEvent.build(
|
||||
gameId = gameId,
|
||||
opponentPubkey = opponentPubkey,
|
||||
message = message ?: "",
|
||||
)
|
||||
account.signAndComputeBroadcast(template)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
override fun getWriteRelayCount(): Int = account.outboxRelays.flow.value.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Android implementation of ChessRelayFetcher.
|
||||
* Uses LocalCache for game state (hybrid approach during migration).
|
||||
*/
|
||||
class AndroidRelayFetcher(
|
||||
private val account: Account,
|
||||
) : ChessRelayFetcher {
|
||||
override suspend fun fetchGameEvents(gameId: String): ChessGameEvents {
|
||||
// Query LocalCache for all game events
|
||||
val challengeNotes =
|
||||
LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note ->
|
||||
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
|
||||
event.gameId() == gameId
|
||||
}
|
||||
val challengeEvent = challengeNotes.firstOrNull()?.event as? LiveChessGameChallengeEvent
|
||||
|
||||
val acceptNotes =
|
||||
LocalCache.addressables.filterIntoSet(LiveChessGameAcceptEvent.KIND) { _, note ->
|
||||
val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false
|
||||
event.gameId() == gameId
|
||||
}
|
||||
val acceptEvent = acceptNotes.firstOrNull()?.event as? LiveChessGameAcceptEvent
|
||||
|
||||
val moveNotes =
|
||||
LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note ->
|
||||
val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
|
||||
event.gameId() == gameId
|
||||
}
|
||||
val moveEvents = moveNotes.mapNotNull { it.event as? LiveChessMoveEvent }
|
||||
|
||||
val endNotes =
|
||||
LocalCache.addressables.filterIntoSet(LiveChessGameEndEvent.KIND) { _, note ->
|
||||
val event = note.event as? LiveChessGameEndEvent ?: return@filterIntoSet false
|
||||
event.gameId() == gameId
|
||||
}
|
||||
val endEvent = endNotes.firstOrNull()?.event as? LiveChessGameEndEvent
|
||||
|
||||
return ChessGameEvents(
|
||||
challenge = challengeEvent,
|
||||
accept = acceptEvent,
|
||||
moves = moveEvents,
|
||||
end = endEvent,
|
||||
drawOffers = emptyList(), // TODO: Fetch draw offers
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun fetchChallenges(): List<LiveChessGameChallengeEvent> {
|
||||
val challengeNotes =
|
||||
LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note ->
|
||||
note.event is LiveChessGameChallengeEvent
|
||||
}
|
||||
return challengeNotes.mapNotNull { it.event as? LiveChessGameChallengeEvent }
|
||||
}
|
||||
|
||||
override suspend fun fetchRecentGames(): List<RelayGameSummary> {
|
||||
// Query LocalCache for recent active games
|
||||
// Group moves by game ID and create summaries
|
||||
val moveNotes =
|
||||
LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note ->
|
||||
note.event is LiveChessMoveEvent
|
||||
}
|
||||
val gameIds = moveNotes.mapNotNull { (it.event as? LiveChessMoveEvent)?.gameId() }.distinct()
|
||||
|
||||
return gameIds.mapNotNull { gameId ->
|
||||
val events = fetchGameEvents(gameId)
|
||||
val challenge = events.challenge ?: return@mapNotNull null
|
||||
val accept = events.accept
|
||||
|
||||
// Determine white/black pubkeys
|
||||
val challengerColor = challenge.playerColor() ?: Color.WHITE
|
||||
val whitePubkey =
|
||||
if (challengerColor == Color.WHITE) {
|
||||
challenge.pubKey
|
||||
} else {
|
||||
accept?.pubKey ?: return@mapNotNull null
|
||||
}
|
||||
val blackPubkey =
|
||||
if (challengerColor == Color.BLACK) {
|
||||
challenge.pubKey
|
||||
} else {
|
||||
accept?.pubKey ?: return@mapNotNull null
|
||||
}
|
||||
|
||||
val lastMove = events.moves.maxByOrNull { it.createdAt }
|
||||
|
||||
RelayGameSummary(
|
||||
gameId = gameId,
|
||||
whitePubkey = whitePubkey,
|
||||
blackPubkey = blackPubkey,
|
||||
moveCount = events.moves.size,
|
||||
lastMoveTime = lastMove?.createdAt ?: challenge.createdAt,
|
||||
isActive = events.end == null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.info?.bestName()
|
||||
?: user.pubkeyDisplayHex()
|
||||
}
|
||||
|
||||
override fun getPictureUrl(pubkey: String): String? {
|
||||
val user = LocalCache.getOrCreateUser(pubkey)
|
||||
return user.info?.profilePicture()
|
||||
}
|
||||
}
|
||||
+178
-18
@@ -23,21 +23,32 @@ 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
|
||||
@@ -82,14 +93,29 @@ fun ChessGameScreen(
|
||||
)
|
||||
|
||||
val activeGames by chessViewModel.activeGames.collectAsState()
|
||||
val spectatingGames by chessViewModel.spectatingGames.collectAsState()
|
||||
val error by chessViewModel.error.collectAsState()
|
||||
val chessStatus by chessViewModel.chessStatus.collectAsState()
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var loadedGameState by remember { mutableStateOf<LiveChessGameState?>(null) }
|
||||
var loadAttempted by remember { mutableStateOf(false) }
|
||||
var showRelaySettings by remember { mutableStateOf(false) }
|
||||
|
||||
// Try to load game from cache if not in activeGames
|
||||
LaunchedEffect(gameId, activeGames) {
|
||||
val existingState = activeGames[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)
|
||||
|
||||
// Try to load game from cache if not in activeGames or spectatingGames
|
||||
LaunchedEffect(gameId, activeGames, spectatingGames) {
|
||||
val existingState = activeGames[gameId] ?: spectatingGames[gameId]
|
||||
if (existingState != null) {
|
||||
loadedGameState = existingState
|
||||
isLoading = false
|
||||
@@ -103,9 +129,9 @@ fun ChessGameScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Update state when activeGames changes (e.g., after refresh loads new moves)
|
||||
LaunchedEffect(activeGames[gameId]) {
|
||||
activeGames[gameId]?.let {
|
||||
// Update state when games change (e.g., after refresh loads new moves)
|
||||
LaunchedEffect(activeGames[gameId], spectatingGames[gameId]) {
|
||||
(activeGames[gameId] ?: spectatingGames[gameId])?.let {
|
||||
loadedGameState = it
|
||||
}
|
||||
}
|
||||
@@ -118,21 +144,47 @@ fun ChessGameScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val gameState = loadedGameState ?: activeGames[gameId]
|
||||
val gameState = loadedGameState ?: activeGames[gameId] ?: spectatingGames[gameId]
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Chess Game") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { nav.popBack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = { Text("Chess Game") },
|
||||
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",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Status banner below top bar
|
||||
ChessStatusBanner(
|
||||
status = chessStatus,
|
||||
onTap = {
|
||||
when (chessStatus) {
|
||||
is ChessStatus.MoveFailed -> {
|
||||
// Could implement retry logic here
|
||||
}
|
||||
is ChessStatus.Desynced -> {
|
||||
chessViewModel.forceRefresh()
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
when {
|
||||
@@ -214,4 +266,112 @@ fun ChessGameScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+423
-37
@@ -31,24 +31,30 @@ 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.Add
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.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
|
||||
@@ -71,6 +77,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -92,15 +99,27 @@ fun ChessLobbyScreen(
|
||||
)
|
||||
|
||||
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 chessStatus by chessViewModel.chessStatus.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)
|
||||
|
||||
@@ -139,6 +158,14 @@ fun ChessLobbyScreen(
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showRelaySettings = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "Relay Settings",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
@@ -170,6 +197,13 @@ fun ChessLobbyScreen(
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
// Broadcast status banner
|
||||
ChessStatusBanner(
|
||||
status = chessStatus,
|
||||
onTap = { /* no-op in lobby */ },
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
// Error display
|
||||
error?.let { errorMsg ->
|
||||
Card(
|
||||
@@ -193,6 +227,8 @@ fun ChessLobbyScreen(
|
||||
ChessLobbyContent(
|
||||
challenges = challenges,
|
||||
activeGames = activeGames,
|
||||
spectatingGames = spectatingGames,
|
||||
publicGames = publicGames,
|
||||
userPubkey = userPubkey,
|
||||
accountViewModel = accountViewModel,
|
||||
onAcceptChallenge = { note ->
|
||||
@@ -201,6 +237,10 @@ fun ChessLobbyScreen(
|
||||
onSelectGame = { gameId ->
|
||||
nav.nav(Route.ChessGame(gameId))
|
||||
},
|
||||
onWatchGame = { gameId ->
|
||||
chessViewModel.loadGameAsSpectator(gameId)
|
||||
nav.nav(Route.ChessGame(gameId))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -216,18 +256,41 @@ fun ChessLobbyScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 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<Note>,
|
||||
activeGames: Map<String, LiveChessGameState>,
|
||||
spectatingGames: Map<String, LiveChessGameState>,
|
||||
publicGames: List<PublicGameInfo>,
|
||||
userPubkey: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
onAcceptChallenge: (Note) -> Unit,
|
||||
onSelectGame: (String) -> Unit,
|
||||
onWatchGame: (String) -> Unit,
|
||||
) {
|
||||
if (activeGames.isEmpty() && challenges.isEmpty()) {
|
||||
val hasContent =
|
||||
activeGames.isNotEmpty() || spectatingGames.isNotEmpty() ||
|
||||
publicGames.isNotEmpty() || challenges.isNotEmpty()
|
||||
|
||||
if (!hasContent) {
|
||||
// Empty state
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -255,18 +318,18 @@ fun ChessLobbyContent(
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Active games section
|
||||
// Active games section (user is participant)
|
||||
if (activeGames.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Active Games",
|
||||
"Your Games",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
items(activeGames.entries.toList(), key = { it.key }) { (gameId, state) ->
|
||||
items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) ->
|
||||
ActiveGameCard(
|
||||
gameId = gameId,
|
||||
opponentPubkey = state.opponentPubkey,
|
||||
@@ -277,6 +340,28 @@ fun ChessLobbyContent(
|
||||
}
|
||||
}
|
||||
|
||||
// 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) ->
|
||||
SpectatingGameCard(
|
||||
gameId = gameId,
|
||||
gameState = state,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = { onSelectGame(gameId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Incoming challenges
|
||||
val incomingChallenges =
|
||||
challenges.filter {
|
||||
@@ -294,7 +379,7 @@ fun ChessLobbyContent(
|
||||
)
|
||||
}
|
||||
|
||||
items(incomingChallenges, key = { it.idHex }) { note ->
|
||||
items(incomingChallenges, key = { "incoming-${it.idHex}" }) { note ->
|
||||
ChallengeCard(
|
||||
note = note,
|
||||
isIncoming = true,
|
||||
@@ -304,31 +389,7 @@ fun ChessLobbyContent(
|
||||
}
|
||||
}
|
||||
|
||||
// User's outgoing challenges
|
||||
val outgoingChallenges =
|
||||
challenges.filter {
|
||||
it.author?.pubkeyHex == userPubkey
|
||||
}
|
||||
if (outgoingChallenges.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Your Challenges",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
items(outgoingChallenges, key = { it.idHex }) { note ->
|
||||
OutgoingChallengeCard(
|
||||
note = note,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Open challenges from others
|
||||
// Open challenges from others (can join)
|
||||
val openChallenges =
|
||||
challenges.filter {
|
||||
val event = it.event as? LiveChessGameChallengeEvent
|
||||
@@ -345,7 +406,7 @@ fun ChessLobbyContent(
|
||||
)
|
||||
}
|
||||
|
||||
items(openChallenges, key = { it.idHex }) { note ->
|
||||
items(openChallenges, key = { "open-${it.idHex}" }) { note ->
|
||||
ChallengeCard(
|
||||
note = note,
|
||||
isIncoming = false,
|
||||
@@ -355,6 +416,51 @@ fun ChessLobbyContent(
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ->
|
||||
PublicGameCard(
|
||||
game = game,
|
||||
accountViewModel = accountViewModel,
|
||||
onWatch = { onWatchGame(game.gameId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// User's outgoing challenges (last, as they're just waiting)
|
||||
val outgoingChallenges =
|
||||
challenges.filter {
|
||||
it.author?.pubkeyHex == userPubkey
|
||||
}
|
||||
if (outgoingChallenges.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Your Challenges",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
items(outgoingChallenges, key = { "outgoing-${it.idHex}" }) { note ->
|
||||
OutgoingChallengeCard(
|
||||
note = note,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom padding for FAB
|
||||
item {
|
||||
Spacer(Modifier.height(80.dp))
|
||||
@@ -375,6 +481,12 @@ private fun ActiveGameCard(
|
||||
accountViewModel.checkGetOrCreateUser(opponentPubkey)?.toBestDisplayName() ?: opponentPubkey.take(8)
|
||||
}
|
||||
|
||||
// Extract human-readable game name if available
|
||||
val gameName =
|
||||
remember(gameId) {
|
||||
ChessGameNameGenerator.extractDisplayName(gameId) ?: gameId.take(12)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
border =
|
||||
@@ -391,14 +503,14 @@ private fun ActiveGameCard(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"vs $displayName",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
gameName,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
"Game: ${gameId.take(12)}...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
"vs $displayName",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -515,3 +627,277 @@ private fun OutgoingChallengeCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpectatingGameCard(
|
||||
gameId: String,
|
||||
gameState: LiveChessGameState,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val moveCount =
|
||||
gameState.moveHistory
|
||||
.collectAsState()
|
||||
.value.size
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
border = BorderStroke(1.dp, Color(0xFF9C27B0)), // Purple for spectating
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
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 = Color(0xFF9C27B0),
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PublicGameCard(
|
||||
game: PublicGameInfo,
|
||||
accountViewModel: AccountViewModel,
|
||||
onWatch: () -> Unit,
|
||||
) {
|
||||
val whiteName =
|
||||
remember(game.whitePubkey) {
|
||||
accountViewModel.checkGetOrCreateUser(game.whitePubkey)?.toBestDisplayName() ?: game.whitePubkey.take(8)
|
||||
}
|
||||
val blackName =
|
||||
remember(game.blackPubkey) {
|
||||
accountViewModel.checkGetOrCreateUser(game.blackPubkey)?.toBestDisplayName() ?: game.blackPubkey.take(8)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
border = BorderStroke(1.dp, Color(0xFF2196F3)), // Blue for live games
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"$whiteName vs $blackName",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"${game.moveCount} moves",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(onClick = onWatch) {
|
||||
Text("Watch")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
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))
|
||||
|
||||
// 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) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = "Connected",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
Text(
|
||||
text = relayUrl.removePrefix("wss://").removePrefix("ws://"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+46
-154
@@ -25,75 +25,61 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder
|
||||
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionState
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Default "since" window for challenges (24 hours)
|
||||
* Challenges older than this are considered expired anyway
|
||||
*/
|
||||
private const val CHALLENGE_WINDOW_SECONDS = 24 * 60 * 60L
|
||||
|
||||
/**
|
||||
* Default "since" window for game events when no EOSE cache exists (7 days)
|
||||
* This prevents loading ancient game history on first connection
|
||||
*/
|
||||
private const val GAME_EVENT_WINDOW_SECONDS = 7 * 24 * 60 * 60L
|
||||
|
||||
/**
|
||||
* Subscribe to chess events when the Chess tab is active.
|
||||
* Respects the Global/Follows filter from the discovery top bar.
|
||||
* Subscribe to chess events when the Chess screen is active.
|
||||
*
|
||||
* Always uses global relays to ensure we see all chess events,
|
||||
* regardless of the user's feed filter setting (Global/Follows).
|
||||
*/
|
||||
@Composable
|
||||
fun ChessSubscription(
|
||||
accountViewModel: AccountViewModel,
|
||||
chessViewModel: ChessViewModel,
|
||||
) {
|
||||
// Observe the current filter selection (Global, All Follows, etc.)
|
||||
val filterSelection by accountViewModel.account.settings.defaultDiscoveryFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val isGlobal = filterSelection == GLOBAL_FOLLOWS
|
||||
|
||||
// Get active game IDs from the view model for game-specific subscriptions
|
||||
val activeGames by chessViewModel.activeGames.collectAsStateWithLifecycle()
|
||||
val activeGameIds = activeGames.keys
|
||||
val spectatingGames by chessViewModel.spectatingGames.collectAsStateWithLifecycle()
|
||||
val activeGameIds = activeGames.keys + spectatingGames.keys
|
||||
|
||||
// Extract opponent pubkeys for move filtering
|
||||
val opponentPubkeys =
|
||||
remember(activeGames) {
|
||||
activeGames.values.map { it.opponentPubkey }.toSet()
|
||||
}
|
||||
|
||||
val state =
|
||||
remember(accountViewModel.account, isGlobal, activeGameIds) {
|
||||
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,
|
||||
// Use proxy relays for global, outbox relays for follows
|
||||
globalRelays =
|
||||
if (isGlobal) {
|
||||
accountViewModel.account.defaultGlobalRelays.flow.value
|
||||
} else {
|
||||
accountViewModel.account.followOutboxesOrProxy.flow.value
|
||||
},
|
||||
isGlobal = isGlobal,
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
|
||||
// Note: Chess subscription is now managed internally by ChessLobbyLogic.
|
||||
// This composable is kept for backward compatibility but the actual
|
||||
// subscription is handled by the ViewModel's ChessLobbyLogic instance.
|
||||
// TODO: Step 7 will integrate this with the new ChessSubscriptionController
|
||||
DisposableEffect(state) {
|
||||
accountViewModel.dataSources().chess.subscribe(state)
|
||||
chessViewModel.refreshChallenges()
|
||||
onDispose {
|
||||
accountViewModel.dataSources().chess.unsubscribe(state)
|
||||
// Subscription cleanup handled by ViewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,6 +93,7 @@ data class ChessQueryState(
|
||||
val globalRelays: Set<NormalizedRelayUrl>,
|
||||
val isGlobal: Boolean,
|
||||
val activeGameIds: Set<String> = emptySet(),
|
||||
val opponentPubkeys: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -143,128 +130,33 @@ class ChessFeedFilterSubAssembler(
|
||||
}
|
||||
|
||||
/**
|
||||
* Create relay filters for chess events.
|
||||
* Create relay filters for chess events using the shared [ChessFilterBuilder].
|
||||
*
|
||||
* Creates filters based on the Global/Follows setting:
|
||||
* - Global: Fetch all chess events from global relays
|
||||
* - Follows: Fetch from followed users' outbox relays
|
||||
* 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
|
||||
*
|
||||
* Also always fetches challenges directed at the user from inbox relays.
|
||||
*
|
||||
* Improvements over basic implementation:
|
||||
* 1. Default "since" timestamps to avoid loading very old events on first connection
|
||||
* 2. Separate challenge filter (shorter window) from game events (longer window)
|
||||
* 3. Game-specific subscriptions for active games using #a tag references
|
||||
* 4. Grouped filters by relay to reduce subscription overhead
|
||||
* This ensures we see:
|
||||
* - Open challenges from anyone
|
||||
* - Challenges directed at us
|
||||
* - Public games to spectate
|
||||
*/
|
||||
fun filterChessEvents(
|
||||
key: ChessQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
val filters = mutableListOf<RelayBasedFilter>()
|
||||
val now = TimeUtils.now()
|
||||
|
||||
// Challenge kinds only (for lobby display)
|
||||
val challengeKinds =
|
||||
listOf(
|
||||
LiveChessGameChallengeEvent.KIND,
|
||||
LiveChessGameAcceptEvent.KIND,
|
||||
// Convert Android ChessQueryState to shared ChessSubscriptionState
|
||||
val state =
|
||||
ChessSubscriptionState(
|
||||
userPubkey = key.userPubkey,
|
||||
relays = key.inboxRelays + key.globalRelays,
|
||||
activeGameIds = key.activeGameIds,
|
||||
opponentPubkeys = key.opponentPubkeys,
|
||||
)
|
||||
|
||||
// Move/end kinds (for active games)
|
||||
val gameEventKinds =
|
||||
listOf(
|
||||
LiveChessMoveEvent.KIND,
|
||||
LiveChessGameEndEvent.KIND,
|
||||
)
|
||||
|
||||
// All chess kinds
|
||||
val allChessKinds = challengeKinds + gameEventKinds
|
||||
|
||||
// ========================================
|
||||
// Filter 1: Personal challenges from inbox relays
|
||||
// Uses 24h window for challenges (they expire anyway)
|
||||
// ========================================
|
||||
val inboxFilter =
|
||||
Filter(
|
||||
kinds = allChessKinds,
|
||||
tags = mapOf("p" to listOf(key.userPubkey)),
|
||||
limit = 100,
|
||||
)
|
||||
|
||||
for (relay in key.inboxRelays) {
|
||||
val sinceTime = since?.get(relay)?.time ?: (now - CHALLENGE_WINDOW_SECONDS)
|
||||
filters.add(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter = inboxFilter.copy(since = sinceTime),
|
||||
),
|
||||
)
|
||||
// Use shared filter builder for consistent behavior with Desktop
|
||||
return ChessFilterBuilder.buildAllFilters(state) { relay ->
|
||||
since?.get(relay)?.time
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Filter 2: General challenges from global/outbox relays
|
||||
// Uses 24h window to show recent open challenges
|
||||
// ========================================
|
||||
val globalChallengeFilter =
|
||||
Filter(
|
||||
kinds = challengeKinds,
|
||||
limit = 50,
|
||||
)
|
||||
|
||||
for (relay in key.globalRelays) {
|
||||
val sinceTime = since?.get(relay)?.time ?: (now - CHALLENGE_WINDOW_SECONDS)
|
||||
filters.add(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter = globalChallengeFilter.copy(since = sinceTime),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Filter 3: Active game events
|
||||
// Uses longer window (7 days) or no window for active games
|
||||
// Follows jesterui pattern: subscribe to game-specific events via #d tag
|
||||
// ========================================
|
||||
if (key.activeGameIds.isNotEmpty()) {
|
||||
// Create filters for each active game's events
|
||||
// Using #d tag to match the game_id in addressable events
|
||||
val gameSpecificFilter =
|
||||
Filter(
|
||||
kinds = gameEventKinds,
|
||||
tags = mapOf("d" to key.activeGameIds.toList()),
|
||||
limit = 200, // More moves per game
|
||||
)
|
||||
|
||||
// Query all relays for active game events (no since - need full history)
|
||||
val allRelays = key.inboxRelays + key.globalRelays
|
||||
for (relay in allRelays) {
|
||||
filters.add(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter = gameSpecificFilter,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// No active games - use general game event filter with window
|
||||
val generalGameFilter =
|
||||
Filter(
|
||||
kinds = gameEventKinds,
|
||||
limit = 100,
|
||||
)
|
||||
|
||||
for (relay in key.globalRelays) {
|
||||
val sinceTime = since?.get(relay)?.time ?: (now - GAME_EVENT_WINDOW_SECONDS)
|
||||
filters.add(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter = generalGameFilter.copy(since = sinceTime),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
|
||||
+442
-32
@@ -29,14 +29,17 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.nip64Chess.GameResult
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.PieceType
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -44,7 +47,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* ViewModel for managing chess game state, challenges, and event publishing
|
||||
@@ -64,11 +66,19 @@ class ChessViewModel(
|
||||
const val CLEANUP_INTERVAL_MS = 5 * 60 * 1000L
|
||||
}
|
||||
|
||||
// Active games being played
|
||||
// Active games being played (user is a participant)
|
||||
private val _activeGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap())
|
||||
val activeGames: StateFlow<Map<String, LiveChessGameState>> = _activeGames.asStateFlow()
|
||||
|
||||
// Pending challenges (incoming and outgoing)
|
||||
// Public games we can spectate (user is NOT a participant)
|
||||
private val _publicGames = MutableStateFlow<List<PublicGameInfo>>(emptyList())
|
||||
val publicGames: StateFlow<List<PublicGameInfo>> = _publicGames.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()
|
||||
|
||||
// Pending challenges (ALL non-expired challenges)
|
||||
private val _challenges = MutableStateFlow<List<Note>>(emptyList())
|
||||
val challenges: StateFlow<List<Note>> = _challenges.asStateFlow()
|
||||
|
||||
@@ -88,6 +98,14 @@ class ChessViewModel(
|
||||
private val _pendingRetries = MutableStateFlow<Map<String, RetryOperation>>(emptyMap())
|
||||
val pendingRetries: StateFlow<Map<String, RetryOperation>> = _pendingRetries.asStateFlow()
|
||||
|
||||
// Chess status for UI feedback (broadcasting, syncing, etc.)
|
||||
private val _chessStatus = MutableStateFlow<ChessStatus>(ChessStatus.Idle)
|
||||
val chessStatus: StateFlow<ChessStatus> = _chessStatus.asStateFlow()
|
||||
|
||||
// Connected relays for display
|
||||
private val _connectedRelays = MutableStateFlow<List<String>>(emptyList())
|
||||
val connectedRelays: StateFlow<List<String>> = _connectedRelays.asStateFlow()
|
||||
|
||||
// Polling delegate for periodic refresh
|
||||
private val pollingDelegate =
|
||||
ChessPollingDelegate(
|
||||
@@ -101,6 +119,9 @@ class ChessViewModel(
|
||||
},
|
||||
)
|
||||
|
||||
// Track games currently being created to prevent race conditions (like Desktop)
|
||||
private val gamesBeingCreated = mutableSetOf<String>()
|
||||
|
||||
init {
|
||||
subscribeToChessEvents()
|
||||
// Start polling - will do initial fetch
|
||||
@@ -201,6 +222,11 @@ class ChessViewModel(
|
||||
* Handle game acceptance event
|
||||
*/
|
||||
private fun handleGameAccepted(event: LiveChessGameAcceptEvent) {
|
||||
val gameId = event.gameId() ?: return
|
||||
|
||||
// Skip if game already exists (prevents overwrite)
|
||||
if (_activeGames.value.containsKey(gameId)) return
|
||||
|
||||
// Check if this is acceptance of our challenge
|
||||
if (event.challengerPubkey() == account.signer.pubKey) {
|
||||
startGameFromAcceptance(event)
|
||||
@@ -267,6 +293,14 @@ class ChessViewModel(
|
||||
val gameId = generateGameId()
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val relayCount = acc.outboxRelays.flow.value.size
|
||||
_chessStatus.value =
|
||||
ChessStatus.BroadcastingMove(
|
||||
san = "Challenge",
|
||||
successCount = 0,
|
||||
totalRelays = relayCount,
|
||||
)
|
||||
|
||||
val success =
|
||||
retryWithBackoff("challenge-$gameId") {
|
||||
val template =
|
||||
@@ -281,8 +315,23 @@ class ChessViewModel(
|
||||
}
|
||||
|
||||
if (success) {
|
||||
_chessStatus.value =
|
||||
ChessStatus.MoveSuccess(
|
||||
san = "Challenge",
|
||||
relayCount = relayCount,
|
||||
)
|
||||
_error.value = null
|
||||
refreshChallenges()
|
||||
delay(3000)
|
||||
if (_chessStatus.value is ChessStatus.MoveSuccess) {
|
||||
_chessStatus.value = ChessStatus.Idle
|
||||
}
|
||||
} else {
|
||||
_chessStatus.value =
|
||||
ChessStatus.MoveFailed(
|
||||
san = "Challenge",
|
||||
error = "Failed after $MAX_RETRIES attempts",
|
||||
)
|
||||
_error.value = "Failed to create challenge after $MAX_RETRIES attempts"
|
||||
}
|
||||
}
|
||||
@@ -297,6 +346,11 @@ class ChessViewModel(
|
||||
challengerPubkey: String,
|
||||
playerColor: Color,
|
||||
) {
|
||||
// Mark as being created to prevent duplicate from relay echo
|
||||
synchronized(gamesBeingCreated) {
|
||||
if (!gamesBeingCreated.add(gameId)) return // Already being created
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val template =
|
||||
@@ -308,6 +362,16 @@ class ChessViewModel(
|
||||
|
||||
account.signAndComputeBroadcast(template)
|
||||
|
||||
// Check if game was already created by relay echo while we were broadcasting
|
||||
if (_activeGames.value.containsKey(gameId)) {
|
||||
synchronized(gamesBeingCreated) {
|
||||
gamesBeingCreated.remove(gameId)
|
||||
}
|
||||
_selectedGameId.value = gameId
|
||||
_error.value = null
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Create game state
|
||||
val engine = ChessEngine()
|
||||
engine.reset()
|
||||
@@ -322,10 +386,16 @@ class ChessViewModel(
|
||||
)
|
||||
|
||||
_activeGames.value = _activeGames.value + (gameId to gameState)
|
||||
synchronized(gamesBeingCreated) {
|
||||
gamesBeingCreated.remove(gameId)
|
||||
}
|
||||
pollingDelegate.addGameId(gameId)
|
||||
_selectedGameId.value = gameId
|
||||
_error.value = null
|
||||
} catch (e: Exception) {
|
||||
synchronized(gamesBeingCreated) {
|
||||
gamesBeingCreated.remove(gameId)
|
||||
}
|
||||
_error.value = "Failed to accept challenge: ${e.message}"
|
||||
}
|
||||
}
|
||||
@@ -338,6 +408,13 @@ class ChessViewModel(
|
||||
val challengeEvent = challengeNote.event as? LiveChessGameChallengeEvent ?: return
|
||||
|
||||
val gameId = challengeEvent.gameId() ?: return
|
||||
|
||||
// Skip if game already exists (prevents overwrite)
|
||||
if (_activeGames.value.containsKey(gameId)) {
|
||||
_selectedGameId.value = gameId
|
||||
return
|
||||
}
|
||||
|
||||
val challengerPubkey = challengeEvent.pubKey
|
||||
val challengerColor = challengeEvent.playerColor() ?: Color.WHITE
|
||||
val playerColor = challengerColor.opposite()
|
||||
@@ -353,6 +430,13 @@ class ChessViewModel(
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val gameId = acceptEvent.gameId() ?: return@launch
|
||||
|
||||
// Skip if game already exists or is being created (prevents overwrite)
|
||||
if (_activeGames.value.containsKey(gameId)) return@launch
|
||||
synchronized(gamesBeingCreated) {
|
||||
if (!gamesBeingCreated.add(gameId)) return@launch // Already being created
|
||||
}
|
||||
|
||||
val opponentPubkey = acceptEvent.pubKey
|
||||
|
||||
// Find our challenge to get player color
|
||||
@@ -376,6 +460,9 @@ class ChessViewModel(
|
||||
)
|
||||
|
||||
_activeGames.value = _activeGames.value + (gameId to gameState)
|
||||
synchronized(gamesBeingCreated) {
|
||||
gamesBeingCreated.remove(gameId)
|
||||
}
|
||||
pollingDelegate.addGameId(gameId)
|
||||
_selectedGameId.value = gameId
|
||||
}
|
||||
@@ -390,12 +477,36 @@ class ChessViewModel(
|
||||
to: String,
|
||||
) {
|
||||
val gameState = _activeGames.value[gameId] ?: return
|
||||
val moveResult = gameState.makeMove(from, to)
|
||||
|
||||
// Parse promotion from 'to' if present (e.g., "e8q" -> square="e8", promotion=QUEEN)
|
||||
val (targetSquare, promotion) = parsePromotionFromTarget(to)
|
||||
|
||||
val moveResult = gameState.makeMove(from, targetSquare, promotion)
|
||||
if (moveResult != null) {
|
||||
publishMove(gameId, moveResult)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse promotion piece from target square string.
|
||||
* e.g., "e8q" -> ("e8", QUEEN), "e4" -> ("e4", null)
|
||||
*/
|
||||
private fun parsePromotionFromTarget(to: String): Pair<String, PieceType?> {
|
||||
if (to.length == 3) {
|
||||
val square = to.substring(0, 2)
|
||||
val promotion =
|
||||
when (to[2].lowercaseChar()) {
|
||||
'q' -> PieceType.QUEEN
|
||||
'r' -> PieceType.ROOK
|
||||
'b' -> PieceType.BISHOP
|
||||
'n' -> PieceType.KNIGHT
|
||||
else -> null
|
||||
}
|
||||
return square to promotion
|
||||
}
|
||||
return to to null
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a move for the current game (full version with ChessMoveEvent)
|
||||
*/
|
||||
@@ -405,6 +516,15 @@ class ChessViewModel(
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// Update status to broadcasting
|
||||
val relayCount = account.outboxRelays.flow.value.size
|
||||
_chessStatus.value =
|
||||
ChessStatus.BroadcastingMove(
|
||||
san = moveEvent.san,
|
||||
successCount = 0,
|
||||
totalRelays = relayCount,
|
||||
)
|
||||
|
||||
val template =
|
||||
LiveChessMoveEvent.build(
|
||||
gameId = moveEvent.gameId,
|
||||
@@ -415,8 +535,33 @@ class ChessViewModel(
|
||||
)
|
||||
|
||||
account.signAndComputeBroadcast(template)
|
||||
|
||||
// Update status to success
|
||||
_chessStatus.value =
|
||||
ChessStatus.MoveSuccess(
|
||||
san = moveEvent.san,
|
||||
relayCount = relayCount,
|
||||
)
|
||||
|
||||
// Clear status after delay
|
||||
delay(3000)
|
||||
if (_chessStatus.value is ChessStatus.MoveSuccess) {
|
||||
val gameState = _activeGames.value[gameId]
|
||||
_chessStatus.value =
|
||||
if (gameState?.isPlayerTurn() == false) {
|
||||
ChessStatus.WaitingForOpponent
|
||||
} else {
|
||||
ChessStatus.Idle
|
||||
}
|
||||
}
|
||||
|
||||
_error.value = null
|
||||
} catch (e: Exception) {
|
||||
_chessStatus.value =
|
||||
ChessStatus.MoveFailed(
|
||||
san = moveEvent.san,
|
||||
error = e.message ?: "Unknown error",
|
||||
)
|
||||
_error.value = "Failed to publish move: ${e.message}"
|
||||
}
|
||||
}
|
||||
@@ -508,28 +653,241 @@ class ChessViewModel(
|
||||
|
||||
/**
|
||||
* Refresh challenges from local cache
|
||||
*
|
||||
* Following jesterui pattern: fetch ALL non-expired challenges,
|
||||
* let UI filter by category (incoming, outgoing, open)
|
||||
*
|
||||
* NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables
|
||||
*/
|
||||
fun refreshChallenges() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val userPubkey = account.signer.pubKey
|
||||
val now = TimeUtils.now()
|
||||
|
||||
// Query LocalCache for chess challenge events
|
||||
// Query LocalCache.addressables for chess challenge events (kind 30064)
|
||||
// Chess events are addressable events, not regular notes!
|
||||
val challengeNotes =
|
||||
LocalCache.notes.filterIntoSet { _, note ->
|
||||
LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note ->
|
||||
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
|
||||
|
||||
// Check if challenge is not expired
|
||||
// Check if challenge is not expired (24h)
|
||||
val createdAt = event.createdAt
|
||||
if ((now - createdAt) >= CHALLENGE_EXPIRY_SECONDS) return@filterIntoSet false
|
||||
|
||||
// Include challenges directed at us, or open challenges (not from us)
|
||||
// Check if game isn't already active (accepted)
|
||||
val gameId = event.gameId() ?: return@filterIntoSet false
|
||||
if (_activeGames.value.containsKey(gameId)) return@filterIntoSet false
|
||||
|
||||
// Include all challenges:
|
||||
// - Directed at us
|
||||
// - Open challenges (no specific opponent)
|
||||
// - Our own challenges (waiting for accept)
|
||||
val opponentPubkey = event.opponentPubkey()
|
||||
opponentPubkey == userPubkey || (opponentPubkey == null && event.pubKey != userPubkey) || event.pubKey == userPubkey
|
||||
val isDirectedAtUs = opponentPubkey == userPubkey
|
||||
val isOpenChallenge = opponentPubkey == null
|
||||
val isFromUs = event.pubKey == userPubkey
|
||||
|
||||
isDirectedAtUs || isOpenChallenge || isFromUs
|
||||
}
|
||||
|
||||
_challenges.value = challengeNotes.toList()
|
||||
updateBadgeCount()
|
||||
|
||||
// Also refresh public games
|
||||
refreshPublicGames()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh list of public games that can be spectated
|
||||
*
|
||||
* NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables
|
||||
*/
|
||||
private fun refreshPublicGames() {
|
||||
val userPubkey = account.signer.pubKey
|
||||
val now = TimeUtils.now()
|
||||
|
||||
// Find all games where both challenge and accept exist
|
||||
// Game ID -> (ChallengeEvent, AcceptEvent)
|
||||
val gameData = mutableMapOf<String, Pair<LiveChessGameChallengeEvent?, LiveChessGameAcceptEvent?>>()
|
||||
|
||||
// Collect challenges from addressables (kind 30064)
|
||||
LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note ->
|
||||
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
|
||||
val gameId = event.gameId() ?: return@filterIntoSet false
|
||||
gameData[gameId] = (event to gameData[gameId]?.second)
|
||||
false // Don't need to collect, just iterate
|
||||
}
|
||||
|
||||
// Collect accepts from addressables (kind 30065)
|
||||
LocalCache.addressables.filterIntoSet(LiveChessGameAcceptEvent.KIND) { _, note ->
|
||||
val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false
|
||||
val gameId = event.gameId() ?: return@filterIntoSet false
|
||||
gameData[gameId] = (gameData[gameId]?.first to event)
|
||||
false
|
||||
}
|
||||
|
||||
// Filter to games that are active and user is NOT a participant
|
||||
val publicGames = mutableListOf<PublicGameInfo>()
|
||||
|
||||
for ((gameId, data) in gameData) {
|
||||
val (challenge, accept) = data
|
||||
if (challenge == null || accept == null) continue
|
||||
|
||||
// Skip if user is a participant
|
||||
val challengerPubkey = challenge.pubKey
|
||||
val acceptorPubkey = accept.pubKey
|
||||
if (challengerPubkey == userPubkey || acceptorPubkey == userPubkey) continue
|
||||
|
||||
// Skip if already in our active/spectating games
|
||||
if (_activeGames.value.containsKey(gameId) || _spectatingGames.value.containsKey(gameId)) continue
|
||||
|
||||
// Determine white/black based on challenger color
|
||||
val challengerColor = challenge.playerColor() ?: Color.WHITE
|
||||
val (whitePubkey, blackPubkey) =
|
||||
if (challengerColor == Color.WHITE) {
|
||||
challengerPubkey to acceptorPubkey
|
||||
} else {
|
||||
acceptorPubkey to challengerPubkey
|
||||
}
|
||||
|
||||
// Count moves and get last move time from addressables (kind 30066)
|
||||
var moveCount = 0
|
||||
var lastMoveTime = accept.createdAt
|
||||
|
||||
LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note ->
|
||||
val moveEvent = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
|
||||
if (moveEvent.gameId() != gameId) return@filterIntoSet false
|
||||
moveCount++
|
||||
if (moveEvent.createdAt > lastMoveTime) {
|
||||
lastMoveTime = moveEvent.createdAt
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// Skip inactive games (no moves in 24 hours)
|
||||
if ((now - lastMoveTime) > CHALLENGE_EXPIRY_SECONDS) continue
|
||||
|
||||
publicGames.add(
|
||||
PublicGameInfo(
|
||||
gameId = gameId,
|
||||
whitePubkey = whitePubkey,
|
||||
blackPubkey = blackPubkey,
|
||||
moveCount = moveCount,
|
||||
lastMoveTime = lastMoveTime,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Sort by most recent activity
|
||||
_publicGames.value = publicGames.sortedByDescending { it.lastMoveTime }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a game as spectator (watch-only mode)
|
||||
* Returns the game state or null if loading failed
|
||||
*
|
||||
* NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables
|
||||
*/
|
||||
fun loadGameAsSpectator(gameId: String): LiveChessGameState? {
|
||||
// Check if already spectating
|
||||
_spectatingGames.value[gameId]?.let { return it }
|
||||
|
||||
// Check if we're actually a participant (shouldn't load as spectator)
|
||||
_activeGames.value[gameId]?.let {
|
||||
_error.value = "You are a participant in this game"
|
||||
return null
|
||||
}
|
||||
|
||||
val userPubkey = account.signer.pubKey
|
||||
|
||||
// Find challenge and accept events from addressables
|
||||
val challengeNotes =
|
||||
LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note ->
|
||||
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
|
||||
event.gameId() == gameId
|
||||
}
|
||||
val acceptNotes =
|
||||
LocalCache.addressables.filterIntoSet(LiveChessGameAcceptEvent.KIND) { _, note ->
|
||||
val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false
|
||||
event.gameId() == gameId
|
||||
}
|
||||
|
||||
val challengeEvent = challengeNotes.firstOrNull()?.event as? LiveChessGameChallengeEvent
|
||||
val acceptEvent = acceptNotes.firstOrNull()?.event as? LiveChessGameAcceptEvent
|
||||
|
||||
if (challengeEvent == null) {
|
||||
_error.value = "Challenge not found for game"
|
||||
return null
|
||||
}
|
||||
|
||||
if (acceptEvent == null) {
|
||||
_error.value = "Game not started yet - waiting for opponent"
|
||||
return null
|
||||
}
|
||||
|
||||
// Determine white/black
|
||||
val challengerPubkey = challengeEvent.pubKey
|
||||
val acceptorPubkey = acceptEvent.pubKey
|
||||
val challengerColor = challengeEvent.playerColor() ?: Color.WHITE
|
||||
|
||||
val (whitePubkey, blackPubkey) =
|
||||
if (challengerColor == Color.WHITE) {
|
||||
challengerPubkey to acceptorPubkey
|
||||
} else {
|
||||
acceptorPubkey to challengerPubkey
|
||||
}
|
||||
|
||||
// Build engine state by replaying moves from addressables
|
||||
val engine = ChessEngine()
|
||||
engine.reset()
|
||||
|
||||
val moveNotes =
|
||||
LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note ->
|
||||
val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
|
||||
event.gameId() == gameId
|
||||
}
|
||||
|
||||
val sortedMoves =
|
||||
moveNotes
|
||||
.mapNotNull { it.event as? LiveChessMoveEvent }
|
||||
.sortedBy { it.moveNumber() ?: Int.MAX_VALUE }
|
||||
|
||||
for (moveEvent in sortedMoves) {
|
||||
val san = moveEvent.san() ?: continue
|
||||
try {
|
||||
engine.makeMove(san)
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error loading move $san: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
// Create spectator game state - always view from white's perspective
|
||||
val gameState =
|
||||
LiveChessGameState(
|
||||
gameId = gameId,
|
||||
playerPubkey = userPubkey,
|
||||
opponentPubkey = blackPubkey, // Opponent relative to spectator view (white)
|
||||
playerColor = Color.WHITE, // Spectators see from white's perspective
|
||||
engine = engine,
|
||||
isSpectator = true,
|
||||
)
|
||||
|
||||
// Add to spectating games
|
||||
_spectatingGames.value = _spectatingGames.value + (gameId to gameState)
|
||||
pollingDelegate.addGameId(gameId)
|
||||
|
||||
_error.value = null
|
||||
return gameState
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop spectating a game
|
||||
*/
|
||||
fun stopSpectating(gameId: String) {
|
||||
if (_spectatingGames.value.containsKey(gameId)) {
|
||||
_spectatingGames.value = _spectatingGames.value - gameId
|
||||
pollingDelegate.removeGameId(gameId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,32 +987,31 @@ class ChessViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique game ID
|
||||
* Generate unique game ID with human-readable component
|
||||
*/
|
||||
private fun generateGameId(): String {
|
||||
val timestamp = TimeUtils.now()
|
||||
val random = UUID.randomUUID().toString().take(8)
|
||||
return "chess-$timestamp-$random"
|
||||
}
|
||||
private fun generateGameId(): String = ChessGameNameGenerator.generateGameId(TimeUtils.now())
|
||||
|
||||
/**
|
||||
* Refresh game state from LocalCache for specific game IDs
|
||||
* Called periodically by polling delegate
|
||||
*
|
||||
* NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables
|
||||
*/
|
||||
private suspend fun refreshGamesFromCache(gameIds: Set<String>) {
|
||||
val userPubkey = account.signer.pubKey
|
||||
|
||||
for (gameId in gameIds) {
|
||||
// Find moves for this game
|
||||
// Find moves for this game from addressables (kind 30066)
|
||||
val moveNotes =
|
||||
LocalCache.notes.filterIntoSet { _, note ->
|
||||
LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note ->
|
||||
val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
|
||||
event.gameId() == gameId
|
||||
}
|
||||
|
||||
val gameState = _activeGames.value[gameId]
|
||||
if (gameState != null) {
|
||||
// Apply any new moves
|
||||
// Check active games first
|
||||
val activeGameState = _activeGames.value[gameId]
|
||||
if (activeGameState != null) {
|
||||
// Apply any new moves from opponent
|
||||
moveNotes
|
||||
.mapNotNull { it.event as? LiveChessMoveEvent }
|
||||
.filter { it.pubKey != userPubkey } // Only opponent moves
|
||||
@@ -663,9 +1020,29 @@ class ChessViewModel(
|
||||
val san = moveEvent.san() ?: return@forEach
|
||||
val fen = moveEvent.fen() ?: return@forEach
|
||||
val moveNumber = moveEvent.moveNumber()
|
||||
gameState.applyOpponentMove(san, fen, moveNumber)
|
||||
activeGameState.applyOpponentMove(san, fen, moveNumber)
|
||||
}
|
||||
}
|
||||
|
||||
// Check spectating games - apply ALL moves (from both players)
|
||||
val spectatingGameState = _spectatingGames.value[gameId]
|
||||
if (spectatingGameState != null) {
|
||||
moveNotes
|
||||
.mapNotNull { it.event as? LiveChessMoveEvent }
|
||||
.sortedBy { it.moveNumber() }
|
||||
.forEach { moveEvent ->
|
||||
val san = moveEvent.san() ?: return@forEach
|
||||
val fen = moveEvent.fen() ?: return@forEach
|
||||
val moveNumber = moveEvent.moveNumber()
|
||||
spectatingGameState.applyOpponentMove(san, fen, moveNumber)
|
||||
}
|
||||
}
|
||||
|
||||
// Game is being polled but not yet active — accept event may have arrived since
|
||||
// initial load. Retry loading from cache to pick up late-arriving accept events.
|
||||
if (activeGameState == null && spectatingGameState == null) {
|
||||
loadGameFromCache(gameId)
|
||||
}
|
||||
}
|
||||
|
||||
updateBadgeCount()
|
||||
@@ -675,6 +1052,8 @@ class ChessViewModel(
|
||||
* Load or rebuild game state from LocalCache for a specific gameId
|
||||
* Used when navigating to a game screen
|
||||
*
|
||||
* NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables
|
||||
*
|
||||
* @return GameLoadResult with either the game state or an error reason
|
||||
*/
|
||||
fun loadGameFromCache(gameId: String): LiveChessGameState? {
|
||||
@@ -683,17 +1062,17 @@ class ChessViewModel(
|
||||
|
||||
val userPubkey = account.signer.pubKey
|
||||
|
||||
// Find the challenge event for this game
|
||||
// Find the challenge event for this game from addressables (kind 30064)
|
||||
val challengeNotes =
|
||||
LocalCache.notes.filterIntoSet { _, note ->
|
||||
LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note ->
|
||||
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
|
||||
event.gameId() == gameId
|
||||
}
|
||||
val challengeNote = challengeNotes.firstOrNull()
|
||||
|
||||
// Find accept event for this game
|
||||
// Find accept event for this game from addressables (kind 30065)
|
||||
val acceptNotes =
|
||||
LocalCache.notes.filterIntoSet { _, note ->
|
||||
LocalCache.addressables.filterIntoSet(LiveChessGameAcceptEvent.KIND) { _, note ->
|
||||
val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false
|
||||
event.gameId() == gameId
|
||||
}
|
||||
@@ -724,9 +1103,10 @@ class ChessViewModel(
|
||||
challengerColor.opposite() to challengerPubkey
|
||||
}
|
||||
challengerPubkey == userPubkey && acceptorPubkey == null -> {
|
||||
// We created challenge but no one accepted yet
|
||||
_error.value = "Waiting for opponent to accept challenge"
|
||||
return null
|
||||
// We created challenge but no one accepted yet - show it anyway
|
||||
// Use targeted opponent or empty string for open challenges
|
||||
val targetedOpponent = challengeEvent.opponentPubkey() ?: ""
|
||||
challengerColor to targetedOpponent
|
||||
}
|
||||
else -> {
|
||||
_error.value = "You are not a participant in this game"
|
||||
@@ -738,9 +1118,9 @@ class ChessViewModel(
|
||||
val engine = ChessEngine()
|
||||
engine.reset()
|
||||
|
||||
// Find and apply all moves in order
|
||||
// Find and apply all moves in order from addressables (kind 30066)
|
||||
val moveNotes =
|
||||
LocalCache.notes.filterIntoSet { _, note ->
|
||||
LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note ->
|
||||
val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
|
||||
event.gameId() == gameId
|
||||
}
|
||||
@@ -767,6 +1147,10 @@ class ChessViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// Only pending if we're the challenger, no accept event found, AND no moves exist
|
||||
// (moves prove the game was accepted even if accept event isn't in cache)
|
||||
val isPendingChallenge = challengerPubkey == userPubkey && acceptorPubkey == null && sortedMoves.isEmpty()
|
||||
|
||||
val gameState =
|
||||
LiveChessGameState(
|
||||
gameId = gameId,
|
||||
@@ -774,13 +1158,28 @@ class ChessViewModel(
|
||||
opponentPubkey = opponentPubkey,
|
||||
playerColor = playerColor,
|
||||
engine = engine,
|
||||
isPendingChallenge = isPendingChallenge,
|
||||
)
|
||||
|
||||
// Mark loaded moves as received to prevent re-application during refresh
|
||||
gameState.markMovesAsReceived(loadedMoveNumbers)
|
||||
|
||||
// Add to active games
|
||||
_activeGames.value = _activeGames.value + (gameId to gameState)
|
||||
// Check for end event (kind 30067) — game may have been resigned/finished
|
||||
val endNotes =
|
||||
LocalCache.addressables.filterIntoSet(LiveChessGameEndEvent.KIND) { _, note ->
|
||||
val event = note.event as? LiveChessGameEndEvent ?: return@filterIntoSet false
|
||||
event.gameId() == gameId
|
||||
}
|
||||
val endEvent = endNotes.firstOrNull()?.event as? LiveChessGameEndEvent
|
||||
if (endEvent != null) {
|
||||
val result = GameResult.parse(endEvent.result() ?: "*")
|
||||
gameState.markAsFinished(result)
|
||||
}
|
||||
|
||||
// Only add to active games if not pending (pending challenges are view-only)
|
||||
if (!isPendingChallenge) {
|
||||
_activeGames.value = _activeGames.value + (gameId to gameState)
|
||||
}
|
||||
|
||||
// Update polling delegate with this game
|
||||
pollingDelegate.addGameId(gameId)
|
||||
@@ -808,3 +1207,14 @@ data class RetryOperation(
|
||||
val currentAttempt: Int,
|
||||
val maxAttempts: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Info about a public game that can be spectated
|
||||
*/
|
||||
data class PublicGameInfo(
|
||||
val gameId: String,
|
||||
val whitePubkey: String,
|
||||
val blackPubkey: String,
|
||||
val moveCount: Int,
|
||||
val lastMoveTime: Long,
|
||||
)
|
||||
|
||||
+632
@@ -0,0 +1,632 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT 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.ChessGameEnd
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvents
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Interface for platform-specific chess event publishing
|
||||
*/
|
||||
interface ChessEventPublisher {
|
||||
suspend fun publishChallenge(
|
||||
gameId: String,
|
||||
playerColor: Color,
|
||||
opponentPubkey: String?,
|
||||
timeControl: String?,
|
||||
): Boolean
|
||||
|
||||
suspend fun publishAccept(
|
||||
gameId: String,
|
||||
challengeEventId: String,
|
||||
challengerPubkey: String,
|
||||
): Boolean
|
||||
|
||||
suspend fun publishMove(move: ChessMoveEvent): Boolean
|
||||
|
||||
suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean
|
||||
|
||||
suspend fun publishDrawOffer(
|
||||
gameId: String,
|
||||
opponentPubkey: String,
|
||||
message: String?,
|
||||
): Boolean
|
||||
|
||||
fun getWriteRelayCount(): Int
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay-first fetcher interface. 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 */
|
||||
suspend fun fetchGameEvents(gameId: String): ChessGameEvents
|
||||
|
||||
/** Fetch recent challenge events */
|
||||
suspend fun fetchChallenges(): List<LiveChessGameChallengeEvent>
|
||||
|
||||
/** Fetch recent public game summaries for spectating */
|
||||
suspend fun fetchRecentGames(): List<RelayGameSummary>
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of a game found on relays (for lobby display / spectating)
|
||||
*/
|
||||
data class RelayGameSummary(
|
||||
val gameId: String,
|
||||
val whitePubkey: String,
|
||||
val blackPubkey: String,
|
||||
val moveCount: Int,
|
||||
val lastMoveTime: Long,
|
||||
val isActive: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Shared chess lobby logic — relay-first architecture.
|
||||
*
|
||||
* 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()
|
||||
|
||||
fun forceRefresh() = pollingDelegate.refreshNow()
|
||||
|
||||
// ========================================
|
||||
// Incoming event routing (real-time / optimistic)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Route an incoming relay event to the appropriate handler.
|
||||
* Called by platform subscription callbacks for real-time updates.
|
||||
*/
|
||||
fun handleIncomingEvent(event: Event) {
|
||||
when (event) {
|
||||
is LiveChessGameChallengeEvent -> handleChallenge(event)
|
||||
is LiveChessGameAcceptEvent -> handleAccept(event)
|
||||
is LiveChessMoveEvent -> handleMove(event)
|
||||
is LiveChessGameEndEvent -> handleGameEnd(event)
|
||||
is LiveChessDrawOfferEvent -> handleDrawOffer(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleChallenge(event: LiveChessGameChallengeEvent) {
|
||||
val gameId = event.gameId() ?: return
|
||||
val challengerColor = event.playerColor() ?: Color.WHITE
|
||||
|
||||
val challenge =
|
||||
ChessChallenge(
|
||||
eventId = event.id,
|
||||
gameId = gameId,
|
||||
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 handleAccept(event: LiveChessGameAcceptEvent) {
|
||||
val gameId = event.gameId() ?: return
|
||||
|
||||
// If this is an accept for our challenge, auto-load the game
|
||||
val ourChallenge = state.outgoingChallenges().find { it.gameId == gameId }
|
||||
if (ourChallenge != null) {
|
||||
handleGameAccepted(gameId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMove(event: LiveChessMoveEvent) {
|
||||
val gameId = event.gameId() ?: return
|
||||
val san = event.san() ?: return
|
||||
val fen = event.fen() ?: return
|
||||
val moveNumber = event.moveNumber()
|
||||
|
||||
val gameState = state.getGameState(gameId) ?: return
|
||||
|
||||
// Only apply opponent moves optimistically
|
||||
if (event.pubKey != userPubkey) {
|
||||
gameState.applyOpponentMove(san, fen, moveNumber)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGameEnd(event: LiveChessGameEndEvent) {
|
||||
val gameId = event.gameId() ?: return
|
||||
val gameState = state.getGameState(gameId) ?: return
|
||||
|
||||
val resultStr = event.result()
|
||||
val result =
|
||||
when (resultStr) {
|
||||
"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 -> return
|
||||
}
|
||||
|
||||
gameState.markAsFinished(result)
|
||||
state.moveToCompleted(gameId, result.notation, event.termination())
|
||||
pollingDelegate.removeGameId(gameId)
|
||||
}
|
||||
|
||||
private fun handleDrawOffer(event: LiveChessDrawOfferEvent) {
|
||||
val gameId = event.gameId() ?: return
|
||||
val gameState = state.getGameState(gameId) ?: return
|
||||
|
||||
if (event.pubKey != userPubkey) {
|
||||
gameState.receiveDrawOffer(event.pubKey)
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Challenge operations
|
||||
// ========================================
|
||||
|
||||
fun createChallenge(
|
||||
opponentPubkey: String? = null,
|
||||
playerColor: Color = Color.WHITE,
|
||||
timeControl: String? = null,
|
||||
) {
|
||||
val gameId = generateGameId()
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
state.setBroadcastStatus(
|
||||
ChessBroadcastStatus.Broadcasting(
|
||||
san = "Challenge",
|
||||
successCount = 0,
|
||||
totalRelays = publisher.getWriteRelayCount(),
|
||||
),
|
||||
)
|
||||
|
||||
val success = retryWithBackoff { publisher.publishChallenge(gameId, playerColor, opponentPubkey, timeControl) }
|
||||
|
||||
if (success) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun acceptChallenge(challenge: ChessChallenge) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val success =
|
||||
retryWithBackoff {
|
||||
publisher.publishAccept(
|
||||
gameId = challenge.gameId,
|
||||
challengeEventId = challenge.eventId,
|
||||
challengerPubkey = challenge.challengerPubkey,
|
||||
)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
val playerColor = challenge.challengerColor.opposite()
|
||||
val gameState =
|
||||
ChessGameLoader.createNewGame(
|
||||
gameId = challenge.gameId,
|
||||
playerPubkey = userPubkey,
|
||||
opponentPubkey = challenge.challengerPubkey,
|
||||
playerColor = playerColor,
|
||||
)
|
||||
state.addActiveGame(challenge.gameId, gameState)
|
||||
pollingDelegate.addGameId(challenge.gameId)
|
||||
state.selectGame(challenge.gameId)
|
||||
state.setError(null)
|
||||
} else {
|
||||
state.setError("Failed to accept challenge")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When we detect our challenge was accepted, load game from relays.
|
||||
*/
|
||||
fun handleGameAccepted(gameId: String) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
|
||||
|
||||
val events = fetcher.fetchGameEvents(gameId)
|
||||
val result = ChessGameLoader.loadGame(events, userPubkey)
|
||||
|
||||
when (result) {
|
||||
is LoadGameResult.Success -> {
|
||||
state.addActiveGame(gameId, result.liveState)
|
||||
pollingDelegate.addGameId(gameId)
|
||||
state.selectGame(gameId)
|
||||
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(
|
||||
gameId: String,
|
||||
from: String,
|
||||
to: String,
|
||||
) {
|
||||
val gameState = state.getGameState(gameId) ?: return
|
||||
|
||||
if (state.isSpectating(gameId)) {
|
||||
state.setError("Cannot move while spectating")
|
||||
return
|
||||
}
|
||||
|
||||
val moveResult = gameState.makeMove(from, to) ?: return
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
state.setBroadcastStatus(
|
||||
ChessBroadcastStatus.Broadcasting(
|
||||
san = moveResult.san,
|
||||
successCount = 0,
|
||||
totalRelays = publisher.getWriteRelayCount(),
|
||||
),
|
||||
)
|
||||
|
||||
val success = retryWithBackoff { publisher.publishMove(moveResult) }
|
||||
|
||||
if (success) {
|
||||
state.setBroadcastStatus(
|
||||
ChessBroadcastStatus.Success(moveResult.san, publisher.getWriteRelayCount()),
|
||||
)
|
||||
delay(3000)
|
||||
|
||||
val currentState = state.getGameState(gameId)
|
||||
state.setBroadcastStatus(
|
||||
if (currentState?.isPlayerTurn() == false) {
|
||||
ChessBroadcastStatus.WaitingForOpponent
|
||||
} else {
|
||||
ChessBroadcastStatus.Idle
|
||||
},
|
||||
)
|
||||
state.setError(null)
|
||||
} else {
|
||||
state.setBroadcastStatus(
|
||||
ChessBroadcastStatus.Failed(moveResult.san, "Failed to publish move"),
|
||||
)
|
||||
state.setError("Failed to publish move")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resign(gameId: String) {
|
||||
val gameState = state.getGameState(gameId) ?: return
|
||||
|
||||
if (state.isSpectating(gameId)) {
|
||||
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(gameId, endData.result.notation, endData.termination.name.lowercase())
|
||||
pollingDelegate.removeGameId(gameId)
|
||||
state.setError(null)
|
||||
} else {
|
||||
state.setError("Failed to resign")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun offerDraw(gameId: String) {
|
||||
val gameState = state.getGameState(gameId) ?: return
|
||||
|
||||
if (state.isSpectating(gameId)) {
|
||||
state.setError("Cannot offer draw while spectating")
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val drawOffer = gameState.offerDraw()
|
||||
val success =
|
||||
retryWithBackoff {
|
||||
publisher.publishDrawOffer(
|
||||
gameId = drawOffer.gameId,
|
||||
opponentPubkey = drawOffer.opponentPubkey,
|
||||
message = drawOffer.message,
|
||||
)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
state.setError(null)
|
||||
} else {
|
||||
state.setError("Failed to offer draw")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun acceptDraw(gameId: String) {
|
||||
val gameState = state.getGameState(gameId) ?: return
|
||||
val endData = gameState.acceptDraw() ?: return
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val success = retryWithBackoff { publisher.publishGameEnd(endData) }
|
||||
|
||||
if (success) {
|
||||
state.moveToCompleted(gameId, endData.result.notation, "draw_agreement")
|
||||
pollingDelegate.removeGameId(gameId)
|
||||
state.setError(null)
|
||||
} else {
|
||||
state.setError("Failed to accept draw")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun declineDraw(gameId: String) {
|
||||
val gameState = state.getGameState(gameId) ?: return
|
||||
gameState.declineDraw()
|
||||
}
|
||||
|
||||
fun claimAbandonmentVictory(gameId: String) {
|
||||
val gameState = state.getGameState(gameId) ?: return
|
||||
val endData = gameState.claimAbandonmentVictory() ?: return
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val success = retryWithBackoff { publisher.publishGameEnd(endData) }
|
||||
|
||||
if (success) {
|
||||
state.moveToCompleted(gameId, endData.result.notation, "abandonment")
|
||||
pollingDelegate.removeGameId(gameId)
|
||||
state.setError(null)
|
||||
} else {
|
||||
state.setError("Failed to claim abandonment victory")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Spectator mode
|
||||
// ========================================
|
||||
|
||||
fun loadGameAsSpectator(gameId: String) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
|
||||
|
||||
val events = fetcher.fetchGameEvents(gameId)
|
||||
val result = ChessGameLoader.loadGame(events, userPubkey)
|
||||
|
||||
when (result) {
|
||||
is LoadGameResult.Success -> {
|
||||
state.addSpectatingGame(gameId, result.liveState)
|
||||
pollingDelegate.addGameId(gameId)
|
||||
state.selectGame(gameId)
|
||||
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
|
||||
state.setError(null)
|
||||
}
|
||||
is LoadGameResult.Error -> {
|
||||
state.setError("Failed to load game: ${result.message}")
|
||||
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadGame(gameId: String) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
if (state.getGameState(gameId) != null) {
|
||||
state.selectGame(gameId)
|
||||
return@launch
|
||||
}
|
||||
|
||||
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
|
||||
|
||||
val events = fetcher.fetchGameEvents(gameId)
|
||||
val result = ChessGameLoader.loadGame(events, userPubkey)
|
||||
|
||||
when (result) {
|
||||
is LoadGameResult.Success -> {
|
||||
if (result.liveState.isSpectator) {
|
||||
state.addSpectatingGame(gameId, result.liveState)
|
||||
} else {
|
||||
state.addActiveGame(gameId, result.liveState)
|
||||
}
|
||||
pollingDelegate.addGameId(gameId)
|
||||
state.selectGame(gameId)
|
||||
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
|
||||
state.setError(null)
|
||||
}
|
||||
is LoadGameResult.Error -> {
|
||||
state.setError("Failed to load game: ${result.message}")
|
||||
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Relay-first refresh (periodic reconstruction)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Full reconstruction refresh for active games.
|
||||
* One-shot REQ → transient collector → reconstruct → diff + update.
|
||||
*/
|
||||
private suspend fun refreshGames(gameIds: Set<String>) {
|
||||
for (gameId in gameIds) {
|
||||
refreshGame(gameId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshGame(gameId: String) {
|
||||
val events = fetcher.fetchGameEvents(gameId)
|
||||
val result = ChessGameLoader.loadGame(events, userPubkey)
|
||||
|
||||
when (result) {
|
||||
is LoadGameResult.Success -> {
|
||||
state.replaceGameState(gameId, result.liveState)
|
||||
}
|
||||
is LoadGameResult.Error -> {
|
||||
// Don't overwrite error for periodic refresh failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshChallenges() {
|
||||
val challengeEvents = fetcher.fetchChallenges()
|
||||
|
||||
val challenges =
|
||||
challengeEvents.mapNotNull { event ->
|
||||
val gameId = event.gameId() ?: return@mapNotNull null
|
||||
val challengerColor = event.playerColor() ?: Color.WHITE
|
||||
|
||||
ChessChallenge(
|
||||
eventId = event.id,
|
||||
gameId = gameId,
|
||||
challengerPubkey = event.pubKey,
|
||||
challengerDisplayName = metadataProvider.getDisplayName(event.pubKey),
|
||||
challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey),
|
||||
opponentPubkey = event.opponentPubkey(),
|
||||
challengerColor = challengerColor,
|
||||
createdAt = event.createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
state.updateChallenges(challenges)
|
||||
|
||||
// Also refresh public games
|
||||
val recentGames = fetcher.fetchRecentGames()
|
||||
val publicGames =
|
||||
recentGames.map { summary ->
|
||||
PublicGame(
|
||||
gameId = summary.gameId,
|
||||
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)
|
||||
}
|
||||
|
||||
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 fun generateGameId(): String {
|
||||
val timestamp = TimeUtils.now()
|
||||
val random = UUID.randomUUID().toString().take(8)
|
||||
return "chess-$timestamp-$random"
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a publish operation with exponential backoff.
|
||||
* Returns true if any attempt succeeds.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
state.setError(null)
|
||||
}
|
||||
|
||||
fun selectGame(gameId: String?) {
|
||||
state.selectGame(gameId)
|
||||
}
|
||||
}
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT 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
|
||||
|
||||
/**
|
||||
* 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"
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
// 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()
|
||||
|
||||
// Selected game ID for navigation
|
||||
private val _selectedGameId = MutableStateFlow<String?>(null)
|
||||
val selectedGameId: StateFlow<String?> = _selectedGameId.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 { it + (gameId to state) }
|
||||
// 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).
|
||||
*/
|
||||
fun replaceGameState(
|
||||
gameId: String,
|
||||
newState: LiveChessGameState,
|
||||
) {
|
||||
if (_activeGames.value.containsKey(gameId)) {
|
||||
_activeGames.update { it + (gameId to newState) }
|
||||
} else if (_spectatingGames.value.containsKey(gameId)) {
|
||||
_spectatingGames.update { it + (gameId to newState) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {
|
||||
_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 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)
|
||||
|
||||
fun clearAll() {
|
||||
_activeGames.value = emptyMap()
|
||||
_publicGames.value = emptyList()
|
||||
_challenges.value = emptyList()
|
||||
_spectatingGames.value = emptyMap()
|
||||
_completedGames.value = emptyList()
|
||||
_broadcastStatus.value = ChessBroadcastStatus.Idle
|
||||
_error.value = null
|
||||
_selectedGameId.value = null
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT 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
|
||||
|
||||
/**
|
||||
* 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 all relays to send EOSE
|
||||
* @return Deduplicated list of events received before timeout/EOSE
|
||||
*/
|
||||
suspend fun fetchEvents(
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
timeoutMs: Long = 30_000,
|
||||
): List<Event> {
|
||||
if (filters.isEmpty()) return emptyList()
|
||||
|
||||
val events = ConcurrentHashMap<String, Event>()
|
||||
val relayCount = filters.keys.size
|
||||
val eoseReceived = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>()
|
||||
val allEose = CompletableDeferred<Unit>()
|
||||
val subId = newSubId()
|
||||
|
||||
val listener =
|
||||
object : IRequestListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
events[event.id] = event
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
eoseReceived.add(relay)
|
||||
if (eoseReceived.size >= relayCount) {
|
||||
allEose.complete(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.openReqSubscription(subId, filters, listener)
|
||||
withTimeoutOrNull(timeoutMs) { allEose.await() }
|
||||
client.close(subId)
|
||||
|
||||
return events.values.toList()
|
||||
}
|
||||
}
|
||||
+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?
|
||||
}
|
||||
+186
-50
@@ -24,6 +24,7 @@ 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
|
||||
@@ -31,8 +32,11 @@ 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
|
||||
@@ -69,15 +73,27 @@ fun InteractiveChessBoard(
|
||||
boardSize: Dp = 400.dp,
|
||||
flipped: Boolean = false,
|
||||
playerColor: ChessColor? = null, // null = allow both (for local play)
|
||||
positionVersion: Int = 0, // External trigger for position refresh (e.g. moveHistory.size)
|
||||
onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> },
|
||||
) {
|
||||
// Track move count to trigger recomposition when board changes
|
||||
var moveCount by remember { mutableStateOf(0) }
|
||||
val position = remember(engine, moveCount) { engine.getPosition() }
|
||||
val sideToMove = remember(engine, moveCount) { engine.getSideToMove() }
|
||||
// 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 it's your turn (or playerColor is null for local play)
|
||||
val canInteract = playerColor == null || sideToMove == playerColor
|
||||
|
||||
@@ -87,66 +103,118 @@ fun InteractiveChessBoard(
|
||||
val rankRange = if (flipped) (0..7) else (7 downTo 0)
|
||||
val fileRange = if (flipped) (7 downTo 0) else (0..7)
|
||||
|
||||
Column(modifier = modifier.size(boardSize)) {
|
||||
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)
|
||||
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
|
||||
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) return@InteractiveChessSquare
|
||||
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 (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)) {
|
||||
// Valid move - callback will make the actual move
|
||||
selectedSquare = null
|
||||
legalMoves = emptyList()
|
||||
// Pass empty san - callback will get it from makeMove result
|
||||
onMoveMade(from, to, "")
|
||||
moveCount++ // Trigger recomposition after move is made
|
||||
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 {
|
||||
// Not a legal move target - try selecting this square instead
|
||||
// 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)
|
||||
} 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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,3 +322,71 @@ private fun ChessPiece.toImageVector(): ImageVector {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+147
-31
@@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.min
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
||||
|
||||
@@ -189,6 +190,9 @@ fun LiveChessGameScreen(
|
||||
val currentPosition by gameState.currentPosition.collectAsState()
|
||||
val moveHistory by gameState.moveHistory.collectAsState()
|
||||
|
||||
// Pending challenges and spectators cannot make moves
|
||||
val canMakeMoves = !gameState.isSpectator && !gameState.isPendingChallenge
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
@@ -212,15 +216,23 @@ fun LiveChessGameScreen(
|
||||
opponentName = opponentName,
|
||||
playerColor = gameState.playerColor,
|
||||
currentTurn = currentPosition.activeColor,
|
||||
isSpectator = gameState.isSpectator,
|
||||
isPendingChallenge = gameState.isPendingChallenge,
|
||||
)
|
||||
|
||||
// Interactive chess board - flip when playing black
|
||||
// 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 = gameState.playerColor == Color.BLACK,
|
||||
onMoveMade = onMoveMade,
|
||||
flipped = !gameState.isSpectator && gameState.playerColor == Color.BLACK,
|
||||
positionVersion = moveHistory.size,
|
||||
onMoveMade =
|
||||
if (canMakeMoves) {
|
||||
onMoveMade
|
||||
} else {
|
||||
{ _, _, _ -> } // No-op for spectators and pending challenges
|
||||
},
|
||||
)
|
||||
|
||||
// Move history (scrollable) - observed from state flow
|
||||
@@ -228,11 +240,12 @@ fun LiveChessGameScreen(
|
||||
moves = moveHistory,
|
||||
)
|
||||
|
||||
// Game controls
|
||||
GameControls(
|
||||
onResign = onResign,
|
||||
onOfferDraw = onOfferDraw,
|
||||
)
|
||||
// Show appropriate controls based on game state
|
||||
when {
|
||||
gameState.isPendingChallenge -> PendingChallengeInfo()
|
||||
gameState.isSpectator -> SpectatorInfo()
|
||||
else -> GameControls(onResign = onResign, onOfferDraw = onOfferDraw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,6 +304,7 @@ fun LiveChessGameScreen(
|
||||
engine = engine,
|
||||
boardSize = boardSize,
|
||||
flipped = playerColor == Color.BLACK,
|
||||
positionVersion = engine.getMoveHistory().size,
|
||||
onMoveMade = onMoveMade,
|
||||
)
|
||||
|
||||
@@ -317,45 +331,147 @@ private fun GameInfoHeader(
|
||||
opponentName: String,
|
||||
playerColor: Color,
|
||||
currentTurn: Color,
|
||||
isSpectator: Boolean = false,
|
||||
isPendingChallenge: Boolean = false,
|
||||
) {
|
||||
// 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 = "Game: ${gameId.take(8)}...",
|
||||
text = if (gameName != null) gameId.take(16) else "Game: ${gameId.take(8)}...",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "vs $opponentName",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
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 = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
val turnText =
|
||||
if (currentTurn == playerColor) {
|
||||
"Your turn"
|
||||
} else {
|
||||
"Opponent's turn"
|
||||
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,
|
||||
)
|
||||
|
||||
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 = turnText,
|
||||
text = "Watching game - spectator mode",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
if (currentTurn == playerColor) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
fontWeight = if (currentTurn == playerColor) FontWeight.Bold else FontWeight.Normal,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT 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.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
|
||||
|
||||
/**
|
||||
* Shared filter builder for chess subscriptions.
|
||||
* Used by both Android and Desktop to ensure identical subscription behavior.
|
||||
*
|
||||
* Builds 4 types of filters:
|
||||
* 1. Personal filters - Events tagged with user's pubkey
|
||||
* 2. Challenge filters - All challenges (like jesterui pattern)
|
||||
* 3. Active game filters - Game-specific move/end subscriptions
|
||||
* 4. Recent game filters - For spectating discovery (7 day window)
|
||||
*/
|
||||
object ChessFilterBuilder {
|
||||
/** Challenge and accept event kinds (for lobby display) */
|
||||
private val CHALLENGE_KINDS =
|
||||
listOf(
|
||||
LiveChessGameChallengeEvent.KIND,
|
||||
LiveChessGameAcceptEvent.KIND,
|
||||
)
|
||||
|
||||
/** Move and end event kinds (for active games) */
|
||||
private val GAME_EVENT_KINDS =
|
||||
listOf(
|
||||
LiveChessMoveEvent.KIND,
|
||||
LiveChessGameEndEvent.KIND,
|
||||
)
|
||||
|
||||
/** All chess event kinds */
|
||||
private val ALL_CHESS_KINDS = CHALLENGE_KINDS + GAME_EVENT_KINDS
|
||||
|
||||
/**
|
||||
* 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 challenges (for lobby display)
|
||||
filters.addAll(
|
||||
buildChallengeFilters(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, game ends.
|
||||
*/
|
||||
fun buildPersonalFilters(
|
||||
userPubkey: String,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
sinceForRelay: (NormalizedRelayUrl) -> Long?,
|
||||
now: Long = TimeUtils.now(),
|
||||
): List<RelayBasedFilter> {
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = ALL_CHESS_KINDS,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All challenge events (like jesterui pattern).
|
||||
* No author/tag restriction - fetch everything, filter client-side.
|
||||
* This ensures we see open challenges and public games.
|
||||
*/
|
||||
fun buildChallengeFilters(
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
sinceForRelay: (NormalizedRelayUrl) -> Long?,
|
||||
now: Long = TimeUtils.now(),
|
||||
): List<RelayBasedFilter> {
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = CHALLENGE_KINDS,
|
||||
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.
|
||||
*
|
||||
* Note: Move d-tags are "gameId-moveNumber" so we can't filter by #d for moves.
|
||||
* We use two strategies:
|
||||
* 1. Filter by authors (opponent pubkeys) - catches moves they make
|
||||
* 2. Filter by #p tag (opponent tagged us) - catches moves tagged with us
|
||||
*
|
||||
* End events use gameId as d-tag so we can filter those directly.
|
||||
*/
|
||||
fun buildActiveGameFilters(
|
||||
gameIds: Set<String>,
|
||||
userPubkey: String,
|
||||
opponentPubkeys: Set<String>,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
): List<RelayBasedFilter> {
|
||||
if (gameIds.isEmpty()) return emptyList()
|
||||
|
||||
println("[ChessFilterBuilder] Building filters for ${gameIds.size} games, ${opponentPubkeys.size} opponents: $opponentPubkeys")
|
||||
|
||||
val filters = mutableListOf<RelayBasedFilter>()
|
||||
|
||||
// End events: filter by d-tag (gameId)
|
||||
val endFilter =
|
||||
Filter(
|
||||
kinds = listOf(LiveChessGameEndEvent.KIND),
|
||||
tags = mapOf("d" to gameIds.toList()),
|
||||
limit = 50,
|
||||
)
|
||||
|
||||
// Move events: filter by p-tag (opponent tagged us)
|
||||
// This catches all moves for games where we're a participant
|
||||
val moveFilterByTag =
|
||||
Filter(
|
||||
kinds = listOf(LiveChessMoveEvent.KIND),
|
||||
tags = mapOf("p" to listOf(userPubkey)),
|
||||
limit = 200,
|
||||
)
|
||||
|
||||
relays.forEach { relay ->
|
||||
filters.add(RelayBasedFilter(relay = relay, filter = endFilter))
|
||||
filters.add(RelayBasedFilter(relay = relay, filter = moveFilterByTag))
|
||||
}
|
||||
|
||||
// Move events: filter by authors (opponent pubkeys)
|
||||
// This directly catches moves made by our opponents
|
||||
if (opponentPubkeys.isNotEmpty()) {
|
||||
println("[ChessFilterBuilder] Adding author filter for opponents: $opponentPubkeys")
|
||||
val moveFilterByAuthor =
|
||||
Filter(
|
||||
kinds = listOf(LiveChessMoveEvent.KIND),
|
||||
authors = opponentPubkeys.toList(),
|
||||
limit = 200,
|
||||
)
|
||||
|
||||
relays.forEach { relay ->
|
||||
filters.add(RelayBasedFilter(relay = relay, filter = moveFilterByAuthor))
|
||||
}
|
||||
} else {
|
||||
println("[ChessFilterBuilder] WARNING: No opponent pubkeys provided!")
|
||||
}
|
||||
|
||||
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 = GAME_EVENT_KINDS,
|
||||
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.
|
||||
*/
|
||||
fun gameEventsFilter(gameId: String): Filter =
|
||||
Filter(
|
||||
kinds = ALL_CHESS_KINDS + listOf(com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent.KIND),
|
||||
tags = mapOf("d" to listOf(gameId)),
|
||||
limit = 500,
|
||||
)
|
||||
|
||||
/**
|
||||
* Filter for challenge events in the last 24 hours.
|
||||
* Used for one-shot fetch to populate lobby.
|
||||
*/
|
||||
fun challengesFilter(userPubkey: String): Filter {
|
||||
val now = TimeUtils.now()
|
||||
return Filter(
|
||||
kinds = CHALLENGE_KINDS,
|
||||
since = now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS,
|
||||
limit = 100,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter for recent game activity for spectating discovery.
|
||||
* Fetches move events from the last 7 days.
|
||||
*/
|
||||
fun recentGamesFilter(): Filter {
|
||||
val now = TimeUtils.now()
|
||||
return Filter(
|
||||
kinds = ALL_CHESS_KINDS,
|
||||
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
|
||||
}
|
||||
+56
-18
@@ -69,9 +69,10 @@ import com.vitorpamplona.amethyst.commons.data.UserMetadataCache
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createChessSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.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.LiveChessGameChallengeEvent
|
||||
|
||||
/**
|
||||
@@ -91,14 +92,26 @@ fun ChessScreen(
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val refreshKey by viewModel.refreshKey.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val activeGames by viewModel.activeGames.collectAsState()
|
||||
|
||||
// Subscribe to chess events from relays (re-subscribes when refreshKey changes)
|
||||
rememberSubscription(relayStatuses, account, refreshKey, relayManager = relayManager) {
|
||||
// Extract opponent pubkeys from active games for move filtering
|
||||
val opponentPubkeys =
|
||||
remember(activeGames) {
|
||||
val pubkeys = activeGames.values.map { it.opponentPubkey }.toSet()
|
||||
println("[ChessScreen] Active games: ${activeGames.keys}, Opponent pubkeys: $pubkeys")
|
||||
pubkeys
|
||||
}
|
||||
|
||||
// Subscribe to chess events from relays
|
||||
// Re-subscribes when relays, refreshKey, or active games change
|
||||
rememberSubscription(relayStatuses, account, refreshKey, activeGames.keys, opponentPubkeys, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
createChessSubscription(
|
||||
createChessSubscriptionWithGames(
|
||||
relays = configuredRelays,
|
||||
userPubkey = account.pubKeyHex,
|
||||
activeGameIds = activeGames.keys,
|
||||
opponentPubkeys = opponentPubkeys,
|
||||
onEvent = { event, _, _, _ ->
|
||||
viewModel.handleIncomingEvent(event)
|
||||
},
|
||||
@@ -128,7 +141,6 @@ fun ChessScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val activeGames by viewModel.activeGames.collectAsState()
|
||||
val challenges by viewModel.challenges.collectAsState()
|
||||
val completedGames by viewModel.completedGames.collectAsState()
|
||||
// Observe metadata changes to trigger recomposition
|
||||
@@ -280,7 +292,7 @@ private fun ChessLobby(
|
||||
)
|
||||
}
|
||||
|
||||
items(activeGames.entries.toList(), key = { it.key }) { (gameId, state) ->
|
||||
items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) ->
|
||||
ActiveGameCard(
|
||||
gameId = gameId,
|
||||
opponentPubkey = state.opponentPubkey,
|
||||
@@ -375,7 +387,10 @@ private fun ChessLobby(
|
||||
)
|
||||
}
|
||||
|
||||
items(completedGames.take(10), key = { it.gameId }) { game ->
|
||||
items(
|
||||
completedGames.distinctBy { it.gameId }.take(10),
|
||||
key = { "completed-${it.gameId}-${it.completedAt}" },
|
||||
) { game ->
|
||||
CompletedGameCard(
|
||||
game = game,
|
||||
userPubkey = userPubkey,
|
||||
@@ -428,6 +443,12 @@ private fun ActiveGameCard(
|
||||
isYourTurn: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
// Extract human-readable game name if available
|
||||
val gameName =
|
||||
remember(gameId) {
|
||||
ChessGameNameGenerator.extractDisplayName(gameId) ?: gameId.take(12)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
border =
|
||||
@@ -453,14 +474,14 @@ private fun ActiveGameCard(
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
"vs $opponentName",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
gameName,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
"Game: ${gameId.take(12)}...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
"vs $opponentName",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -685,6 +706,7 @@ private fun DesktopChessGameLayout(
|
||||
boardSize = 520.dp,
|
||||
flipped = playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK,
|
||||
playerColor = playerColor,
|
||||
positionVersion = moveHistory.size,
|
||||
onMoveMade = onMoveMade,
|
||||
)
|
||||
}
|
||||
@@ -698,6 +720,12 @@ private fun DesktopChessGameLayout(
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Extract human-readable game name
|
||||
val gameName =
|
||||
remember(gameId) {
|
||||
ChessGameNameGenerator.extractDisplayName(gameId)
|
||||
}
|
||||
|
||||
// Game info card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -706,11 +734,21 @@ private fun DesktopChessGameLayout(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Game Info",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
// 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,
|
||||
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT 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.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.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.nip64Chess.ChessGameEnd
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvents
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
|
||||
|
||||
/**
|
||||
* Desktop implementation of ChessEventPublisher.
|
||||
* Wraps account.signer.sign() + relayManager.broadcastToAll() for event publishing.
|
||||
*/
|
||||
class DesktopChessPublisher(
|
||||
private val account: AccountState.LoggedIn,
|
||||
private val relayManager: DesktopRelayConnectionManager,
|
||||
) : ChessEventPublisher {
|
||||
override suspend fun publishChallenge(
|
||||
gameId: String,
|
||||
playerColor: Color,
|
||||
opponentPubkey: String?,
|
||||
timeControl: String?,
|
||||
): Boolean =
|
||||
try {
|
||||
val template =
|
||||
LiveChessGameChallengeEvent.build(
|
||||
gameId = gameId,
|
||||
playerColor = playerColor,
|
||||
opponentPubkey = opponentPubkey,
|
||||
timeControl = timeControl,
|
||||
)
|
||||
val signedEvent = account.signer.sign(template)
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
override suspend fun publishAccept(
|
||||
gameId: String,
|
||||
challengeEventId: String,
|
||||
challengerPubkey: String,
|
||||
): Boolean =
|
||||
try {
|
||||
val template =
|
||||
LiveChessGameAcceptEvent.build(
|
||||
gameId = gameId,
|
||||
challengeEventId = challengeEventId,
|
||||
challengerPubkey = challengerPubkey,
|
||||
)
|
||||
val signedEvent = account.signer.sign(template)
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
override suspend fun publishMove(move: ChessMoveEvent): Boolean =
|
||||
try {
|
||||
val template =
|
||||
LiveChessMoveEvent.build(
|
||||
gameId = move.gameId,
|
||||
moveNumber = move.moveNumber,
|
||||
san = move.san,
|
||||
fen = move.fen,
|
||||
opponentPubkey = move.opponentPubkey,
|
||||
)
|
||||
val signedEvent = account.signer.sign(template)
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
override suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean =
|
||||
try {
|
||||
val template =
|
||||
LiveChessGameEndEvent.build(
|
||||
gameId = gameEnd.gameId,
|
||||
result = gameEnd.result,
|
||||
termination = gameEnd.termination,
|
||||
winnerPubkey = gameEnd.winnerPubkey,
|
||||
opponentPubkey = gameEnd.opponentPubkey,
|
||||
pgn = gameEnd.pgn ?: "",
|
||||
)
|
||||
val signedEvent = account.signer.sign(template)
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
override suspend fun publishDrawOffer(
|
||||
gameId: String,
|
||||
opponentPubkey: String,
|
||||
message: String?,
|
||||
): Boolean =
|
||||
try {
|
||||
val template =
|
||||
LiveChessDrawOfferEvent.build(
|
||||
gameId = gameId,
|
||||
opponentPubkey = opponentPubkey,
|
||||
message = message ?: "",
|
||||
)
|
||||
val signedEvent = account.signer.sign(template)
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
override fun getWriteRelayCount(): Int = relayManager.connectedRelays.value.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop implementation of ChessRelayFetcher.
|
||||
* Uses ChessRelayFetchHelper for one-shot relay queries.
|
||||
*/
|
||||
class DesktopRelayFetcher(
|
||||
private val relayManager: DesktopRelayConnectionManager,
|
||||
private val userPubkey: String,
|
||||
) : ChessRelayFetcher {
|
||||
private val fetchHelper = ChessRelayFetchHelper(relayManager.client)
|
||||
|
||||
override suspend fun fetchGameEvents(gameId: String): ChessGameEvents {
|
||||
val filters = ChessFilterBuilder.gameEventsFilter(gameId)
|
||||
val relayFilters = relayManager.connectedRelays.value.associateWith { listOf(filters) }
|
||||
|
||||
val events = fetchHelper.fetchEvents(relayFilters)
|
||||
|
||||
val challengeEvent =
|
||||
events
|
||||
.filterIsInstance<LiveChessGameChallengeEvent>()
|
||||
.firstOrNull { it.gameId() == gameId }
|
||||
?: events
|
||||
.filter { it.kind == LiveChessGameChallengeEvent.KIND }
|
||||
.mapNotNull { event ->
|
||||
LiveChessGameChallengeEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
).takeIf { it.gameId() == gameId }
|
||||
}.firstOrNull()
|
||||
|
||||
val acceptEvent =
|
||||
events
|
||||
.filterIsInstance<LiveChessGameAcceptEvent>()
|
||||
.firstOrNull { it.gameId() == gameId }
|
||||
?: events
|
||||
.filter { it.kind == LiveChessGameAcceptEvent.KIND }
|
||||
.mapNotNull { event ->
|
||||
LiveChessGameAcceptEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
).takeIf { it.gameId() == gameId }
|
||||
}.firstOrNull()
|
||||
|
||||
val moveEvents =
|
||||
events
|
||||
.filterIsInstance<LiveChessMoveEvent>()
|
||||
.filter { it.gameId() == gameId }
|
||||
.ifEmpty {
|
||||
events
|
||||
.filter { it.kind == LiveChessMoveEvent.KIND }
|
||||
.mapNotNull { event ->
|
||||
LiveChessMoveEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
).takeIf { it.gameId() == gameId }
|
||||
}
|
||||
}
|
||||
|
||||
val endEvent =
|
||||
events
|
||||
.filterIsInstance<LiveChessGameEndEvent>()
|
||||
.firstOrNull { it.gameId() == gameId }
|
||||
?: events
|
||||
.filter { it.kind == LiveChessGameEndEvent.KIND }
|
||||
.mapNotNull { event ->
|
||||
LiveChessGameEndEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
).takeIf { it.gameId() == gameId }
|
||||
}.firstOrNull()
|
||||
|
||||
val drawOffers =
|
||||
events
|
||||
.filterIsInstance<LiveChessDrawOfferEvent>()
|
||||
.filter { it.gameId() == gameId }
|
||||
.ifEmpty {
|
||||
events
|
||||
.filter { it.kind == LiveChessDrawOfferEvent.KIND }
|
||||
.mapNotNull { event ->
|
||||
LiveChessDrawOfferEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
).takeIf { it.gameId() == gameId }
|
||||
}
|
||||
}
|
||||
|
||||
return ChessGameEvents(
|
||||
challenge = challengeEvent,
|
||||
accept = acceptEvent,
|
||||
moves = moveEvents,
|
||||
end = endEvent,
|
||||
drawOffers = drawOffers,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun fetchChallenges(): List<LiveChessGameChallengeEvent> {
|
||||
val filters = ChessFilterBuilder.challengesFilter(userPubkey)
|
||||
val relayFilters = relayManager.connectedRelays.value.associateWith { listOf(filters) }
|
||||
|
||||
val events = fetchHelper.fetchEvents(relayFilters)
|
||||
|
||||
return events
|
||||
.filterIsInstance<LiveChessGameChallengeEvent>()
|
||||
.ifEmpty {
|
||||
events
|
||||
.filter { it.kind == LiveChessGameChallengeEvent.KIND }
|
||||
.map { event ->
|
||||
LiveChessGameChallengeEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchRecentGames(): List<RelayGameSummary> {
|
||||
val filters = ChessFilterBuilder.recentGamesFilter()
|
||||
val relayFilters = relayManager.connectedRelays.value.associateWith { listOf(filters) }
|
||||
|
||||
val events = fetchHelper.fetchEvents(relayFilters)
|
||||
|
||||
// Group by game ID and create summaries
|
||||
val gameIds =
|
||||
events
|
||||
.filter { it.kind == LiveChessMoveEvent.KIND }
|
||||
.mapNotNull { event ->
|
||||
val move =
|
||||
if (event is LiveChessMoveEvent) {
|
||||
event
|
||||
} else {
|
||||
LiveChessMoveEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
)
|
||||
}
|
||||
move.gameId()
|
||||
}.distinct()
|
||||
|
||||
return gameIds.mapNotNull { gameId ->
|
||||
// Find challenge and accept for this game
|
||||
val challenge =
|
||||
events
|
||||
.filter { it.kind == LiveChessGameChallengeEvent.KIND }
|
||||
.mapNotNull { event ->
|
||||
val e =
|
||||
if (event is LiveChessGameChallengeEvent) {
|
||||
event
|
||||
} else {
|
||||
LiveChessGameChallengeEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
)
|
||||
}
|
||||
e.takeIf { it.gameId() == gameId }
|
||||
}.firstOrNull() ?: return@mapNotNull null
|
||||
|
||||
val accept =
|
||||
events
|
||||
.filter { it.kind == LiveChessGameAcceptEvent.KIND }
|
||||
.mapNotNull { event ->
|
||||
val e =
|
||||
if (event is LiveChessGameAcceptEvent) {
|
||||
event
|
||||
} else {
|
||||
LiveChessGameAcceptEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
)
|
||||
}
|
||||
e.takeIf { it.gameId() == gameId }
|
||||
}.firstOrNull()
|
||||
|
||||
val challengerColor = challenge.playerColor() ?: Color.WHITE
|
||||
val whitePubkey =
|
||||
if (challengerColor == Color.WHITE) {
|
||||
challenge.pubKey
|
||||
} else {
|
||||
accept?.pubKey ?: return@mapNotNull null
|
||||
}
|
||||
val blackPubkey =
|
||||
if (challengerColor == Color.BLACK) {
|
||||
challenge.pubKey
|
||||
} else {
|
||||
accept?.pubKey ?: return@mapNotNull null
|
||||
}
|
||||
|
||||
val moves =
|
||||
events
|
||||
.filter { it.kind == LiveChessMoveEvent.KIND }
|
||||
.mapNotNull { event ->
|
||||
val m =
|
||||
if (event is LiveChessMoveEvent) {
|
||||
event
|
||||
} else {
|
||||
LiveChessMoveEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
)
|
||||
}
|
||||
m.takeIf { it.gameId() == gameId }
|
||||
}
|
||||
|
||||
val endEvent =
|
||||
events
|
||||
.filter { it.kind == LiveChessGameEndEvent.KIND }
|
||||
.mapNotNull { event ->
|
||||
val e =
|
||||
if (event is LiveChessGameEndEvent) {
|
||||
event
|
||||
} else {
|
||||
LiveChessGameEndEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
)
|
||||
}
|
||||
e.takeIf { it.gameId() == gameId }
|
||||
}.firstOrNull()
|
||||
|
||||
val lastMove = moves.maxByOrNull { it.createdAt }
|
||||
|
||||
RelayGameSummary(
|
||||
gameId = gameId,
|
||||
whitePubkey = whitePubkey,
|
||||
blackPubkey = blackPubkey,
|
||||
moveCount = moves.size,
|
||||
lastMoveTime = lastMove?.createdAt ?: challenge.createdAt,
|
||||
isActive = endEvent == null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
+195
-49
@@ -20,12 +20,16 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.chess
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDelegate
|
||||
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionController
|
||||
import com.vitorpamplona.amethyst.commons.data.UserMetadataCache
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.nip64Chess.GameResult
|
||||
@@ -36,6 +40,7 @@ import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.PieceType
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -44,7 +49,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Desktop ViewModel for managing chess game state and event publishing.
|
||||
@@ -106,18 +110,59 @@ class DesktopChessViewModel(
|
||||
private val pendingAccepts = mutableMapOf<String, LiveChessGameAcceptEvent>()
|
||||
|
||||
// Track event IDs we've already processed to avoid duplicates
|
||||
private val processedEventIds = mutableSetOf<String>()
|
||||
// Uses ConcurrentHashMap for thread-safe check-and-add
|
||||
private val processedEventIds =
|
||||
java.util.concurrent.ConcurrentHashMap
|
||||
.newKeySet<String>()
|
||||
|
||||
// Track games currently being created to prevent race conditions
|
||||
private val gamesBeingCreated = mutableSetOf<String>()
|
||||
|
||||
// Subscription controller for dynamic filter updates
|
||||
private var subscriptionController: ChessSubscriptionController? = null
|
||||
|
||||
// Polling delegate for periodic refresh (fallback when relay events are missed)
|
||||
private val pollingDelegate =
|
||||
ChessPollingDelegate(
|
||||
config = ChessPollingDefaults.desktop,
|
||||
scope = scope,
|
||||
onRefreshGames = { gameIds -> refreshGamesFromRelay(gameIds) },
|
||||
onRefreshChallenges = { /* Subscriptions handle challenges */ },
|
||||
onCleanup = { cleanupExpiredChallenges() },
|
||||
)
|
||||
|
||||
init {
|
||||
// Start polling for move updates
|
||||
pollingDelegate.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the subscription controller for dynamic filter updates.
|
||||
* Should be called after creating the ViewModel.
|
||||
*/
|
||||
fun setSubscriptionController(controller: ChessSubscriptionController) {
|
||||
subscriptionController = controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify subscription controller and polling delegate of active game changes.
|
||||
* Call this whenever _activeGames is modified.
|
||||
*/
|
||||
private fun notifyActiveGamesChanged() {
|
||||
val gameIds = _activeGames.value.keys
|
||||
subscriptionController?.updateActiveGames(
|
||||
activeGameIds = gameIds,
|
||||
spectatingGameIds = emptySet(),
|
||||
)
|
||||
pollingDelegate.setActiveGameIds(gameIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process incoming chess event from relay subscription
|
||||
*/
|
||||
fun handleIncomingEvent(event: Event) {
|
||||
// Skip already processed events
|
||||
if (processedEventIds.contains(event.id)) return
|
||||
processedEventIds.add(event.id)
|
||||
// Skip already processed events (atomic check-and-add)
|
||||
if (!processedEventIds.add(event.id)) return
|
||||
|
||||
// Check by kind since events may not be cast to specific types
|
||||
when (event.kind) {
|
||||
@@ -230,8 +275,7 @@ class DesktopChessViewModel(
|
||||
}
|
||||
|
||||
private fun handleIncomingMove(event: LiveChessMoveEvent) {
|
||||
// Don't process our own moves
|
||||
if (event.pubKey == account.pubKeyHex) return
|
||||
println("[Chess] Received move event: gameId=${event.gameId()}, san=${event.san()}, moveNum=${event.moveNumber()}, from=${event.pubKey.take(8)}")
|
||||
|
||||
val gameId = event.gameId() ?: return
|
||||
val san = event.san() ?: return
|
||||
@@ -241,14 +285,25 @@ class DesktopChessViewModel(
|
||||
val gameState = _activeGames.value[gameId]
|
||||
|
||||
if (gameState == null) {
|
||||
// Game doesn't exist yet - buffer the move for later
|
||||
// Game doesn't exist yet - buffer the move for later (including our own for FEN sync)
|
||||
println("[Chess] Game $gameId not found, buffering move $san (fen for sync)")
|
||||
val moveList = pendingMoves.getOrPut(gameId) { mutableListOf() }
|
||||
moveList.add(Triple(san, fen, moveNumber))
|
||||
return
|
||||
}
|
||||
|
||||
if (event.pubKey != gameState.opponentPubkey) return
|
||||
// Don't process our own moves for active games (already applied locally)
|
||||
if (event.pubKey == account.pubKeyHex) {
|
||||
println("[Chess] Skipping own move (already applied locally)")
|
||||
return
|
||||
}
|
||||
|
||||
if (event.pubKey != gameState.opponentPubkey) {
|
||||
println("[Chess] Move not from opponent (expected ${gameState.opponentPubkey.take(8)}, got ${event.pubKey.take(8)})")
|
||||
return
|
||||
}
|
||||
|
||||
println("[Chess] Applying opponent move: $san (move #$moveNumber)")
|
||||
gameState.applyOpponentMove(san, fen, moveNumber)
|
||||
updateBadgeCount()
|
||||
}
|
||||
@@ -283,9 +338,10 @@ class DesktopChessViewModel(
|
||||
private fun handleGameEnded(event: LiveChessGameEndEvent) {
|
||||
val gameId = event.gameId() ?: return
|
||||
|
||||
// Store game in history before removing
|
||||
// Store game in history before removing (skip if already in completed list)
|
||||
val alreadyCompleted = _completedGames.value.any { it.gameId == gameId }
|
||||
val gameState = _activeGames.value[gameId]
|
||||
if (gameState != null) {
|
||||
if (gameState != null && !alreadyCompleted) {
|
||||
val completedGame =
|
||||
CompletedGame(
|
||||
gameId = gameId,
|
||||
@@ -303,6 +359,7 @@ class DesktopChessViewModel(
|
||||
// Remove from active games
|
||||
if (_activeGames.value.containsKey(gameId)) {
|
||||
_activeGames.value = _activeGames.value - gameId
|
||||
notifyActiveGamesChanged()
|
||||
}
|
||||
|
||||
// Clean up challenge references
|
||||
@@ -460,6 +517,7 @@ class DesktopChessViewModel(
|
||||
)
|
||||
|
||||
_activeGames.value = _activeGames.value + (gameId to gameState)
|
||||
notifyActiveGamesChanged()
|
||||
gamesBeingCreated.remove(gameId)
|
||||
_selectedGameId.value = gameId
|
||||
_challenges.value = _challenges.value.filter { it.id != challengeEvent.id }
|
||||
@@ -480,9 +538,18 @@ class DesktopChessViewModel(
|
||||
val challengerPubkey = acceptEvent.challengerPubkey() ?: return@launch
|
||||
val accepterPubkey = acceptEvent.pubKey
|
||||
|
||||
println("[Chess] startGameFromAcceptance: gameId=$gameId, isChallenger=$isChallenger")
|
||||
println("[Chess] Pending moves in buffer: ${pendingMoves.keys}")
|
||||
|
||||
// Skip if game already exists or is being created
|
||||
if (_activeGames.value.containsKey(gameId)) return@launch
|
||||
if (!gamesBeingCreated.add(gameId)) return@launch // Returns false if already present
|
||||
if (_activeGames.value.containsKey(gameId)) {
|
||||
println("[Chess] Game $gameId already exists, skipping")
|
||||
return@launch
|
||||
}
|
||||
if (!gamesBeingCreated.add(gameId)) {
|
||||
println("[Chess] Game $gameId already being created, skipping")
|
||||
return@launch // Returns false if already present
|
||||
}
|
||||
|
||||
val opponentPubkey: String
|
||||
val playerColor: Color
|
||||
@@ -514,11 +581,14 @@ class DesktopChessViewModel(
|
||||
engine = engine,
|
||||
)
|
||||
|
||||
println("[Chess] Adding game $gameId to active games, opponent=$opponentPubkey")
|
||||
_activeGames.value = _activeGames.value + (gameId to gameState)
|
||||
notifyActiveGamesChanged()
|
||||
_challenges.value = _challenges.value.filter { it.gameId() != gameId }
|
||||
gamesBeingCreated.remove(gameId)
|
||||
|
||||
// Apply any pending moves that arrived before the game was created
|
||||
println("[Chess] About to apply pending moves for $gameId, buffer has: ${pendingMoves[gameId]?.size ?: 0} moves")
|
||||
applyPendingMoves(gameId, gameState)
|
||||
}
|
||||
}
|
||||
@@ -527,13 +597,37 @@ class DesktopChessViewModel(
|
||||
gameId: String,
|
||||
gameState: LiveChessGameState,
|
||||
) {
|
||||
val moves = pendingMoves.remove(gameId) ?: return
|
||||
val moves = pendingMoves.remove(gameId)
|
||||
if (moves == null) {
|
||||
println("[Chess] No pending moves for game $gameId")
|
||||
return
|
||||
}
|
||||
|
||||
// Sort by move number if available, then apply in order
|
||||
println("[Chess] Processing ${moves.size} pending moves for game $gameId")
|
||||
|
||||
// Sort by move number to find the latest move
|
||||
val sortedMoves = moves.sortedBy { it.third ?: Int.MAX_VALUE }
|
||||
|
||||
for ((san, fen, moveNumber) in sortedMoves) {
|
||||
gameState.applyOpponentMove(san, fen, moveNumber)
|
||||
if (sortedMoves.isEmpty()) return
|
||||
|
||||
// Find the move with highest move number - its FEN has the current board state
|
||||
val latestMove = sortedMoves.maxByOrNull { it.third ?: 0 }
|
||||
if (latestMove != null) {
|
||||
val (san, fen, moveNumber) = latestMove
|
||||
println("[Chess] Syncing to latest position from move #$moveNumber: $san")
|
||||
println("[Chess] FEN: $fen")
|
||||
|
||||
// Use forceResync to set the board to the correct position
|
||||
gameState.forceResync(fen)
|
||||
|
||||
// Mark all received move numbers as processed to avoid duplicates
|
||||
sortedMoves.forEach { (_, _, num) ->
|
||||
if (num != null) {
|
||||
gameState.markMovesAsReceived(setOf(num))
|
||||
}
|
||||
}
|
||||
|
||||
println("[Chess] Board synced to position after ${sortedMoves.size} moves")
|
||||
}
|
||||
|
||||
updateBadgeCount()
|
||||
@@ -548,12 +642,36 @@ class DesktopChessViewModel(
|
||||
to: String,
|
||||
) {
|
||||
val gameState = _activeGames.value[gameId] ?: return
|
||||
val moveResult = gameState.makeMove(from, to)
|
||||
|
||||
// Parse promotion from 'to' if present (e.g., "e8q" -> square="e8", promotion=QUEEN)
|
||||
val (targetSquare, promotion) = parsePromotionFromTarget(to)
|
||||
|
||||
val moveResult = gameState.makeMove(from, targetSquare, promotion)
|
||||
if (moveResult != null) {
|
||||
publishMoveEvent(gameId, moveResult)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse promotion piece from target square string.
|
||||
* e.g., "e8q" -> ("e8", QUEEN), "e4" -> ("e4", null)
|
||||
*/
|
||||
private fun parsePromotionFromTarget(to: String): Pair<String, PieceType?> {
|
||||
if (to.length == 3) {
|
||||
val square = to.substring(0, 2)
|
||||
val promotion =
|
||||
when (to[2].lowercaseChar()) {
|
||||
'q' -> PieceType.QUEEN
|
||||
'r' -> PieceType.ROOK
|
||||
'b' -> PieceType.BISHOP
|
||||
'n' -> PieceType.KNIGHT
|
||||
else -> null
|
||||
}
|
||||
return square to promotion
|
||||
}
|
||||
return to to null
|
||||
}
|
||||
|
||||
private fun publishMoveEvent(
|
||||
gameId: String,
|
||||
moveEvent: ChessMoveEvent,
|
||||
@@ -606,20 +724,24 @@ class DesktopChessViewModel(
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// Store in history
|
||||
val completedGame =
|
||||
CompletedGame(
|
||||
gameId = gameId,
|
||||
opponentPubkey = gameState.opponentPubkey,
|
||||
playerColor = gameState.playerColor,
|
||||
result = endData.result.notation,
|
||||
termination = endData.termination.name.lowercase(),
|
||||
winnerPubkey = endData.winnerPubkey,
|
||||
completedAt = TimeUtils.now(),
|
||||
moveCount = gameState.moveHistory.value.size,
|
||||
)
|
||||
_completedGames.value = listOf(completedGame) + _completedGames.value
|
||||
// Store in history (skip if already completed)
|
||||
val alreadyCompleted = _completedGames.value.any { it.gameId == gameId }
|
||||
if (!alreadyCompleted) {
|
||||
val completedGame =
|
||||
CompletedGame(
|
||||
gameId = gameId,
|
||||
opponentPubkey = gameState.opponentPubkey,
|
||||
playerColor = gameState.playerColor,
|
||||
result = endData.result.notation,
|
||||
termination = endData.termination.name.lowercase(),
|
||||
winnerPubkey = endData.winnerPubkey,
|
||||
completedAt = TimeUtils.now(),
|
||||
moveCount = gameState.moveHistory.value.size,
|
||||
)
|
||||
_completedGames.value = listOf(completedGame) + _completedGames.value
|
||||
}
|
||||
_activeGames.value = _activeGames.value - gameId
|
||||
notifyActiveGamesChanged()
|
||||
_error.value = null
|
||||
} else {
|
||||
_error.value = "Failed to resign"
|
||||
@@ -685,20 +807,24 @@ class DesktopChessViewModel(
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// Store in history and remove from active
|
||||
val completedGame =
|
||||
CompletedGame(
|
||||
gameId = gameId,
|
||||
opponentPubkey = gameState.opponentPubkey,
|
||||
playerColor = gameState.playerColor,
|
||||
result = GameResult.DRAW.notation,
|
||||
termination = GameTermination.DRAW_AGREEMENT.name.lowercase(),
|
||||
winnerPubkey = null,
|
||||
completedAt = TimeUtils.now(),
|
||||
moveCount = gameState.moveHistory.value.size,
|
||||
)
|
||||
_completedGames.value = listOf(completedGame) + _completedGames.value
|
||||
// Store in history and remove from active (skip if already completed)
|
||||
val alreadyCompleted = _completedGames.value.any { it.gameId == gameId }
|
||||
if (!alreadyCompleted) {
|
||||
val completedGame =
|
||||
CompletedGame(
|
||||
gameId = gameId,
|
||||
opponentPubkey = gameState.opponentPubkey,
|
||||
playerColor = gameState.playerColor,
|
||||
result = GameResult.DRAW.notation,
|
||||
termination = GameTermination.DRAW_AGREEMENT.name.lowercase(),
|
||||
winnerPubkey = null,
|
||||
completedAt = TimeUtils.now(),
|
||||
moveCount = gameState.moveHistory.value.size,
|
||||
)
|
||||
_completedGames.value = listOf(completedGame) + _completedGames.value
|
||||
}
|
||||
_activeGames.value = _activeGames.value - gameId
|
||||
notifyActiveGamesChanged()
|
||||
_error.value = null
|
||||
} else {
|
||||
_error.value = "Failed to accept draw"
|
||||
@@ -754,6 +880,30 @@ class DesktopChessViewModel(
|
||||
_isLoading.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh game states - polling callback.
|
||||
* Triggers a subscription refresh to catch any missed events.
|
||||
* The fixed filter (with opponent authors) will fetch opponent moves.
|
||||
*/
|
||||
private suspend fun refreshGamesFromRelay(gameIds: Set<String>) {
|
||||
if (gameIds.isEmpty()) return
|
||||
|
||||
// Trigger subscription controller refresh to re-fetch with current filters
|
||||
// The filter now includes opponent pubkeys as authors, so it will catch their moves
|
||||
subscriptionController?.forceRefresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired challenges (older than 24 hours)
|
||||
*/
|
||||
private fun cleanupExpiredChallenges() {
|
||||
val now = TimeUtils.now()
|
||||
_challenges.value =
|
||||
_challenges.value.filter { challenge ->
|
||||
(now - challenge.createdAt) < CHALLENGE_EXPIRY_SECONDS
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateBadgeCount() {
|
||||
val incomingChallenges = _challenges.value.count { it.opponentPubkey() == account.pubKeyHex }
|
||||
val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() }
|
||||
@@ -776,11 +926,7 @@ class DesktopChessViewModel(
|
||||
return false
|
||||
}
|
||||
|
||||
private fun generateGameId(): String {
|
||||
val timestamp = TimeUtils.now()
|
||||
val random = UUID.randomUUID().toString().take(8)
|
||||
return "chess-$timestamp-$random"
|
||||
}
|
||||
private fun generateGameId(): String = ChessGameNameGenerator.generateGameId(TimeUtils.now())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+13
@@ -95,6 +95,19 @@ object FilterBuilders {
|
||||
authors = pubKeyHexList,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for user metadata (kind 0) from multiple authors.
|
||||
* Alias for userMetadataBatch for API compatibility.
|
||||
*
|
||||
* @param pubKeys List of author public keys (hex-encoded, 64 chars each)
|
||||
* @return Filter for user metadata
|
||||
*/
|
||||
fun userMetadataMultiple(pubKeys: List<String>): Filter =
|
||||
Filter(
|
||||
kinds = listOf(0),
|
||||
authors = pubKeys,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for contact list (kind 3) from a specific author.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
+9
-8
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
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
|
||||
@@ -48,7 +48,7 @@ class LiveChessGameChallengeEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
companion object {
|
||||
const val KIND = 30064
|
||||
|
||||
@@ -100,7 +100,7 @@ class LiveChessGameAcceptEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
companion object {
|
||||
const val KIND = 30065
|
||||
|
||||
@@ -148,7 +148,7 @@ class LiveChessMoveEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
companion object {
|
||||
const val KIND = 30066
|
||||
|
||||
@@ -162,7 +162,8 @@ class LiveChessMoveEvent(
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<LiveChessMoveEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, comment, createdAt) {
|
||||
add(arrayOf("d", gameId))
|
||||
add(arrayOf("d", "$gameId-$moveNumber"))
|
||||
add(arrayOf("game_id", gameId))
|
||||
add(arrayOf("move_number", moveNumber.toString()))
|
||||
add(arrayOf("san", san))
|
||||
add(arrayOf("fen", fen))
|
||||
@@ -172,7 +173,7 @@ class LiveChessMoveEvent(
|
||||
}
|
||||
}
|
||||
|
||||
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
|
||||
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "game_id" }?.get(1)
|
||||
|
||||
fun moveNumber(): Int? =
|
||||
tags
|
||||
@@ -211,7 +212,7 @@ class LiveChessGameEndEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
companion object {
|
||||
const val KIND = 30067
|
||||
|
||||
@@ -268,7 +269,7 @@ class LiveChessDrawOfferEvent(
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
companion object {
|
||||
const val KIND = 30068
|
||||
|
||||
|
||||
+16
-2
@@ -40,6 +40,8 @@ class LiveChessGameState(
|
||||
val playerColor: Color,
|
||||
val engine: ChessEngine,
|
||||
val createdAt: Long = TimeUtils.now(),
|
||||
val isSpectator: Boolean = false,
|
||||
val isPendingChallenge: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
// Abandon timeout: 24 hours without a move
|
||||
@@ -92,8 +94,9 @@ class LiveChessGameState(
|
||||
|
||||
/**
|
||||
* Check if it's the player's turn
|
||||
* Spectators never have a turn
|
||||
*/
|
||||
fun isPlayerTurn(): Boolean = engine.getSideToMove() == playerColor
|
||||
fun isPlayerTurn(): Boolean = !isSpectator && engine.getSideToMove() == playerColor
|
||||
|
||||
/**
|
||||
* Make a move (called when player makes a move on the board)
|
||||
@@ -129,9 +132,12 @@ class LiveChessGameState(
|
||||
checkGameEnd()
|
||||
|
||||
// Return move event to publish
|
||||
// Use ply count (half-move count) for moveNumber so each move gets a unique
|
||||
// d-tag in the Nostr addressable event. The full-move counter from chesslib
|
||||
// repeats for White/Black in the same move pair, causing d-tag collisions.
|
||||
return ChessMoveEvent(
|
||||
gameId = gameId,
|
||||
moveNumber = result.position.moveNumber,
|
||||
moveNumber = _moveHistory.value.size,
|
||||
san = result.san,
|
||||
fen = engine.getFen(),
|
||||
opponentPubkey = opponentPubkey,
|
||||
@@ -403,6 +409,14 @@ class LiveChessGameState(
|
||||
""".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
|
||||
*/
|
||||
|
||||
@@ -113,6 +113,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
|
||||
@@ -193,6 +194,7 @@ class EventFactory {
|
||||
LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveChessMoveEvent.KIND -> LiveChessMoveEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveChessGameEndEvent.KIND -> LiveChessGameEndEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveChessDrawOfferEvent.KIND -> LiveChessDrawOfferEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
+123
-10
@@ -27,32 +27,38 @@ import com.github.bhlangonijr.chesslib.Square
|
||||
import com.github.bhlangonijr.chesslib.move.Move
|
||||
|
||||
/**
|
||||
* JVM/Android implementation of ChessEngine using kchesslib
|
||||
* 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 {
|
||||
val move = board.doMove(san)
|
||||
if (move != null) {
|
||||
if (board.doMove(san)) {
|
||||
// Input is already SAN from Nostr events, store directly
|
||||
sanHistory.add(san)
|
||||
MoveResult(
|
||||
success = true,
|
||||
san = san,
|
||||
position = boardToPosition(),
|
||||
)
|
||||
} else {
|
||||
board.undoMove()
|
||||
MoveResult(
|
||||
success = false,
|
||||
error = "Invalid move: $san",
|
||||
@@ -86,8 +92,9 @@ actual class ChessEngine {
|
||||
val move = Move(fromSquare, toSquare, promotionPiece)
|
||||
|
||||
if (board.legalMoves().contains(move)) {
|
||||
val san = computeSan(move)
|
||||
board.doMove(move)
|
||||
val san = move.toString() // kchesslib converts to SAN
|
||||
sanHistory.add(san)
|
||||
MoveResult(
|
||||
success = true,
|
||||
san = san,
|
||||
@@ -119,8 +126,7 @@ actual class ChessEngine {
|
||||
|
||||
actual fun isLegalMove(san: String): Boolean =
|
||||
try {
|
||||
val move = board.doMove(san)
|
||||
if (move != null) {
|
||||
if (board.doMove(san)) {
|
||||
board.undoMove()
|
||||
true
|
||||
} else {
|
||||
@@ -162,6 +168,9 @@ actual class ChessEngine {
|
||||
|
||||
actual fun undoMove() {
|
||||
board.undoMove()
|
||||
if (sanHistory.isNotEmpty()) {
|
||||
sanHistory.removeAt(sanHistory.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun getPosition(): ChessPosition = boardToPosition()
|
||||
@@ -172,11 +181,115 @@ actual class ChessEngine {
|
||||
Side.BLACK -> Color.BLACK
|
||||
}
|
||||
|
||||
actual fun getMoveHistory(): List<String> {
|
||||
// kchesslib stores move history
|
||||
return board.backup.mapNotNull { it?.move?.toString() }
|
||||
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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user