Merge branch 'main' into feat/desktop-private-dms
This commit is contained in:
+57
@@ -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
|
||||
}
|
||||
}
|
||||
+241
@@ -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
|
||||
}
|
||||
+266
@@ -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)
|
||||
}
|
||||
}
|
||||
+327
@@ -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,
|
||||
)
|
||||
+182
@@ -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
|
||||
}
|
||||
+172
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+342
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+845
@@ -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
|
||||
}
|
||||
+518
@@ -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
|
||||
}
|
||||
}
|
||||
+1018
File diff suppressed because it is too large
Load Diff
+134
@@ -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()
|
||||
}
|
||||
}
|
||||
+311
@@ -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
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.chess
|
||||
|
||||
/**
|
||||
* Platform-specific metadata provider for enriching chess challenges with display info.
|
||||
*
|
||||
* Android: wraps LocalCache.users[pubkey].info
|
||||
* Desktop: wraps UserMetadataCache
|
||||
*/
|
||||
interface IUserMetadataProvider {
|
||||
fun getDisplayName(pubkey: String): String
|
||||
|
||||
fun getPictureUrl(pubkey: String): String?
|
||||
}
|
||||
+394
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+927
@@ -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,
|
||||
)
|
||||
+137
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+310
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.chess.subscription
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Interface for platform-specific chess subscription management.
|
||||
*
|
||||
* Implementations handle the actual relay subscription mechanics while
|
||||
* using [ChessFilterBuilder] for consistent filter construction.
|
||||
*
|
||||
* Key responsibilities:
|
||||
* - Subscribe/unsubscribe to chess events on relays
|
||||
* - Update filters when active games change
|
||||
* - Track EOSE (End of Stored Events) per relay for efficient re-subscription
|
||||
*/
|
||||
interface ChessSubscriptionController {
|
||||
/** Current subscription state, null if not subscribed */
|
||||
val currentState: StateFlow<ChessSubscriptionState?>
|
||||
|
||||
/**
|
||||
* Subscribe to chess events with the given state.
|
||||
* Replaces any existing subscription.
|
||||
*/
|
||||
fun subscribe(state: ChessSubscriptionState)
|
||||
|
||||
/** Unsubscribe from all chess events */
|
||||
fun unsubscribe()
|
||||
|
||||
/**
|
||||
* Update active game IDs and refresh subscription filters.
|
||||
* This is the key method for dynamic subscription updates.
|
||||
*
|
||||
* When a game starts or ends, call this to update the filters
|
||||
* so moves are properly received for active games.
|
||||
*
|
||||
* @param activeGameIds Game IDs the user is actively playing
|
||||
* @param spectatingGameIds Game IDs the user is watching (optional)
|
||||
*/
|
||||
fun updateActiveGames(
|
||||
activeGameIds: Set<String>,
|
||||
spectatingGameIds: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Force refresh all filters from relays.
|
||||
* Clears EOSE cache so full history is re-fetched.
|
||||
*/
|
||||
fun forceRefresh()
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.chess.subscription
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* Immutable state for chess relay subscriptions.
|
||||
* Used to generate subscription filters and track subscription identity.
|
||||
*
|
||||
* When any of these values change, a new subscription should be created
|
||||
* with updated filters.
|
||||
*/
|
||||
@Immutable
|
||||
data class ChessSubscriptionState(
|
||||
/** User's public key (hex) for filtering personal events */
|
||||
val userPubkey: String,
|
||||
/** Set of relays to subscribe to */
|
||||
val relays: Set<NormalizedRelayUrl>,
|
||||
/** Game IDs the user is actively playing */
|
||||
val activeGameIds: Set<String> = emptySet(),
|
||||
/** Game IDs the user is spectating */
|
||||
val spectatingGameIds: Set<String> = emptySet(),
|
||||
/** Opponent pubkeys for active games (to filter their moves) */
|
||||
val opponentPubkeys: Set<String> = emptySet(),
|
||||
) {
|
||||
/**
|
||||
* Unique subscription ID that changes when game IDs change.
|
||||
* Used for:
|
||||
* - Subscription deduplication
|
||||
* - EOSE cache keying
|
||||
* - Detecting when filters need to be refreshed
|
||||
*/
|
||||
fun subscriptionId(): String =
|
||||
buildString {
|
||||
append("chess-")
|
||||
append(userPubkey.take(8))
|
||||
if (allGameIds.isNotEmpty()) {
|
||||
append("-g")
|
||||
append(allGameIds.sorted().hashCode())
|
||||
}
|
||||
}
|
||||
|
||||
/** All game IDs requiring move subscriptions (active + spectating) */
|
||||
val allGameIds: Set<String>
|
||||
get() = activeGameIds + spectatingGameIds
|
||||
|
||||
/** True if user has any active or spectating games */
|
||||
val hasActiveGames: Boolean
|
||||
get() = allGameIds.isNotEmpty()
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.chess.subscription
|
||||
|
||||
/**
|
||||
* Shared time window constants for chess subscriptions.
|
||||
* Used by both Android and Desktop to ensure consistent behavior.
|
||||
*/
|
||||
object ChessTimeWindows {
|
||||
/**
|
||||
* 24 hours - challenges older than this are considered expired.
|
||||
* Used for challenge and accept event filters.
|
||||
*/
|
||||
const val CHALLENGE_WINDOW_SECONDS = 24 * 60 * 60L
|
||||
|
||||
/**
|
||||
* 7 days - default lookback for game events when no EOSE cache exists.
|
||||
* This prevents loading ancient game history on first connection
|
||||
* while still allowing recovery of recent games.
|
||||
*/
|
||||
const val GAME_EVENT_WINDOW_SECONDS = 7 * 24 * 60 * 60L
|
||||
}
|
||||
+144
@@ -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
|
||||
}
|
||||
|
||||
+8
-18
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+146
@@ -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
|
||||
}
|
||||
+192
@@ -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
|
||||
}
|
||||
+46
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user