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