Merge branch 'main' into feat/desktop-private-dms

This commit is contained in:
Vitor Pamplona
2026-02-24 17:50:48 -05:00
committed by GitHub
298 changed files with 23719 additions and 2908 deletions
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
/**
* Global registry of accepted game IDs.
*
* This singleton ensures that accepted game state is shared across all ViewModel instances.
* Without this, different ViewModels (e.g., lobby vs game screen) would have separate
* acceptedGameIds sets, causing the game screen to incorrectly load as spectator.
*/
object AcceptedGamesRegistry {
private val acceptedGameIds = mutableSetOf<String>()
private val lock = Any()
fun markAsAccepted(gameId: String) {
synchronized(lock) {
acceptedGameIds.add(gameId)
}
}
fun wasAccepted(gameId: String): Boolean =
synchronized(lock) {
acceptedGameIds.contains(gameId)
}
fun clear() {
synchronized(lock) {
acceptedGameIds.clear()
}
}
/** Remove old entries - call periodically to prevent memory leak */
fun clearOldEntries(keepGameIds: Set<String>) {
synchronized(lock) {
acceptedGameIds.retainAll(keepGameIds)
}
}
}
@@ -0,0 +1,146 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.quartz.nip64Chess.ChessPiece
import com.vitorpamplona.quartz.nip64Chess.ChessPosition
import com.vitorpamplona.quartz.nip64Chess.PieceType
import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
/**
* Renders an 8x8 chess board with pieces
* Shared composable for Android and Desktop platforms
*
* @param position The chess position to display
* @param modifier Modifier for the board
* @param boardSize Total size of the board in dp
* @param showCoordinates Whether to show file labels (a-h) on bottom rank
*/
@Composable
fun ChessBoard(
position: ChessPosition,
modifier: Modifier = Modifier,
boardSize: Dp = 320.dp,
showCoordinates: Boolean = true,
) {
val squareSize = boardSize / 8
Column(modifier = modifier.size(boardSize)) {
// Render ranks 8 down to 1 (from white's perspective)
for (rank in 7 downTo 0) {
Row {
for (file in 0..7) {
val piece = position.pieceAt(file, rank)
val isLightSquare = (rank + file) % 2 == 0
ChessSquare(
piece = piece,
isLight = isLightSquare,
size = squareSize,
coordinate =
if (showCoordinates && rank == 0) {
('a' + file).toString()
} else {
null
},
)
}
}
}
}
}
/**
* Renders a single square on the chess board
*/
@Composable
private fun ChessSquare(
piece: com.vitorpamplona.quartz.nip64Chess.ChessPiece?,
isLight: Boolean,
size: Dp,
coordinate: String?,
) {
Box(
modifier =
Modifier
.size(size)
.background(
if (isLight) Color(0xFFF0D9B5) else Color(0xFFB58863),
),
contentAlignment = Alignment.Center,
) {
// Display piece using CBurnett ImageVector icons
piece?.let {
Image(
imageVector = it.toImageVector(),
contentDescription = "${it.color} ${it.type}",
modifier = Modifier.fillMaxSize().padding(2.dp),
)
}
// Show file coordinate (a-h) on bottom rank
coordinate?.let {
Text(
text = it,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = if (isLight) Color(0xFFB58863) else Color(0xFFF0D9B5),
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(2.dp),
)
}
}
}
/**
* Extension function to convert ChessPiece to CBurnett ImageVector
*/
private fun ChessPiece.toImageVector(): ImageVector {
val white = color == ChessColor.WHITE
return when (type) {
PieceType.KING -> if (white) ChessPieceVectors.WhiteKing else ChessPieceVectors.BlackKing
PieceType.QUEEN -> if (white) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen
PieceType.ROOK -> if (white) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook
PieceType.BISHOP -> if (white) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop
PieceType.KNIGHT -> if (white) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight
PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn
}
}
@@ -0,0 +1,241 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.CloudSync
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.HourglassBottom
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* Shared banner showing chess broadcast status - broadcast progress, sync status, etc.
* Works on both Android and Desktop via Compose Multiplatform.
*/
@Composable
fun ChessBroadcastBanner(
status: ChessBroadcastStatus,
onTap: () -> Unit,
modifier: Modifier = Modifier,
) {
val isVisible = status !is ChessBroadcastStatus.Idle
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(tween(200)),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(tween(150)),
modifier = modifier,
) {
Surface(
color = getStatusBackgroundColor(status),
tonalElevation = 2.dp,
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onTap),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.animateContentSize(),
) {
Icon(
imageVector = getStatusIcon(status),
contentDescription = null,
tint = getStatusIconColor(status),
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = getStatusText(status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Text(
text = getStatusDetail(status),
style = MaterialTheme.typography.labelMedium,
color = getStatusDetailColor(status),
)
}
// Show progress bar for broadcasting/syncing
val progress = getStatusProgress(status)
if (progress != null) {
Spacer(Modifier.height(4.dp))
val animatedProgress by animateFloatAsState(
targetValue = progress,
animationSpec = tween(300),
label = "progress",
)
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.fillMaxWidth(),
color = getStatusProgressColor(status),
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
}
}
}
@Composable
private fun getStatusBackgroundColor(status: ChessBroadcastStatus): Color =
when (status) {
is ChessBroadcastStatus.Failed, is ChessBroadcastStatus.Desynced -> {
MaterialTheme.colorScheme.errorContainer
}
is ChessBroadcastStatus.Success -> {
MaterialTheme.colorScheme.primaryContainer
}
else -> {
MaterialTheme.colorScheme.surfaceContainer
}
}
private fun getStatusIcon(status: ChessBroadcastStatus): ImageVector =
when (status) {
is ChessBroadcastStatus.Broadcasting -> Icons.Default.Sync
is ChessBroadcastStatus.Success -> Icons.Default.CheckCircle
is ChessBroadcastStatus.Failed -> Icons.Default.Error
is ChessBroadcastStatus.WaitingForOpponent -> Icons.Default.HourglassBottom
is ChessBroadcastStatus.Syncing -> Icons.Default.CloudSync
is ChessBroadcastStatus.Desynced -> Icons.Default.Error
is ChessBroadcastStatus.Idle -> Icons.Default.CheckCircle
}
@Composable
private fun getStatusIconColor(status: ChessBroadcastStatus): Color =
when (status) {
is ChessBroadcastStatus.Failed, is ChessBroadcastStatus.Desynced -> {
MaterialTheme.colorScheme.error
}
is ChessBroadcastStatus.Success -> {
MaterialTheme.colorScheme.primary
}
is ChessBroadcastStatus.WaitingForOpponent -> {
MaterialTheme.colorScheme.secondary
}
else -> {
MaterialTheme.colorScheme.primary
}
}
private fun getStatusText(status: ChessBroadcastStatus): String =
when (status) {
is ChessBroadcastStatus.Broadcasting -> "Broadcasting: ${status.san}"
is ChessBroadcastStatus.Success -> "Sent: ${status.san}"
is ChessBroadcastStatus.Failed -> "Failed: ${status.san}"
is ChessBroadcastStatus.WaitingForOpponent -> "Waiting for opponent's move..."
is ChessBroadcastStatus.Syncing -> "Syncing game state..."
is ChessBroadcastStatus.Desynced -> "Game desynced: ${status.message}"
is ChessBroadcastStatus.Idle -> ""
}
private fun getStatusDetail(status: ChessBroadcastStatus): String =
when (status) {
is ChessBroadcastStatus.Broadcasting -> "[${status.successCount}/${status.totalRelays}]"
is ChessBroadcastStatus.Success -> "${status.relayCount} relays"
is ChessBroadcastStatus.Failed -> "Tap to retry"
is ChessBroadcastStatus.Syncing -> "${(status.progress * 100).toInt()}%"
is ChessBroadcastStatus.Desynced -> "Tap to resync"
else -> ""
}
@Composable
private fun getStatusDetailColor(status: ChessBroadcastStatus): Color =
when (status) {
is ChessBroadcastStatus.Failed, is ChessBroadcastStatus.Desynced -> {
MaterialTheme.colorScheme.error
}
else -> {
MaterialTheme.colorScheme.primary
}
}
private fun getStatusProgress(status: ChessBroadcastStatus): Float? =
when (status) {
is ChessBroadcastStatus.Broadcasting -> status.progress
is ChessBroadcastStatus.Syncing -> status.progress
else -> null
}
@Composable
private fun getStatusProgressColor(status: ChessBroadcastStatus): Color =
when (status) {
is ChessBroadcastStatus.Failed -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
/**
* Global chess configuration shared across Android and Desktop.
*
* These relays are the primary relays used by Jester and other Nostr chess apps.
* Using a small, fixed set ensures fast queries and reliable game discovery.
*/
object ChessConfig {
/**
* The 3 main relays for chess events.
* These are used for both fetching and publishing chess events.
*/
val CHESS_RELAYS =
listOf(
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
)
/**
* Display names for the chess relays (without protocol prefix)
*/
val CHESS_RELAY_NAMES =
listOf(
"relay.damus.io",
"nos.lol",
"relay.primal.net",
)
/**
* Timeout for relay queries in milliseconds.
* With only 3 relays, we can wait for all of them.
*/
const val FETCH_TIMEOUT_MS = 10_000L
}
@@ -0,0 +1,266 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip64Chess.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents
import com.vitorpamplona.quartz.nip64Chess.JesterProtocol
import com.vitorpamplona.quartz.nip64Chess.toJesterEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
/**
* Collects and aggregates Jester chess events for a game from any source.
*
* This class provides a unified way to collect events regardless of whether
* they come from LocalCache (Android), direct relay (Desktop), or test fixtures.
* It handles:
* - Deduplication by event ID
* - Thread-safe concurrent updates
* - Producing JesterGameEvents snapshots for reconstruction
*
* Jester Protocol:
* - All chess events use kind 30
* - Start events (content.kind=0) reference START_POSITION_HASH via e-tag
* - Move events (content.kind=1) reference [startEventId, headEventId] via e-tags
* - Full move history is included in every move event
*
* Usage:
* ```
* val collector = ChessEventCollector(startEventId)
*
* // Add events as they arrive from any source
* collector.addEvent(jesterEvent)
*
* // Get current state for reconstruction
* val events = collector.getEvents()
* val result = ChessStateReconstructor.reconstruct(events, viewerPubkey)
* ```
*/
class ChessEventCollector(
/** The start event ID - this is the game identifier in Jester protocol */
val startEventId: String,
) {
// Start event (only one per game)
private val _startEvent = MutableStateFlow<JesterEvent?>(null)
val startEvent: StateFlow<JesterEvent?> = _startEvent.asStateFlow()
// Move events (deduplicated by event ID)
private val moves = ConcurrentHashMap<String, JesterEvent>()
// Track all processed event IDs for fast deduplication
private val processedEventIds = ConcurrentHashMap.newKeySet<String>()
// Flow that emits when any event is added (for reactive updates)
private val _eventCount = MutableStateFlow(0)
val eventCount: StateFlow<Int> = _eventCount.asStateFlow()
/**
* Check if an event has already been processed.
*/
fun hasEvent(eventId: String): Boolean = processedEventIds.contains(eventId)
/**
* Add a Jester event for this game.
* Automatically categorizes as start or move based on content.kind.
*
* @return true if the event was added, false if already exists or invalid
*/
fun addEvent(event: JesterEvent): Boolean {
if (processedEventIds.contains(event.id)) return false
// Check if this event belongs to our game
val eventStartId = event.startEventId()
val isStartEvent = event.isStartEvent() && event.id == startEventId
val isMoveEvent = event.isMoveEvent() && eventStartId == startEventId
if (!isStartEvent && !isMoveEvent) return false
return if (isStartEvent) {
addStartEvent(event)
} else {
addMoveEvent(event)
}
}
/**
* Add a raw Event if it's a valid Jester event for this game.
*
* @return true if the event was added, false if invalid or not for this game
*/
fun addRawEvent(event: Event): Boolean {
if (event.kind != JesterProtocol.KIND) return false
val jesterEvent = event.toJesterEvent() ?: return false
return addEvent(jesterEvent)
}
/**
* Add the start event for this game.
* Only the first valid start is kept.
*
* @return true if the event was added, false if already exists or invalid
*/
private fun addStartEvent(event: JesterEvent): Boolean {
if (processedEventIds.contains(event.id)) return false
if (!event.isStartEvent()) return false
if (event.id != startEventId) return false
if (_startEvent.compareAndSet(null, event)) {
processedEventIds.add(event.id)
incrementEventCount()
return true
}
return false
}
/**
* Add a move event for this game.
* Moves are deduplicated by event ID.
*
* @return true if the event was added, false if already exists or invalid
*/
private fun addMoveEvent(event: JesterEvent): Boolean {
if (processedEventIds.contains(event.id)) return false
if (!event.isMoveEvent()) return false
if (event.startEventId() != startEventId) return false
if (moves.putIfAbsent(event.id, event) == null) {
processedEventIds.add(event.id)
incrementEventCount()
return true
}
return false
}
/**
* Get a snapshot of all collected events for reconstruction.
*/
fun getEvents(): JesterGameEvents =
JesterGameEvents(
startEvent = _startEvent.value,
moves = moves.values.toList(),
)
/**
* Check if the game has a start event.
*/
fun hasStartEvent(): Boolean = _startEvent.value != null
/**
* Check if the game has any moves (indicates game is active).
*/
fun hasMoves(): Boolean = moves.isNotEmpty()
/**
* Check if the game has ended (has a move with result).
*/
fun hasEnded(): Boolean = moves.values.any { it.result() != null }
/**
* Get the number of moves collected.
*/
fun moveCount(): Int = moves.size
/**
* Get the latest move (with longest history).
*/
fun latestMove(): JesterEvent? = moves.values.maxByOrNull { it.history().size }
/**
* Clear all collected events.
*/
fun clear() {
_startEvent.value = null
moves.clear()
processedEventIds.clear()
_eventCount.value = 0
}
private fun incrementEventCount() {
_eventCount.value = processedEventIds.size
}
}
/**
* Manager for multiple game collectors.
*
* This is useful when managing multiple concurrent games,
* such as in a chess lobby or when spectating multiple games.
*/
class ChessEventCollectorManager {
private val collectors = ConcurrentHashMap<String, ChessEventCollector>()
/**
* Get or create a collector for a game.
*
* @param startEventId The start event ID (game identifier)
*/
fun getOrCreate(startEventId: String): ChessEventCollector = collectors.getOrPut(startEventId) { ChessEventCollector(startEventId) }
/**
* Get a collector if it exists.
*
* @param startEventId The start event ID (game identifier)
*/
fun get(startEventId: String): ChessEventCollector? = collectors[startEventId]
/**
* Remove a collector for a game.
*
* @param startEventId The start event ID (game identifier)
*/
fun remove(startEventId: String): ChessEventCollector? = collectors.remove(startEventId)
/**
* Get all active game IDs (start event IDs).
*/
fun activeGameIds(): Set<String> = collectors.keys.toSet()
/**
* Clear all collectors.
*/
fun clear() {
collectors.values.forEach { it.clear() }
collectors.clear()
}
/**
* Add an event to the appropriate collector (auto-routing).
* Creates a collector if needed for start events.
*
* @return true if the event was added to a collector
*/
fun addEvent(event: JesterEvent): Boolean {
// For start events, create collector with event ID
if (event.isStartEvent()) {
val collector = getOrCreate(event.id)
return collector.addEvent(event)
}
// For move events, find the collector by startEventId
val startId = event.startEventId() ?: return false
val collector = collectors[startId] ?: return false
return collector.addEvent(event)
}
}
@@ -0,0 +1,327 @@
/*
* 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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Configuration for chess event polling
*/
data class ChessPollingConfig(
/** Interval for polling active games (ms) */
val activeGamePollInterval: Long = 10_000L,
/** Interval for polling challenges (ms) */
val challengePollInterval: Long = 30_000L,
/** Whether to continue polling in background */
val pollInBackground: Boolean = false,
/** Challenge expiry time (seconds) */
val challengeExpirySeconds: Long = 24 * 60 * 60L,
/** Cleanup interval (ms) */
val cleanupInterval: Long = 5 * 60 * 1000L,
)
/**
* Platform-specific defaults
*/
object ChessPollingDefaults {
/** Android: moderate intervals, no background polling */
val android =
ChessPollingConfig(
activeGamePollInterval = 5_000L, // 5 seconds for responsive gameplay
challengePollInterval = 15_000L, // 15 seconds for challenges
pollInBackground = false,
)
/** Desktop: fast polling for responsive gameplay */
val desktop =
ChessPollingConfig(
activeGamePollInterval = 2_000L, // 2 seconds for fast updates
challengePollInterval = 10_000L, // 10 seconds for challenges
pollInBackground = true,
)
}
/**
* Delegate for managing chess event polling
*
* Usage:
* ```
* class ChessViewModel(...) : ViewModel() {
* private val pollingDelegate = ChessPollingDelegate(
* config = ChessPollingDefaults.android,
* scope = viewModelScope,
* onRefreshGames = { gameIds -> refreshGamesFromCache(gameIds) },
* onRefreshChallenges = { refreshChallengesFromCache() },
* )
*
* fun startPolling() = pollingDelegate.start()
* fun stopPolling() = pollingDelegate.stop()
* }
* ```
*/
class ChessPollingDelegate(
private val config: ChessPollingConfig,
private val scope: CoroutineScope,
private val onRefreshGames: suspend (Set<String>) -> Unit,
private val onRefreshChallenges: suspend () -> Unit,
private val onCleanup: suspend () -> Unit = {},
) {
private var gamePollingJob: Job? = null
private var challengePollingJob: Job? = null
private var cleanupJob: Job? = null
private var manualRefreshJob: Job? = null
private val _isPolling = MutableStateFlow(false)
val isPolling: StateFlow<Boolean> = _isPolling.asStateFlow()
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
internal val activeGameIdsFlow = MutableStateFlow<Set<String>>(emptySet())
/**
* Focused game ID - when set, only this game is polled for updates.
* Used when viewing a specific game screen to avoid refreshing unrelated games.
* When null, all games in activeGameIdsFlow are polled (lobby mode).
*/
private val _focusedGameId = MutableStateFlow<String?>(null)
/**
* Set focused game mode - only poll this specific game.
* Call this when entering a game screen.
* Pass null to return to lobby mode (poll all games).
*/
fun setFocusedGame(gameId: String?) {
_focusedGameId.value = gameId
}
/**
* Get current focused game ID (null = lobby mode, polls all games)
*/
fun getFocusedGameId(): String? = _focusedGameId.value
/**
* Get the effective game IDs to poll based on focused mode.
* In focused mode: only the focused game.
* In lobby mode: all active games.
*/
private fun getEffectiveGameIds(): Set<String> {
val focused = _focusedGameId.value
return if (focused != null) {
// Focused mode - only poll this game
setOf(focused)
} else {
// Lobby mode - poll all games
activeGameIdsFlow.value
}
}
/**
* Update the set of game IDs to poll for
*/
fun setActiveGameIds(gameIds: Set<String>) {
activeGameIdsFlow.value = gameIds
}
/**
* Add a game ID to poll for
*/
fun addGameId(gameId: String) {
activeGameIdsFlow.value = activeGameIdsFlow.value + gameId
}
/**
* Remove a game ID from polling
*/
fun removeGameId(gameId: String) {
activeGameIdsFlow.value = activeGameIdsFlow.value - gameId
}
/**
* Start polling for chess events
*/
fun start() {
if (_isPolling.value) {
return
}
_isPolling.value = true
// Poll for active games
gamePollingJob =
scope.launch {
while (isActive) {
val gameIds = getEffectiveGameIds()
if (gameIds.isNotEmpty()) {
try {
onRefreshGames(gameIds)
} catch (_: Exception) {
// Error during refresh - continue polling
}
}
delay(config.activeGamePollInterval)
}
}
// Poll for challenges (only in lobby mode)
challengePollingJob =
scope.launch {
// Initial fetch (only if not in focused mode)
if (_focusedGameId.value == null) {
onRefreshChallenges()
}
while (isActive) {
delay(config.challengePollInterval)
// Skip challenge polling in focused mode - game screen doesn't need it
if (_focusedGameId.value == null) {
onRefreshChallenges()
}
}
}
// Cleanup job
cleanupJob =
scope.launch {
while (isActive) {
delay(config.cleanupInterval)
onCleanup()
}
}
}
/**
* Stop polling
*/
fun stop() {
_isPolling.value = false
gamePollingJob?.cancel()
challengePollingJob?.cancel()
cleanupJob?.cancel()
gamePollingJob = null
challengePollingJob = null
cleanupJob = null
}
/**
* Pause polling (e.g., when app goes to background on Android)
*/
fun pause() {
if (!config.pollInBackground) {
stop()
}
}
/**
* Resume polling (e.g., when app comes to foreground on Android)
*/
fun resume() {
if (!_isPolling.value) {
start()
}
}
/**
* Force an immediate refresh (debounced - ignores calls if refresh already in progress)
*/
fun refreshNow() {
// Debounce: skip if already refreshing
if (_isRefreshing.value) {
return
}
// Cancel any pending manual refresh
manualRefreshJob?.cancel()
manualRefreshJob =
scope.launch {
_isRefreshing.value = true
try {
// In focused mode, skip challenge refresh (game screen doesn't need it)
val focusedId = _focusedGameId.value
if (focusedId == null) {
onRefreshChallenges()
}
val gameIds = getEffectiveGameIds()
if (gameIds.isNotEmpty()) {
onRefreshGames(gameIds)
}
} finally {
_isRefreshing.value = false
}
}
}
}
/**
* Interface for chess event sources that can be refreshed
*/
interface ChessEventSource {
/**
* Fetch/refresh challenges from the event source
*/
suspend fun fetchChallenges(): List<ChessChallengeData>
/**
* Fetch/refresh game state for specific game IDs
*/
suspend fun fetchGameUpdates(gameIds: Set<String>): Map<String, ChessGameUpdate>
}
/**
* Data class representing a chess challenge
*/
data class ChessChallengeData(
val id: String,
val gameId: String,
val challengerPubkey: String,
val opponentPubkey: String?,
val challengerColor: com.vitorpamplona.quartz.nip64Chess.Color,
val createdAt: Long,
val isExpired: Boolean = false,
)
/**
* Data class representing a game update
*/
data class ChessGameUpdate(
val gameId: String,
val moves: List<ChessMoveData>,
val isEnded: Boolean = false,
val endReason: String? = null,
)
/**
* Data class representing a chess move
*/
data class ChessMoveData(
val san: String,
val fen: String,
val moveNumber: Int,
val playerPubkey: String,
val timestamp: Long,
)
@@ -0,0 +1,182 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessStateReconstructor
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import com.vitorpamplona.quartz.nip64Chess.ReconstructedGameState
import com.vitorpamplona.quartz.nip64Chess.ReconstructionResult
import com.vitorpamplona.quartz.nip64Chess.ViewerRole
/**
* Converts a ReconstructedGameState (from ChessStateReconstructor) into a
* LiveChessGameState (used by ViewModels for reactive updates).
*
* This bridges the gap between the deterministic reconstruction algorithm
* and the stateful game management that ViewModels need.
*
* Usage:
* ```
* // Collect events from any source
* val collector = ChessEventCollector(startEventId)
* collector.addEvent(startEvent)
* collector.addEvent(moveEvent1)
* collector.addEvent(moveEvent2)
*
* // Reconstruct using shared algorithm
* val result = ChessStateReconstructor.reconstruct(collector.getEvents(), viewerPubkey)
*
* // Convert to LiveChessGameState for ViewModel use
* val gameState = ChessGameLoader.toLiveGameState(result, viewerPubkey)
* ```
*/
object ChessGameLoader {
/**
* Convert a ReconstructionResult to a LiveChessGameState for ViewModel use.
*
* @param result The result from ChessStateReconstructor
* @param viewerPubkey The pubkey of the user viewing the game
* @return LiveChessGameState or null if reconstruction failed
*/
fun toLiveGameState(
result: ReconstructionResult,
viewerPubkey: String,
): LiveChessGameState? {
if (result !is ReconstructionResult.Success) return null
val state = result.state
val engine = result.engine
val (playerPubkey, opponentPubkey) =
when (state.viewerRole) {
ViewerRole.WHITE_PLAYER -> viewerPubkey to (state.blackPubkey ?: "")
ViewerRole.BLACK_PLAYER -> viewerPubkey to (state.whitePubkey ?: "")
ViewerRole.SPECTATOR -> viewerPubkey to (state.blackPubkey ?: "")
}
return LiveChessGameState(
startEventId = state.startEventId,
playerPubkey = playerPubkey,
opponentPubkey = opponentPubkey,
playerColor = state.playerColor,
engine = engine,
createdAt = state.challengeCreatedAt,
isSpectator = state.viewerRole == ViewerRole.SPECTATOR,
isPendingChallenge = state.isPendingChallenge,
initialHeadEventId = state.headEventId,
).also { gameState ->
// Mark all loaded moves as received to prevent re-application during polling
gameState.markMovesAsReceived(state.appliedMoveNumbers)
// Mark finished if the game has ended
if (state.isFinished() && state.gameStatus is com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished) {
gameState.markAsFinished(
(state.gameStatus as com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished).result,
)
}
// Handle pending draw offer
val drawOfferer = state.pendingDrawOffer
if (drawOfferer != null && drawOfferer != viewerPubkey) {
gameState.receiveDrawOffer(drawOfferer)
}
}
}
/**
* Load a game using the deterministic reconstruction algorithm.
*
* @param events Collected Jester events for the game
* @param viewerPubkey The pubkey of the user viewing the game
* @return Pair of (LiveChessGameState, ReconstructedGameState) or null if failed
*/
fun loadGame(
events: JesterGameEvents,
viewerPubkey: String,
): LoadGameResult {
val result = ChessStateReconstructor.reconstruct(events, viewerPubkey)
return when (result) {
is ReconstructionResult.Success -> {
val liveState = toLiveGameState(result, viewerPubkey)
if (liveState != null) {
LoadGameResult.Success(liveState, result.state)
} else {
LoadGameResult.Error("Failed to convert reconstructed state to live state")
}
}
is ReconstructionResult.Error -> {
LoadGameResult.Error(result.message)
}
}
}
/**
* Create a new game state without any events (for when accepting a challenge locally).
*
* @param startEventId The start event ID (game identifier)
* @param playerPubkey The player's pubkey
* @param opponentPubkey The opponent's pubkey
* @param playerColor The player's color
* @return A fresh LiveChessGameState
*/
fun createNewGame(
startEventId: String,
playerPubkey: String,
opponentPubkey: String,
playerColor: Color,
isPendingChallenge: Boolean = false,
): LiveChessGameState {
val engine = ChessEngine()
engine.reset()
return LiveChessGameState(
startEventId = startEventId,
playerPubkey = playerPubkey,
opponentPubkey = opponentPubkey,
playerColor = playerColor,
engine = engine,
isPendingChallenge = isPendingChallenge,
)
}
}
/**
* Result of loading a game.
*/
sealed class LoadGameResult {
data class Success(
val liveState: LiveChessGameState,
val reconstructedState: ReconstructedGameState,
) : LoadGameResult()
data class Error(
val message: String,
) : LoadGameResult()
fun isSuccess(): Boolean = this is Success
fun getOrNull(): LiveChessGameState? = (this as? Success)?.liveState
}
@@ -0,0 +1,172 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.vitorpamplona.quartz.nip64Chess.PGNParser
/**
* Complete chess game viewer with board, metadata, and navigation
* Shared composable for Android and Desktop platforms
*
* Parses PGN content and displays:
* - Game metadata (event, players, result)
* - Interactive chess board
* - Move navigation controls
*
* @param pgnContent PGN format string
* @param modifier Modifier for the viewer
*/
@Composable
fun ChessGameViewer(
pgnContent: String,
modifier: Modifier = Modifier,
) {
val gameResult =
remember(pgnContent) {
PGNParser.parse(pgnContent)
}
gameResult.fold(
onSuccess = { game ->
ChessGameDisplay(game, modifier)
},
onFailure = { error ->
ChessGameError(
errorMessage = error.message ?: "Failed to parse PGN",
pgnContent = pgnContent,
modifier = modifier,
)
},
)
}
/**
* Displays a successfully parsed chess game
*/
@Composable
private fun ChessGameDisplay(
game: com.vitorpamplona.quartz.nip64Chess.ChessGame,
modifier: Modifier = Modifier,
) {
var currentMoveIndex by remember { mutableStateOf(0) }
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Game metadata header
PGNMetadata(game = game)
// Chess board
ChessBoard(
position = game.positionAt(currentMoveIndex),
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(1f),
boardSize = 320.dp,
)
// Move navigation
MoveNavigator(
currentMove = currentMoveIndex,
totalMoves = game.moves.size,
onMoveChange = { currentMoveIndex = it },
)
}
}
}
/**
* Displays error state when PGN parsing fails
*/
@Composable
private fun ChessGameError(
errorMessage: String,
pgnContent: String,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Error title
Text(
text = "Invalid Chess Game",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error,
)
// Error message
Text(
text = errorMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
// PGN content label
Text(
text = "PGN Content:",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(top = 4.dp),
)
// Raw PGN content
Text(
text = pgnContent,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
@@ -0,0 +1,342 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
// Color constants for card borders
private val IncomingChallengeColor = Color(0xFFFF9800) // Orange
private val OpenChallengeColor = Color(0xFF4CAF50) // Green
private val SpectatingColor = Color(0xFF9C27B0) // Purple
private val LiveGameColor = Color(0xFF2196F3) // Blue
/**
* Card for an active game where the user is a participant
*/
@Composable
fun ActiveGameCard(
gameId: String,
opponentName: String,
isYourTurn: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
avatar: @Composable (() -> Unit)? = null,
) {
val gameName =
remember(gameId) {
ChessGameNameGenerator.extractDisplayName(gameId) ?: gameId.take(12)
}
Card(
modifier = modifier.fillMaxWidth().clickable(onClick = onClick),
border =
if (isYourTurn) {
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
} else {
null
},
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
avatar?.invoke()
Column(modifier = Modifier.weight(1f)) {
Text(
gameName,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
"vs $opponentName",
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
if (isYourTurn) "Your turn" else "Waiting...",
style = MaterialTheme.typography.bodyMedium,
color = if (isYourTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
/**
* Card for an incoming or open challenge
*/
@Composable
fun ChallengeCard(
challengerName: String,
challengerPlaysWhite: Boolean,
isIncoming: Boolean,
onAccept: () -> Unit,
modifier: Modifier = Modifier,
avatar: @Composable (() -> Unit)? = null,
) {
val borderColor = if (isIncoming) IncomingChallengeColor else OpenChallengeColor
Card(
modifier = modifier.fillMaxWidth(),
border = BorderStroke(2.dp, borderColor),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
avatar?.invoke()
Column(modifier = Modifier.weight(1f)) {
Text(
if (isIncoming) "Challenge from $challengerName" else "Open challenge by $challengerName",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
"Challenger plays ${if (challengerPlaysWhite) "White" else "Black"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Button(onClick = onAccept) {
Text("Accept")
}
}
}
}
/**
* Card for user's outgoing challenge (waiting for acceptance)
* Clickable so user can open the game board and make the first move when ready
*/
@Composable
fun OutgoingChallengeCard(
opponentName: String?,
userPlaysWhite: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
avatar: @Composable (() -> Unit)? = null,
) {
Card(
modifier = modifier.fillMaxWidth().clickable(onClick = onClick),
border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
avatar?.invoke()
Column(modifier = Modifier.weight(1f)) {
Text(
if (opponentName != null) {
"Challenge to $opponentName"
} else {
"Open challenge (awaiting opponent)"
},
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
"You play ${if (userPlaysWhite) "White" else "Black"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
"Waiting...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* Card for a game the user is spectating
*/
@Composable
fun SpectatingGameCard(
moveCount: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth().clickable(onClick = onClick),
border = BorderStroke(1.dp, SpectatingColor),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Watching game",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
"$moveCount moves played",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
"Spectating",
style = MaterialTheme.typography.bodyMedium,
color = SpectatingColor,
fontWeight = FontWeight.Medium,
)
}
}
}
/**
* Card for a public live game that can be watched
*/
@Composable
fun PublicGameCard(
whiteName: String,
blackName: String,
moveCount: Int,
onWatch: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
border = BorderStroke(1.dp, LiveGameColor),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"$whiteName vs $blackName",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
"$moveCount moves",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(onClick = onWatch) {
Text("Watch")
}
}
}
}
/**
* Card for a completed game in history
*/
@Composable
fun CompletedGameCard(
opponentName: String,
result: String,
didUserWin: Boolean,
isDraw: Boolean,
moveCount: Int,
modifier: Modifier = Modifier,
avatar: @Composable (() -> Unit)? = null,
) {
val resultText =
when {
isDraw -> "Draw"
didUserWin -> "Won"
else -> "Lost"
}
val resultColor =
when {
isDraw -> MaterialTheme.colorScheme.onSurfaceVariant
didUserWin -> Color(0xFF4CAF50)
// Green
else -> Color(0xFFF44336) // Red
}
Card(
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
avatar?.invoke()
Column(modifier = Modifier.weight(1f)) {
Text(
"vs $opponentName",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
"$moveCount moves • $result",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
resultText,
style = MaterialTheme.typography.bodyMedium,
color = resultColor,
fontWeight = FontWeight.Bold,
)
}
}
}
@@ -0,0 +1,845 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents
import com.vitorpamplona.quartz.nip64Chess.PieceType
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Interface for platform-specific chess event publishing using Jester protocol.
*
* All chess events use kind 30 with JSON content:
* - Start events: content.kind=0
* - Move events: content.kind=1 with full history
*/
interface ChessEventPublisher {
/**
* Publish a game start event (challenge).
* Returns the startEventId (event ID) if successful.
*/
suspend fun publishStart(
playerColor: Color,
opponentPubkey: String?,
): String?
/**
* Publish a move event.
* Move events include full history and link to previous move.
*/
suspend fun publishMove(move: ChessMoveEvent): String?
/**
* Publish a game end event (includes result in content).
*/
suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean
/**
* Get count of write relays for UI feedback.
*/
fun getWriteRelayCount(): Int
}
/**
* Relay-first fetcher interface for Jester protocol.
* Platforms provide one-shot relay queries.
*
* Every fetch does: one-shot REQ → collect events → EOSE → close.
* No caching — relays are the single source of truth.
*/
interface ChessRelayFetcher {
/** Fetch all events for a specific game by startEventId */
suspend fun fetchGameEvents(startEventId: String): JesterGameEvents
/** Fetch recent start/challenge events with optional progress callback */
suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)? = null): List<JesterEvent>
/** Fetch recent public game summaries for spectating */
suspend fun fetchRecentGames(): List<RelayGameSummary>
/** Fetch game IDs (startEventIds) where user is a participant */
suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)? = null): Set<String>
/** Get the list of relay URLs that will be used for fetching */
fun getRelayUrls(): List<String>
}
/**
* Summary of a game found on relays (for lobby display / spectating)
*/
data class RelayGameSummary(
val startEventId: String,
val whitePubkey: String,
val blackPubkey: String,
val moveCount: Int,
val lastMoveTime: Long,
val isActive: Boolean,
) {
// Legacy compatibility
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val gameId: String get() = startEventId
}
/**
* Shared chess lobby logic — relay-first architecture with Jester protocol.
*
* 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()
}
/**
* Ensure a game ID is being polled for updates.
* Call this when entering a game screen to guarantee polling is active for that game.
*/
fun ensureGamePolling(gameId: String) {
pollingDelegate.addGameId(gameId)
}
/**
* Set focused game mode - only poll this specific game.
* Call this when entering a game screen to avoid refreshing unrelated games.
* Also ensures the game is in the polling set.
*/
fun setFocusedGame(gameId: String) {
pollingDelegate.addGameId(gameId)
pollingDelegate.setFocusedGame(gameId)
}
/**
* Clear focused game mode - return to lobby mode (poll all games).
* Call this when returning to the lobby screen.
*/
fun clearFocusedGame() {
pollingDelegate.setFocusedGame(null)
}
fun forceRefresh() {
pollingDelegate.refreshNow()
}
// ========================================
// Incoming event routing (real-time / optimistic)
// ========================================
/**
* Route an incoming Jester event to the appropriate handler.
* Called by platform subscription callbacks for real-time updates.
*/
fun handleIncomingEvent(event: JesterEvent) {
when {
event.isStartEvent() -> handleStartEvent(event)
event.isMoveEvent() -> handleMoveEvent(event)
}
}
private fun handleStartEvent(event: JesterEvent) {
val startEventId = event.id
val challengerColor = event.playerColor() ?: Color.WHITE
val challenge =
ChessChallenge(
eventId = event.id,
gameId = startEventId, // In Jester, gameId = startEventId
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 handleMoveEvent(event: JesterEvent) {
val startEventId = event.startEventId() ?: return
val san = event.move() ?: return
val fen = event.fen() ?: return
val history = event.history()
val moveNumber = history.size
// Check if this is our game (we're either the author or tagged as opponent)
val opponentFromTag = event.opponentPubkey()
val isOurGame = event.pubKey == userPubkey || opponentFromTag == userPubkey
var gameState = state.getGameState(startEventId)
// If this is our game but we haven't loaded it yet, load it now
// This happens when someone accepts our challenge (makes first move)
if (gameState == null && isOurGame) {
handleGameAccepted(startEventId)
return // handleGameAccepted will load the game and poll for events
}
if (gameState == null) return
// Check for game end
val result = event.result()
if (result != null) {
val gameResult =
when (result) {
"1-0" -> com.vitorpamplona.quartz.nip64Chess.GameResult.WHITE_WINS
"0-1" -> com.vitorpamplona.quartz.nip64Chess.GameResult.BLACK_WINS
"1/2-1/2" -> com.vitorpamplona.quartz.nip64Chess.GameResult.DRAW
else -> null
}
if (gameResult != null) {
gameState.markAsFinished(gameResult)
state.moveToCompleted(startEventId, result, event.termination())
pollingDelegate.removeGameId(startEventId)
return
}
}
// Only apply opponent moves optimistically
if (event.pubKey != userPubkey) {
gameState.applyOpponentMove(san, fen, moveNumber)
// Update head event ID for move linking
gameState.updateHeadEventId(event.id)
}
}
// ========================================
// Challenge operations
// ========================================
fun createChallenge(
opponentPubkey: String? = null,
playerColor: Color = Color.WHITE,
timeControl: String? = null, // Not supported in Jester, kept for API compatibility
) {
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(
ChessBroadcastStatus.Broadcasting(
san = "Challenge",
successCount = 0,
totalRelays = publisher.getWriteRelayCount(),
),
)
val startEventId = retryWithBackoffResult { publisher.publishStart(playerColor, opponentPubkey) }
if (startEventId != null) {
// Add challenge to local state - shows in "Your Challenges" section
val challenge =
ChessChallenge(
eventId = startEventId,
gameId = startEventId,
challengerPubkey = userPubkey,
challengerDisplayName = metadataProvider.getDisplayName(userPubkey),
challengerAvatarUrl = metadataProvider.getPictureUrl(userPubkey),
opponentPubkey = opponentPubkey,
challengerColor = playerColor,
createdAt = TimeUtils.now(),
)
state.addChallenge(challenge)
pollingDelegate.addGameId(startEventId)
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")
}
}
}
/**
* Accept a challenge by loading the game and making the first move (if we're black)
* or waiting for opponent's move (if we're white).
*
* In Jester protocol, acceptance is implicit - we just track the game locally.
*/
fun acceptChallenge(challenge: ChessChallenge) {
// Mark as accepted SYNCHRONOUSLY before launching coroutine
// This prevents race where navigation happens before coroutine runs,
// which would cause loadGame() to incorrectly mark as spectator
state.markAsAccepted(challenge.gameId)
state.removeChallenge(challenge.gameId)
// Add to polling delegate FIRST - before adding to activeGames
// This prevents race where Compose sees the game but forceRefresh() doesn't include it
pollingDelegate.addGameId(challenge.gameId)
scope.launch(Dispatchers.Default) {
val playerColor = challenge.challengerColor.opposite()
val gameState =
ChessGameLoader.createNewGame(
startEventId = challenge.gameId, // gameId = startEventId in Jester
playerPubkey = userPubkey,
opponentPubkey = challenge.challengerPubkey,
playerColor = playerColor,
)
state.addActiveGame(challenge.gameId, gameState)
state.selectGame(challenge.gameId)
state.setError(null)
// Immediately fetch game from relays to load any existing moves
// This ensures moves are loaded without waiting for polling interval
refreshGame(challenge.gameId)
}
}
/**
* Open user's own outgoing challenge to view the board and make moves.
* Creates game state and navigates to game view.
*/
fun openOwnChallenge(challenge: ChessChallenge) {
// Add to polling delegate FIRST - before adding to activeGames
pollingDelegate.addGameId(challenge.gameId)
// Create game state with user's chosen color
val gameState =
ChessGameLoader.createNewGame(
startEventId = challenge.gameId,
playerPubkey = userPubkey,
opponentPubkey = challenge.opponentPubkey ?: "",
playerColor = challenge.challengerColor,
isPendingChallenge = true,
)
state.addActiveGame(challenge.gameId, gameState)
state.selectGame(challenge.gameId)
// Fetch from relays in case opponent has already made moves
scope.launch(Dispatchers.Default) {
refreshGame(challenge.gameId)
}
}
/**
* When we detect our challenge was accepted (opponent made first move), load game from relays.
*/
fun handleGameAccepted(startEventId: String) {
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
val events = fetcher.fetchGameEvents(startEventId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
state.addActiveGame(startEventId, result.liveState)
pollingDelegate.addGameId(startEventId)
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(
startEventId: String,
from: String,
to: String,
) {
val gameState = state.getGameState(startEventId) ?: return
if (state.isSpectating(startEventId)) {
state.setError("Cannot move while spectating")
return
}
// Parse promotion suffix from `to` if present (e.g., "e8q" -> "e8" + QUEEN)
val (actualTo, promotion) = parsePromotionFromTo(to)
val moveResult = gameState.makeMove(from, actualTo, promotion) ?: return
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(
ChessBroadcastStatus.Broadcasting(
san = moveResult.san,
successCount = 0,
totalRelays = publisher.getWriteRelayCount(),
),
)
val newEventId = retryWithBackoffResult { publisher.publishMove(moveResult) }
if (newEventId != null) {
// Update head event ID for next move linking
gameState.updateHeadEventId(newEventId)
state.setBroadcastStatus(
ChessBroadcastStatus.Success(moveResult.san, publisher.getWriteRelayCount()),
)
delay(3000)
val currentState = state.getGameState(startEventId)
state.setBroadcastStatus(
if (currentState?.isPlayerTurn() == false) {
ChessBroadcastStatus.WaitingForOpponent
} else {
ChessBroadcastStatus.Idle
},
)
state.setError(null)
} else {
// Revert the move since publishing failed
gameState.undoLastMove()
state.setBroadcastStatus(
ChessBroadcastStatus.Failed(moveResult.san, "Failed to publish move"),
)
state.setError("Failed to publish move - move reverted")
}
}
}
fun resign(startEventId: String) {
val gameState = state.getGameState(startEventId) ?: return
if (state.isSpectating(startEventId)) {
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(startEventId, endData.result.notation, endData.termination.name.lowercase())
pollingDelegate.removeGameId(startEventId)
state.setError(null)
} else {
state.setError("Failed to resign")
}
}
}
fun claimAbandonmentVictory(startEventId: String) {
val gameState = state.getGameState(startEventId) ?: return
val endData = gameState.claimAbandonmentVictory() ?: return
scope.launch(Dispatchers.Default) {
val success = retryWithBackoff { publisher.publishGameEnd(endData) }
if (success) {
state.moveToCompleted(startEventId, endData.result.notation, "abandonment")
pollingDelegate.removeGameId(startEventId)
state.setError(null)
} else {
state.setError("Failed to claim abandonment victory")
}
}
}
// ========================================
// Spectator mode
// ========================================
fun loadGameAsSpectator(startEventId: String) {
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
val events = fetcher.fetchGameEvents(startEventId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
state.addSpectatingGame(startEventId, result.liveState)
pollingDelegate.addGameId(startEventId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
state.setError(null)
// Auto-select the game for Desktop (Android uses route navigation)
state.selectGame(startEventId)
}
is LoadGameResult.Error -> {
state.setError("Failed to load game: ${result.message}")
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
}
}
}
}
fun loadGame(startEventId: String) {
scope.launch(Dispatchers.Default) {
// Don't load if game already exists or was accepted (acceptChallenge will handle it)
if (state.getGameState(startEventId) != null || state.wasAccepted(startEventId)) {
return@launch
}
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
val events = fetcher.fetchGameEvents(startEventId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
if (result.liveState.isSpectator) {
state.addSpectatingGame(startEventId, result.liveState)
} else {
state.addActiveGame(startEventId, result.liveState)
}
pollingDelegate.addGameId(startEventId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
state.setError(null)
}
is LoadGameResult.Error -> {
// Check again if game was added while we were fetching
// (e.g., by acceptChallenge completing in parallel)
if (state.getGameState(startEventId) != null) {
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
return@launch
}
state.setError("Failed to load game: ${result.message}")
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
}
}
}
}
// ========================================
// Relay-first refresh (periodic reconstruction)
// ========================================
private suspend fun refreshGames(gameIds: Set<String>) {
for (gameId in gameIds) {
refreshGame(gameId)
}
}
private suspend fun refreshGame(startEventId: String) {
val events = fetcher.fetchGameEvents(startEventId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
state.replaceGameState(startEventId, result.liveState)
}
is LoadGameResult.Error -> {
// Don't overwrite error for periodic refresh failures
}
}
}
private suspend fun refreshChallenges() {
state.setRefreshing(true)
try {
refreshChallengesInternal()
} finally {
state.setRefreshing(false)
}
}
private suspend fun refreshChallengesInternal() {
val relayUrls = fetcher.getRelayUrls()
val relayStatesMap = mutableMapOf<String, RelaySyncState>()
relayUrls.forEach { url ->
val displayName = url.substringAfter("://").substringBefore("/")
relayStatesMap[url] = RelaySyncState(url, displayName, RelaySyncStatus.CONNECTING, 0)
}
var totalEvents = 0
state.setSyncStatus(
ChessSyncStatus.Syncing(
phase = "challenges",
relayStates = relayStatesMap.values.toList(),
totalEventsReceived = 0,
),
)
val startEvents =
fetcher.fetchChallenges { progress ->
val relayUrl = progress.relay.url
val displayName = relayUrl.substringAfter("://").substringBefore("/")
val status =
when (progress.status) {
RelayFetchStatus.WAITING -> RelaySyncStatus.WAITING
RelayFetchStatus.RECEIVING -> RelaySyncStatus.RECEIVING
RelayFetchStatus.EOSE_RECEIVED -> RelaySyncStatus.EOSE_RECEIVED
RelayFetchStatus.TIMEOUT -> RelaySyncStatus.FAILED
}
relayStatesMap[relayUrl] =
RelaySyncState(
relayUrl,
displayName,
status,
progress.eventCount,
)
totalEvents = relayStatesMap.values.sumOf { it.eventsReceived }
state.setSyncStatus(
ChessSyncStatus.Syncing(
phase = "challenges",
relayStates = relayStatesMap.values.toList(),
totalEventsReceived = totalEvents,
),
)
}
val fetchedChallenges =
startEvents.mapNotNull { event ->
if (!event.isStartEvent()) return@mapNotNull null
val startEventId = event.id
// Skip challenges that user has already accepted
if (state.wasAccepted(startEventId)) return@mapNotNull null
val challengerColor = event.playerColor() ?: Color.WHITE
ChessChallenge(
eventId = event.id,
gameId = startEventId,
challengerPubkey = event.pubKey,
challengerDisplayName = metadataProvider.getDisplayName(event.pubKey),
challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey),
opponentPubkey = event.opponentPubkey(),
challengerColor = challengerColor,
createdAt = event.createdAt,
)
}
// Merge only RECENT optimistic challenges (created in last 5 minutes, not yet propagated)
// This prevents stale challenges from accumulating across sessions
// Also exclude challenges that user has accepted (tracked in _acceptedGameIds)
val now = TimeUtils.now()
val recentThreshold = 5 * 60L // 5 minutes
val fetchedGameIds = fetchedChallenges.map { it.gameId }.toSet()
val optimisticChallenges =
state.challenges.value.filter { challenge ->
val isRecent = (now - challenge.createdAt) < recentThreshold
val notFetched = challenge.gameId !in fetchedGameIds
val notAccepted = !state.wasAccepted(challenge.gameId)
isRecent && notFetched && notAccepted
}
val mergedChallenges = fetchedChallenges + optimisticChallenges
state.updateChallenges(mergedChallenges)
state.setSyncStatus(
ChessSyncStatus.Syncing(
phase = "games",
relayStates = relayStatesMap.values.toList(),
totalEventsReceived = totalEvents,
),
)
discoverUserGames()
val recentGames = fetcher.fetchRecentGames()
val publicGames =
recentGames.map { summary ->
PublicGame(
gameId = summary.startEventId,
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)
val failedCount = relayStatesMap.values.count { it.status == RelaySyncStatus.FAILED }
val activeGamesCount = state.activeGames.value.size
if (failedCount > 0 && failedCount < relayStatesMap.size) {
state.setSyncStatus(
ChessSyncStatus.PartialSync(
relayStates = relayStatesMap.values.toList(),
message = "$failedCount relay(s) timed out",
),
)
} else if (failedCount == relayStatesMap.size) {
state.setSyncStatus(
ChessSyncStatus.PartialSync(
relayStates = relayStatesMap.values.toList(),
message = "All relays failed",
),
)
} else {
state.setSyncStatus(
ChessSyncStatus.Synced(
relayStates = relayStatesMap.values.toList(),
challengeCount = mergedChallenges.size,
gameCount = activeGamesCount,
totalEventsReceived = totalEvents,
),
)
}
}
private suspend fun discoverUserGames() {
val discoveredGameIds = fetcher.fetchUserGameIds()
val currentActiveIds = state.activeGames.value.keys
val currentSpectatingIds = state.spectatingGames.value.keys
val newGameIds = discoveredGameIds - currentActiveIds - currentSpectatingIds
for (startEventId in newGameIds) {
val events = fetcher.fetchGameEvents(startEventId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
if (!result.liveState.isSpectator) {
state.addActiveGame(startEventId, result.liveState)
pollingDelegate.addGameId(startEventId)
}
}
is LoadGameResult.Error -> {
// Failed to load game - continue with others
}
}
}
}
private fun cleanupExpiredChallenges() {
val now = TimeUtils.now()
val validChallenges =
state.challenges.value.filter { challenge ->
(now - challenge.createdAt) < CHALLENGE_EXPIRY_SECONDS
}
state.updateChallenges(validChallenges)
}
// ========================================
// Utilities
// ========================================
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
}
private suspend fun <T> retryWithBackoffResult(
maxRetries: Int = 3,
initialDelayMs: Long = 1000,
action: suspend () -> T?,
): T? {
var delayMs = initialDelayMs
repeat(maxRetries) { attempt ->
val result = action()
if (result != null) return result
if (attempt < maxRetries - 1) {
delay(delayMs)
delayMs *= 2
}
}
return null
}
fun clearError() {
state.setError(null)
}
fun selectGame(gameId: String?) {
state.selectGame(gameId)
}
}
/**
* Parse promotion piece from a "to" square string.
* For example: "e8q" -> Pair("e8", PieceType.QUEEN)
* Regular moves: "e4" -> Pair("e4", null)
*/
private fun parsePromotionFromTo(to: String): Pair<String, PieceType?> {
if (to.length == 3) {
val square = to.take(2)
val promotion =
when (to.last().lowercaseChar()) {
'q' -> PieceType.QUEEN
'r' -> PieceType.ROOK
'b' -> PieceType.BISHOP
'n' -> PieceType.KNIGHT
else -> null
}
if (promotion != null) {
return square to promotion
}
}
return to to null
}
@@ -0,0 +1,518 @@
/*
* 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
import java.util.concurrent.atomic.AtomicLong
/**
* 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"
}
/**
* Individual relay status during sync
*/
@Immutable
data class RelaySyncState(
val url: String,
val displayName: String,
val status: RelaySyncStatus,
val eventsReceived: Int = 0,
)
@Immutable
enum class RelaySyncStatus {
CONNECTING,
WAITING,
RECEIVING,
EOSE_RECEIVED,
FAILED,
}
/**
* Sync status for incoming events (subscription-side)
*/
@Immutable
sealed class ChessSyncStatus {
data object Idle : ChessSyncStatus()
data class Syncing(
val phase: String,
val relayStates: List<RelaySyncState>,
val totalEventsReceived: Int,
) : ChessSyncStatus() {
val connectedCount: Int get() = relayStates.count { it.status != RelaySyncStatus.FAILED }
val eoseCount: Int get() = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED }
val totalCount: Int get() = relayStates.size
}
data class Synced(
val relayStates: List<RelaySyncState>,
val challengeCount: Int,
val gameCount: Int,
val totalEventsReceived: Int,
) : ChessSyncStatus() {
val successCount: Int get() = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED }
val totalCount: Int get() = relayStates.size
}
data class PartialSync(
val relayStates: List<RelaySyncState>,
val message: String,
) : ChessSyncStatus() {
val successCount: Int get() = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED }
val failedCount: Int get() = relayStates.count { it.status == RelaySyncStatus.FAILED }
val totalCount: Int get() = relayStates.size
}
}
/**
* Chess status for UI feedback
*/
@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()
// Game IDs that user has accepted - uses global singleton to share across ViewModel instances
// This is critical because lobby and game screen may have different ViewModel instances
// Broadcast status
private val _broadcastStatus = MutableStateFlow<ChessBroadcastStatus>(ChessBroadcastStatus.Idle)
val broadcastStatus: StateFlow<ChessBroadcastStatus> = _broadcastStatus.asStateFlow()
// Error state
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
// Loading/refreshing state
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
// Sync status for subscription banner
private val _syncStatus = MutableStateFlow<ChessSyncStatus>(ChessSyncStatus.Idle)
val syncStatus: StateFlow<ChessSyncStatus> = _syncStatus.asStateFlow()
// Selected game ID for navigation
private val _selectedGameId = MutableStateFlow<String?>(null)
val selectedGameId: StateFlow<String?> = _selectedGameId.asStateFlow()
// State version counter - increments on every game state update
// UI can observe this to force recomposition when internal state changes
private val stateVersionCounter = AtomicLong(0)
private val _stateVersion = MutableStateFlow(0L)
val stateVersion: StateFlow<Long> = _stateVersion.asStateFlow()
// Badge count (incoming challenges + your turn games)
val badgeCount: Int
get() {
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 { current ->
val existing = current[gameId]
if (existing != null) {
// Only replace if new state has at least as many moves
val existingMoves = existing.moveHistory.value.size
val newMoves = state.moveHistory.value.size
if (newMoves < existingMoves) {
return@update current
}
}
current + (gameId to state)
}
// Track as accepted to prevent refresh from re-adding as optimistic challenge
AcceptedGamesRegistry.markAsAccepted(gameId)
// Remove from challenges if present
removeChallenge(gameId)
}
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).
*
* IMPORTANT: Only replaces if new state has >= moves than current state.
* This prevents race conditions where polling refresh could revert
* a user's move before it propagates to relays.
*
* IMPORTANT: Never replace a participant game with a spectator state.
* This prevents the race where relay fetch returns before acceptance propagates,
* which would incorrectly mark an accepted game as spectating.
*/
fun replaceGameState(
gameId: String,
newState: LiveChessGameState,
) {
val inActiveGames = _activeGames.value.containsKey(gameId)
val inSpectatingGames = _spectatingGames.value.containsKey(gameId)
val currentState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId]
val currentMoveCount = currentState?.moveHistory?.value?.size ?: 0
val newMoveCount = newState.moveHistory.value.size
// Only replace if new state has at least as many moves
// This prevents reverting user's local moves during refresh
if (newMoveCount < currentMoveCount) {
return
}
// Handle case where new state incorrectly has isSpectator=true but current is participant
// This happens with open challenges where opponent can't be determined from relay events alone
// Solution: Apply moves from new state while preserving participant status from current
val stateToUse =
if (currentState != null && !currentState.isSpectator && newState.isSpectator && newMoveCount > currentMoveCount) {
// Apply opponent's moves to current state's engine
currentState.applyMovesFrom(newState)
currentState // Keep using current state with updated engine
} else if (currentState != null && !currentState.isSpectator && newState.isSpectator) {
return
} else {
newState
}
if (inActiveGames) {
_activeGames.update { it + (gameId to stateToUse) }
// Increment version to force UI recomposition even if map equals() returns true
val newVersion = stateVersionCounter.incrementAndGet()
_stateVersion.value = newVersion
} else if (inSpectatingGames) {
_spectatingGames.update { it + (gameId to stateToUse) }
val newVersion = stateVersionCounter.incrementAndGet()
_stateVersion.value = newVersion
}
}
/**
* 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,
) {
// Never add to spectating if this game was accepted - user is a participant
if (AcceptedGamesRegistry.wasAccepted(gameId)) {
// Add to active games instead
_activeGames.update { it + (gameId to state) }
return
}
_spectatingGames.update { it + (gameId to state) }
}
fun removeSpectatingGame(gameId: String) {
_spectatingGames.update { it - gameId }
}
fun setBroadcastStatus(status: ChessBroadcastStatus) {
_broadcastStatus.value = status
}
fun setError(error: String?) {
_error.value = error
}
fun setRefreshing(refreshing: Boolean) {
_isRefreshing.value = refreshing
}
fun setSyncStatus(status: ChessSyncStatus) {
_syncStatus.value = status
}
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)
/** Whether a game ID was accepted (prevents refresh from re-adding as challenge) */
fun wasAccepted(gameId: String): Boolean = AcceptedGamesRegistry.wasAccepted(gameId)
/** Mark a game as accepted synchronously (call before async game creation) */
fun markAsAccepted(gameId: String) {
AcceptedGamesRegistry.markAsAccepted(gameId)
}
fun clearAll() {
_activeGames.value = emptyMap()
_publicGames.value = emptyList()
_challenges.value = emptyList()
_spectatingGames.value = emptyMap()
_completedGames.value = emptyList()
AcceptedGamesRegistry.clear()
_broadcastStatus.value = ChessBroadcastStatus.Idle
_error.value = null
_selectedGameId.value = null
}
}
@@ -0,0 +1,134 @@
/*
* 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
/**
* Progress callback for relay fetch operations
*/
data class RelayFetchProgress(
val relay: NormalizedRelayUrl,
val status: RelayFetchStatus,
val eventCount: Int,
)
enum class RelayFetchStatus {
WAITING,
RECEIVING,
EOSE_RECEIVED,
TIMEOUT,
}
/**
* One-shot relay fetch helper for chess events.
*
* 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 relays to respond (default from ChessConfig)
* @param onProgress Optional callback for progress updates per relay
* @return Deduplicated list of events received before timeout/EOSE
*/
suspend fun fetchEvents(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = ChessConfig.FETCH_TIMEOUT_MS,
onProgress: ((RelayFetchProgress) -> Unit)? = null,
): List<Event> {
if (filters.isEmpty()) return emptyList()
val events = ConcurrentHashMap<String, Event>()
val relayCount = filters.keys.size
val eoseReceived = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>()
val relayEventCounts = ConcurrentHashMap<NormalizedRelayUrl, Int>()
val allEose = CompletableDeferred<Unit>()
val subId = newSubId()
// Initialize all relays as WAITING
filters.keys.forEach { relay ->
relayEventCounts[relay] = 0
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.WAITING, 0))
}
val listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
events[event.id] = event
val count = relayEventCounts.compute(relay) { _, v -> (v ?: 0) + 1 } ?: 1
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.RECEIVING, count))
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eoseReceived.add(relay)
val count = relayEventCounts[relay] ?: 0
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.EOSE_RECEIVED, count))
// Complete when all relays respond
if (eoseReceived.size >= relayCount) {
allEose.complete(Unit)
}
}
}
client.openReqSubscription(subId, filters, listener)
val eoseResult = withTimeoutOrNull(timeoutMs) { allEose.await() }
// Mark timed-out relays
if (eoseResult == null) {
filters.keys.forEach { relay ->
if (relay !in eoseReceived) {
val count = relayEventCounts[relay] ?: 0
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.TIMEOUT, count))
}
}
}
client.close(subId)
return events.values.toList()
}
}
@@ -0,0 +1,311 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* Shared banner showing chess sync/subscription status with expandable relay details.
* Shows incoming event progress from relays during refresh.
*/
@Composable
fun ChessSyncBanner(
status: ChessSyncStatus,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
val isVisible = status !is ChessSyncStatus.Idle
var isExpanded by remember { mutableStateOf(false) }
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(tween(200)),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(tween(150)),
modifier = modifier,
) {
Surface(
color = getSyncStatusBackgroundColor(status),
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier =
Modifier
.animateContentSize()
.clickable {
if (status is ChessSyncStatus.PartialSync) {
onRetry()
} else {
isExpanded = !isExpanded
}
},
) {
// Main status row
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Icon(
imageVector = getSyncStatusIcon(status),
contentDescription = null,
tint = getSyncStatusIconColor(status),
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = getSyncStatusText(status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Text(
text = getSyncStatusDetail(status),
style = MaterialTheme.typography.labelMedium,
color = getSyncStatusDetailColor(status),
)
}
// Progress bar for syncing
if (status is ChessSyncStatus.Syncing) {
Spacer(Modifier.height(4.dp))
val progress = status.eoseCount.toFloat() / status.totalCount.coerceAtLeast(1)
val animatedProgress by animateFloatAsState(
targetValue = progress,
animationSpec = tween(300),
label = "syncProgress",
)
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
// Expand/collapse icon
if (status !is ChessSyncStatus.Idle && getRelayStates(status).isNotEmpty()) {
Icon(
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
// Expandable relay details
AnimatedVisibility(
visible = isExpanded && getRelayStates(status).isNotEmpty(),
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp,
)
RelayStatusList(
relayStates = getRelayStates(status),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
}
}
}
}
@Composable
private fun RelayStatusList(
relayStates: List<RelaySyncState>,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
relayStates.forEach { relay ->
RelayStatusRow(relay)
}
}
}
@Composable
private fun RelayStatusRow(relay: RelaySyncState) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = getRelayStatusIcon(relay.status),
contentDescription = null,
tint = getRelayStatusColor(relay.status),
modifier = Modifier.size(14.dp),
)
Text(
text = relay.displayName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = "${relay.eventsReceived} events",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
}
private fun getRelayStates(status: ChessSyncStatus): List<RelaySyncState> =
when (status) {
is ChessSyncStatus.Syncing -> status.relayStates
is ChessSyncStatus.Synced -> status.relayStates
is ChessSyncStatus.PartialSync -> status.relayStates
is ChessSyncStatus.Idle -> emptyList()
}
private fun getRelayStatusIcon(status: RelaySyncStatus): ImageVector =
when (status) {
RelaySyncStatus.CONNECTING -> Icons.Default.HourglassEmpty
RelaySyncStatus.WAITING -> Icons.Default.HourglassEmpty
RelaySyncStatus.RECEIVING -> Icons.Default.CloudDownload
RelaySyncStatus.EOSE_RECEIVED -> Icons.Default.CheckCircle
RelaySyncStatus.FAILED -> Icons.Default.Error
}
@Composable
private fun getRelayStatusColor(status: RelaySyncStatus): Color =
when (status) {
RelaySyncStatus.CONNECTING -> MaterialTheme.colorScheme.secondary
RelaySyncStatus.WAITING -> MaterialTheme.colorScheme.secondary
RelaySyncStatus.RECEIVING -> MaterialTheme.colorScheme.primary
RelaySyncStatus.EOSE_RECEIVED -> MaterialTheme.colorScheme.primary
RelaySyncStatus.FAILED -> MaterialTheme.colorScheme.error
}
@Composable
private fun getSyncStatusBackgroundColor(status: ChessSyncStatus): Color =
when (status) {
is ChessSyncStatus.PartialSync -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f)
is ChessSyncStatus.Synced -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f)
else -> MaterialTheme.colorScheme.surfaceContainer
}
private fun getSyncStatusIcon(status: ChessSyncStatus): ImageVector =
when (status) {
is ChessSyncStatus.Syncing -> Icons.Default.CloudDownload
is ChessSyncStatus.Synced -> Icons.Default.CheckCircle
is ChessSyncStatus.PartialSync -> Icons.Default.Warning
is ChessSyncStatus.Idle -> Icons.Default.CheckCircle
}
@Composable
private fun getSyncStatusIconColor(status: ChessSyncStatus): Color =
when (status) {
is ChessSyncStatus.PartialSync -> MaterialTheme.colorScheme.error
is ChessSyncStatus.Synced -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.primary
}
private fun getSyncStatusText(status: ChessSyncStatus): String =
when (status) {
is ChessSyncStatus.Syncing -> "Syncing ${status.phase}..."
is ChessSyncStatus.Synced -> "Synced"
is ChessSyncStatus.PartialSync -> status.message
is ChessSyncStatus.Idle -> ""
}
private fun getSyncStatusDetail(status: ChessSyncStatus): String =
when (status) {
is ChessSyncStatus.Syncing -> "${status.eoseCount}/${status.totalCount} relays • ${status.totalEventsReceived} events"
is ChessSyncStatus.Synced -> "${status.successCount}/${status.totalCount} relays • ${status.challengeCount} challenges • ${status.gameCount} games"
is ChessSyncStatus.PartialSync -> "Tap to retry"
is ChessSyncStatus.Idle -> ""
}
@Composable
private fun getSyncStatusDetailColor(status: ChessSyncStatus): Color =
when (status) {
is ChessSyncStatus.PartialSync -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
}
@@ -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?
}
@@ -0,0 +1,394 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.foundation.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
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
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessPiece
import com.vitorpamplona.quartz.nip64Chess.PieceType
import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
/**
* Interactive chess board that allows piece selection and moves
*
* @param engine Chess engine for move validation and legal moves
* @param modifier Modifier for the board
* @param boardSize Total size of the board in dp
* @param flipped If true, renders from black's perspective (rank 1 at top)
* @param playerColor The color the player is playing as (only allows moving these pieces)
* @param isSpectator If true, disables all interaction (spectator/watch mode)
* @param onMoveMade Callback when a valid move is made, receives (from, to, san)
* NOTE: This callback should make the actual move - this component only validates
*/
@Composable
fun InteractiveChessBoard(
engine: ChessEngine,
modifier: Modifier = Modifier,
boardSize: Dp = 400.dp,
flipped: Boolean = false,
playerColor: ChessColor? = null, // null = allow both (for local play)
isSpectator: Boolean = false,
positionVersion: Int = 0, // External trigger for position refresh (e.g. moveHistory.size)
onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> },
) {
// 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 not spectating and it's your turn (or playerColor is null for local play)
val canInteract = !isSpectator && (playerColor == null || sideToMove == playerColor)
val squareSize = boardSize / 8
// Rank iteration order based on perspective
val rankRange = if (flipped) (0..7) else (7 downTo 0)
val fileRange = if (flipped) (7 downTo 0) else (0..7)
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
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 (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 {
// 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()
},
)
}
}
}
/**
* Single interactive chess square
*/
@Composable
private fun InteractiveChessSquare(
piece: com.vitorpamplona.quartz.nip64Chess.ChessPiece?,
isLight: Boolean,
size: Dp,
isSelected: Boolean,
isLegalMove: Boolean,
showCoordinate: Boolean,
file: Int,
rank: Int,
onClick: () -> Unit,
) {
val backgroundColor =
when {
isSelected -> Color(0xFF7FA650)
isLegalMove -> Color(0xFFAAC75B).copy(alpha = 0.6f)
isLight -> Color(0xFFF0D9B5)
else -> Color(0xFFB58863)
}
Box(
modifier =
Modifier
.size(size)
.background(backgroundColor)
.clickable { onClick() }
.let {
if (isSelected) {
it.border(2.dp, Color(0xFF4A7536))
} else {
it
}
},
contentAlignment = Alignment.Center,
) {
// Display piece using CBurnett ImageVector icons
piece?.let {
Image(
imageVector = it.toImageVector(),
contentDescription = "${it.color} ${it.type}",
modifier = Modifier.fillMaxSize().padding(2.dp),
)
}
// Show legal move indicator
if (isLegalMove && piece == null) {
Box(
modifier =
Modifier
.size(size * 0.25f)
.background(Color(0xFF7FA650), CircleShape)
.alpha(0.5f),
)
} else if (isLegalMove && piece != null) {
// Capture indicator - ring around piece
Box(
modifier =
Modifier
.size(size * 0.8f)
.border(3.dp, Color(0xFF7FA650).copy(alpha = 0.5f), CircleShape),
)
}
// Show file coordinate (a-h) on bottom rank
if (showCoordinate) {
Text(
text = ('a' + file).toString(),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = if (isLight) Color(0xFFB58863) else Color(0xFFF0D9B5),
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(2.dp),
)
}
}
}
/**
* Convert file/rank to algebraic notation (e.g., 0,0 -> "a1")
*/
private fun fileRankToSquare(
file: Int,
rank: Int,
): String = "${('a' + file)}${rank + 1}"
/**
* Extension function to convert ChessPiece to CBurnett ImageVector
*/
private fun ChessPiece.toImageVector(): ImageVector {
val white = color == ChessColor.WHITE
return when (type) {
PieceType.KING -> if (white) ChessPieceVectors.WhiteKing else ChessPieceVectors.BlackKing
PieceType.QUEEN -> if (white) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen
PieceType.ROOK -> if (white) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook
PieceType.BISHOP -> if (white) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop
PieceType.KNIGHT -> if (white) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight
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),
)
}
}
}
}
}
}
@@ -0,0 +1,927 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.GameResult
import com.vitorpamplona.quartz.nip64Chess.GameStatus
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import androidx.compose.ui.graphics.Color as ComposeColor
/**
* Dialog for creating a new chess game challenge
*
* @param onDismiss Callback when dialog is dismissed
* @param onCreateGame Callback when game is created (opponentPubkey, playerColor)
*/
@Composable
fun NewChessGameDialog(
onDismiss: () -> Unit,
onCreateGame: (opponentPubkey: String?, playerColor: Color) -> Unit,
) {
var opponentPubkey by remember { mutableStateOf("") }
var selectedColor by remember { mutableStateOf(Color.WHITE) }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "New Chess Game",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Text(
text = "Create a new chess game challenge",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = opponentPubkey,
onValueChange = { opponentPubkey = it },
label = { Text("Opponent npub (optional)") },
placeholder = { Text("Leave blank for open challenge") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Text(
text = "Your color:",
style = MaterialTheme.typography.labelMedium,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = { selectedColor = Color.WHITE },
modifier = Modifier.weight(1f),
) {
Text(
text = if (selectedColor == Color.WHITE) "✓ White" else "White",
fontWeight = if (selectedColor == Color.WHITE) FontWeight.Bold else FontWeight.Normal,
)
}
OutlinedButton(
onClick = { selectedColor = Color.BLACK },
modifier = Modifier.weight(1f),
) {
Text(
text = if (selectedColor == Color.BLACK) "✓ Black" else "Black",
fontWeight = if (selectedColor == Color.BLACK) FontWeight.Bold else FontWeight.Normal,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(onClick = onDismiss) {
Text("Cancel")
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
onCreateGame(
opponentPubkey.ifBlank { null },
selectedColor,
)
},
) {
Text("Create Game")
}
}
}
}
}
}
/**
* Complete live chess game UI with board, controls, and game info
*
* This version observes LiveChessGameState flows for automatic UI updates
* when polling refreshes the game state.
*
* @param modifier Modifier for the root layout
* @param gameState Live chess game state (observed for updates)
* @param opponentName Opponent's display name
* @param onMoveMade Callback when player makes a move (from, to, san)
* @param onResign Callback when player resigns
* @param isSpectatorOverride If non-null, overrides gameState.isSpectator. Use this when
* spectator status is determined by which map the game is in (activeGames vs spectatingGames)
* rather than the potentially stale isSpectator flag from relay reconstruction.
*/
@Composable
fun LiveChessGameScreen(
modifier: Modifier = Modifier,
gameState: LiveChessGameState,
opponentName: String,
onMoveMade: (from: String, to: String, san: String) -> Unit,
onResign: () -> Unit,
isSpectatorOverride: Boolean? = null,
onGameEndDismiss: (() -> Unit)? = null,
) {
// Observe state flows for automatic recomposition on updates
val currentPosition by gameState.currentPosition.collectAsState()
val moveHistory by gameState.moveHistory.collectAsState()
val gameStatus by gameState.gameStatus.collectAsState()
// Use override if provided, otherwise fall back to gameState flag
val isSpectator = isSpectatorOverride ?: gameState.isSpectator
// Pending challenges and spectators cannot make moves
val canMakeMoves = !isSpectator && !gameState.isPendingChallenge
// Track if game end overlay was dismissed
var gameEndDismissed by remember { mutableStateOf(false) }
Box(modifier = modifier.fillMaxSize()) {
BoxWithConstraints(
modifier = Modifier.fillMaxSize(),
) {
// Calculate board size dynamically based on available space
// Use a percentage of available height to leave room for other UI elements
val availableWidth = maxWidth - 32.dp // Account for horizontal padding
// Reserve ~35% of height for header, history, controls, and any banners
val availableHeight = maxHeight * 0.65f
val boardSize = min(availableWidth, availableHeight).coerceIn(150.dp, 520.dp)
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Game info - use currentPosition.activeColor for turn display
GameInfoHeader(
gameId = gameState.gameId,
opponentName = opponentName,
playerColor = gameState.playerColor,
currentTurn = currentPosition.activeColor,
isSpectator = isSpectator,
isPendingChallenge = gameState.isPendingChallenge,
gameStatus = gameStatus,
)
// Interactive chess board - flip when playing black (spectators see from white's view)
// Auto-sized to fit available space
InteractiveChessBoard(
engine = gameState.engine,
boardSize = boardSize,
flipped = !isSpectator && gameState.playerColor == Color.BLACK,
playerColor = gameState.playerColor,
isSpectator = !canMakeMoves,
positionVersion = moveHistory.size,
onMoveMade = onMoveMade,
)
// Move history (scrollable) - observed from state flow
MoveHistoryDisplay(
moves = moveHistory,
)
// Show appropriate controls based on game state
when {
gameStatus is GameStatus.Finished -> {
GameEndInfo(
result = (gameStatus as GameStatus.Finished).result,
playerColor = gameState.playerColor,
isSpectator = isSpectator,
)
}
gameState.isPendingChallenge -> {
PendingChallengeInfo()
}
isSpectator -> {
SpectatorInfo()
}
else -> {
GameControls(onResign = onResign)
}
}
}
}
// Game end celebration overlay
if (gameStatus is GameStatus.Finished && !gameEndDismissed) {
GameEndOverlay(
result = (gameStatus as GameStatus.Finished).result,
playerColor = gameState.playerColor,
isSpectator = isSpectator,
onDismiss = {
gameEndDismissed = true
onGameEndDismiss?.invoke()
},
)
}
}
}
/**
* Legacy version that takes engine directly (for backwards compatibility)
*
* @param modifier Modifier for the root layout
* @param engine Chess engine instance
* @param playerColor Color the player is playing as
* @param gameId Unique game identifier
* @param opponentName Opponent's display name
* @param onMoveMade Callback when player makes a move (from, to, san)
* @param onResign Callback when player resigns
*/
@Composable
fun LiveChessGameScreen(
modifier: Modifier = Modifier,
engine: ChessEngine,
playerColor: Color,
gameId: String,
opponentName: String,
onMoveMade: (from: String, to: String, san: String) -> Unit,
onResign: () -> Unit,
) {
BoxWithConstraints(
modifier = modifier.fillMaxSize(),
) {
// Calculate board size based on available space
// Leave room for header (~100dp), history (~60dp), controls (~60dp), and padding
val availableWidth = maxWidth - 32.dp // Account for horizontal padding
val availableHeight = maxHeight - 250.dp // Account for other UI elements
val boardSize = min(availableWidth, availableHeight).coerceAtLeast(200.dp)
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Game info
GameInfoHeader(
gameId = gameId,
opponentName = opponentName,
playerColor = playerColor,
currentTurn = engine.getSideToMove(),
)
// Interactive chess board - flip when playing black
// Auto-sized to fit available space
InteractiveChessBoard(
engine = engine,
boardSize = boardSize,
flipped = playerColor == Color.BLACK,
positionVersion = engine.getMoveHistory().size,
onMoveMade = onMoveMade,
)
// Move history (scrollable)
MoveHistoryDisplay(
moves = engine.getMoveHistory(),
)
// Game controls
GameControls(
onResign = onResign,
)
}
}
}
/**
* Game information header showing game ID, opponent, and current turn
*/
@Composable
private fun GameInfoHeader(
gameId: String,
opponentName: String,
playerColor: Color,
currentTurn: Color,
isSpectator: Boolean = false,
isPendingChallenge: Boolean = false,
gameStatus: GameStatus = GameStatus.InProgress,
) {
// 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 = if (gameName != null) gameId.take(16) else "Game: ${gameId.take(8)}...",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
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 = 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,
)
// Show turn or game result
when (gameStatus) {
is GameStatus.Finished -> {
val result = (gameStatus as GameStatus.Finished).result
val resultText =
when {
result == GameResult.DRAW -> "Draw"
(result == GameResult.WHITE_WINS && playerColor == Color.WHITE) ||
(result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> "You won!"
else -> "You lost"
}
val resultColor =
when {
result == GameResult.DRAW -> {
MaterialTheme.colorScheme.secondary
}
(result == GameResult.WHITE_WINS && playerColor == Color.WHITE) ||
(result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> {
ComposeColor(0xFF4CAF50)
}
else -> {
MaterialTheme.colorScheme.error
}
}
Text(
text = resultText,
style = MaterialTheme.typography.bodyMedium,
color = resultColor,
fontWeight = FontWeight.Bold,
)
}
else -> {
val turnText =
if (currentTurn == playerColor) {
"Your turn"
} else {
"Opponent's turn"
}
Text(
text = turnText,
style = MaterialTheme.typography.bodyMedium,
color =
if (currentTurn == playerColor) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
fontWeight = if (currentTurn == playerColor) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
}
}
}
/**
* 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 = "Watching game - spectator mode",
style = MaterialTheme.typography.bodyMedium,
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,
)
}
}
/**
* Display move history in SAN notation with move numbers
* Shows in a horizontally scrollable container
*/
@Composable
private fun MoveHistoryDisplay(moves: List<String>) {
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Move History",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 4.dp),
)
Box(
modifier =
Modifier
.fillMaxWidth()
.height(48.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
RoundedCornerShape(8.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
) {
if (moves.isEmpty()) {
Text(
text = "No moves yet",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterStart),
)
} else {
val scrollState = rememberScrollState()
Row(
modifier =
Modifier
.horizontalScroll(scrollState)
.align(Alignment.CenterStart),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Group moves into pairs (white, black)
moves.chunked(2).forEachIndexed { index, movePair ->
// Move number
Text(
text = "${index + 1}.",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// White's move
Text(
text = movePair[0],
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
modifier =
Modifier
.background(
MaterialTheme.colorScheme.surface,
RoundedCornerShape(4.dp),
).padding(horizontal = 4.dp, vertical = 2.dp),
)
// Black's move (if exists)
if (movePair.size > 1) {
Text(
text = movePair[1],
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
modifier =
Modifier
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(4.dp),
).padding(horizontal = 4.dp, vertical = 2.dp),
)
}
Spacer(modifier = Modifier.width(8.dp))
}
}
}
}
}
}
/**
* Game control buttons (Resign)
* Note: Draw offers not supported in Jester protocol
*/
@Composable
private fun GameControls(onResign: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
) {
OutlinedButton(onClick = onResign) {
Text("Resign")
}
}
}
/**
* Game end celebration overlay
*/
@Composable
private fun GameEndOverlay(
result: GameResult,
playerColor: Color,
isSpectator: Boolean,
onDismiss: () -> Unit,
) {
val playerWon =
when (result) {
GameResult.WHITE_WINS -> playerColor == Color.WHITE
GameResult.BLACK_WINS -> playerColor == Color.BLACK
GameResult.DRAW, GameResult.IN_PROGRESS -> false
}
val isDraw = result == GameResult.DRAW
// Determine display based on result
val (emoji, title, subtitle, backgroundColor) =
when {
isSpectator -> {
val winnerText =
when (result) {
GameResult.WHITE_WINS -> "White wins!"
GameResult.BLACK_WINS -> "Black wins!"
GameResult.DRAW -> "Draw!"
GameResult.IN_PROGRESS -> "Game Over"
}
Quadruple("", "Game Over", winnerText, MaterialTheme.colorScheme.surfaceVariant)
}
playerWon -> {
Quadruple(
"",
"Victory!",
"Congratulations!",
ComposeColor(0xFF4CAF50).copy(alpha = 0.95f),
)
}
isDraw -> {
Quadruple(
"",
"Draw",
"Game ended in a draw",
MaterialTheme.colorScheme.surfaceVariant,
)
}
else -> {
Quadruple(
"",
"Defeat",
"Better luck next time!",
ComposeColor(0xFFE57373).copy(alpha = 0.95f),
)
}
}
// Animated visibility
AnimatedVisibility(
visible = true,
enter = fadeIn(animationSpec = tween(300)) + scaleIn(animationSpec = tween(300)),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(ComposeColor.Black.copy(alpha = 0.7f))
.clickable { onDismiss() },
contentAlignment = Alignment.Center,
) {
Card(
modifier =
Modifier
.padding(32.dp)
.clickable { /* prevent dismiss on card click */ },
shape = RoundedCornerShape(24.dp),
colors =
CardDefaults.cardColors(
containerColor = backgroundColor,
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
) {
Column(
modifier =
Modifier
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Trophy/emoji based on result
val displayEmoji =
when {
isSpectator -> "GG"
playerWon -> "GG"
isDraw -> "="
else -> "GG"
}
Box(
modifier =
Modifier
.size(80.dp)
.background(
when {
isSpectator -> MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
playerWon -> ComposeColor(0xFFFFD700).copy(alpha = 0.3f)
isDraw -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f)
else -> MaterialTheme.colorScheme.error.copy(alpha = 0.2f)
},
CircleShape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = displayEmoji,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color =
when {
isSpectator -> MaterialTheme.colorScheme.primary
playerWon -> ComposeColor(0xFFFFD700)
isDraw -> MaterialTheme.colorScheme.secondary
else -> MaterialTheme.colorScheme.error
},
)
}
Text(
text = title,
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color =
when {
isSpectator -> MaterialTheme.colorScheme.onSurfaceVariant
playerWon -> ComposeColor.White
isDraw -> MaterialTheme.colorScheme.onSurfaceVariant
else -> ComposeColor.White
},
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyLarge,
color =
when {
isSpectator -> MaterialTheme.colorScheme.onSurfaceVariant
playerWon -> ComposeColor.White.copy(alpha = 0.9f)
isDraw -> MaterialTheme.colorScheme.onSurfaceVariant
else -> ComposeColor.White.copy(alpha = 0.9f)
},
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onDismiss,
) {
Text("Continue")
}
}
}
}
}
}
/**
* Compact game end info shown in controls area
*/
@Composable
private fun GameEndInfo(
result: GameResult,
playerColor: Color,
isSpectator: Boolean,
) {
val resultText =
when {
isSpectator -> {
when (result) {
GameResult.WHITE_WINS -> "White wins"
GameResult.BLACK_WINS -> "Black wins"
GameResult.DRAW -> "Draw"
GameResult.IN_PROGRESS -> "In progress"
}
}
result == GameResult.DRAW -> {
"Game drawn"
}
(result == GameResult.WHITE_WINS && playerColor == Color.WHITE) ||
(result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> {
"You won!"
}
else -> {
"You lost"
}
}
val backgroundColor =
when {
isSpectator -> {
MaterialTheme.colorScheme.surfaceVariant
}
result == GameResult.DRAW -> {
MaterialTheme.colorScheme.secondaryContainer
}
(result == GameResult.WHITE_WINS && playerColor == Color.WHITE) ||
(result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> {
ComposeColor(0xFF4CAF50).copy(alpha = 0.3f)
}
else -> {
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f)
}
}
Box(
modifier =
Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(8.dp))
.padding(12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "Game Over - $resultText",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
/**
* Helper data class for quadruple values
*/
private data class Quadruple<A, B, C, D>(
val first: A,
val second: B,
val third: C,
val fourth: D,
)
@@ -0,0 +1,137 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Move navigation controls for stepping through chess game positions
* Shared composable for Android and Desktop platforms
*
* @param currentMove Current move index (0 = starting position)
* @param totalMoves Total number of moves in the game
* @param onMoveChange Callback when user navigates to a different move
* @param modifier Modifier for the navigator
*/
@Composable
fun MoveNavigator(
currentMove: Int,
totalMoves: Int,
onMoveChange: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
// First move (starting position)
IconButton(
onClick = { onMoveChange(0) },
enabled = currentMove > 0,
) {
Icon(
Icons.Default.SkipPrevious,
contentDescription = "Start position",
tint =
if (currentMove > 0) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
}
// Previous move
IconButton(
onClick = { onMoveChange((currentMove - 1).coerceAtLeast(0)) },
enabled = currentMove > 0,
) {
Icon(
Icons.Default.KeyboardArrowLeft,
contentDescription = "Previous move",
tint =
if (currentMove > 0) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
}
// Current position indicator
Text(
text = "Move $currentMove / $totalMoves",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp),
)
// Next move
IconButton(
onClick = { onMoveChange((currentMove + 1).coerceAtMost(totalMoves)) },
enabled = currentMove < totalMoves,
) {
Icon(
Icons.Default.KeyboardArrowRight,
contentDescription = "Next move",
tint =
if (currentMove < totalMoves) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
}
// Last move (final position)
IconButton(
onClick = { onMoveChange(totalMoves) },
enabled = currentMove < totalMoves,
) {
Icon(
Icons.Default.SkipNext,
contentDescription = "Final position",
tint =
if (currentMove < totalMoves) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
}
}
}
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.quartz.nip64Chess.ChessGame
/**
* Display PGN game metadata (Event, players, result, etc.)
* Shared composable for Android and Desktop platforms
*
* @param game The chess game with metadata to display
* @param modifier Modifier for the metadata display
*/
@Composable
fun PGNMetadata(
game: ChessGame,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// Event name
game.event?.let { event ->
Text(
text = event,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
}
// Site/location (if available)
game.site?.let { site ->
Text(
text = site,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Players
if (game.white != null || game.black != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
game.white?.let { white ->
Text(
text = white,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = "vs",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
game.black?.let { black ->
Text(
text = black,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
// Date and Result
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
game.date?.let { date ->
Text(
text = date,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Result
Text(
text = "Result: ${game.result.notation}",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Round (if available)
game.round?.let { round ->
Text(
text = "Round $round",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,310 @@
/*
* 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.JesterProtocol
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Shared filter builder for chess subscriptions using Jester protocol.
* Used by both Android and Desktop to ensure identical subscription behavior.
*
* Jester Protocol (kind 30):
* - All chess events use kind 30
* - Start events: e-tag references START_POSITION_HASH
* - Move events: e-tags [startEventId, headEventId]
* - Content JSON determines event type (kind: 0=start, 1=move, 2=chat)
*
* Builds 4 types of filters:
* 1. Personal filters - Events tagged with user's pubkey
* 2. Start/Challenge filters - Events referencing START_POSITION_HASH
* 3. Active game filters - Events referencing specific game startEventIds
* 4. Recent game filters - For spectating discovery
*/
object ChessFilterBuilder {
/** Jester protocol kind for all chess events */
private const val JESTER_KIND = JesterProtocol.KIND
/**
* 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 start/challenge events (for lobby display)
filters.addAll(
buildStartEventFilters(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.
*/
fun buildPersonalFilters(
userPubkey: String,
relays: Set<NormalizedRelayUrl>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
now: Long = TimeUtils.now(),
): List<RelayBasedFilter> {
val filter =
Filter(
kinds = listOf(JESTER_KIND),
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),
)
}
}
/**
* Start/challenge events (Jester pattern).
* In Jester protocol, start events reference the START_POSITION_HASH.
* This fetches all game starts for lobby display.
*/
fun buildStartEventFilters(
relays: Set<NormalizedRelayUrl>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
now: Long = TimeUtils.now(),
): List<RelayBasedFilter> {
val filter =
Filter(
kinds = listOf(JESTER_KIND),
tags = mapOf("e" to listOf(JesterProtocol.START_POSITION_HASH)),
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.
*
* In Jester protocol:
* - Move events have e-tags: [startEventId, headEventId]
* - We filter by the first e-tag (startEventId) to get all moves for a game
* - We also filter by opponent authors and p-tag for redundancy
*/
fun buildActiveGameFilters(
startEventIds: Set<String>,
userPubkey: String,
opponentPubkeys: Set<String>,
relays: Set<NormalizedRelayUrl>,
): List<RelayBasedFilter> {
if (startEventIds.isEmpty()) return emptyList()
val filters = mutableListOf<RelayBasedFilter>()
// Game events: filter by e-tag (startEventId)
// This catches all moves/events for these games
val gameFilter =
Filter(
kinds = listOf(JESTER_KIND),
tags = mapOf("e" to startEventIds.toList()),
limit = 500,
)
relays.forEach { relay ->
filters.add(RelayBasedFilter(relay = relay, filter = gameFilter))
}
// Also filter by p-tag (opponent tagged us) for redundancy
val tagFilter =
Filter(
kinds = listOf(JESTER_KIND),
tags = mapOf("p" to listOf(userPubkey)),
limit = 200,
)
relays.forEach { relay ->
filters.add(RelayBasedFilter(relay = relay, filter = tagFilter))
}
// Also filter by authors (opponent pubkeys) for redundancy
if (opponentPubkeys.isNotEmpty()) {
val authorFilter =
Filter(
kinds = listOf(JESTER_KIND),
authors = opponentPubkeys.toList(),
limit = 200,
)
relays.forEach { relay ->
filters.add(RelayBasedFilter(relay = relay, filter = authorFilter))
}
}
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 = listOf(JESTER_KIND),
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.
*
* In Jester protocol, all game events reference the startEventId via e-tag.
*/
fun gameEventsFilter(startEventId: String): Filter =
Filter(
kinds = listOf(JESTER_KIND),
tags = mapOf("e" to listOf(startEventId)),
limit = 500,
)
/**
* Filter for start/challenge events in the last 24 hours.
* Used for one-shot fetch to populate lobby.
*
* Start events reference the START_POSITION_HASH.
*/
fun challengesFilter(userPubkey: String? = null): Filter {
val now = TimeUtils.now()
return Filter(
kinds = listOf(JESTER_KIND),
tags = mapOf("e" to listOf(JesterProtocol.START_POSITION_HASH)),
since = now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS,
limit = 100,
)
}
/**
* Filter for recent game activity for spectating discovery.
* Fetches events from the last 7 days.
*/
fun recentGamesFilter(): Filter {
val now = TimeUtils.now()
return Filter(
kinds = listOf(JESTER_KIND),
since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS,
limit = 200,
)
}
/**
* Filter for user's own chess events (events they authored).
* Used to discover games the user is participating in.
*/
fun userGamesFilter(userPubkey: String): Filter {
val now = TimeUtils.now()
return Filter(
kinds = listOf(JESTER_KIND),
authors = listOf(userPubkey),
since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS,
limit = 200,
)
}
/**
* Filter for events tagged with user's pubkey (games they're participating in).
* Complements userGamesFilter to find games where user is the opponent.
*/
fun userTaggedFilter(userPubkey: String): Filter {
val now = TimeUtils.now()
return Filter(
kinds = listOf(JESTER_KIND),
tags = mapOf("p" to listOf(userPubkey)),
since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS,
limit = 200,
)
}
}
@@ -0,0 +1,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
}
@@ -0,0 +1,144 @@
/*
* 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.data
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Simple data class for cached user display info
*/
data class UserDisplayInfo(
val pubkey: String,
val displayName: String?,
val pictureUrl: String?,
val nip05: String?,
)
/**
* Lightweight user metadata cache for UI display.
* Can be used by any feature (chess, chat, etc.) that needs user info.
*
* Thread-safe via StateFlow updates.
*/
class UserMetadataCache {
private val _metadata = MutableStateFlow<Map<String, UserMetadata>>(emptyMap())
val metadata: StateFlow<Map<String, UserMetadata>> = _metadata.asStateFlow()
private val _pubkeysNeeded = MutableStateFlow<Set<String>>(emptySet())
val pubkeysNeeded: StateFlow<Set<String>> = _pubkeysNeeded.asStateFlow()
/**
* Add or update metadata for a pubkey
*/
fun put(
pubkey: String,
userMetadata: UserMetadata,
) {
_metadata.value = _metadata.value + (pubkey to userMetadata)
_pubkeysNeeded.value = _pubkeysNeeded.value - pubkey
}
/**
* Get metadata for a pubkey (may be null if not cached)
*/
fun get(pubkey: String): UserMetadata? = _metadata.value[pubkey]
/**
* Check if metadata is cached for a pubkey
*/
fun contains(pubkey: String): Boolean = _metadata.value.containsKey(pubkey)
/**
* Request metadata for a pubkey (adds to needed set if not cached)
*/
fun request(pubkey: String) {
if (!contains(pubkey) && pubkey !in _pubkeysNeeded.value) {
_pubkeysNeeded.value = _pubkeysNeeded.value + pubkey
}
}
/**
* Request metadata for multiple pubkeys
*/
fun requestAll(pubkeys: Collection<String>) {
val newNeeded = pubkeys.filter { !contains(it) && it !in _pubkeysNeeded.value }
if (newNeeded.isNotEmpty()) {
_pubkeysNeeded.value = _pubkeysNeeded.value + newNeeded
}
}
/**
* Get display name for a pubkey, falling back to truncated pubkey
*/
fun getDisplayName(pubkey: String): String {
val meta = get(pubkey)
return meta?.bestName() ?: formatPubkeyShort(pubkey)
}
/**
* Get profile picture URL for a pubkey
*/
fun getPictureUrl(pubkey: String): String? = get(pubkey)?.profilePicture()
/**
* Get display info for a pubkey (for UI binding)
*/
fun getDisplayInfo(pubkey: String): UserDisplayInfo {
val meta = get(pubkey)
return UserDisplayInfo(
pubkey = pubkey,
displayName = meta?.bestName(),
pictureUrl = meta?.profilePicture(),
nip05 = meta?.nip05,
)
}
/**
* Clear metadata that was requested but hasn't been fetched
* (call when pubkeys are no longer needed)
*/
fun clearNeeded(pubkeys: Collection<String>) {
_pubkeysNeeded.value = _pubkeysNeeded.value - pubkeys.toSet()
}
/**
* Clear all cached metadata
*/
fun clear() {
_metadata.value = emptyMap()
_pubkeysNeeded.value = emptySet()
}
companion object {
/**
* Format pubkey for display (npub-style truncation)
*/
fun formatPubkeyShort(pubkey: String): String =
if (pubkey.length > 12) {
"${pubkey.take(8)}...${pubkey.takeLast(4)}"
} else {
pubkey
}
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache
import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread
import com.vitorpamplona.amethyst.commons.util.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.commons.util.replace
@@ -131,6 +132,12 @@ open class Note(
removeReport(note)
}
var poll: PollResponsesCache? = null
fun pollStateOrNull(): PollResponsesCache? = poll
fun pollState(): PollResponsesCache = poll ?: PollResponsesCache().also { poll = it }
// These fields are updated every time an event related to this note is received.
var replies = listOf<Note>()
private set
@@ -1007,6 +1014,34 @@ public inline fun <T> Iterable<Note>.anyEvent(predicate: (T) -> Boolean): Boolea
return false
}
public inline fun <T> Iterable<Note>.filterEvents(predicate: (T) -> Boolean): List<T> {
if (this is Collection && isEmpty()) return emptyList()
val dest = ArrayList<T>()
for (note in this) {
val noteEvent = note.event as? T
if (noteEvent != null && predicate(noteEvent)) {
dest.add(noteEvent)
}
}
return dest
}
public inline fun <T> Iterable<Note>.filterAuthoredEvents(pubkey: HexKey): List<T> {
if (this is Collection && isEmpty()) return emptyList()
val dest = ArrayList<T>()
for (note in this) {
if (note.author?.pubkeyHex != pubkey) {
val noteEvent = note.event as? T
if (noteEvent != null) {
dest.add(noteEvent)
}
}
}
return dest
}
public inline fun Iterable<Note>.anyNotNullEvent(predicate: (Event) -> Boolean): Boolean {
if (this is Collection && isEmpty()) return false
for (note in this) {
@@ -1015,3 +1050,21 @@ public inline fun Iterable<Note>.anyNotNullEvent(predicate: (Event) -> Boolean):
}
return false
}
fun <T : Event> List<Note>.latestByAuthor(): Map<User, T> {
val oneResponsePerUser = mutableMapOf<User, T>()
forEach { note ->
val author = note.author ?: return@forEach
val event = note.event as? T ?: return@forEach
val currentResponse = oneResponsePerUser[author]
if (currentResponse == null) {
oneResponsePerUser[author] = event
} else if (event.createdAt > currentResponse.createdAt) {
oneResponsePerUser[author] = event
}
}
return oneResponsePerUser
}
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.model.nip18Reposts
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
@@ -35,34 +35,26 @@ object RepostAction {
/**
* Creates a signed repost event.
*
* @param event The event to repost
* @param eventHint The event to repost
* @param signer The NostrSigner to sign the event
* @param noteHint Optional relay hint where the note was seen
* @param authorHint Optional relay hint for the author's outbox
* @return Signed repost event ready to broadcast
* @throws IllegalStateException if signer is not writeable
*/
suspend fun repost(
event: Event,
eventHint: EventHintBundle<Event>,
signer: NostrSigner,
noteHint: String? = null,
authorHint: String? = null,
): Event {
if (!signer.isWriteable()) {
throw IllegalStateException("Cannot repost: signer is not writeable")
}
// Normalize relay hints
val normalizedNoteHint = noteHint?.let { RelayUrlNormalizer.normalizeOrNull(it) }
val normalizedAuthorHint = authorHint?.let { RelayUrlNormalizer.normalizeOrNull(it) }
// Use NIP-18 RepostEvent (kind 6) for text notes (kind 1)
// Use GenericRepostEvent (kind 16) for all other kinds
val template =
if (event.kind == 1) {
RepostEvent.build(event, normalizedNoteHint, normalizedAuthorHint)
if (eventHint.event.kind == 1) {
RepostEvent.build(eventHint)
} else {
GenericRepostEvent.build(event, normalizedNoteHint, normalizedAuthorHint)
GenericRepostEvent.build(eventHint)
}
return signer.sign(template)
@@ -86,10 +78,8 @@ object RepostAction {
if (!signer.isWriteable()) return null
if (note.hasBoostedInTheLast5Minutes(signer.pubKey)) return null
val noteEvent = note.event ?: return null
val noteHint = note.relayHintUrl()?.url
val authorHint = note.author?.bestRelayHint()?.url
val hint = note.toEventHint<Event>() ?: return null
return repost(noteEvent, signer, noteHint, authorHint)
return repost(hint, signer)
}
}
@@ -24,7 +24,6 @@ import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
@@ -49,18 +48,14 @@ object ReactionAction {
* @throws IllegalStateException if signer is not writeable
*/
suspend fun reactTo(
event: Event,
eventHint: EventHintBundle<Event>,
reaction: String,
signer: NostrSigner,
relayHint: String? = null,
): ReactionEvent {
if (!signer.isWriteable()) {
throw IllegalStateException("Cannot react: signer is not writeable")
}
val normalizedRelayHint = relayHint?.let { RelayUrlNormalizer.normalizeOrNull(it) }
val eventHint = EventHintBundle(event, normalizedRelayHint)
// Handle custom emoji reactions (format: ":emoji_name:")
val template =
if (reaction.startsWith(":")) {
@@ -82,10 +77,9 @@ object ReactionAction {
* Creates a "like" reaction ("+").
*/
suspend fun like(
event: Event,
event: EventHintBundle<Event>,
signer: NostrSigner,
relayHint: String? = null,
): ReactionEvent = reactTo(event, "+", signer, relayHint)
): ReactionEvent = reactTo(event, "+", signer)
/**
* Advanced: React to an event with support for NIP-17 private groups.
@@ -102,7 +96,6 @@ object ReactionAction {
* @param onPrivate Callback for private group reactions (returns NIP17Factory.Result)
*/
suspend fun reactToWithGroupSupport(
event: Event,
eventHint: EventHintBundle<Event>,
reaction: String,
signer: NostrSigner,
@@ -113,6 +106,8 @@ object ReactionAction {
throw IllegalStateException("Cannot react: signer is not writeable")
}
val event = eventHint.event
// Check if this is a NIP-17 private group event
if (event is NIP17Group) {
val users = event.groupMembers().toList()
@@ -185,9 +180,8 @@ object ReactionAction {
if (!signer.isWriteable()) return
if (note.hasReacted(by, reaction)) return
val noteEvent = note.event ?: return
val eventHint = note.toEventHint<Event>() ?: return
reactToWithGroupSupport(noteEvent, eventHint, reaction, signer, onPublic, onPrivate)
reactToWithGroupSupport(eventHint, reaction, signer, onPublic, onPrivate)
}
}
@@ -0,0 +1,146 @@
/*
* 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.model.nip88Polls
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.UserDependencies
import com.vitorpamplona.amethyst.commons.model.latestByAuthor
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.set
@Stable
class PollResponsesCache : UserDependencies {
companion object {
val DefaultFeedOrder: Comparator<PollResponseEvent> =
compareByDescending<PollResponseEvent> { it.createdAt }.thenBy { it.id }
}
class ResponseTally(
val allResponses: List<Note> = emptyList(),
) {
val votes: Map<User, PollResponseEvent> = allResponses.latestByAuthor()
val tally: Map<String, Set<User>> = votes.votesByOption()
fun winning() = tally.maxByOrNull { it.value.size }?.key
fun totalVotes() = tally.entries.sumOf { it.value.size }
}
val responses = MutableStateFlow(ResponseTally())
fun addResponse(note: Note) {
// if it's already there, quick exit
if (responses.value.allResponses.contains(note)) return
responses.update {
ResponseTally(
it.allResponses + note,
)
}
}
fun removeResponse(deleteNote: Note) {
// if it's not already there, quick exit
if (!responses.value.allResponses.contains(deleteNote)) return
responses.update {
ResponseTally(
it.allResponses - deleteNote,
)
}
}
fun ResponseTally.filterTo(
code: String,
forKey: HexKey,
priority: Set<HexKey>,
): TallyResults {
val comparator = compareByDescending<User> { it.pubkeyHex == forKey }.thenByDescending { it.pubkeyHex in priority }.thenBy { it.pubkeyHex }
val usersThatVotedForThisOption = tally[code] ?: emptyList()
val votes = totalVotes()
val percent =
if (votes > 0) {
usersThatVotedForThisOption.size.toFloat() / votes.toFloat()
} else {
0f
}
val sortedUsers = usersThatVotedForThisOption.sortedWith(comparator)
return TallyResults(sortedUsers, percent, code == winning())
}
fun currentTally(
code: String,
forKey: HexKey,
priorityAccounts: Set<HexKey>,
): TallyResults = responses.value.filterTo(code, forKey, priorityAccounts)
fun tallyFlow(
code: String,
forKey: HexKey,
priorityAccounts: Flow<Set<HexKey>>,
): Flow<TallyResults> =
combine(responses, priorityAccounts) { responses, priority ->
responses.filterTo(code, forKey, priority)
}
fun hasPubKeyVoted(user: User): Boolean = responses.value.votes.containsKey(user)
fun hasPubKeyVotedFlow(user: User): Flow<Boolean> = responses.map { hasPubKeyVoted(user) }.distinctUntilChanged()
}
@Stable
class TallyResults(
val users: List<User> = emptyList(),
val percent: Float = 0.0f,
val isWinning: Boolean = false,
)
fun Map<User, PollResponseEvent>.votesByOption(): Map<String, Set<User>> {
val tally = mutableMapOf<String, MutableSet<User>>()
this.forEach { (user, responseEvent) ->
responseEvent.responses().forEach { code ->
val currentTally = tally[code]
if (currentTally == null) {
tally[code] = mutableSetOf(user)
} else {
currentTally.add(user)
}
}
}
return tally
}
@@ -0,0 +1,192 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.profile
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* Shared banner showing profile metadata broadcast progress.
* Works on both Android and Desktop via Compose Multiplatform.
*/
@Composable
fun ProfileBroadcastBanner(
status: ProfileBroadcastStatus,
onTap: () -> Unit,
modifier: Modifier = Modifier,
) {
val isVisible = status !is ProfileBroadcastStatus.Idle
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(tween(200)),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(tween(150)),
modifier = modifier,
) {
Surface(
color = getStatusBackgroundColor(status),
tonalElevation = 2.dp,
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onTap),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(horizontal = 16.dp, vertical = 10.dp)
.animateContentSize(),
) {
Icon(
imageVector = getStatusIcon(status),
contentDescription = null,
tint = getStatusIconColor(status),
modifier = Modifier.size(18.dp),
)
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = getStatusText(status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Text(
text = getStatusDetail(status),
style = MaterialTheme.typography.labelMedium,
color = getStatusDetailColor(status),
)
}
// Progress bar for broadcasting
if (status is ProfileBroadcastStatus.Broadcasting) {
Spacer(Modifier.height(4.dp))
val animatedProgress by animateFloatAsState(
targetValue = status.progress,
animationSpec = tween(300),
label = "broadcastProgress",
)
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
}
}
}
@Composable
private fun getStatusBackgroundColor(status: ProfileBroadcastStatus): Color =
when (status) {
is ProfileBroadcastStatus.Failed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.9f)
is ProfileBroadcastStatus.Success -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f)
else -> MaterialTheme.colorScheme.surfaceContainer
}
private fun getStatusIcon(status: ProfileBroadcastStatus): ImageVector =
when (status) {
is ProfileBroadcastStatus.Broadcasting -> Icons.Default.Sync
is ProfileBroadcastStatus.Success -> Icons.Default.CheckCircle
is ProfileBroadcastStatus.Failed -> Icons.Default.Error
is ProfileBroadcastStatus.Idle -> Icons.Default.CheckCircle
}
@Composable
private fun getStatusIconColor(status: ProfileBroadcastStatus): Color =
when (status) {
is ProfileBroadcastStatus.Failed -> MaterialTheme.colorScheme.error
is ProfileBroadcastStatus.Success -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.primary
}
private fun getStatusText(status: ProfileBroadcastStatus): String =
when (status) {
is ProfileBroadcastStatus.Broadcasting -> "Updating ${status.fieldName}..."
is ProfileBroadcastStatus.Success -> "Updated ${status.fieldName}"
is ProfileBroadcastStatus.Failed -> "Failed to update ${status.fieldName}"
is ProfileBroadcastStatus.Idle -> ""
}
private fun getStatusDetail(status: ProfileBroadcastStatus): String =
when (status) {
is ProfileBroadcastStatus.Broadcasting -> "[${status.successCount}/${status.totalRelays}]"
is ProfileBroadcastStatus.Success -> "${status.relayCount} relays"
is ProfileBroadcastStatus.Failed -> "Tap to retry"
is ProfileBroadcastStatus.Idle -> ""
}
@Composable
private fun getStatusDetailColor(status: ProfileBroadcastStatus): Color =
when (status) {
is ProfileBroadcastStatus.Failed -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.profile
/**
* Status of profile metadata broadcast to relays.
*/
sealed class ProfileBroadcastStatus {
data object Idle : ProfileBroadcastStatus()
data class Broadcasting(
val fieldName: String,
val successCount: Int,
val totalRelays: Int,
) : ProfileBroadcastStatus() {
val progress: Float get() = if (totalRelays > 0) successCount.toFloat() / totalRelays else 0f
}
data class Success(
val fieldName: String,
val relayCount: Int,
) : ProfileBroadcastStatus()
data class Failed(
val fieldName: String,
val error: String,
) : ProfileBroadcastStatus()
}
@@ -28,14 +28,18 @@ import kotlinx.coroutines.flow.MutableStateFlow
@Stable
sealed class FeedState {
@Stable
object Loading : FeedState()
@Stable
class Loaded(
val feed: MutableStateFlow<LoadedFeedState<Note>>,
) : FeedState()
@Stable
object Empty : FeedState()
@Stable
class FeedError(
val errorMessage: String,
) : FeedState()
@@ -0,0 +1,152 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponse
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip64Chess.JesterProtocol
import kotlinx.coroutines.delay
/**
* Result of broadcasting an event
*/
data class BroadcastResult(
val success: Boolean,
val relayResults: Map<NormalizedRelayUrl, Boolean>,
val message: String,
)
/**
* Helper for broadcasting chess events to relays with reliable delivery.
*
* Uses sendAndWaitForResponse to get actual OK confirmations from relays,
* ensuring the event was actually received and accepted.
*/
class ChessEventBroadcaster(
private val client: INostrClient,
) {
/**
* Broadcast an event to the chess relays with confirmation.
*
* This method:
* 1. Triggers relay connections via a dummy subscription
* 2. Waits for relays to connect
* 3. Sends the event and waits for OK responses
*
* @param event The signed event to broadcast
* @param timeoutSeconds Maximum time to wait for relay responses
* @return BroadcastResult with success status
*/
suspend fun broadcast(
event: Event,
timeoutSeconds: Long = 15L,
): BroadcastResult {
val targetRelays = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) }.toSet()
val subId = newSubId()
// Step 1: Check which relays are already connected
val initialConnected = client.connectedRelaysFlow().value
val alreadyConnected = targetRelays.intersect(initialConnected)
val needsConnection = targetRelays - alreadyConnected
// Step 2: If some relays need connection, open a subscription to trigger it
if (needsConnection.isNotEmpty()) {
// Use a valid filter with recent since timestamp to trigger connection
// without expecting real results
val dummyFilter =
Filter(
kinds = listOf(JesterProtocol.KIND),
since = (System.currentTimeMillis() / 1000) + 3600, // 1 hour in future = no results
limit = 1,
)
val listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) { }
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) { }
}
// Open subscription to all target relays (triggers connection)
val filterMap = targetRelays.associateWith { listOf(dummyFilter) }
client.openReqSubscription(subId, filterMap, listener)
// Wait for relays to connect (poll with timeout)
waitForRelays(targetRelays, 5000L)
// Close the dummy subscription
client.close(subId)
}
// Step 3: Send the event and wait for OK responses
val success = client.sendAndWaitForResponse(event, targetRelays, timeoutSeconds)
// Note: sendAndWaitForResponse only returns aggregate success (any relay accepted)
// We don't have per-relay results, so relayResults is empty
return BroadcastResult(
success = success,
relayResults = emptyMap(),
message = if (success) "Event accepted by at least one relay" else "No relay accepted the event",
)
}
/**
* Wait for target relays to appear in connectedRelaysFlow.
*/
private suspend fun waitForRelays(
targetRelays: Set<NormalizedRelayUrl>,
timeoutMs: Long,
): Set<NormalizedRelayUrl> {
val startTime = System.currentTimeMillis()
val pollInterval = 200L
while (System.currentTimeMillis() - startTime < timeoutMs) {
val connected = client.connectedRelaysFlow().value
val targetConnected = targetRelays.intersect(connected)
if (targetConnected.size == targetRelays.size) {
return targetConnected
}
// At least one connected is good enough after half the timeout
if (targetConnected.isNotEmpty() && System.currentTimeMillis() - startTime > timeoutMs / 2) {
return targetConnected
}
delay(pollInterval)
}
return client.connectedRelaysFlow().value.intersect(targetRelays)
}
}