feat(chess): focused polling, promotion fix, and game end celebration

- Add focused game mode to ChessPollingDelegate: when viewing a game,
  only that game is polled instead of all active games
- Fix pawn promotion by parsing promotion suffix in publishMove
  (e.g., "e8q" -> "e8" + QUEEN)
- Add game end overlay with victory/defeat/draw celebration
- Remove all debug println statements for production readiness

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-02-10 11:24:17 +02:00
parent 3c89c442bc
commit 8d098cf86d
42 changed files with 7397 additions and 4118 deletions
@@ -0,0 +1,47 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
/**
* Global registry of accepted game IDs.
*
* This singleton ensures that accepted game state is shared across all ViewModel instances.
* Without this, different ViewModels (e.g., lobby vs game screen) would have separate
* acceptedGameIds sets, causing the game screen to incorrectly load as spectator.
*/
object AcceptedGamesRegistry {
private val acceptedGameIds = mutableSetOf<String>()
fun markAsAccepted(gameId: String) {
acceptedGameIds.add(gameId)
}
fun wasAccepted(gameId: String): Boolean = acceptedGameIds.contains(gameId)
fun clear() {
acceptedGameIds.clear()
}
/** Remove old entries - call periodically to prevent memory leak */
fun clearOldEntries(keepGameIds: Set<String>) {
acceptedGameIds.retainAll(keepGameIds)
}
}
@@ -0,0 +1,56 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
/**
* Global chess configuration shared across Android and Desktop.
*
* These relays are the primary relays used by Jester and other Nostr chess apps.
* Using a small, fixed set ensures fast queries and reliable game discovery.
*/
object ChessConfig {
/**
* The 3 main relays for chess events.
* These are used for both fetching and publishing chess events.
*/
val CHESS_RELAYS =
listOf(
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
)
/**
* Display names for the chess relays (without protocol prefix)
*/
val CHESS_RELAY_NAMES =
listOf(
"relay.damus.io",
"nos.lol",
"relay.primal.net",
)
/**
* Timeout for relay queries in milliseconds.
* With only 3 relays, we can wait for all of them.
*/
const val FETCH_TIMEOUT_MS = 10_000L
}
@@ -0,0 +1,266 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip64Chess.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents
import com.vitorpamplona.quartz.nip64Chess.JesterProtocol
import com.vitorpamplona.quartz.nip64Chess.toJesterEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
/**
* Collects and aggregates Jester chess events for a game from any source.
*
* This class provides a unified way to collect events regardless of whether
* they come from LocalCache (Android), direct relay (Desktop), or test fixtures.
* It handles:
* - Deduplication by event ID
* - Thread-safe concurrent updates
* - Producing JesterGameEvents snapshots for reconstruction
*
* Jester Protocol:
* - All chess events use kind 30
* - Start events (content.kind=0) reference START_POSITION_HASH via e-tag
* - Move events (content.kind=1) reference [startEventId, headEventId] via e-tags
* - Full move history is included in every move event
*
* Usage:
* ```
* val collector = ChessEventCollector(startEventId)
*
* // Add events as they arrive from any source
* collector.addEvent(jesterEvent)
*
* // Get current state for reconstruction
* val events = collector.getEvents()
* val result = ChessStateReconstructor.reconstruct(events, viewerPubkey)
* ```
*/
class ChessEventCollector(
/** The start event ID - this is the game identifier in Jester protocol */
val startEventId: String,
) {
// Start event (only one per game)
private val _startEvent = MutableStateFlow<JesterEvent?>(null)
val startEvent: StateFlow<JesterEvent?> = _startEvent.asStateFlow()
// Move events (deduplicated by event ID)
private val moves = ConcurrentHashMap<String, JesterEvent>()
// Track all processed event IDs for fast deduplication
private val processedEventIds = ConcurrentHashMap.newKeySet<String>()
// Flow that emits when any event is added (for reactive updates)
private val _eventCount = MutableStateFlow(0)
val eventCount: StateFlow<Int> = _eventCount.asStateFlow()
/**
* Check if an event has already been processed.
*/
fun hasEvent(eventId: String): Boolean = processedEventIds.contains(eventId)
/**
* Add a Jester event for this game.
* Automatically categorizes as start or move based on content.kind.
*
* @return true if the event was added, false if already exists or invalid
*/
fun addEvent(event: JesterEvent): Boolean {
if (processedEventIds.contains(event.id)) return false
// Check if this event belongs to our game
val eventStartId = event.startEventId()
val isStartEvent = event.isStartEvent() && event.id == startEventId
val isMoveEvent = event.isMoveEvent() && eventStartId == startEventId
if (!isStartEvent && !isMoveEvent) return false
return if (isStartEvent) {
addStartEvent(event)
} else {
addMoveEvent(event)
}
}
/**
* Add a raw Event if it's a valid Jester event for this game.
*
* @return true if the event was added, false if invalid or not for this game
*/
fun addRawEvent(event: Event): Boolean {
if (event.kind != JesterProtocol.KIND) return false
val jesterEvent = event.toJesterEvent() ?: return false
return addEvent(jesterEvent)
}
/**
* Add the start event for this game.
* Only the first valid start is kept.
*
* @return true if the event was added, false if already exists or invalid
*/
private fun addStartEvent(event: JesterEvent): Boolean {
if (processedEventIds.contains(event.id)) return false
if (!event.isStartEvent()) return false
if (event.id != startEventId) return false
if (_startEvent.compareAndSet(null, event)) {
processedEventIds.add(event.id)
incrementEventCount()
return true
}
return false
}
/**
* Add a move event for this game.
* Moves are deduplicated by event ID.
*
* @return true if the event was added, false if already exists or invalid
*/
private fun addMoveEvent(event: JesterEvent): Boolean {
if (processedEventIds.contains(event.id)) return false
if (!event.isMoveEvent()) return false
if (event.startEventId() != startEventId) return false
if (moves.putIfAbsent(event.id, event) == null) {
processedEventIds.add(event.id)
incrementEventCount()
return true
}
return false
}
/**
* Get a snapshot of all collected events for reconstruction.
*/
fun getEvents(): JesterGameEvents =
JesterGameEvents(
startEvent = _startEvent.value,
moves = moves.values.toList(),
)
/**
* Check if the game has a start event.
*/
fun hasStartEvent(): Boolean = _startEvent.value != null
/**
* Check if the game has any moves (indicates game is active).
*/
fun hasMoves(): Boolean = moves.isNotEmpty()
/**
* Check if the game has ended (has a move with result).
*/
fun hasEnded(): Boolean = moves.values.any { it.result() != null }
/**
* Get the number of moves collected.
*/
fun moveCount(): Int = moves.size
/**
* Get the latest move (with longest history).
*/
fun latestMove(): JesterEvent? = moves.values.maxByOrNull { it.history().size }
/**
* Clear all collected events.
*/
fun clear() {
_startEvent.value = null
moves.clear()
processedEventIds.clear()
_eventCount.value = 0
}
private fun incrementEventCount() {
_eventCount.value = processedEventIds.size
}
}
/**
* Manager for multiple game collectors.
*
* This is useful when managing multiple concurrent games,
* such as in a chess lobby or when spectating multiple games.
*/
class ChessEventCollectorManager {
private val collectors = ConcurrentHashMap<String, ChessEventCollector>()
/**
* Get or create a collector for a game.
*
* @param startEventId The start event ID (game identifier)
*/
fun getOrCreate(startEventId: String): ChessEventCollector = collectors.getOrPut(startEventId) { ChessEventCollector(startEventId) }
/**
* Get a collector if it exists.
*
* @param startEventId The start event ID (game identifier)
*/
fun get(startEventId: String): ChessEventCollector? = collectors[startEventId]
/**
* Remove a collector for a game.
*
* @param startEventId The start event ID (game identifier)
*/
fun remove(startEventId: String): ChessEventCollector? = collectors.remove(startEventId)
/**
* Get all active game IDs (start event IDs).
*/
fun activeGameIds(): Set<String> = collectors.keys.toSet()
/**
* Clear all collectors.
*/
fun clear() {
collectors.values.forEach { it.clear() }
collectors.clear()
}
/**
* Add an event to the appropriate collector (auto-routing).
* Creates a collector if needed for start events.
*
* @return true if the event was added to a collector
*/
fun addEvent(event: JesterEvent): Boolean {
// For start events, create collector with event ID
if (event.isStartEvent()) {
val collector = getOrCreate(event.id)
return collector.addEvent(event)
}
// For move events, find the collector by startEventId
val startId = event.startEventId() ?: return false
val collector = collectors[startId] ?: return false
return collector.addEvent(event)
}
}
@@ -49,19 +49,19 @@ data class ChessPollingConfig(
* Platform-specific defaults
*/
object ChessPollingDefaults {
/** Android: shorter intervals, no background polling */
/** Android: moderate intervals, no background polling */
val android =
ChessPollingConfig(
activeGamePollInterval = 10_000L,
challengePollInterval = 30_000L,
activeGamePollInterval = 5_000L, // 5 seconds for responsive gameplay
challengePollInterval = 15_000L, // 15 seconds for challenges
pollInBackground = false,
)
/** Desktop: can poll in background */
/** Desktop: fast polling for responsive gameplay */
val desktop =
ChessPollingConfig(
activeGamePollInterval = 10_000L,
challengePollInterval = 30_000L,
activeGamePollInterval = 2_000L, // 2 seconds for fast updates
challengePollInterval = 10_000L, // 10 seconds for challenges
pollInBackground = true,
)
}
@@ -94,11 +94,52 @@ class ChessPollingDelegate(
private var gamePollingJob: Job? = null
private var challengePollingJob: Job? = null
private var cleanupJob: Job? = null
private var manualRefreshJob: Job? = null
private val _isPolling = MutableStateFlow(false)
val isPolling: StateFlow<Boolean> = _isPolling.asStateFlow()
private val activeGameIdsFlow = MutableStateFlow<Set<String>>(emptySet())
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
internal val activeGameIdsFlow = MutableStateFlow<Set<String>>(emptySet())
/**
* Focused game ID - when set, only this game is polled for updates.
* Used when viewing a specific game screen to avoid refreshing unrelated games.
* When null, all games in activeGameIdsFlow are polled (lobby mode).
*/
private val _focusedGameId = MutableStateFlow<String?>(null)
/**
* Set focused game mode - only poll this specific game.
* Call this when entering a game screen.
* Pass null to return to lobby mode (poll all games).
*/
fun setFocusedGame(gameId: String?) {
_focusedGameId.value = gameId
}
/**
* Get current focused game ID (null = lobby mode, polls all games)
*/
fun getFocusedGameId(): String? = _focusedGameId.value
/**
* Get the effective game IDs to poll based on focused mode.
* In focused mode: only the focused game.
* In lobby mode: all active games.
*/
private fun getEffectiveGameIds(): Set<String> {
val focused = _focusedGameId.value
return if (focused != null) {
// Focused mode - only poll this game
setOf(focused)
} else {
// Lobby mode - poll all games
activeGameIdsFlow.value
}
}
/**
* Update the set of game IDs to poll for
@@ -125,30 +166,41 @@ class ChessPollingDelegate(
* Start polling for chess events
*/
fun start() {
if (_isPolling.value) return
if (_isPolling.value) {
return
}
_isPolling.value = true
// Poll for active games
gamePollingJob =
scope.launch {
while (isActive) {
val gameIds = activeGameIdsFlow.value
val gameIds = getEffectiveGameIds()
if (gameIds.isNotEmpty()) {
onRefreshGames(gameIds)
try {
onRefreshGames(gameIds)
} catch (_: Exception) {
// Error during refresh - continue polling
}
}
delay(config.activeGamePollInterval)
}
}
// Poll for challenges
// Poll for challenges (only in lobby mode)
challengePollingJob =
scope.launch {
// Initial fetch
onRefreshChallenges()
// Initial fetch (only if not in focused mode)
if (_focusedGameId.value == null) {
onRefreshChallenges()
}
while (isActive) {
delay(config.challengePollInterval)
onRefreshChallenges()
// Skip challenge polling in focused mode - game screen doesn't need it
if (_focusedGameId.value == null) {
onRefreshChallenges()
}
}
}
@@ -194,16 +246,34 @@ class ChessPollingDelegate(
}
/**
* Force an immediate refresh
* Force an immediate refresh (debounced - ignores calls if refresh already in progress)
*/
fun refreshNow() {
scope.launch {
onRefreshChallenges()
val gameIds = activeGameIdsFlow.value
if (gameIds.isNotEmpty()) {
onRefreshGames(gameIds)
}
// Debounce: skip if already refreshing
if (_isRefreshing.value) {
return
}
// Cancel any pending manual refresh
manualRefreshJob?.cancel()
manualRefreshJob =
scope.launch {
_isRefreshing.value = true
try {
// In focused mode, skip challenge refresh (game screen doesn't need it)
val focusedId = _focusedGameId.value
if (focusedId == null) {
onRefreshChallenges()
}
val gameIds = getEffectiveGameIds()
if (gameIds.isNotEmpty()) {
onRefreshGames(gameIds)
}
} finally {
_isRefreshing.value = false
}
}
}
}
@@ -0,0 +1,179 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessStateReconstructor
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import com.vitorpamplona.quartz.nip64Chess.ReconstructedGameState
import com.vitorpamplona.quartz.nip64Chess.ReconstructionResult
import com.vitorpamplona.quartz.nip64Chess.ViewerRole
/**
* Converts a ReconstructedGameState (from ChessStateReconstructor) into a
* LiveChessGameState (used by ViewModels for reactive updates).
*
* This bridges the gap between the deterministic reconstruction algorithm
* and the stateful game management that ViewModels need.
*
* Usage:
* ```
* // Collect events from any source
* val collector = ChessEventCollector(startEventId)
* collector.addEvent(startEvent)
* collector.addEvent(moveEvent1)
* collector.addEvent(moveEvent2)
*
* // Reconstruct using shared algorithm
* val result = ChessStateReconstructor.reconstruct(collector.getEvents(), viewerPubkey)
*
* // Convert to LiveChessGameState for ViewModel use
* val gameState = ChessGameLoader.toLiveGameState(result, viewerPubkey)
* ```
*/
object ChessGameLoader {
/**
* Convert a ReconstructionResult to a LiveChessGameState for ViewModel use.
*
* @param result The result from ChessStateReconstructor
* @param viewerPubkey The pubkey of the user viewing the game
* @return LiveChessGameState or null if reconstruction failed
*/
fun toLiveGameState(
result: ReconstructionResult,
viewerPubkey: String,
): LiveChessGameState? {
if (result !is ReconstructionResult.Success) return null
val state = result.state
val engine = result.engine
val (playerPubkey, opponentPubkey) =
when (state.viewerRole) {
ViewerRole.WHITE_PLAYER -> viewerPubkey to (state.blackPubkey ?: "")
ViewerRole.BLACK_PLAYER -> viewerPubkey to (state.whitePubkey ?: "")
ViewerRole.SPECTATOR -> viewerPubkey to (state.blackPubkey ?: "")
}
return LiveChessGameState(
startEventId = state.startEventId,
playerPubkey = playerPubkey,
opponentPubkey = opponentPubkey,
playerColor = state.playerColor,
engine = engine,
createdAt = state.challengeCreatedAt,
isSpectator = state.viewerRole == ViewerRole.SPECTATOR,
isPendingChallenge = state.isPendingChallenge,
initialHeadEventId = state.headEventId,
).also { gameState ->
// Mark all loaded moves as received to prevent re-application during polling
gameState.markMovesAsReceived(state.appliedMoveNumbers)
// Mark finished if the game has ended
if (state.isFinished() && state.gameStatus is com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished) {
gameState.markAsFinished(
(state.gameStatus as com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished).result,
)
}
// Handle pending draw offer
val drawOfferer = state.pendingDrawOffer
if (drawOfferer != null && drawOfferer != viewerPubkey) {
gameState.receiveDrawOffer(drawOfferer)
}
}
}
/**
* Load a game using the deterministic reconstruction algorithm.
*
* @param events Collected Jester events for the game
* @param viewerPubkey The pubkey of the user viewing the game
* @return Pair of (LiveChessGameState, ReconstructedGameState) or null if failed
*/
fun loadGame(
events: JesterGameEvents,
viewerPubkey: String,
): LoadGameResult {
val result = ChessStateReconstructor.reconstruct(events, viewerPubkey)
return when (result) {
is ReconstructionResult.Success -> {
val liveState = toLiveGameState(result, viewerPubkey)
if (liveState != null) {
LoadGameResult.Success(liveState, result.state)
} else {
LoadGameResult.Error("Failed to convert reconstructed state to live state")
}
}
is ReconstructionResult.Error -> LoadGameResult.Error(result.message)
}
}
/**
* Create a new game state without any events (for when accepting a challenge locally).
*
* @param startEventId The start event ID (game identifier)
* @param playerPubkey The player's pubkey
* @param opponentPubkey The opponent's pubkey
* @param playerColor The player's color
* @return A fresh LiveChessGameState
*/
fun createNewGame(
startEventId: String,
playerPubkey: String,
opponentPubkey: String,
playerColor: Color,
isPendingChallenge: Boolean = false,
): LiveChessGameState {
val engine = ChessEngine()
engine.reset()
return LiveChessGameState(
startEventId = startEventId,
playerPubkey = playerPubkey,
opponentPubkey = opponentPubkey,
playerColor = playerColor,
engine = engine,
isPendingChallenge = isPendingChallenge,
)
}
}
/**
* Result of loading a game.
*/
sealed class LoadGameResult {
data class Success(
val liveState: LiveChessGameState,
val reconstructedState: ReconstructedGameState,
) : LoadGameResult()
data class Error(
val message: String,
) : LoadGameResult()
fun isSuccess(): Boolean = this is Success
fun getOrNull(): LiveChessGameState? = (this as? Success)?.liveState
}
@@ -0,0 +1,339 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
// Color constants for card borders
private val IncomingChallengeColor = Color(0xFFFF9800) // Orange
private val OpenChallengeColor = Color(0xFF4CAF50) // Green
private val SpectatingColor = Color(0xFF9C27B0) // Purple
private val LiveGameColor = Color(0xFF2196F3) // Blue
/**
* Card for an active game where the user is a participant
*/
@Composable
fun ActiveGameCard(
gameId: String,
opponentName: String,
isYourTurn: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
avatar: @Composable (() -> Unit)? = null,
) {
val gameName =
remember(gameId) {
ChessGameNameGenerator.extractDisplayName(gameId) ?: gameId.take(12)
}
Card(
modifier = modifier.fillMaxWidth().clickable(onClick = onClick),
border =
if (isYourTurn) {
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
} else {
null
},
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
avatar?.invoke()
Column(modifier = Modifier.weight(1f)) {
Text(
gameName,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
"vs $opponentName",
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
if (isYourTurn) "Your turn" else "Waiting...",
style = MaterialTheme.typography.bodyMedium,
color = if (isYourTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
/**
* Card for an incoming or open challenge
*/
@Composable
fun ChallengeCard(
challengerName: String,
challengerPlaysWhite: Boolean,
isIncoming: Boolean,
onAccept: () -> Unit,
modifier: Modifier = Modifier,
avatar: @Composable (() -> Unit)? = null,
) {
val borderColor = if (isIncoming) IncomingChallengeColor else OpenChallengeColor
Card(
modifier = modifier.fillMaxWidth(),
border = BorderStroke(2.dp, borderColor),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
avatar?.invoke()
Column(modifier = Modifier.weight(1f)) {
Text(
if (isIncoming) "Challenge from $challengerName" else "Open challenge by $challengerName",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
"Challenger plays ${if (challengerPlaysWhite) "White" else "Black"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Button(onClick = onAccept) {
Text("Accept")
}
}
}
}
/**
* Card for user's outgoing challenge (waiting for acceptance)
* Clickable so user can open the game board and make the first move when ready
*/
@Composable
fun OutgoingChallengeCard(
opponentName: String?,
userPlaysWhite: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
avatar: @Composable (() -> Unit)? = null,
) {
Card(
modifier = modifier.fillMaxWidth().clickable(onClick = onClick),
border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
avatar?.invoke()
Column(modifier = Modifier.weight(1f)) {
Text(
if (opponentName != null) {
"Challenge to $opponentName"
} else {
"Open challenge (awaiting opponent)"
},
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
"You play ${if (userPlaysWhite) "White" else "Black"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
"Waiting...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* Card for a game the user is spectating
*/
@Composable
fun SpectatingGameCard(
moveCount: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth().clickable(onClick = onClick),
border = BorderStroke(1.dp, SpectatingColor),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Watching game",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
"$moveCount moves played",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
"Spectating",
style = MaterialTheme.typography.bodyMedium,
color = SpectatingColor,
fontWeight = FontWeight.Medium,
)
}
}
}
/**
* Card for a public live game that can be watched
*/
@Composable
fun PublicGameCard(
whiteName: String,
blackName: String,
moveCount: Int,
onWatch: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
border = BorderStroke(1.dp, LiveGameColor),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"$whiteName vs $blackName",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
"$moveCount moves",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(onClick = onWatch) {
Text("Watch")
}
}
}
}
/**
* Card for a completed game in history
*/
@Composable
fun CompletedGameCard(
opponentName: String,
result: String,
didUserWin: Boolean,
isDraw: Boolean,
moveCount: Int,
modifier: Modifier = Modifier,
avatar: @Composable (() -> Unit)? = null,
) {
val resultText =
when {
isDraw -> "Draw"
didUserWin -> "Won"
else -> "Lost"
}
val resultColor =
when {
isDraw -> MaterialTheme.colorScheme.onSurfaceVariant
didUserWin -> Color(0xFF4CAF50) // Green
else -> Color(0xFFF44336) // Red
}
Card(
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
avatar?.invoke()
Column(modifier = Modifier.weight(1f)) {
Text(
"vs $opponentName",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
"$moveCount moves • $result",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
resultText,
style = MaterialTheme.typography.bodyMedium,
color = resultColor,
fontWeight = FontWeight.Bold,
)
}
}
}
@@ -20,84 +20,94 @@
*/
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.nip64Chess.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents
import com.vitorpamplona.quartz.nip64Chess.PieceType
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.UUID
/**
* Interface for platform-specific chess event publishing
* Interface for platform-specific chess event publishing using Jester protocol.
*
* All chess events use kind 30 with JSON content:
* - Start events: content.kind=0
* - Move events: content.kind=1 with full history
*/
interface ChessEventPublisher {
suspend fun publishChallenge(
gameId: String,
/**
* Publish a game start event (challenge).
* Returns the startEventId (event ID) if successful.
*/
suspend fun publishStart(
playerColor: Color,
opponentPubkey: String?,
timeControl: String?,
): Boolean
): String?
suspend fun publishAccept(
gameId: String,
challengeEventId: String,
challengerPubkey: String,
): Boolean
suspend fun publishMove(move: ChessMoveEvent): Boolean
/**
* Publish a move event.
* Move events include full history and link to previous move.
*/
suspend fun publishMove(move: ChessMoveEvent): String?
/**
* Publish a game end event (includes result in content).
*/
suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean
suspend fun publishDrawOffer(
gameId: String,
opponentPubkey: String,
message: String?,
): Boolean
/**
* Get count of write relays for UI feedback.
*/
fun getWriteRelayCount(): Int
}
/**
* Relay-first fetcher interface. Platforms provide one-shot relay queries.
* Relay-first fetcher interface for Jester protocol.
* Platforms provide one-shot relay queries.
*
* Every fetch does: one-shot REQ → collect events → EOSE → close.
* No caching — relays are the single source of truth.
*/
interface ChessRelayFetcher {
/** Fetch all events for a specific game */
suspend fun fetchGameEvents(gameId: String): ChessGameEvents
/** Fetch all events for a specific game by startEventId */
suspend fun fetchGameEvents(startEventId: String): JesterGameEvents
/** Fetch recent challenge events */
suspend fun fetchChallenges(): List<LiveChessGameChallengeEvent>
/** Fetch recent start/challenge events with optional progress callback */
suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)? = null): List<JesterEvent>
/** Fetch recent public game summaries for spectating */
suspend fun fetchRecentGames(): List<RelayGameSummary>
/** Fetch game IDs (startEventIds) where user is a participant */
suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)? = null): Set<String>
/** Get the list of relay URLs that will be used for fetching */
fun getRelayUrls(): List<String>
}
/**
* Summary of a game found on relays (for lobby display / spectating)
*/
data class RelayGameSummary(
val gameId: String,
val startEventId: String,
val whitePubkey: String,
val blackPubkey: String,
val moveCount: Int,
val lastMoveTime: Long,
val isActive: Boolean,
)
) {
// Legacy compatibility
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val gameId: String get() = startEventId
}
/**
* Shared chess lobby logic — relay-first architecture.
* Shared chess lobby logic — relay-first architecture with Jester protocol.
*
* Both Android and Desktop use this identically.
* Platform-specific code only implements:
@@ -128,38 +138,67 @@ class ChessLobbyLogic(
// Lifecycle
// ========================================
fun startPolling() = pollingDelegate.start()
fun startPolling() {
pollingDelegate.start()
}
fun stopPolling() = pollingDelegate.stop()
fun stopPolling() {
pollingDelegate.stop()
}
fun forceRefresh() = pollingDelegate.refreshNow()
/**
* Ensure a game ID is being polled for updates.
* Call this when entering a game screen to guarantee polling is active for that game.
*/
fun ensureGamePolling(gameId: String) {
pollingDelegate.addGameId(gameId)
}
/**
* Set focused game mode - only poll this specific game.
* Call this when entering a game screen to avoid refreshing unrelated games.
* Also ensures the game is in the polling set.
*/
fun setFocusedGame(gameId: String) {
pollingDelegate.addGameId(gameId)
pollingDelegate.setFocusedGame(gameId)
}
/**
* Clear focused game mode - return to lobby mode (poll all games).
* Call this when returning to the lobby screen.
*/
fun clearFocusedGame() {
pollingDelegate.setFocusedGame(null)
}
fun forceRefresh() {
pollingDelegate.refreshNow()
}
// ========================================
// Incoming event routing (real-time / optimistic)
// ========================================
/**
* Route an incoming relay event to the appropriate handler.
* Route an incoming Jester 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)
fun handleIncomingEvent(event: JesterEvent) {
when {
event.isStartEvent() -> handleStartEvent(event)
event.isMoveEvent() -> handleMoveEvent(event)
}
}
private fun handleChallenge(event: LiveChessGameChallengeEvent) {
val gameId = event.gameId() ?: return
private fun handleStartEvent(event: JesterEvent) {
val startEventId = event.id
val challengerColor = event.playerColor() ?: Color.WHITE
val challenge =
ChessChallenge(
eventId = event.id,
gameId = gameId,
gameId = startEventId, // In Jester, gameId = startEventId
challengerPubkey = event.pubKey,
challengerDisplayName = metadataProvider.getDisplayName(event.pubKey),
challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey),
@@ -170,54 +209,51 @@ class ChessLobbyLogic(
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
private fun handleMoveEvent(event: JesterEvent) {
val startEventId = event.startEventId() ?: return
val san = event.move() ?: return
val fen = event.fen() ?: return
val moveNumber = event.moveNumber()
val history = event.history()
val moveNumber = history.size
val gameState = state.getGameState(gameId) ?: return
// Check if this is our game (we're either the author or tagged as opponent)
val opponentFromTag = event.opponentPubkey()
val isOurGame = event.pubKey == userPubkey || opponentFromTag == userPubkey
var gameState = state.getGameState(startEventId)
// If this is our game but we haven't loaded it yet, load it now
// This happens when someone accepts our challenge (makes first move)
if (gameState == null && isOurGame) {
handleGameAccepted(startEventId)
return // handleGameAccepted will load the game and poll for events
}
if (gameState == null) return
// Check for game end
val result = event.result()
if (result != null) {
val gameResult =
when (result) {
"1-0" -> com.vitorpamplona.quartz.nip64Chess.GameResult.WHITE_WINS
"0-1" -> com.vitorpamplona.quartz.nip64Chess.GameResult.BLACK_WINS
"1/2-1/2" -> com.vitorpamplona.quartz.nip64Chess.GameResult.DRAW
else -> null
}
if (gameResult != null) {
gameState.markAsFinished(gameResult)
state.moveToCompleted(startEventId, result, event.termination())
pollingDelegate.removeGameId(startEventId)
return
}
}
// Only apply opponent moves optimistically
if (event.pubKey != userPubkey) {
gameState.applyOpponentMove(san, fen, moveNumber)
}
}
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)
// Update head event ID for move linking
gameState.updateHeadEventId(event.id)
}
}
@@ -228,10 +264,8 @@ class ChessLobbyLogic(
fun createChallenge(
opponentPubkey: String? = null,
playerColor: Color = Color.WHITE,
timeControl: String? = null,
timeControl: String? = null, // Not supported in Jester, kept for API compatibility
) {
val gameId = generateGameId()
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(
ChessBroadcastStatus.Broadcasting(
@@ -241,9 +275,24 @@ class ChessLobbyLogic(
),
)
val success = retryWithBackoff { publisher.publishChallenge(gameId, playerColor, opponentPubkey, timeControl) }
val startEventId = retryWithBackoffResult { publisher.publishStart(playerColor, opponentPubkey) }
if (startEventId != null) {
// Add challenge to local state - shows in "Your Challenges" section
val challenge =
ChessChallenge(
eventId = startEventId,
gameId = startEventId,
challengerPubkey = userPubkey,
challengerDisplayName = metadataProvider.getDisplayName(userPubkey),
challengerAvatarUrl = metadataProvider.getPictureUrl(userPubkey),
opponentPubkey = opponentPubkey,
challengerColor = playerColor,
createdAt = TimeUtils.now(),
)
state.addChallenge(challenge)
pollingDelegate.addGameId(startEventId)
if (success) {
state.setBroadcastStatus(
ChessBroadcastStatus.Success("Challenge", publisher.getWriteRelayCount()),
)
@@ -259,51 +308,84 @@ class ChessLobbyLogic(
}
}
/**
* Accept a challenge by loading the game and making the first move (if we're black)
* or waiting for opponent's move (if we're white).
*
* In Jester protocol, acceptance is implicit - we just track the game locally.
*/
fun acceptChallenge(challenge: ChessChallenge) {
scope.launch(Dispatchers.Default) {
val success =
retryWithBackoff {
publisher.publishAccept(
gameId = challenge.gameId,
challengeEventId = challenge.eventId,
challengerPubkey = challenge.challengerPubkey,
)
}
// Mark as accepted SYNCHRONOUSLY before launching coroutine
// This prevents race where navigation happens before coroutine runs,
// which would cause loadGame() to incorrectly mark as spectator
state.markAsAccepted(challenge.gameId)
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")
}
state.removeChallenge(challenge.gameId)
// Add to polling delegate FIRST - before adding to activeGames
// This prevents race where Compose sees the game but forceRefresh() doesn't include it
pollingDelegate.addGameId(challenge.gameId)
scope.launch(Dispatchers.Default) {
val playerColor = challenge.challengerColor.opposite()
val gameState =
ChessGameLoader.createNewGame(
startEventId = challenge.gameId, // gameId = startEventId in Jester
playerPubkey = userPubkey,
opponentPubkey = challenge.challengerPubkey,
playerColor = playerColor,
)
state.addActiveGame(challenge.gameId, gameState)
state.selectGame(challenge.gameId)
state.setError(null)
// Immediately fetch game from relays to load any existing moves
// This ensures moves are loaded without waiting for polling interval
refreshGame(challenge.gameId)
}
}
/**
* When we detect our challenge was accepted, load game from relays.
* Open user's own outgoing challenge to view the board and make moves.
* Creates game state and navigates to game view.
*/
fun handleGameAccepted(gameId: String) {
fun openOwnChallenge(challenge: ChessChallenge) {
// Add to polling delegate FIRST - before adding to activeGames
pollingDelegate.addGameId(challenge.gameId)
// Create game state with user's chosen color
val gameState =
ChessGameLoader.createNewGame(
startEventId = challenge.gameId,
playerPubkey = userPubkey,
opponentPubkey = challenge.opponentPubkey ?: "",
playerColor = challenge.challengerColor,
isPendingChallenge = true,
)
state.addActiveGame(challenge.gameId, gameState)
state.selectGame(challenge.gameId)
// Fetch from relays in case opponent has already made moves
scope.launch(Dispatchers.Default) {
refreshGame(challenge.gameId)
}
}
/**
* When we detect our challenge was accepted (opponent made first move), load game from relays.
*/
fun handleGameAccepted(startEventId: String) {
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
val events = fetcher.fetchGameEvents(gameId)
val events = fetcher.fetchGameEvents(startEventId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
state.addActiveGame(gameId, result.liveState)
pollingDelegate.addGameId(gameId)
state.selectGame(gameId)
state.addActiveGame(startEventId, result.liveState)
pollingDelegate.addGameId(startEventId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
state.setError(null)
}
@@ -320,18 +402,21 @@ class ChessLobbyLogic(
// ========================================
fun publishMove(
gameId: String,
startEventId: String,
from: String,
to: String,
) {
val gameState = state.getGameState(gameId) ?: return
val gameState = state.getGameState(startEventId) ?: return
if (state.isSpectating(gameId)) {
if (state.isSpectating(startEventId)) {
state.setError("Cannot move while spectating")
return
}
val moveResult = gameState.makeMove(from, to) ?: return
// Parse promotion suffix from `to` if present (e.g., "e8q" -> "e8" + QUEEN)
val (actualTo, promotion) = parsePromotionFromTo(to)
val moveResult = gameState.makeMove(from, actualTo, promotion) ?: return
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(
@@ -342,15 +427,18 @@ class ChessLobbyLogic(
),
)
val success = retryWithBackoff { publisher.publishMove(moveResult) }
val newEventId = retryWithBackoffResult { publisher.publishMove(moveResult) }
if (newEventId != null) {
// Update head event ID for next move linking
gameState.updateHeadEventId(newEventId)
if (success) {
state.setBroadcastStatus(
ChessBroadcastStatus.Success(moveResult.san, publisher.getWriteRelayCount()),
)
delay(3000)
val currentState = state.getGameState(gameId)
val currentState = state.getGameState(startEventId)
state.setBroadcastStatus(
if (currentState?.isPlayerTurn() == false) {
ChessBroadcastStatus.WaitingForOpponent
@@ -360,18 +448,21 @@ class ChessLobbyLogic(
)
state.setError(null)
} else {
// Revert the move since publishing failed
gameState.undoLastMove()
state.setBroadcastStatus(
ChessBroadcastStatus.Failed(moveResult.san, "Failed to publish move"),
)
state.setError("Failed to publish move")
state.setError("Failed to publish move - move reverted")
}
}
}
fun resign(gameId: String) {
val gameState = state.getGameState(gameId) ?: return
fun resign(startEventId: String) {
val gameState = state.getGameState(startEventId) ?: return
if (state.isSpectating(gameId)) {
if (state.isSpectating(startEventId)) {
state.setError("Cannot resign while spectating")
return
}
@@ -381,8 +472,8 @@ class ChessLobbyLogic(
val success = retryWithBackoff { publisher.publishGameEnd(endData) }
if (success) {
state.moveToCompleted(gameId, endData.result.notation, endData.termination.name.lowercase())
pollingDelegate.removeGameId(gameId)
state.moveToCompleted(startEventId, endData.result.notation, endData.termination.name.lowercase())
pollingDelegate.removeGameId(startEventId)
state.setError(null)
} else {
state.setError("Failed to resign")
@@ -390,65 +481,16 @@ class ChessLobbyLogic(
}
}
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
fun claimAbandonmentVictory(startEventId: String) {
val gameState = state.getGameState(startEventId) ?: return
val endData = gameState.claimAbandonmentVictory() ?: return
scope.launch(Dispatchers.Default) {
val success = retryWithBackoff { publisher.publishGameEnd(endData) }
if (success) {
state.moveToCompleted(gameId, endData.result.notation, "abandonment")
pollingDelegate.removeGameId(gameId)
state.moveToCompleted(startEventId, endData.result.notation, "abandonment")
pollingDelegate.removeGameId(startEventId)
state.setError(null)
} else {
state.setError("Failed to claim abandonment victory")
@@ -460,20 +502,21 @@ class ChessLobbyLogic(
// Spectator mode
// ========================================
fun loadGameAsSpectator(gameId: String) {
fun loadGameAsSpectator(startEventId: String) {
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
val events = fetcher.fetchGameEvents(gameId)
val events = fetcher.fetchGameEvents(startEventId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
state.addSpectatingGame(gameId, result.liveState)
pollingDelegate.addGameId(gameId)
state.selectGame(gameId)
state.addSpectatingGame(startEventId, result.liveState)
pollingDelegate.addGameId(startEventId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
state.setError(null)
// Auto-select the game for Desktop (Android uses route navigation)
state.selectGame(startEventId)
}
is LoadGameResult.Error -> {
state.setError("Failed to load game: ${result.message}")
@@ -483,31 +526,36 @@ class ChessLobbyLogic(
}
}
fun loadGame(gameId: String) {
fun loadGame(startEventId: String) {
scope.launch(Dispatchers.Default) {
if (state.getGameState(gameId) != null) {
state.selectGame(gameId)
// Don't load if game already exists or was accepted (acceptChallenge will handle it)
if (state.getGameState(startEventId) != null || state.wasAccepted(startEventId)) {
return@launch
}
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
val events = fetcher.fetchGameEvents(gameId)
val events = fetcher.fetchGameEvents(startEventId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
if (result.liveState.isSpectator) {
state.addSpectatingGame(gameId, result.liveState)
state.addSpectatingGame(startEventId, result.liveState)
} else {
state.addActiveGame(gameId, result.liveState)
state.addActiveGame(startEventId, result.liveState)
}
pollingDelegate.addGameId(gameId)
state.selectGame(gameId)
pollingDelegate.addGameId(startEventId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
state.setError(null)
}
is LoadGameResult.Error -> {
// Check again if game was added while we were fetching
// (e.g., by acceptChallenge completing in parallel)
if (state.getGameState(startEventId) != null) {
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
return@launch
}
state.setError("Failed to load game: ${result.message}")
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
}
@@ -519,23 +567,20 @@ class ChessLobbyLogic(
// 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)
private suspend fun refreshGame(startEventId: String) {
val events = fetcher.fetchGameEvents(startEventId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
state.replaceGameState(gameId, result.liveState)
state.replaceGameState(startEventId, result.liveState)
}
is LoadGameResult.Error -> {
// Don't overwrite error for periodic refresh failures
@@ -544,16 +589,72 @@ class ChessLobbyLogic(
}
private suspend fun refreshChallenges() {
val challengeEvents = fetcher.fetchChallenges()
state.setRefreshing(true)
try {
refreshChallengesInternal()
} finally {
state.setRefreshing(false)
}
}
private suspend fun refreshChallengesInternal() {
val relayUrls = fetcher.getRelayUrls()
val relayStatesMap = mutableMapOf<String, RelaySyncState>()
relayUrls.forEach { url ->
val displayName = url.substringAfter("://").substringBefore("/")
relayStatesMap[url] = RelaySyncState(url, displayName, RelaySyncStatus.CONNECTING, 0)
}
var totalEvents = 0
state.setSyncStatus(
ChessSyncStatus.Syncing(
phase = "challenges",
relayStates = relayStatesMap.values.toList(),
totalEventsReceived = 0,
),
)
val startEvents =
fetcher.fetchChallenges { progress ->
val relayUrl = progress.relay.url
val displayName = relayUrl.substringAfter("://").substringBefore("/")
val status =
when (progress.status) {
RelayFetchStatus.WAITING -> RelaySyncStatus.WAITING
RelayFetchStatus.RECEIVING -> RelaySyncStatus.RECEIVING
RelayFetchStatus.EOSE_RECEIVED -> RelaySyncStatus.EOSE_RECEIVED
RelayFetchStatus.TIMEOUT -> RelaySyncStatus.FAILED
}
relayStatesMap[relayUrl] =
RelaySyncState(
relayUrl,
displayName,
status,
progress.eventCount,
)
totalEvents = relayStatesMap.values.sumOf { it.eventsReceived }
state.setSyncStatus(
ChessSyncStatus.Syncing(
phase = "challenges",
relayStates = relayStatesMap.values.toList(),
totalEventsReceived = totalEvents,
),
)
}
val fetchedChallenges =
startEvents.mapNotNull { event ->
if (!event.isStartEvent()) return@mapNotNull null
val startEventId = event.id
// Skip challenges that user has already accepted
if (state.wasAccepted(startEventId)) return@mapNotNull null
val challenges =
challengeEvents.mapNotNull { event ->
val gameId = event.gameId() ?: return@mapNotNull null
val challengerColor = event.playerColor() ?: Color.WHITE
ChessChallenge(
eventId = event.id,
gameId = gameId,
gameId = startEventId,
challengerPubkey = event.pubKey,
challengerDisplayName = metadataProvider.getDisplayName(event.pubKey),
challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey),
@@ -563,14 +664,38 @@ class ChessLobbyLogic(
)
}
state.updateChallenges(challenges)
// Merge only RECENT optimistic challenges (created in last 5 minutes, not yet propagated)
// This prevents stale challenges from accumulating across sessions
// Also exclude challenges that user has accepted (tracked in _acceptedGameIds)
val now = TimeUtils.now()
val recentThreshold = 5 * 60L // 5 minutes
val fetchedGameIds = fetchedChallenges.map { it.gameId }.toSet()
val optimisticChallenges =
state.challenges.value.filter { challenge ->
val isRecent = (now - challenge.createdAt) < recentThreshold
val notFetched = challenge.gameId !in fetchedGameIds
val notAccepted = !state.wasAccepted(challenge.gameId)
isRecent && notFetched && notAccepted
}
val mergedChallenges = fetchedChallenges + optimisticChallenges
state.updateChallenges(mergedChallenges)
state.setSyncStatus(
ChessSyncStatus.Syncing(
phase = "games",
relayStates = relayStatesMap.values.toList(),
totalEventsReceived = totalEvents,
),
)
discoverUserGames()
// Also refresh public games
val recentGames = fetcher.fetchRecentGames()
val publicGames =
recentGames.map { summary ->
PublicGame(
gameId = summary.gameId,
gameId = summary.startEventId,
whitePubkey = summary.whitePubkey,
whiteDisplayName = metadataProvider.getDisplayName(summary.whitePubkey),
blackPubkey = summary.blackPubkey,
@@ -581,6 +706,59 @@ class ChessLobbyLogic(
)
}
state.updatePublicGames(publicGames)
val failedCount = relayStatesMap.values.count { it.status == RelaySyncStatus.FAILED }
val activeGamesCount = state.activeGames.value.size
if (failedCount > 0 && failedCount < relayStatesMap.size) {
state.setSyncStatus(
ChessSyncStatus.PartialSync(
relayStates = relayStatesMap.values.toList(),
message = "$failedCount relay(s) timed out",
),
)
} else if (failedCount == relayStatesMap.size) {
state.setSyncStatus(
ChessSyncStatus.PartialSync(
relayStates = relayStatesMap.values.toList(),
message = "All relays failed",
),
)
} else {
state.setSyncStatus(
ChessSyncStatus.Synced(
relayStates = relayStatesMap.values.toList(),
challengeCount = mergedChallenges.size,
gameCount = activeGamesCount,
totalEventsReceived = totalEvents,
),
)
}
}
private suspend fun discoverUserGames() {
val discoveredGameIds = fetcher.fetchUserGameIds()
val currentActiveIds = state.activeGames.value.keys
val currentSpectatingIds = state.spectatingGames.value.keys
val newGameIds = discoveredGameIds - currentActiveIds - currentSpectatingIds
for (startEventId in newGameIds) {
val events = fetcher.fetchGameEvents(startEventId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
if (!result.liveState.isSpectator) {
state.addActiveGame(startEventId, result.liveState)
pollingDelegate.addGameId(startEventId)
}
}
is LoadGameResult.Error -> {
// Failed to load game - continue with others
}
}
}
}
private fun cleanupExpiredChallenges() {
@@ -596,16 +774,6 @@ class ChessLobbyLogic(
// 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,
@@ -622,6 +790,23 @@ class ChessLobbyLogic(
return false
}
private suspend fun <T> retryWithBackoffResult(
maxRetries: Int = 3,
initialDelayMs: Long = 1000,
action: suspend () -> T?,
): T? {
var delayMs = initialDelayMs
repeat(maxRetries) { attempt ->
val result = action()
if (result != null) return result
if (attempt < maxRetries - 1) {
delay(delayMs)
delayMs *= 2
}
}
return null
}
fun clearError() {
state.setError(null)
}
@@ -630,3 +815,26 @@ class ChessLobbyLogic(
state.selectGame(gameId)
}
}
/**
* Parse promotion piece from a "to" square string.
* For example: "e8q" -> Pair("e8", PieceType.QUEEN)
* Regular moves: "e4" -> Pair("e4", null)
*/
private fun parsePromotionFromTo(to: String): Pair<String, PieceType?> {
if (to.length == 3) {
val square = to.take(2)
val promotion =
when (to.last().lowercaseChar()) {
'q' -> PieceType.QUEEN
'r' -> PieceType.ROOK
'b' -> PieceType.BISHOP
'n' -> PieceType.KNIGHT
else -> null
}
if (promotion != null) {
return square to promotion
}
}
return to to null
}
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.util.concurrent.atomic.AtomicLong
/**
* Challenge expiry: 24 hours
@@ -100,6 +101,63 @@ data class CompletedGame(
val isDraw: Boolean get() = result == "1/2-1/2"
}
/**
* Individual relay status during sync
*/
@Immutable
data class RelaySyncState(
val url: String,
val displayName: String,
val status: RelaySyncStatus,
val eventsReceived: Int = 0,
)
@Immutable
enum class RelaySyncStatus {
CONNECTING,
WAITING,
RECEIVING,
EOSE_RECEIVED,
FAILED,
}
/**
* Sync status for incoming events (subscription-side)
*/
@Immutable
sealed class ChessSyncStatus {
data object Idle : ChessSyncStatus()
data class Syncing(
val phase: String,
val relayStates: List<RelaySyncState>,
val totalEventsReceived: Int,
) : ChessSyncStatus() {
val connectedCount: Int get() = relayStates.count { it.status != RelaySyncStatus.FAILED }
val eoseCount: Int get() = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED }
val totalCount: Int get() = relayStates.size
}
data class Synced(
val relayStates: List<RelaySyncState>,
val challengeCount: Int,
val gameCount: Int,
val totalEventsReceived: Int,
) : ChessSyncStatus() {
val successCount: Int get() = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED }
val totalCount: Int get() = relayStates.size
}
data class PartialSync(
val relayStates: List<RelaySyncState>,
val message: String,
) : ChessSyncStatus() {
val successCount: Int get() = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED }
val failedCount: Int get() = relayStates.count { it.status == RelaySyncStatus.FAILED }
val totalCount: Int get() = relayStates.size
}
}
/**
* Chess status for UI feedback
*/
@@ -163,6 +221,9 @@ class ChessLobbyState(
private val _completedGames = MutableStateFlow<List<CompletedGame>>(emptyList())
val completedGames: StateFlow<List<CompletedGame>> = _completedGames.asStateFlow()
// Game IDs that user has accepted - uses global singleton to share across ViewModel instances
// This is critical because lobby and game screen may have different ViewModel instances
// Broadcast status
private val _broadcastStatus = MutableStateFlow<ChessBroadcastStatus>(ChessBroadcastStatus.Idle)
val broadcastStatus: StateFlow<ChessBroadcastStatus> = _broadcastStatus.asStateFlow()
@@ -171,10 +232,24 @@ class ChessLobbyState(
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
// Loading/refreshing state
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
// Sync status for subscription banner
private val _syncStatus = MutableStateFlow<ChessSyncStatus>(ChessSyncStatus.Idle)
val syncStatus: StateFlow<ChessSyncStatus> = _syncStatus.asStateFlow()
// Selected game ID for navigation
private val _selectedGameId = MutableStateFlow<String?>(null)
val selectedGameId: StateFlow<String?> = _selectedGameId.asStateFlow()
// State version counter - increments on every game state update
// UI can observe this to force recomposition when internal state changes
private val stateVersionCounter = AtomicLong(0)
private val _stateVersion = MutableStateFlow(0L)
val stateVersion: StateFlow<Long> = _stateVersion.asStateFlow()
// Badge count (incoming challenges + your turn games)
val badgeCount: Int
get() {
@@ -228,7 +303,20 @@ class ChessLobbyState(
gameId: String,
state: LiveChessGameState,
) {
_activeGames.update { it + (gameId to state) }
_activeGames.update { current ->
val existing = current[gameId]
if (existing != null) {
// Only replace if new state has at least as many moves
val existingMoves = existing.moveHistory.value.size
val newMoves = state.moveHistory.value.size
if (newMoves < existingMoves) {
return@update current
}
}
current + (gameId to state)
}
// Track as accepted to prevent refresh from re-adding as optimistic challenge
AcceptedGamesRegistry.markAsAccepted(gameId)
// Remove from challenges if present
removeChallenge(gameId)
}
@@ -251,15 +339,55 @@ class ChessLobbyState(
/**
* Replace game state entirely after full reconstruction from relays.
* Preserves game location (active vs spectating).
*
* IMPORTANT: Only replaces if new state has >= moves than current state.
* This prevents race conditions where polling refresh could revert
* a user's move before it propagates to relays.
*
* IMPORTANT: Never replace a participant game with a spectator state.
* This prevents the race where relay fetch returns before acceptance propagates,
* which would incorrectly mark an accepted game as spectating.
*/
fun replaceGameState(
gameId: String,
newState: LiveChessGameState,
) {
if (_activeGames.value.containsKey(gameId)) {
_activeGames.update { it + (gameId to newState) }
} else if (_spectatingGames.value.containsKey(gameId)) {
_spectatingGames.update { it + (gameId to newState) }
val inActiveGames = _activeGames.value.containsKey(gameId)
val inSpectatingGames = _spectatingGames.value.containsKey(gameId)
val currentState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId]
val currentMoveCount = currentState?.moveHistory?.value?.size ?: 0
val newMoveCount = newState.moveHistory.value.size
// Only replace if new state has at least as many moves
// This prevents reverting user's local moves during refresh
if (newMoveCount < currentMoveCount) {
return
}
// Handle case where new state incorrectly has isSpectator=true but current is participant
// This happens with open challenges where opponent can't be determined from relay events alone
// Solution: Apply moves from new state while preserving participant status from current
val stateToUse =
if (currentState != null && !currentState.isSpectator && newState.isSpectator && newMoveCount > currentMoveCount) {
// Apply opponent's moves to current state's engine
currentState.applyMovesFrom(newState)
currentState // Keep using current state with updated engine
} else if (currentState != null && !currentState.isSpectator && newState.isSpectator) {
return
} else {
newState
}
if (inActiveGames) {
_activeGames.update { it + (gameId to stateToUse) }
// Increment version to force UI recomposition even if map equals() returns true
val newVersion = stateVersionCounter.incrementAndGet()
_stateVersion.value = newVersion
} else if (inSpectatingGames) {
_spectatingGames.update { it + (gameId to stateToUse) }
val newVersion = stateVersionCounter.incrementAndGet()
_stateVersion.value = newVersion
}
}
@@ -329,6 +457,12 @@ class ChessLobbyState(
gameId: String,
state: LiveChessGameState,
) {
// Never add to spectating if this game was accepted - user is a participant
if (AcceptedGamesRegistry.wasAccepted(gameId)) {
// Add to active games instead
_activeGames.update { it + (gameId to state) }
return
}
_spectatingGames.update { it + (gameId to state) }
}
@@ -344,6 +478,14 @@ class ChessLobbyState(
_error.value = error
}
fun setRefreshing(refreshing: Boolean) {
_isRefreshing.value = refreshing
}
fun setSyncStatus(status: ChessSyncStatus) {
_syncStatus.value = status
}
fun selectGame(gameId: String?) {
_selectedGameId.value = gameId
}
@@ -354,12 +496,21 @@ class ChessLobbyState(
fun isSpectating(gameId: String): Boolean = _spectatingGames.value.containsKey(gameId)
/** Whether a game ID was accepted (prevents refresh from re-adding as challenge) */
fun wasAccepted(gameId: String): Boolean = AcceptedGamesRegistry.wasAccepted(gameId)
/** Mark a game as accepted synchronously (call before async game creation) */
fun markAsAccepted(gameId: String) {
AcceptedGamesRegistry.markAsAccepted(gameId)
}
fun clearAll() {
_activeGames.value = emptyMap()
_publicGames.value = emptyList()
_challenges.value = emptyList()
_spectatingGames.value = emptyMap()
_completedGames.value = emptyList()
AcceptedGamesRegistry.clear()
_broadcastStatus.value = ChessBroadcastStatus.Idle
_error.value = null
_selectedGameId.value = null
@@ -30,6 +30,22 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap
/**
* Progress callback for relay fetch operations
*/
data class RelayFetchProgress(
val relay: NormalizedRelayUrl,
val status: RelayFetchStatus,
val eventCount: Int,
)
enum class RelayFetchStatus {
WAITING,
RECEIVING,
EOSE_RECEIVED,
TIMEOUT,
}
/**
* One-shot relay fetch helper for chess events.
*
@@ -47,21 +63,30 @@ class ChessRelayFetchHelper(
* 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
* @param timeoutMs Max time to wait for relays to respond (default from ChessConfig)
* @param onProgress Optional callback for progress updates per relay
* @return Deduplicated list of events received before timeout/EOSE
*/
suspend fun fetchEvents(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000,
timeoutMs: Long = ChessConfig.FETCH_TIMEOUT_MS,
onProgress: ((RelayFetchProgress) -> Unit)? = null,
): List<Event> {
if (filters.isEmpty()) return emptyList()
val events = ConcurrentHashMap<String, Event>()
val relayCount = filters.keys.size
val eoseReceived = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>()
val relayEventCounts = ConcurrentHashMap<NormalizedRelayUrl, Int>()
val allEose = CompletableDeferred<Unit>()
val subId = newSubId()
// Initialize all relays as WAITING
filters.keys.forEach { relay ->
relayEventCounts[relay] = 0
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.WAITING, 0))
}
val listener =
object : IRequestListener {
override fun onEvent(
@@ -71,6 +96,8 @@ class ChessRelayFetchHelper(
forFilters: List<Filter>?,
) {
events[event.id] = event
val count = relayEventCounts.compute(relay) { _, v -> (v ?: 0) + 1 } ?: 1
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.RECEIVING, count))
}
override fun onEose(
@@ -78,6 +105,9 @@ class ChessRelayFetchHelper(
forFilters: List<Filter>?,
) {
eoseReceived.add(relay)
val count = relayEventCounts[relay] ?: 0
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.EOSE_RECEIVED, count))
// Complete when all relays respond
if (eoseReceived.size >= relayCount) {
allEose.complete(Unit)
}
@@ -85,7 +115,18 @@ class ChessRelayFetchHelper(
}
client.openReqSubscription(subId, filters, listener)
withTimeoutOrNull(timeoutMs) { allEose.await() }
val eoseResult = withTimeoutOrNull(timeoutMs) { allEose.await() }
// Mark timed-out relays
if (eoseResult == null) {
filters.keys.forEach { relay ->
if (relay !in eoseReceived) {
val count = relayEventCounts[relay] ?: 0
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.TIMEOUT, count))
}
}
}
client.close(subId)
return events.values.toList()
@@ -0,0 +1,311 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* Shared banner showing chess sync/subscription status with expandable relay details.
* Shows incoming event progress from relays during refresh.
*/
@Composable
fun ChessSyncBanner(
status: ChessSyncStatus,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
val isVisible = status !is ChessSyncStatus.Idle
var isExpanded by remember { mutableStateOf(false) }
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(tween(200)),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(tween(150)),
modifier = modifier,
) {
Surface(
color = getSyncStatusBackgroundColor(status),
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier =
Modifier
.animateContentSize()
.clickable {
if (status is ChessSyncStatus.PartialSync) {
onRetry()
} else {
isExpanded = !isExpanded
}
},
) {
// Main status row
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Icon(
imageVector = getSyncStatusIcon(status),
contentDescription = null,
tint = getSyncStatusIconColor(status),
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = getSyncStatusText(status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Text(
text = getSyncStatusDetail(status),
style = MaterialTheme.typography.labelMedium,
color = getSyncStatusDetailColor(status),
)
}
// Progress bar for syncing
if (status is ChessSyncStatus.Syncing) {
Spacer(Modifier.height(4.dp))
val progress = status.eoseCount.toFloat() / status.totalCount.coerceAtLeast(1)
val animatedProgress by animateFloatAsState(
targetValue = progress,
animationSpec = tween(300),
label = "syncProgress",
)
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
// Expand/collapse icon
if (status !is ChessSyncStatus.Idle && getRelayStates(status).isNotEmpty()) {
Icon(
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
// Expandable relay details
AnimatedVisibility(
visible = isExpanded && getRelayStates(status).isNotEmpty(),
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp,
)
RelayStatusList(
relayStates = getRelayStates(status),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
}
}
}
}
@Composable
private fun RelayStatusList(
relayStates: List<RelaySyncState>,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
relayStates.forEach { relay ->
RelayStatusRow(relay)
}
}
}
@Composable
private fun RelayStatusRow(relay: RelaySyncState) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = getRelayStatusIcon(relay.status),
contentDescription = null,
tint = getRelayStatusColor(relay.status),
modifier = Modifier.size(14.dp),
)
Text(
text = relay.displayName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = "${relay.eventsReceived} events",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
}
private fun getRelayStates(status: ChessSyncStatus): List<RelaySyncState> =
when (status) {
is ChessSyncStatus.Syncing -> status.relayStates
is ChessSyncStatus.Synced -> status.relayStates
is ChessSyncStatus.PartialSync -> status.relayStates
is ChessSyncStatus.Idle -> emptyList()
}
private fun getRelayStatusIcon(status: RelaySyncStatus): ImageVector =
when (status) {
RelaySyncStatus.CONNECTING -> Icons.Default.HourglassEmpty
RelaySyncStatus.WAITING -> Icons.Default.HourglassEmpty
RelaySyncStatus.RECEIVING -> Icons.Default.CloudDownload
RelaySyncStatus.EOSE_RECEIVED -> Icons.Default.CheckCircle
RelaySyncStatus.FAILED -> Icons.Default.Error
}
@Composable
private fun getRelayStatusColor(status: RelaySyncStatus): Color =
when (status) {
RelaySyncStatus.CONNECTING -> MaterialTheme.colorScheme.secondary
RelaySyncStatus.WAITING -> MaterialTheme.colorScheme.secondary
RelaySyncStatus.RECEIVING -> MaterialTheme.colorScheme.primary
RelaySyncStatus.EOSE_RECEIVED -> MaterialTheme.colorScheme.primary
RelaySyncStatus.FAILED -> MaterialTheme.colorScheme.error
}
@Composable
private fun getSyncStatusBackgroundColor(status: ChessSyncStatus): Color =
when (status) {
is ChessSyncStatus.PartialSync -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f)
is ChessSyncStatus.Synced -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f)
else -> MaterialTheme.colorScheme.surfaceContainer
}
private fun getSyncStatusIcon(status: ChessSyncStatus): ImageVector =
when (status) {
is ChessSyncStatus.Syncing -> Icons.Default.CloudDownload
is ChessSyncStatus.Synced -> Icons.Default.CheckCircle
is ChessSyncStatus.PartialSync -> Icons.Default.Warning
is ChessSyncStatus.Idle -> Icons.Default.CheckCircle
}
@Composable
private fun getSyncStatusIconColor(status: ChessSyncStatus): Color =
when (status) {
is ChessSyncStatus.PartialSync -> MaterialTheme.colorScheme.error
is ChessSyncStatus.Synced -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.primary
}
private fun getSyncStatusText(status: ChessSyncStatus): String =
when (status) {
is ChessSyncStatus.Syncing -> "Syncing ${status.phase}..."
is ChessSyncStatus.Synced -> "Synced"
is ChessSyncStatus.PartialSync -> status.message
is ChessSyncStatus.Idle -> ""
}
private fun getSyncStatusDetail(status: ChessSyncStatus): String =
when (status) {
is ChessSyncStatus.Syncing -> "${status.eoseCount}/${status.totalCount} relays • ${status.totalEventsReceived} events"
is ChessSyncStatus.Synced -> "${status.successCount}/${status.totalCount} relays • ${status.challengeCount} challenges • ${status.gameCount} games"
is ChessSyncStatus.PartialSync -> "Tap to retry"
is ChessSyncStatus.Idle -> ""
}
@Composable
private fun getSyncStatusDetailColor(status: ChessSyncStatus): Color =
when (status) {
is ChessSyncStatus.PartialSync -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
}
@@ -63,6 +63,7 @@ import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
* @param boardSize Total size of the board in dp
* @param flipped If true, renders from black's perspective (rank 1 at top)
* @param playerColor The color the player is playing as (only allows moving these pieces)
* @param isSpectator If true, disables all interaction (spectator/watch mode)
* @param onMoveMade Callback when a valid move is made, receives (from, to, san)
* NOTE: This callback should make the actual move - this component only validates
*/
@@ -73,6 +74,7 @@ fun InteractiveChessBoard(
boardSize: Dp = 400.dp,
flipped: Boolean = false,
playerColor: ChessColor? = null, // null = allow both (for local play)
isSpectator: Boolean = false,
positionVersion: Int = 0, // External trigger for position refresh (e.g. moveHistory.size)
onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> },
) {
@@ -94,8 +96,8 @@ fun InteractiveChessBoard(
pendingPromotion = null
}
// Can only interact if it's your turn (or playerColor is null for local play)
val canInteract = playerColor == null || sideToMove == playerColor
// Can only interact if not spectating and it's your turn (or playerColor is null for local play)
val canInteract = !isSpectator && (playerColor == null || sideToMove == playerColor)
val squareSize = boardSize / 8
@@ -20,7 +20,12 @@
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -32,11 +37,14 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -49,14 +57,20 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.GameResult
import com.vitorpamplona.quartz.nip64Chess.GameStatus
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import androidx.compose.ui.graphics.Color as ComposeColor
/**
* Dialog for creating a new chess game challenge
@@ -175,7 +189,9 @@ fun NewChessGameDialog(
* @param opponentName Opponent's display name
* @param onMoveMade Callback when player makes a move (from, to, san)
* @param onResign Callback when player resigns
* @param onOfferDraw Callback when player offers draw
* @param isSpectatorOverride If non-null, overrides gameState.isSpectator. Use this when
* spectator status is determined by which map the game is in (activeGames vs spectatingGames)
* rather than the potentially stale isSpectator flag from relay reconstruction.
*/
@Composable
fun LiveChessGameScreen(
@@ -184,69 +200,97 @@ fun LiveChessGameScreen(
opponentName: String,
onMoveMade: (from: String, to: String, san: String) -> Unit,
onResign: () -> Unit,
onOfferDraw: () -> Unit,
isSpectatorOverride: Boolean? = null,
onGameEndDismiss: (() -> Unit)? = null,
) {
// Observe state flows for automatic recomposition on updates
val currentPosition by gameState.currentPosition.collectAsState()
val moveHistory by gameState.moveHistory.collectAsState()
val gameStatus by gameState.gameStatus.collectAsState()
// Use override if provided, otherwise fall back to gameState flag
val isSpectator = isSpectatorOverride ?: gameState.isSpectator
// Pending challenges and spectators cannot make moves
val canMakeMoves = !gameState.isSpectator && !gameState.isPendingChallenge
val canMakeMoves = !isSpectator && !gameState.isPendingChallenge
BoxWithConstraints(
modifier = modifier.fillMaxSize(),
) {
// Calculate board size based on available space
// Leave room for header (~100dp), history (~60dp), controls (~60dp), and padding
val availableWidth = maxWidth - 32.dp // Account for horizontal padding
val availableHeight = maxHeight - 250.dp // Account for other UI elements
val boardSize = min(availableWidth, availableHeight).coerceAtLeast(200.dp)
// Track if game end overlay was dismissed
var gameEndDismissed by remember { mutableStateOf(false) }
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
Box(modifier = modifier.fillMaxSize()) {
BoxWithConstraints(
modifier = Modifier.fillMaxSize(),
) {
// Game info - use currentPosition.activeColor for turn display
GameInfoHeader(
gameId = gameState.gameId,
opponentName = opponentName,
playerColor = gameState.playerColor,
currentTurn = currentPosition.activeColor,
isSpectator = gameState.isSpectator,
isPendingChallenge = gameState.isPendingChallenge,
)
// Calculate board size dynamically based on available space
// Use a percentage of available height to leave room for other UI elements
val availableWidth = maxWidth - 32.dp // Account for horizontal padding
// Reserve ~35% of height for header, history, controls, and any banners
val availableHeight = maxHeight * 0.65f
val boardSize = min(availableWidth, availableHeight).coerceIn(150.dp, 520.dp)
// 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.isSpectator && gameState.playerColor == Color.BLACK,
positionVersion = moveHistory.size,
onMoveMade =
if (canMakeMoves) {
onMoveMade
} else {
{ _, _, _ -> } // No-op for spectators and pending challenges
},
)
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Game info - use currentPosition.activeColor for turn display
GameInfoHeader(
gameId = gameState.gameId,
opponentName = opponentName,
playerColor = gameState.playerColor,
currentTurn = currentPosition.activeColor,
isSpectator = isSpectator,
isPendingChallenge = gameState.isPendingChallenge,
gameStatus = gameStatus,
)
// Move history (scrollable) - observed from state flow
MoveHistoryDisplay(
moves = moveHistory,
)
// Interactive chess board - flip when playing black (spectators see from white's view)
// Auto-sized to fit available space
InteractiveChessBoard(
engine = gameState.engine,
boardSize = boardSize,
flipped = !isSpectator && gameState.playerColor == Color.BLACK,
playerColor = gameState.playerColor,
isSpectator = !canMakeMoves,
positionVersion = moveHistory.size,
onMoveMade = onMoveMade,
)
// Show appropriate controls based on game state
when {
gameState.isPendingChallenge -> PendingChallengeInfo()
gameState.isSpectator -> SpectatorInfo()
else -> GameControls(onResign = onResign, onOfferDraw = onOfferDraw)
// Move history (scrollable) - observed from state flow
MoveHistoryDisplay(
moves = moveHistory,
)
// Show appropriate controls based on game state
when {
gameStatus is GameStatus.Finished ->
GameEndInfo(
result = (gameStatus as GameStatus.Finished).result,
playerColor = gameState.playerColor,
isSpectator = isSpectator,
)
gameState.isPendingChallenge -> PendingChallengeInfo()
isSpectator -> SpectatorInfo()
else -> GameControls(onResign = onResign)
}
}
}
// Game end celebration overlay
if (gameStatus is GameStatus.Finished && !gameEndDismissed) {
GameEndOverlay(
result = (gameStatus as GameStatus.Finished).result,
playerColor = gameState.playerColor,
isSpectator = isSpectator,
onDismiss = {
gameEndDismissed = true
onGameEndDismiss?.invoke()
},
)
}
}
}
@@ -260,7 +304,6 @@ fun LiveChessGameScreen(
* @param opponentName Opponent's display name
* @param onMoveMade Callback when player makes a move (from, to, san)
* @param onResign Callback when player resigns
* @param onOfferDraw Callback when player offers draw
*/
@Composable
fun LiveChessGameScreen(
@@ -271,7 +314,6 @@ fun LiveChessGameScreen(
opponentName: String,
onMoveMade: (from: String, to: String, san: String) -> Unit,
onResign: () -> Unit,
onOfferDraw: () -> Unit,
) {
BoxWithConstraints(
modifier = modifier.fillMaxSize(),
@@ -316,7 +358,6 @@ fun LiveChessGameScreen(
// Game controls
GameControls(
onResign = onResign,
onOfferDraw = onOfferDraw,
)
}
}
@@ -333,6 +374,7 @@ private fun GameInfoHeader(
currentTurn: Color,
isSpectator: Boolean = false,
isPendingChallenge: Boolean = false,
gameStatus: GameStatus = GameStatus.InProgress,
) {
// Extract human-readable game name if available
val gameName =
@@ -407,24 +449,53 @@ private fun GameInfoHeader(
style = MaterialTheme.typography.bodyMedium,
)
val turnText =
if (currentTurn == playerColor) {
"Your turn"
} else {
"Opponent's turn"
// Show turn or game result
when (gameStatus) {
is GameStatus.Finished -> {
val result = (gameStatus as GameStatus.Finished).result
val resultText =
when {
result == GameResult.DRAW -> "Draw"
(result == GameResult.WHITE_WINS && playerColor == Color.WHITE) ||
(result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> "You won!"
else -> "You lost"
}
val resultColor =
when {
result == GameResult.DRAW -> MaterialTheme.colorScheme.secondary
(result == GameResult.WHITE_WINS && playerColor == Color.WHITE) ||
(result == GameResult.BLACK_WINS && playerColor == Color.BLACK) ->
ComposeColor(0xFF4CAF50)
else -> MaterialTheme.colorScheme.error
}
Text(
text = resultText,
style = MaterialTheme.typography.bodyMedium,
color = resultColor,
fontWeight = FontWeight.Bold,
)
}
else -> {
val turnText =
if (currentTurn == playerColor) {
"Your turn"
} else {
"Opponent's turn"
}
Text(
text = turnText,
style = MaterialTheme.typography.bodyMedium,
color =
if (currentTurn == playerColor) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
fontWeight = if (currentTurn == playerColor) FontWeight.Bold else FontWeight.Normal,
)
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,
)
}
}
}
}
}
@@ -567,23 +638,242 @@ private fun MoveHistoryDisplay(moves: List<String>) {
}
/**
* Game control buttons (Resign, Offer Draw)
* Game control buttons (Resign)
* Note: Draw offers not supported in Jester protocol
*/
@Composable
private fun GameControls(
onResign: () -> Unit,
onOfferDraw: () -> Unit,
) {
private fun GameControls(onResign: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
) {
OutlinedButton(onClick = onOfferDraw) {
Text("Offer Draw")
}
OutlinedButton(onClick = onResign) {
Text("Resign")
}
}
}
/**
* Game end celebration overlay
*/
@Composable
private fun GameEndOverlay(
result: GameResult,
playerColor: Color,
isSpectator: Boolean,
onDismiss: () -> Unit,
) {
val playerWon =
when (result) {
GameResult.WHITE_WINS -> playerColor == Color.WHITE
GameResult.BLACK_WINS -> playerColor == Color.BLACK
GameResult.DRAW, GameResult.IN_PROGRESS -> false
}
val isDraw = result == GameResult.DRAW
// Determine display based on result
val (emoji, title, subtitle, backgroundColor) =
when {
isSpectator -> {
val winnerText =
when (result) {
GameResult.WHITE_WINS -> "White wins!"
GameResult.BLACK_WINS -> "Black wins!"
GameResult.DRAW -> "Draw!"
GameResult.IN_PROGRESS -> "Game Over"
}
Quadruple("", "Game Over", winnerText, MaterialTheme.colorScheme.surfaceVariant)
}
playerWon ->
Quadruple(
"",
"Victory!",
"Congratulations!",
ComposeColor(0xFF4CAF50).copy(alpha = 0.95f),
)
isDraw ->
Quadruple(
"",
"Draw",
"Game ended in a draw",
MaterialTheme.colorScheme.surfaceVariant,
)
else ->
Quadruple(
"",
"Defeat",
"Better luck next time!",
ComposeColor(0xFFE57373).copy(alpha = 0.95f),
)
}
// Animated visibility
AnimatedVisibility(
visible = true,
enter = fadeIn(animationSpec = tween(300)) + scaleIn(animationSpec = tween(300)),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(ComposeColor.Black.copy(alpha = 0.7f))
.clickable { onDismiss() },
contentAlignment = Alignment.Center,
) {
Card(
modifier =
Modifier
.padding(32.dp)
.clickable { /* prevent dismiss on card click */ },
shape = RoundedCornerShape(24.dp),
colors =
CardDefaults.cardColors(
containerColor = backgroundColor,
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
) {
Column(
modifier =
Modifier
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Trophy/emoji based on result
val displayEmoji =
when {
isSpectator -> "GG"
playerWon -> "GG"
isDraw -> "="
else -> "GG"
}
Box(
modifier =
Modifier
.size(80.dp)
.background(
when {
isSpectator -> MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
playerWon -> ComposeColor(0xFFFFD700).copy(alpha = 0.3f)
isDraw -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f)
else -> MaterialTheme.colorScheme.error.copy(alpha = 0.2f)
},
CircleShape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = displayEmoji,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color =
when {
isSpectator -> MaterialTheme.colorScheme.primary
playerWon -> ComposeColor(0xFFFFD700)
isDraw -> MaterialTheme.colorScheme.secondary
else -> MaterialTheme.colorScheme.error
},
)
}
Text(
text = title,
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color =
when {
isSpectator -> MaterialTheme.colorScheme.onSurfaceVariant
playerWon -> ComposeColor.White
isDraw -> MaterialTheme.colorScheme.onSurfaceVariant
else -> ComposeColor.White
},
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyLarge,
color =
when {
isSpectator -> MaterialTheme.colorScheme.onSurfaceVariant
playerWon -> ComposeColor.White.copy(alpha = 0.9f)
isDraw -> MaterialTheme.colorScheme.onSurfaceVariant
else -> ComposeColor.White.copy(alpha = 0.9f)
},
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onDismiss,
) {
Text("Continue")
}
}
}
}
}
}
/**
* Compact game end info shown in controls area
*/
@Composable
private fun GameEndInfo(
result: GameResult,
playerColor: Color,
isSpectator: Boolean,
) {
val resultText =
when {
isSpectator ->
when (result) {
GameResult.WHITE_WINS -> "White wins"
GameResult.BLACK_WINS -> "Black wins"
GameResult.DRAW -> "Draw"
GameResult.IN_PROGRESS -> "In progress"
}
result == GameResult.DRAW -> "Game drawn"
(result == GameResult.WHITE_WINS && playerColor == Color.WHITE) ||
(result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> "You won!"
else -> "You lost"
}
val backgroundColor =
when {
isSpectator -> MaterialTheme.colorScheme.surfaceVariant
result == GameResult.DRAW -> MaterialTheme.colorScheme.secondaryContainer
(result == GameResult.WHITE_WINS && playerColor == Color.WHITE) ||
(result == GameResult.BLACK_WINS && playerColor == Color.BLACK) ->
ComposeColor(0xFF4CAF50).copy(alpha = 0.3f)
else -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f)
}
Box(
modifier =
Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(8.dp))
.padding(12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "Game Over - $resultText",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
/**
* Helper data class for quadruple values
*/
private data class Quadruple<A, B, C, D>(
val first: A,
val second: B,
val third: C,
val fourth: D,
)
@@ -23,39 +23,28 @@ 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.nip64Chess.JesterProtocol
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Shared filter builder for chess subscriptions.
* Shared filter builder for chess subscriptions using Jester protocol.
* Used by both Android and Desktop to ensure identical subscription behavior.
*
* Jester Protocol (kind 30):
* - All chess events use kind 30
* - Start events: e-tag references START_POSITION_HASH
* - Move events: e-tags [startEventId, headEventId]
* - Content JSON determines event type (kind: 0=start, 1=move, 2=chat)
*
* Builds 4 types of filters:
* 1. Personal filters - Events tagged with user's pubkey
* 2. 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)
* 2. Start/Challenge filters - Events referencing START_POSITION_HASH
* 3. Active game filters - Events referencing specific game startEventIds
* 4. Recent game filters - For spectating discovery
*/
object ChessFilterBuilder {
/** 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
/** Jester protocol kind for all chess events */
private const val JESTER_KIND = JesterProtocol.KIND
/**
* Build all chess filters for a given subscription state.
@@ -77,9 +66,9 @@ object ChessFilterBuilder {
buildPersonalFilters(state.userPubkey, state.relays, sinceForRelay, now),
)
// Filter 2: All challenges (for lobby display)
// Filter 2: All start/challenge events (for lobby display)
filters.addAll(
buildChallengeFilters(state.relays, sinceForRelay, now),
buildStartEventFilters(state.relays, sinceForRelay, now),
)
// Filter 3: Active game subscriptions (game-specific)
@@ -104,7 +93,7 @@ object ChessFilterBuilder {
/**
* Personal events - tagged with user's pubkey.
* Catches: challenges directed at us, moves in our games, game ends.
* Catches: challenges directed at us, moves in our games.
*/
fun buildPersonalFilters(
userPubkey: String,
@@ -114,7 +103,7 @@ object ChessFilterBuilder {
): List<RelayBasedFilter> {
val filter =
Filter(
kinds = ALL_CHESS_KINDS,
kinds = listOf(JESTER_KIND),
tags = mapOf("p" to listOf(userPubkey)),
limit = 100,
)
@@ -131,18 +120,19 @@ object ChessFilterBuilder {
}
/**
* All challenge events (like jesterui pattern).
* No author/tag restriction - fetch everything, filter client-side.
* This ensures we see open challenges and public games.
* Start/challenge events (Jester pattern).
* In Jester protocol, start events reference the START_POSITION_HASH.
* This fetches all game starts for lobby display.
*/
fun buildChallengeFilters(
fun buildStartEventFilters(
relays: Set<NormalizedRelayUrl>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
now: Long = TimeUtils.now(),
): List<RelayBasedFilter> {
val filter =
Filter(
kinds = CHALLENGE_KINDS,
kinds = listOf(JESTER_KIND),
tags = mapOf("e" to listOf(JesterProtocol.START_POSITION_HASH)),
limit = 100,
)
@@ -161,63 +151,58 @@ object ChessFilterBuilder {
* 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.
* In Jester protocol:
* - Move events have e-tags: [startEventId, headEventId]
* - We filter by the first e-tag (startEventId) to get all moves for a game
* - We also filter by opponent authors and p-tag for redundancy
*/
fun buildActiveGameFilters(
gameIds: Set<String>,
startEventIds: 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")
if (startEventIds.isEmpty()) return emptyList()
val filters = mutableListOf<RelayBasedFilter>()
// End events: filter by d-tag (gameId)
val endFilter =
// Game events: filter by e-tag (startEventId)
// This catches all moves/events for these games
val gameFilter =
Filter(
kinds = listOf(LiveChessGameEndEvent.KIND),
tags = mapOf("d" to gameIds.toList()),
limit = 50,
kinds = listOf(JESTER_KIND),
tags = mapOf("e" to startEventIds.toList()),
limit = 500,
)
// Move events: filter by p-tag (opponent tagged us)
// This catches all moves for games where we're a participant
val moveFilterByTag =
relays.forEach { relay ->
filters.add(RelayBasedFilter(relay = relay, filter = gameFilter))
}
// Also filter by p-tag (opponent tagged us) for redundancy
val tagFilter =
Filter(
kinds = listOf(LiveChessMoveEvent.KIND),
kinds = listOf(JESTER_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))
filters.add(RelayBasedFilter(relay = relay, filter = tagFilter))
}
// Move events: filter by authors (opponent pubkeys)
// This directly catches moves made by our opponents
// Also filter by authors (opponent pubkeys) for redundancy
if (opponentPubkeys.isNotEmpty()) {
println("[ChessFilterBuilder] Adding author filter for opponents: $opponentPubkeys")
val moveFilterByAuthor =
val authorFilter =
Filter(
kinds = listOf(LiveChessMoveEvent.KIND),
kinds = listOf(JESTER_KIND),
authors = opponentPubkeys.toList(),
limit = 200,
)
relays.forEach { relay ->
filters.add(RelayBasedFilter(relay = relay, filter = moveFilterByAuthor))
filters.add(RelayBasedFilter(relay = relay, filter = authorFilter))
}
} else {
println("[ChessFilterBuilder] WARNING: No opponent pubkeys provided!")
}
return filters
@@ -234,7 +219,7 @@ object ChessFilterBuilder {
): List<RelayBasedFilter> {
val filter =
Filter(
kinds = GAME_EVENT_KINDS,
kinds = listOf(JESTER_KIND),
limit = 100,
)
@@ -256,22 +241,27 @@ object ChessFilterBuilder {
/**
* Filter for all events related to a specific game.
* Used for one-shot fetch when loading a game.
*
* In Jester protocol, all game events reference the startEventId via e-tag.
*/
fun gameEventsFilter(gameId: String): Filter =
fun gameEventsFilter(startEventId: String): Filter =
Filter(
kinds = ALL_CHESS_KINDS + listOf(com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent.KIND),
tags = mapOf("d" to listOf(gameId)),
kinds = listOf(JESTER_KIND),
tags = mapOf("e" to listOf(startEventId)),
limit = 500,
)
/**
* Filter for challenge events in the last 24 hours.
* Filter for start/challenge events in the last 24 hours.
* Used for one-shot fetch to populate lobby.
*
* Start events reference the START_POSITION_HASH.
*/
fun challengesFilter(userPubkey: String): Filter {
fun challengesFilter(userPubkey: String? = null): Filter {
val now = TimeUtils.now()
return Filter(
kinds = CHALLENGE_KINDS,
kinds = listOf(JESTER_KIND),
tags = mapOf("e" to listOf(JesterProtocol.START_POSITION_HASH)),
since = now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS,
limit = 100,
)
@@ -279,12 +269,40 @@ object ChessFilterBuilder {
/**
* Filter for recent game activity for spectating discovery.
* Fetches move events from the last 7 days.
* Fetches events from the last 7 days.
*/
fun recentGamesFilter(): Filter {
val now = TimeUtils.now()
return Filter(
kinds = ALL_CHESS_KINDS,
kinds = listOf(JESTER_KIND),
since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS,
limit = 200,
)
}
/**
* Filter for user's own chess events (events they authored).
* Used to discover games the user is participating in.
*/
fun userGamesFilter(userPubkey: String): Filter {
val now = TimeUtils.now()
return Filter(
kinds = listOf(JESTER_KIND),
authors = listOf(userPubkey),
since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS,
limit = 200,
)
}
/**
* Filter for events tagged with user's pubkey (games they're participating in).
* Complements userGamesFilter to find games where user is the opponent.
*/
fun userTaggedFilter(userPubkey: String): Filter {
val now = TimeUtils.now()
return Filter(
kinds = listOf(JESTER_KIND),
tags = mapOf("p" to listOf(userPubkey)),
since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS,
limit = 200,
)
@@ -0,0 +1,192 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.profile
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* Shared banner showing profile metadata broadcast progress.
* Works on both Android and Desktop via Compose Multiplatform.
*/
@Composable
fun ProfileBroadcastBanner(
status: ProfileBroadcastStatus,
onTap: () -> Unit,
modifier: Modifier = Modifier,
) {
val isVisible = status !is ProfileBroadcastStatus.Idle
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(tween(200)),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(tween(150)),
modifier = modifier,
) {
Surface(
color = getStatusBackgroundColor(status),
tonalElevation = 2.dp,
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onTap),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(horizontal = 16.dp, vertical = 10.dp)
.animateContentSize(),
) {
Icon(
imageVector = getStatusIcon(status),
contentDescription = null,
tint = getStatusIconColor(status),
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = getStatusText(status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Text(
text = getStatusDetail(status),
style = MaterialTheme.typography.labelMedium,
color = getStatusDetailColor(status),
)
}
// Progress bar for broadcasting
if (status is ProfileBroadcastStatus.Broadcasting) {
Spacer(Modifier.height(4.dp))
val animatedProgress by animateFloatAsState(
targetValue = status.progress,
animationSpec = tween(300),
label = "broadcastProgress",
)
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
}
}
}
@Composable
private fun getStatusBackgroundColor(status: ProfileBroadcastStatus): Color =
when (status) {
is ProfileBroadcastStatus.Failed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.9f)
is ProfileBroadcastStatus.Success -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f)
else -> MaterialTheme.colorScheme.surfaceContainer
}
private fun getStatusIcon(status: ProfileBroadcastStatus): ImageVector =
when (status) {
is ProfileBroadcastStatus.Broadcasting -> Icons.Default.Sync
is ProfileBroadcastStatus.Success -> Icons.Default.CheckCircle
is ProfileBroadcastStatus.Failed -> Icons.Default.Error
is ProfileBroadcastStatus.Idle -> Icons.Default.CheckCircle
}
@Composable
private fun getStatusIconColor(status: ProfileBroadcastStatus): Color =
when (status) {
is ProfileBroadcastStatus.Failed -> MaterialTheme.colorScheme.error
is ProfileBroadcastStatus.Success -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.primary
}
private fun getStatusText(status: ProfileBroadcastStatus): String =
when (status) {
is ProfileBroadcastStatus.Broadcasting -> "Updating ${status.fieldName}..."
is ProfileBroadcastStatus.Success -> "Updated ${status.fieldName}"
is ProfileBroadcastStatus.Failed -> "Failed to update ${status.fieldName}"
is ProfileBroadcastStatus.Idle -> ""
}
private fun getStatusDetail(status: ProfileBroadcastStatus): String =
when (status) {
is ProfileBroadcastStatus.Broadcasting -> "[${status.successCount}/${status.totalRelays}]"
is ProfileBroadcastStatus.Success -> "${status.relayCount} relays"
is ProfileBroadcastStatus.Failed -> "Tap to retry"
is ProfileBroadcastStatus.Idle -> ""
}
@Composable
private fun getStatusDetailColor(status: ProfileBroadcastStatus): Color =
when (status) {
is ProfileBroadcastStatus.Failed -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
}
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.profile
/**
* Status of profile metadata broadcast to relays.
*/
sealed class ProfileBroadcastStatus {
data object Idle : ProfileBroadcastStatus()
data class Broadcasting(
val fieldName: String,
val successCount: Int,
val totalRelays: Int,
) : ProfileBroadcastStatus() {
val progress: Float get() = if (totalRelays > 0) successCount.toFloat() / totalRelays else 0f
}
data class Success(
val fieldName: String,
val relayCount: Int,
) : ProfileBroadcastStatus()
data class Failed(
val fieldName: String,
val error: String,
) : ProfileBroadcastStatus()
}
@@ -0,0 +1,158 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponse
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip64Chess.JesterProtocol
import kotlinx.coroutines.delay
/**
* Result of broadcasting an event
*/
data class BroadcastResult(
val success: Boolean,
val relayResults: Map<NormalizedRelayUrl, Boolean>,
val message: String,
)
/**
* Helper for broadcasting chess events to relays with reliable delivery.
*
* Uses sendAndWaitForResponse to get actual OK confirmations from relays,
* ensuring the event was actually received and accepted.
*/
class ChessEventBroadcaster(
private val client: INostrClient,
) {
/**
* Broadcast an event to the chess relays with confirmation.
*
* This method:
* 1. Triggers relay connections via a dummy subscription
* 2. Waits for relays to connect
* 3. Sends the event and waits for OK responses
*
* @param event The signed event to broadcast
* @param timeoutSeconds Maximum time to wait for relay responses
* @return BroadcastResult with success status
*/
suspend fun broadcast(
event: Event,
timeoutSeconds: Long = 15L,
): BroadcastResult {
val targetRelays = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) }.toSet()
val subId = newSubId()
println("[ChessEventBroadcaster] Broadcasting event ${event.id.take(8)} to ${targetRelays.size} relays")
// Step 1: Check which relays are already connected
val initialConnected = client.connectedRelaysFlow().value
val alreadyConnected = targetRelays.intersect(initialConnected)
val needsConnection = targetRelays - alreadyConnected
println("[ChessEventBroadcaster] Already connected: ${alreadyConnected.size}, needs connection: ${needsConnection.size}")
// Step 2: If some relays need connection, open a subscription to trigger it
if (needsConnection.isNotEmpty()) {
val dummyFilter =
Filter(
kinds = listOf(JesterProtocol.KIND),
ids = listOf("trigger_connection_${System.currentTimeMillis()}"),
limit = 1,
)
val listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) { }
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
println("[ChessEventBroadcaster] EOSE from ${relay.url}")
}
}
// Open subscription to all target relays (triggers connection)
val filterMap = targetRelays.associateWith { listOf(dummyFilter) }
client.openReqSubscription(subId, filterMap, listener)
// Wait for relays to connect (poll with timeout)
val connected = waitForRelays(targetRelays, 5000L)
println("[ChessEventBroadcaster] After waiting: ${connected.size}/${targetRelays.size} connected")
// Close the dummy subscription
client.close(subId)
}
// Step 3: Send the event and wait for OK responses
println("[ChessEventBroadcaster] Sending event with sendAndWaitForResponse...")
val success = client.sendAndWaitForResponse(event, targetRelays, timeoutSeconds)
println("[ChessEventBroadcaster] Broadcast complete: success=$success")
return BroadcastResult(
success = success,
relayResults = targetRelays.associateWith { success },
message = if (success) "Event accepted by relay(s)" else "No relay accepted the event",
)
}
/**
* Wait for target relays to appear in connectedRelaysFlow.
*/
private suspend fun waitForRelays(
targetRelays: Set<NormalizedRelayUrl>,
timeoutMs: Long,
): Set<NormalizedRelayUrl> {
val startTime = System.currentTimeMillis()
val pollInterval = 200L
while (System.currentTimeMillis() - startTime < timeoutMs) {
val connected = client.connectedRelaysFlow().value
val targetConnected = targetRelays.intersect(connected)
if (targetConnected.size == targetRelays.size) {
return targetConnected
}
// At least one connected is good enough after half the timeout
if (targetConnected.isNotEmpty() && System.currentTimeMillis() - startTime > timeoutMs / 2) {
return targetConnected
}
delay(pollInterval)
}
return client.connectedRelaysFlow().value.intersect(targetRelays)
}
}