feat(chess): add platform adapters and enhance lobby state (steps 5-6)

- Add ChessLobbyState with completedGames, replaceGameState(), moveToCompleted()
- Add challengerAvatarUrl to ChessChallenge
- Add CompletedGame data class for game history

Platform Adapters:
- AndroidChessAdapter: AndroidChessPublisher, AndroidRelayFetcher, AndroidMetadataProvider
- DesktopChessAdapter: DesktopChessPublisher, DesktopRelayFetcher, DesktopMetadataProvider

Shared Infrastructure:
- ChessRelayFetchHelper for one-shot relay queries
- IUserMetadataProvider interface for platform-specific metadata
- ChessFilterBuilder with simple filter methods for fetchers
- ChessSubscriptionController interface for subscription management

Fix ChessSubscription.kt to remove broken dataSources().chess reference
(subscriptions now managed by ChessLobbyLogic)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-02-02 10:00:08 +02:00
parent e0d0e33408
commit 42b33ca04c
26 changed files with 4402 additions and 452 deletions
@@ -0,0 +1,632 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvents
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.UUID
/**
* Interface for platform-specific chess event publishing
*/
interface ChessEventPublisher {
suspend fun publishChallenge(
gameId: String,
playerColor: Color,
opponentPubkey: String?,
timeControl: String?,
): Boolean
suspend fun publishAccept(
gameId: String,
challengeEventId: String,
challengerPubkey: String,
): Boolean
suspend fun publishMove(move: ChessMoveEvent): Boolean
suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean
suspend fun publishDrawOffer(
gameId: String,
opponentPubkey: String,
message: String?,
): Boolean
fun getWriteRelayCount(): Int
}
/**
* Relay-first fetcher interface. Platforms provide one-shot relay queries.
*
* Every fetch does: one-shot REQ → collect events → EOSE → close.
* No caching — relays are the single source of truth.
*/
interface ChessRelayFetcher {
/** Fetch all events for a specific game */
suspend fun fetchGameEvents(gameId: String): ChessGameEvents
/** Fetch recent challenge events */
suspend fun fetchChallenges(): List<LiveChessGameChallengeEvent>
/** Fetch recent public game summaries for spectating */
suspend fun fetchRecentGames(): List<RelayGameSummary>
}
/**
* Summary of a game found on relays (for lobby display / spectating)
*/
data class RelayGameSummary(
val gameId: String,
val whitePubkey: String,
val blackPubkey: String,
val moveCount: Int,
val lastMoveTime: Long,
val isActive: Boolean,
)
/**
* Shared chess lobby logic — relay-first architecture.
*
* Both Android and Desktop use this identically.
* Platform-specific code only implements:
* - ChessEventPublisher: sign + broadcast events
* - ChessRelayFetcher: one-shot relay queries
* - IUserMetadataProvider: display names / avatars
*/
class ChessLobbyLogic(
private val userPubkey: String,
private val publisher: ChessEventPublisher,
private val fetcher: ChessRelayFetcher,
private val metadataProvider: IUserMetadataProvider,
private val scope: CoroutineScope,
pollingConfig: ChessPollingConfig = ChessPollingDefaults.android,
) {
val state = ChessLobbyState(userPubkey, scope)
private val pollingDelegate =
ChessPollingDelegate(
config = pollingConfig,
scope = scope,
onRefreshGames = { gameIds -> refreshGames(gameIds) },
onRefreshChallenges = { refreshChallenges() },
onCleanup = { cleanupExpiredChallenges() },
)
// ========================================
// Lifecycle
// ========================================
fun startPolling() = pollingDelegate.start()
fun stopPolling() = pollingDelegate.stop()
fun forceRefresh() = pollingDelegate.refreshNow()
// ========================================
// Incoming event routing (real-time / optimistic)
// ========================================
/**
* Route an incoming relay event to the appropriate handler.
* Called by platform subscription callbacks for real-time updates.
*/
fun handleIncomingEvent(event: Event) {
when (event) {
is LiveChessGameChallengeEvent -> handleChallenge(event)
is LiveChessGameAcceptEvent -> handleAccept(event)
is LiveChessMoveEvent -> handleMove(event)
is LiveChessGameEndEvent -> handleGameEnd(event)
is LiveChessDrawOfferEvent -> handleDrawOffer(event)
}
}
private fun handleChallenge(event: LiveChessGameChallengeEvent) {
val gameId = event.gameId() ?: return
val challengerColor = event.playerColor() ?: Color.WHITE
val challenge =
ChessChallenge(
eventId = event.id,
gameId = gameId,
challengerPubkey = event.pubKey,
challengerDisplayName = metadataProvider.getDisplayName(event.pubKey),
challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey),
opponentPubkey = event.opponentPubkey(),
challengerColor = challengerColor,
createdAt = event.createdAt,
)
state.addChallenge(challenge)
}
private fun handleAccept(event: LiveChessGameAcceptEvent) {
val gameId = event.gameId() ?: return
// If this is an accept for our challenge, auto-load the game
val ourChallenge = state.outgoingChallenges().find { it.gameId == gameId }
if (ourChallenge != null) {
handleGameAccepted(gameId)
}
}
private fun handleMove(event: LiveChessMoveEvent) {
val gameId = event.gameId() ?: return
val san = event.san() ?: return
val fen = event.fen() ?: return
val moveNumber = event.moveNumber()
val gameState = state.getGameState(gameId) ?: return
// Only apply opponent moves optimistically
if (event.pubKey != userPubkey) {
gameState.applyOpponentMove(san, fen, moveNumber)
}
}
private fun handleGameEnd(event: LiveChessGameEndEvent) {
val gameId = event.gameId() ?: return
val gameState = state.getGameState(gameId) ?: return
val resultStr = event.result()
val result =
when (resultStr) {
"1-0" -> com.vitorpamplona.quartz.nip64Chess.GameResult.WHITE_WINS
"0-1" -> com.vitorpamplona.quartz.nip64Chess.GameResult.BLACK_WINS
"1/2-1/2" -> com.vitorpamplona.quartz.nip64Chess.GameResult.DRAW
else -> return
}
gameState.markAsFinished(result)
state.moveToCompleted(gameId, result.notation, event.termination())
pollingDelegate.removeGameId(gameId)
}
private fun handleDrawOffer(event: LiveChessDrawOfferEvent) {
val gameId = event.gameId() ?: return
val gameState = state.getGameState(gameId) ?: return
if (event.pubKey != userPubkey) {
gameState.receiveDrawOffer(event.pubKey)
}
}
// ========================================
// Challenge operations
// ========================================
fun createChallenge(
opponentPubkey: String? = null,
playerColor: Color = Color.WHITE,
timeControl: String? = null,
) {
val gameId = generateGameId()
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(
ChessBroadcastStatus.Broadcasting(
san = "Challenge",
successCount = 0,
totalRelays = publisher.getWriteRelayCount(),
),
)
val success = retryWithBackoff { publisher.publishChallenge(gameId, playerColor, opponentPubkey, timeControl) }
if (success) {
state.setBroadcastStatus(
ChessBroadcastStatus.Success("Challenge", publisher.getWriteRelayCount()),
)
delay(2000)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
state.setError(null)
} else {
state.setBroadcastStatus(
ChessBroadcastStatus.Failed("Challenge", "Failed to publish"),
)
state.setError("Failed to create challenge")
}
}
}
fun acceptChallenge(challenge: ChessChallenge) {
scope.launch(Dispatchers.Default) {
val success =
retryWithBackoff {
publisher.publishAccept(
gameId = challenge.gameId,
challengeEventId = challenge.eventId,
challengerPubkey = challenge.challengerPubkey,
)
}
if (success) {
val playerColor = challenge.challengerColor.opposite()
val gameState =
ChessGameLoader.createNewGame(
gameId = challenge.gameId,
playerPubkey = userPubkey,
opponentPubkey = challenge.challengerPubkey,
playerColor = playerColor,
)
state.addActiveGame(challenge.gameId, gameState)
pollingDelegate.addGameId(challenge.gameId)
state.selectGame(challenge.gameId)
state.setError(null)
} else {
state.setError("Failed to accept challenge")
}
}
}
/**
* When we detect our challenge was accepted, load game from relays.
*/
fun handleGameAccepted(gameId: String) {
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
val events = fetcher.fetchGameEvents(gameId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
state.addActiveGame(gameId, result.liveState)
pollingDelegate.addGameId(gameId)
state.selectGame(gameId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
state.setError(null)
}
is LoadGameResult.Error -> {
state.setError("Failed to load game: ${result.message}")
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
}
}
}
}
// ========================================
// Game operations
// ========================================
fun publishMove(
gameId: String,
from: String,
to: String,
) {
val gameState = state.getGameState(gameId) ?: return
if (state.isSpectating(gameId)) {
state.setError("Cannot move while spectating")
return
}
val moveResult = gameState.makeMove(from, to) ?: return
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(
ChessBroadcastStatus.Broadcasting(
san = moveResult.san,
successCount = 0,
totalRelays = publisher.getWriteRelayCount(),
),
)
val success = retryWithBackoff { publisher.publishMove(moveResult) }
if (success) {
state.setBroadcastStatus(
ChessBroadcastStatus.Success(moveResult.san, publisher.getWriteRelayCount()),
)
delay(3000)
val currentState = state.getGameState(gameId)
state.setBroadcastStatus(
if (currentState?.isPlayerTurn() == false) {
ChessBroadcastStatus.WaitingForOpponent
} else {
ChessBroadcastStatus.Idle
},
)
state.setError(null)
} else {
state.setBroadcastStatus(
ChessBroadcastStatus.Failed(moveResult.san, "Failed to publish move"),
)
state.setError("Failed to publish move")
}
}
}
fun resign(gameId: String) {
val gameState = state.getGameState(gameId) ?: return
if (state.isSpectating(gameId)) {
state.setError("Cannot resign while spectating")
return
}
scope.launch(Dispatchers.Default) {
val endData = gameState.resign()
val success = retryWithBackoff { publisher.publishGameEnd(endData) }
if (success) {
state.moveToCompleted(gameId, endData.result.notation, endData.termination.name.lowercase())
pollingDelegate.removeGameId(gameId)
state.setError(null)
} else {
state.setError("Failed to resign")
}
}
}
fun offerDraw(gameId: String) {
val gameState = state.getGameState(gameId) ?: return
if (state.isSpectating(gameId)) {
state.setError("Cannot offer draw while spectating")
return
}
scope.launch(Dispatchers.Default) {
val drawOffer = gameState.offerDraw()
val success =
retryWithBackoff {
publisher.publishDrawOffer(
gameId = drawOffer.gameId,
opponentPubkey = drawOffer.opponentPubkey,
message = drawOffer.message,
)
}
if (success) {
state.setError(null)
} else {
state.setError("Failed to offer draw")
}
}
}
fun acceptDraw(gameId: String) {
val gameState = state.getGameState(gameId) ?: return
val endData = gameState.acceptDraw() ?: return
scope.launch(Dispatchers.Default) {
val success = retryWithBackoff { publisher.publishGameEnd(endData) }
if (success) {
state.moveToCompleted(gameId, endData.result.notation, "draw_agreement")
pollingDelegate.removeGameId(gameId)
state.setError(null)
} else {
state.setError("Failed to accept draw")
}
}
}
fun declineDraw(gameId: String) {
val gameState = state.getGameState(gameId) ?: return
gameState.declineDraw()
}
fun claimAbandonmentVictory(gameId: String) {
val gameState = state.getGameState(gameId) ?: return
val endData = gameState.claimAbandonmentVictory() ?: return
scope.launch(Dispatchers.Default) {
val success = retryWithBackoff { publisher.publishGameEnd(endData) }
if (success) {
state.moveToCompleted(gameId, endData.result.notation, "abandonment")
pollingDelegate.removeGameId(gameId)
state.setError(null)
} else {
state.setError("Failed to claim abandonment victory")
}
}
}
// ========================================
// Spectator mode
// ========================================
fun loadGameAsSpectator(gameId: String) {
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
val events = fetcher.fetchGameEvents(gameId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
state.addSpectatingGame(gameId, result.liveState)
pollingDelegate.addGameId(gameId)
state.selectGame(gameId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
state.setError(null)
}
is LoadGameResult.Error -> {
state.setError("Failed to load game: ${result.message}")
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
}
}
}
}
fun loadGame(gameId: String) {
scope.launch(Dispatchers.Default) {
if (state.getGameState(gameId) != null) {
state.selectGame(gameId)
return@launch
}
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
val events = fetcher.fetchGameEvents(gameId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
if (result.liveState.isSpectator) {
state.addSpectatingGame(gameId, result.liveState)
} else {
state.addActiveGame(gameId, result.liveState)
}
pollingDelegate.addGameId(gameId)
state.selectGame(gameId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
state.setError(null)
}
is LoadGameResult.Error -> {
state.setError("Failed to load game: ${result.message}")
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
}
}
}
}
// ========================================
// Relay-first refresh (periodic reconstruction)
// ========================================
/**
* Full reconstruction refresh for active games.
* One-shot REQ → transient collector → reconstruct → diff + update.
*/
private suspend fun refreshGames(gameIds: Set<String>) {
for (gameId in gameIds) {
refreshGame(gameId)
}
}
private suspend fun refreshGame(gameId: String) {
val events = fetcher.fetchGameEvents(gameId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
state.replaceGameState(gameId, result.liveState)
}
is LoadGameResult.Error -> {
// Don't overwrite error for periodic refresh failures
}
}
}
private suspend fun refreshChallenges() {
val challengeEvents = fetcher.fetchChallenges()
val challenges =
challengeEvents.mapNotNull { event ->
val gameId = event.gameId() ?: return@mapNotNull null
val challengerColor = event.playerColor() ?: Color.WHITE
ChessChallenge(
eventId = event.id,
gameId = gameId,
challengerPubkey = event.pubKey,
challengerDisplayName = metadataProvider.getDisplayName(event.pubKey),
challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey),
opponentPubkey = event.opponentPubkey(),
challengerColor = challengerColor,
createdAt = event.createdAt,
)
}
state.updateChallenges(challenges)
// Also refresh public games
val recentGames = fetcher.fetchRecentGames()
val publicGames =
recentGames.map { summary ->
PublicGame(
gameId = summary.gameId,
whitePubkey = summary.whitePubkey,
whiteDisplayName = metadataProvider.getDisplayName(summary.whitePubkey),
blackPubkey = summary.blackPubkey,
blackDisplayName = metadataProvider.getDisplayName(summary.blackPubkey),
moveCount = summary.moveCount,
lastMoveTime = summary.lastMoveTime,
isActive = summary.isActive,
)
}
state.updatePublicGames(publicGames)
}
private fun cleanupExpiredChallenges() {
val now = TimeUtils.now()
val validChallenges =
state.challenges.value.filter { challenge ->
(now - challenge.createdAt) < CHALLENGE_EXPIRY_SECONDS
}
state.updateChallenges(validChallenges)
}
// ========================================
// Utilities
// ========================================
private fun generateGameId(): String {
val timestamp = TimeUtils.now()
val random = UUID.randomUUID().toString().take(8)
return "chess-$timestamp-$random"
}
/**
* Retry a publish operation with exponential backoff.
* Returns true if any attempt succeeds.
*/
private suspend fun retryWithBackoff(
maxRetries: Int = 3,
initialDelayMs: Long = 1000,
action: suspend () -> Boolean,
): Boolean {
var delayMs = initialDelayMs
repeat(maxRetries) { attempt ->
if (action()) return true
if (attempt < maxRetries - 1) {
delay(delayMs)
delayMs *= 2
}
}
return false
}
fun clearError() {
state.setError(null)
}
fun selectGame(gameId: String?) {
state.selectGame(gameId)
}
}
@@ -0,0 +1,367 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Challenge expiry: 24 hours
*/
const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L
/**
* Represents a chess challenge that can be displayed in the lobby
*/
@Immutable
data class ChessChallenge(
val eventId: String,
val gameId: String,
val challengerPubkey: String,
val challengerDisplayName: String?,
val challengerAvatarUrl: String?,
val opponentPubkey: String?,
val challengerColor: Color,
val createdAt: Long,
) {
/** Whether this is an open challenge anyone can accept */
val isOpen: Boolean get() = opponentPubkey == null
/** Whether this challenge is directed at a specific user */
fun isDirectedAt(pubkey: String): Boolean = opponentPubkey == pubkey
/** Whether this challenge was created by a specific user */
fun isFrom(pubkey: String): Boolean = challengerPubkey == pubkey
}
/**
* Represents a public game that can be spectated
*/
@Immutable
data class PublicGame(
val gameId: String,
val whitePubkey: String,
val whiteDisplayName: String?,
val blackPubkey: String,
val blackDisplayName: String?,
val moveCount: Int,
val lastMoveTime: Long,
val isActive: Boolean,
)
/**
* Represents a completed game for history display
*/
@Immutable
data class CompletedGame(
val gameId: String,
val whitePubkey: String,
val whiteDisplayName: String?,
val blackPubkey: String,
val blackDisplayName: String?,
val result: String,
val termination: String?,
val moveCount: Int,
val completedAt: Long,
) {
/** Whether user won this game */
fun didUserWin(userPubkey: String): Boolean =
when (result) {
"1-0" -> whitePubkey == userPubkey
"0-1" -> blackPubkey == userPubkey
else -> false
}
/** Whether this was a draw */
val isDraw: Boolean get() = result == "1/2-1/2"
}
/**
* Chess status for UI feedback
*/
@Immutable
sealed class ChessBroadcastStatus {
data object Idle : ChessBroadcastStatus()
data class Broadcasting(
val san: String,
val successCount: Int,
val totalRelays: Int,
) : ChessBroadcastStatus() {
val progress: Float get() = if (totalRelays > 0) successCount.toFloat() / totalRelays else 0f
}
data class Success(
val san: String,
val relayCount: Int,
) : ChessBroadcastStatus()
data class Failed(
val san: String,
val error: String,
) : ChessBroadcastStatus()
data object WaitingForOpponent : ChessBroadcastStatus()
data class Syncing(
val progress: Float = 0f,
) : ChessBroadcastStatus()
data class Desynced(
val message: String,
) : ChessBroadcastStatus()
}
/**
* Shared chess lobby state that can be used by both Android and Desktop
*/
class ChessLobbyState(
private val userPubkey: String,
private val scope: CoroutineScope,
) {
// Active games where user is a participant
private val _activeGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap())
val activeGames: StateFlow<Map<String, LiveChessGameState>> = _activeGames.asStateFlow()
// Public games that can be spectated
private val _publicGames = MutableStateFlow<List<PublicGame>>(emptyList())
val publicGames: StateFlow<List<PublicGame>> = _publicGames.asStateFlow()
// All challenges (filtered by UI based on type)
private val _challenges = MutableStateFlow<List<ChessChallenge>>(emptyList())
val challenges: StateFlow<List<ChessChallenge>> = _challenges.asStateFlow()
// Spectating games (user is watching but not playing)
private val _spectatingGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap())
val spectatingGames: StateFlow<Map<String, LiveChessGameState>> = _spectatingGames.asStateFlow()
// Completed games history
private val _completedGames = MutableStateFlow<List<CompletedGame>>(emptyList())
val completedGames: StateFlow<List<CompletedGame>> = _completedGames.asStateFlow()
// Broadcast status
private val _broadcastStatus = MutableStateFlow<ChessBroadcastStatus>(ChessBroadcastStatus.Idle)
val broadcastStatus: StateFlow<ChessBroadcastStatus> = _broadcastStatus.asStateFlow()
// Error state
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
// Selected game ID for navigation
private val _selectedGameId = MutableStateFlow<String?>(null)
val selectedGameId: StateFlow<String?> = _selectedGameId.asStateFlow()
// Badge count (incoming challenges + your turn games)
val badgeCount: Int
get() {
val incomingChallenges = _challenges.value.count { it.isDirectedAt(userPubkey) }
val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() }
return incomingChallenges + yourTurnGames
}
// ========================================
// Derived state for UI sections
// ========================================
/** Challenges directed at the user */
fun incomingChallenges(): List<ChessChallenge> = _challenges.value.filter { it.isDirectedAt(userPubkey) }
/** Challenges created by the user */
fun outgoingChallenges(): List<ChessChallenge> = _challenges.value.filter { it.isFrom(userPubkey) }
/** Open challenges from others that user can join */
fun openChallenges(): List<ChessChallenge> = _challenges.value.filter { it.isOpen && !it.isFrom(userPubkey) }
// ========================================
// State updates
// ========================================
fun updateChallenges(challenges: List<ChessChallenge>) {
_challenges.value = challenges
}
fun addChallenge(challenge: ChessChallenge) {
_challenges.update { current ->
if (current.any { it.eventId == challenge.eventId || it.gameId == challenge.gameId }) {
current
} else {
current + challenge
}
}
}
fun removeChallenge(gameId: String) {
_challenges.update { current ->
current.filter { it.gameId != gameId }
}
}
fun updatePublicGames(games: List<PublicGame>) {
_publicGames.value = games
}
fun addActiveGame(
gameId: String,
state: LiveChessGameState,
) {
_activeGames.update { it + (gameId to state) }
// Remove from challenges if present
removeChallenge(gameId)
}
fun removeActiveGame(gameId: String) {
_activeGames.update { it - gameId }
}
fun updateActiveGame(
gameId: String,
update: (LiveChessGameState) -> LiveChessGameState,
) {
_activeGames.update { current ->
current[gameId]?.let { state ->
current + (gameId to update(state))
} ?: current
}
}
/**
* Replace game state entirely after full reconstruction from relays.
* Preserves game location (active vs spectating).
*/
fun replaceGameState(
gameId: String,
newState: LiveChessGameState,
) {
if (_activeGames.value.containsKey(gameId)) {
_activeGames.update { it + (gameId to newState) }
} else if (_spectatingGames.value.containsKey(gameId)) {
_spectatingGames.update { it + (gameId to newState) }
}
}
/**
* Move a game from active/spectating to completed.
* Display names are optional; UI can look them up later if needed.
*/
fun moveToCompleted(
gameId: String,
result: String,
termination: String?,
whiteDisplayName: String? = null,
blackDisplayName: String? = null,
) {
val existingState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId]
existingState?.let { gameState ->
// Derive white/black pubkeys from player color
val whitePubkey =
if (gameState.playerColor == Color.WHITE) {
gameState.playerPubkey
} else {
gameState.opponentPubkey
}
val blackPubkey =
if (gameState.playerColor == Color.BLACK) {
gameState.playerPubkey
} else {
gameState.opponentPubkey
}
val completed =
CompletedGame(
gameId = gameId,
whitePubkey = whitePubkey,
whiteDisplayName = whiteDisplayName,
blackPubkey = blackPubkey,
blackDisplayName = blackDisplayName,
result = result,
termination = termination,
moveCount = gameState.moveHistory.value.size,
completedAt =
com.vitorpamplona.quartz.utils.TimeUtils
.now(),
)
_completedGames.update { current ->
// Avoid duplicates
if (current.any { it.gameId == gameId }) {
current
} else {
listOf(completed) + current
}
}
// Remove from active/spectating
_activeGames.update { it - gameId }
_spectatingGames.update { it - gameId }
// Clear selection if this game was selected
if (_selectedGameId.value == gameId) {
_selectedGameId.value = null
}
}
}
fun addSpectatingGame(
gameId: String,
state: LiveChessGameState,
) {
_spectatingGames.update { it + (gameId to state) }
}
fun removeSpectatingGame(gameId: String) {
_spectatingGames.update { it - gameId }
}
fun setBroadcastStatus(status: ChessBroadcastStatus) {
_broadcastStatus.value = status
}
fun setError(error: String?) {
_error.value = error
}
fun selectGame(gameId: String?) {
_selectedGameId.value = gameId
}
fun getGameState(gameId: String): LiveChessGameState? = _activeGames.value[gameId] ?: _spectatingGames.value[gameId]
fun isUserParticipant(gameId: String): Boolean = _activeGames.value.containsKey(gameId)
fun isSpectating(gameId: String): Boolean = _spectatingGames.value.containsKey(gameId)
fun clearAll() {
_activeGames.value = emptyMap()
_publicGames.value = emptyList()
_challenges.value = emptyList()
_spectatingGames.value = emptyMap()
_completedGames.value = emptyList()
_broadcastStatus.value = ChessBroadcastStatus.Idle
_error.value = null
_selectedGameId.value = null
}
}
@@ -0,0 +1,93 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap
/**
* One-shot relay fetch helper for chess events.
*
* Follows the existing INostrClient + IRequestListener + Channel pattern
* from quartz (see NostrClientSingleDownloadExt.kt).
*
* Each fetch opens a subscription, collects events until EOSE from all relays,
* then closes and returns the collected events. The subscription is transient —
* no state is cached between fetches.
*/
class ChessRelayFetchHelper(
private val client: INostrClient,
) {
/**
* Fetch events matching filters from relays, waiting for EOSE.
*
* @param filters Map of relay → filter list (same format as INostrClient.openReqSubscription)
* @param timeoutMs Max time to wait for all relays to send EOSE
* @return Deduplicated list of events received before timeout/EOSE
*/
suspend fun fetchEvents(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000,
): List<Event> {
if (filters.isEmpty()) return emptyList()
val events = ConcurrentHashMap<String, Event>()
val relayCount = filters.keys.size
val eoseReceived = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>()
val allEose = CompletableDeferred<Unit>()
val subId = newSubId()
val listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
events[event.id] = event
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eoseReceived.add(relay)
if (eoseReceived.size >= relayCount) {
allEose.complete(Unit)
}
}
}
client.openReqSubscription(subId, filters, listener)
withTimeoutOrNull(timeoutMs) { allEose.await() }
client.close(subId)
return events.values.toList()
}
}
@@ -0,0 +1,33 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
/**
* Platform-specific metadata provider for enriching chess challenges with display info.
*
* Android: wraps LocalCache.users[pubkey].info
* Desktop: wraps UserMetadataCache
*/
interface IUserMetadataProvider {
fun getDisplayName(pubkey: String): String
fun getPictureUrl(pubkey: String): String?
}
@@ -24,6 +24,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -31,8 +32,11 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -69,15 +73,27 @@ fun InteractiveChessBoard(
boardSize: Dp = 400.dp,
flipped: Boolean = false,
playerColor: ChessColor? = null, // null = allow both (for local play)
positionVersion: Int = 0, // External trigger for position refresh (e.g. moveHistory.size)
onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> },
) {
// Track move count to trigger recomposition when board changes
var moveCount by remember { mutableStateOf(0) }
val position = remember(engine, moveCount) { engine.getPosition() }
val sideToMove = remember(engine, moveCount) { engine.getSideToMove() }
// Track local move count + external version to trigger recomposition when board changes
var localMoveCount by remember { mutableStateOf(0) }
val effectiveVersion = localMoveCount + positionVersion
val position = remember(engine, effectiveVersion) { engine.getPosition() }
val sideToMove = remember(engine, effectiveVersion) { engine.getSideToMove() }
var selectedSquare by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var legalMoves by remember { mutableStateOf<List<String>>(emptyList()) }
// Pawn promotion state
var pendingPromotion by remember { mutableStateOf<PendingPromotion?>(null) }
// Clear stale selection when position changes externally (e.g. opponent move)
LaunchedEffect(positionVersion) {
selectedSquare = null
legalMoves = emptyList()
pendingPromotion = null
}
// Can only interact if it's your turn (or playerColor is null for local play)
val canInteract = playerColor == null || sideToMove == playerColor
@@ -87,66 +103,118 @@ fun InteractiveChessBoard(
val rankRange = if (flipped) (0..7) else (7 downTo 0)
val fileRange = if (flipped) (7 downTo 0) else (0..7)
Column(modifier = modifier.size(boardSize)) {
for (rank in rankRange) {
Row {
for (file in fileRange) {
val piece = position.pieceAt(file, rank)
val isLightSquare = (rank + file) % 2 == 0
val square = fileRankToSquare(file, rank)
val isSelected = selectedSquare == (file to rank)
val isLegalMove = legalMoves.contains(square)
Box(modifier = modifier.size(boardSize)) {
Column(modifier = Modifier.fillMaxSize()) {
for (rank in rankRange) {
Row {
for (file in fileRange) {
val piece = position.pieceAt(file, rank)
val isLightSquare = (rank + file) % 2 == 0
val square = fileRankToSquare(file, rank)
val isSelected = selectedSquare == (file to rank)
val isLegalMove = legalMoves.contains(square)
val showCoord = if (flipped) rank == 7 else rank == 0
val showCoord = if (flipped) rank == 7 else rank == 0
InteractiveChessSquare(
piece = piece,
isLight = isLightSquare,
size = squareSize,
isSelected = isSelected,
isLegalMove = isLegalMove,
showCoordinate = showCoord,
file = file,
rank = rank,
onClick = {
if (!canInteract) return@InteractiveChessSquare
InteractiveChessSquare(
piece = piece,
isLight = isLightSquare,
size = squareSize,
isSelected = isSelected,
isLegalMove = isLegalMove,
showCoordinate = showCoord,
file = file,
rank = rank,
onClick = {
if (!canInteract || pendingPromotion != null) return@InteractiveChessSquare
if (selectedSquare != null) {
// Attempt to make move - validate first, don't actually make it
val from = fileRankToSquare(selectedSquare!!.first, selectedSquare!!.second)
val to = square
if (selectedSquare != null) {
// Attempt to make move - validate first, don't actually make it
val from = fileRankToSquare(selectedSquare!!.first, selectedSquare!!.second)
val to = square
if (legalMoves.contains(to)) {
// Valid move - callback will make the actual move
selectedSquare = null
legalMoves = emptyList()
// Pass empty san - callback will get it from makeMove result
onMoveMade(from, to, "")
moveCount++ // Trigger recomposition after move is made
if (legalMoves.contains(to)) {
// Check if this is a pawn promotion move
val fromRank = selectedSquare!!.second
val toRank = rank
val movingPiece = position.pieceAt(selectedSquare!!.first, fromRank)
val isPromotion =
movingPiece?.type == PieceType.PAWN &&
(toRank == 7 || toRank == 0)
if (isPromotion) {
// Show promotion dialog
pendingPromotion =
PendingPromotion(
from = from,
to = to,
file = file,
rank = toRank,
color = sideToMove,
)
} else {
// Valid move - callback will make the actual move
selectedSquare = null
legalMoves = emptyList()
// Pass empty san - callback will get it from makeMove result
onMoveMade(from, to, "")
localMoveCount++ // Trigger recomposition after move is made
}
} else {
// Not a legal move target - try selecting this square instead
val validColor = playerColor ?: sideToMove
if (piece != null && piece.color == validColor) {
selectedSquare = file to rank
legalMoves = engine.getLegalMovesFrom(square)
} else {
selectedSquare = null
legalMoves = emptyList()
}
}
} else {
// Not a legal move target - try selecting this square instead
// Select piece - only allow selecting player's pieces
val validColor = playerColor ?: sideToMove
if (piece != null && piece.color == validColor) {
selectedSquare = file to rank
legalMoves = engine.getLegalMovesFrom(square)
} else {
selectedSquare = null
legalMoves = emptyList()
}
}
} else {
// Select piece - only allow selecting player's pieces
val validColor = playerColor ?: sideToMove
if (piece != null && piece.color == validColor) {
selectedSquare = file to rank
legalMoves = engine.getLegalMovesFrom(square)
}
}
},
)
},
)
}
}
}
}
// Promotion dialog overlay
pendingPromotion?.let { promo ->
PromotionPicker(
color = promo.color,
squareSize = squareSize,
onPieceSelected = { pieceType ->
// Make the move with promotion
val promotionSuffix =
when (pieceType) {
PieceType.QUEEN -> "q"
PieceType.ROOK -> "r"
PieceType.BISHOP -> "b"
PieceType.KNIGHT -> "n"
else -> "q"
}
selectedSquare = null
legalMoves = emptyList()
pendingPromotion = null
// Include promotion in the 'to' square for the callback
onMoveMade(promo.from, promo.to + promotionSuffix, "")
localMoveCount++
},
onDismiss = {
pendingPromotion = null
selectedSquare = null
legalMoves = emptyList()
},
)
}
}
}
@@ -254,3 +322,71 @@ private fun ChessPiece.toImageVector(): ImageVector {
PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn
}
}
/**
* Data class for pending pawn promotion
*/
private data class PendingPromotion(
val from: String,
val to: String,
val file: Int,
val rank: Int,
val color: ChessColor,
)
/**
* Promotion piece picker overlay
*/
@Composable
private fun PromotionPicker(
color: ChessColor,
squareSize: Dp,
onPieceSelected: (PieceType) -> Unit,
onDismiss: () -> Unit,
) {
val isWhite = color == ChessColor.WHITE
val promotionPieces =
listOf(
PieceType.QUEEN to if (isWhite) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen,
PieceType.ROOK to if (isWhite) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook,
PieceType.BISHOP to if (isWhite) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop,
PieceType.KNIGHT to if (isWhite) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight,
)
// Semi-transparent overlay
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.clickable { onDismiss() },
contentAlignment = Alignment.Center,
) {
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.clickable { /* prevent dismiss */ },
) {
Row(
modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
promotionPieces.forEach { (pieceType, icon) ->
Box(
modifier =
Modifier
.size(squareSize)
.background(Color(0xFFF0D9B5), RoundedCornerShape(4.dp))
.clickable { onPieceSelected(pieceType) },
contentAlignment = Alignment.Center,
) {
Image(
imageVector = icon,
contentDescription = pieceType.name,
modifier = Modifier.fillMaxSize().padding(4.dp),
)
}
}
}
}
}
}
@@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
@@ -189,6 +190,9 @@ fun LiveChessGameScreen(
val currentPosition by gameState.currentPosition.collectAsState()
val moveHistory by gameState.moveHistory.collectAsState()
// Pending challenges and spectators cannot make moves
val canMakeMoves = !gameState.isSpectator && !gameState.isPendingChallenge
BoxWithConstraints(
modifier = modifier.fillMaxSize(),
) {
@@ -212,15 +216,23 @@ fun LiveChessGameScreen(
opponentName = opponentName,
playerColor = gameState.playerColor,
currentTurn = currentPosition.activeColor,
isSpectator = gameState.isSpectator,
isPendingChallenge = gameState.isPendingChallenge,
)
// Interactive chess board - flip when playing black
// Interactive chess board - flip when playing black (spectators see from white's view)
// Auto-sized to fit available space
InteractiveChessBoard(
engine = gameState.engine,
boardSize = boardSize,
flipped = gameState.playerColor == Color.BLACK,
onMoveMade = onMoveMade,
flipped = !gameState.isSpectator && gameState.playerColor == Color.BLACK,
positionVersion = moveHistory.size,
onMoveMade =
if (canMakeMoves) {
onMoveMade
} else {
{ _, _, _ -> } // No-op for spectators and pending challenges
},
)
// Move history (scrollable) - observed from state flow
@@ -228,11 +240,12 @@ fun LiveChessGameScreen(
moves = moveHistory,
)
// Game controls
GameControls(
onResign = onResign,
onOfferDraw = onOfferDraw,
)
// Show appropriate controls based on game state
when {
gameState.isPendingChallenge -> PendingChallengeInfo()
gameState.isSpectator -> SpectatorInfo()
else -> GameControls(onResign = onResign, onOfferDraw = onOfferDraw)
}
}
}
}
@@ -291,6 +304,7 @@ fun LiveChessGameScreen(
engine = engine,
boardSize = boardSize,
flipped = playerColor == Color.BLACK,
positionVersion = engine.getMoveHistory().size,
onMoveMade = onMoveMade,
)
@@ -317,45 +331,147 @@ private fun GameInfoHeader(
opponentName: String,
playerColor: Color,
currentTurn: Color,
isSpectator: Boolean = false,
isPendingChallenge: Boolean = false,
) {
// Extract human-readable game name if available
val gameName =
remember(gameId) {
ChessGameNameGenerator.extractDisplayName(gameId)
}
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Show readable name prominently if available
if (gameName != null) {
Text(
text = gameName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
Text(
text = "Game: ${gameId.take(8)}...",
text = if (gameName != null) gameId.take(16) else "Game: ${gameId.take(8)}...",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "vs $opponentName",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
when {
isPendingChallenge -> {
Text(
text = "Challenge Pending",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.secondary,
)
Text(
text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}",
style = MaterialTheme.typography.bodyMedium,
)
val turnText =
if (currentTurn == playerColor) {
"Your turn"
} else {
"Opponent's turn"
Text(
text = if (opponentName.isNotEmpty()) "Waiting for $opponentName" else "Open challenge",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
isSpectator -> {
Text(
text = "Spectating",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.tertiary,
)
val turnText = "${if (currentTurn == Color.WHITE) "White" else "Black"}'s turn"
Text(
text = turnText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> {
Text(
text = "vs $opponentName",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}",
style = MaterialTheme.typography.bodyMedium,
)
val turnText =
if (currentTurn == playerColor) {
"Your turn"
} else {
"Opponent's turn"
}
Text(
text = turnText,
style = MaterialTheme.typography.bodyMedium,
color =
if (currentTurn == playerColor) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
fontWeight = if (currentTurn == playerColor) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
}
/**
* Info banner shown when spectating a game
*/
@Composable
private fun SpectatorInfo() {
Box(
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
RoundedCornerShape(8.dp),
).padding(12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = turnText,
text = "Watching game - spectator mode",
style = MaterialTheme.typography.bodyMedium,
color =
if (currentTurn == playerColor) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
fontWeight = if (currentTurn == playerColor) FontWeight.Bold else FontWeight.Normal,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
/**
* Info banner shown when viewing a pending challenge
*/
@Composable
private fun PendingChallengeInfo() {
Box(
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
RoundedCornerShape(8.dp),
).padding(12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "Waiting for opponent to accept challenge",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
@@ -0,0 +1,292 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess.subscription
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Shared filter builder for chess subscriptions.
* Used by both Android and Desktop to ensure identical subscription behavior.
*
* Builds 4 types of filters:
* 1. Personal filters - Events tagged with user's pubkey
* 2. Challenge filters - All challenges (like jesterui pattern)
* 3. Active game filters - Game-specific move/end subscriptions
* 4. Recent game filters - For spectating discovery (7 day window)
*/
object ChessFilterBuilder {
/** Challenge and accept event kinds (for lobby display) */
private val CHALLENGE_KINDS =
listOf(
LiveChessGameChallengeEvent.KIND,
LiveChessGameAcceptEvent.KIND,
)
/** Move and end event kinds (for active games) */
private val GAME_EVENT_KINDS =
listOf(
LiveChessMoveEvent.KIND,
LiveChessGameEndEvent.KIND,
)
/** All chess event kinds */
private val ALL_CHESS_KINDS = CHALLENGE_KINDS + GAME_EVENT_KINDS
/**
* Build all chess filters for a given subscription state.
* This is the main entry point - platforms call this to get filters.
*
* @param state The current subscription state with user info and active games
* @param sinceForRelay Function to get cached EOSE time for a relay (null if no cache)
* @return List of relay-specific filters to subscribe with
*/
fun buildAllFilters(
state: ChessSubscriptionState,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
): List<RelayBasedFilter> {
val filters = mutableListOf<RelayBasedFilter>()
val now = TimeUtils.now()
// Filter 1: Personal events (challenges to us, moves in our games)
filters.addAll(
buildPersonalFilters(state.userPubkey, state.relays, sinceForRelay, now),
)
// Filter 2: All challenges (for lobby display)
filters.addAll(
buildChallengeFilters(state.relays, sinceForRelay, now),
)
// Filter 3: Active game subscriptions (game-specific)
if (state.hasActiveGames) {
filters.addAll(
buildActiveGameFilters(
state.allGameIds,
state.userPubkey,
state.opponentPubkeys,
state.relays,
),
)
}
// Filter 4: Recent game events (for spectating discovery)
filters.addAll(
buildRecentGameFilters(state.relays, sinceForRelay, now),
)
return filters
}
/**
* Personal events - tagged with user's pubkey.
* Catches: challenges directed at us, moves in our games, game ends.
*/
fun buildPersonalFilters(
userPubkey: String,
relays: Set<NormalizedRelayUrl>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
now: Long = TimeUtils.now(),
): List<RelayBasedFilter> {
val filter =
Filter(
kinds = ALL_CHESS_KINDS,
tags = mapOf("p" to listOf(userPubkey)),
limit = 100,
)
return relays.map { relay ->
val sinceTime =
sinceForRelay(relay)
?: (now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS)
RelayBasedFilter(
relay = relay,
filter = filter.copy(since = sinceTime),
)
}
}
/**
* All challenge events (like jesterui pattern).
* No author/tag restriction - fetch everything, filter client-side.
* This ensures we see open challenges and public games.
*/
fun buildChallengeFilters(
relays: Set<NormalizedRelayUrl>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
now: Long = TimeUtils.now(),
): List<RelayBasedFilter> {
val filter =
Filter(
kinds = CHALLENGE_KINDS,
limit = 100,
)
return relays.map { relay ->
val sinceTime =
sinceForRelay(relay)
?: (now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS)
RelayBasedFilter(
relay = relay,
filter = filter.copy(since = sinceTime),
)
}
}
/**
* Active game events - game-specific subscriptions.
* These filters ensure moves are received for games the user is playing.
*
* Note: Move d-tags are "gameId-moveNumber" so we can't filter by #d for moves.
* We use two strategies:
* 1. Filter by authors (opponent pubkeys) - catches moves they make
* 2. Filter by #p tag (opponent tagged us) - catches moves tagged with us
*
* End events use gameId as d-tag so we can filter those directly.
*/
fun buildActiveGameFilters(
gameIds: Set<String>,
userPubkey: String,
opponentPubkeys: Set<String>,
relays: Set<NormalizedRelayUrl>,
): List<RelayBasedFilter> {
if (gameIds.isEmpty()) return emptyList()
println("[ChessFilterBuilder] Building filters for ${gameIds.size} games, ${opponentPubkeys.size} opponents: $opponentPubkeys")
val filters = mutableListOf<RelayBasedFilter>()
// End events: filter by d-tag (gameId)
val endFilter =
Filter(
kinds = listOf(LiveChessGameEndEvent.KIND),
tags = mapOf("d" to gameIds.toList()),
limit = 50,
)
// Move events: filter by p-tag (opponent tagged us)
// This catches all moves for games where we're a participant
val moveFilterByTag =
Filter(
kinds = listOf(LiveChessMoveEvent.KIND),
tags = mapOf("p" to listOf(userPubkey)),
limit = 200,
)
relays.forEach { relay ->
filters.add(RelayBasedFilter(relay = relay, filter = endFilter))
filters.add(RelayBasedFilter(relay = relay, filter = moveFilterByTag))
}
// Move events: filter by authors (opponent pubkeys)
// This directly catches moves made by our opponents
if (opponentPubkeys.isNotEmpty()) {
println("[ChessFilterBuilder] Adding author filter for opponents: $opponentPubkeys")
val moveFilterByAuthor =
Filter(
kinds = listOf(LiveChessMoveEvent.KIND),
authors = opponentPubkeys.toList(),
limit = 200,
)
relays.forEach { relay ->
filters.add(RelayBasedFilter(relay = relay, filter = moveFilterByAuthor))
}
} else {
println("[ChessFilterBuilder] WARNING: No opponent pubkeys provided!")
}
return filters
}
/**
* Recent game events for spectating discovery.
* Uses longer time window (7 days) to catch ongoing games.
*/
fun buildRecentGameFilters(
relays: Set<NormalizedRelayUrl>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
now: Long = TimeUtils.now(),
): List<RelayBasedFilter> {
val filter =
Filter(
kinds = GAME_EVENT_KINDS,
limit = 100,
)
return relays.map { relay ->
val sinceTime =
sinceForRelay(relay)
?: (now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS)
RelayBasedFilter(
relay = relay,
filter = filter.copy(since = sinceTime),
)
}
}
// ===================================================
// Simple filters for one-shot fetches (ChessRelayFetcher)
// ===================================================
/**
* Filter for all events related to a specific game.
* Used for one-shot fetch when loading a game.
*/
fun gameEventsFilter(gameId: String): Filter =
Filter(
kinds = ALL_CHESS_KINDS + listOf(com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent.KIND),
tags = mapOf("d" to listOf(gameId)),
limit = 500,
)
/**
* Filter for challenge events in the last 24 hours.
* Used for one-shot fetch to populate lobby.
*/
fun challengesFilter(userPubkey: String): Filter {
val now = TimeUtils.now()
return Filter(
kinds = CHALLENGE_KINDS,
since = now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS,
limit = 100,
)
}
/**
* Filter for recent game activity for spectating discovery.
* Fetches move events from the last 7 days.
*/
fun recentGamesFilter(): Filter {
val now = TimeUtils.now()
return Filter(
kinds = ALL_CHESS_KINDS,
since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS,
limit = 200,
)
}
}
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess.subscription
import kotlinx.coroutines.flow.StateFlow
/**
* Interface for platform-specific chess subscription management.
*
* Implementations handle the actual relay subscription mechanics while
* using [ChessFilterBuilder] for consistent filter construction.
*
* Key responsibilities:
* - Subscribe/unsubscribe to chess events on relays
* - Update filters when active games change
* - Track EOSE (End of Stored Events) per relay for efficient re-subscription
*/
interface ChessSubscriptionController {
/** Current subscription state, null if not subscribed */
val currentState: StateFlow<ChessSubscriptionState?>
/**
* Subscribe to chess events with the given state.
* Replaces any existing subscription.
*/
fun subscribe(state: ChessSubscriptionState)
/** Unsubscribe from all chess events */
fun unsubscribe()
/**
* Update active game IDs and refresh subscription filters.
* This is the key method for dynamic subscription updates.
*
* When a game starts or ends, call this to update the filters
* so moves are properly received for active games.
*
* @param activeGameIds Game IDs the user is actively playing
* @param spectatingGameIds Game IDs the user is watching (optional)
*/
fun updateActiveGames(
activeGameIds: Set<String>,
spectatingGameIds: Set<String> = emptySet(),
)
/**
* Force refresh all filters from relays.
* Clears EOSE cache so full history is re-fetched.
*/
fun forceRefresh()
}
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess.subscription
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Immutable state for chess relay subscriptions.
* Used to generate subscription filters and track subscription identity.
*
* When any of these values change, a new subscription should be created
* with updated filters.
*/
@Immutable
data class ChessSubscriptionState(
/** User's public key (hex) for filtering personal events */
val userPubkey: String,
/** Set of relays to subscribe to */
val relays: Set<NormalizedRelayUrl>,
/** Game IDs the user is actively playing */
val activeGameIds: Set<String> = emptySet(),
/** Game IDs the user is spectating */
val spectatingGameIds: Set<String> = emptySet(),
/** Opponent pubkeys for active games (to filter their moves) */
val opponentPubkeys: Set<String> = emptySet(),
) {
/**
* Unique subscription ID that changes when game IDs change.
* Used for:
* - Subscription deduplication
* - EOSE cache keying
* - Detecting when filters need to be refreshed
*/
fun subscriptionId(): String =
buildString {
append("chess-")
append(userPubkey.take(8))
if (allGameIds.isNotEmpty()) {
append("-g")
append(allGameIds.sorted().hashCode())
}
}
/** All game IDs requiring move subscriptions (active + spectating) */
val allGameIds: Set<String>
get() = activeGameIds + spectatingGameIds
/** True if user has any active or spectating games */
val hasActiveGames: Boolean
get() = allGameIds.isNotEmpty()
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess.subscription
/**
* Shared time window constants for chess subscriptions.
* Used by both Android and Desktop to ensure consistent behavior.
*/
object ChessTimeWindows {
/**
* 24 hours - challenges older than this are considered expired.
* Used for challenge and accept event filters.
*/
const val CHALLENGE_WINDOW_SECONDS = 24 * 60 * 60L
/**
* 7 days - default lookback for game events when no EOSE cache exists.
* This prevents loading ancient game history on first connection
* while still allowing recovery of recent games.
*/
const val GAME_EVENT_WINDOW_SECONDS = 7 * 24 * 60 * 60L
}