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:
+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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user