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:
nrobi144
2026-02-02 10:00:08 +02:00
parent e0d0e33408
commit 42b33ca04c
26 changed files with 4402 additions and 452 deletions
@@ -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,
@@ -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)
}
@@ -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())
}
/**
@@ -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.
*