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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-02-10 12:14:34 +02:00
co-authored by Claude Opus 4.5
parent e0d0e33408
commit 42b33ca04c
26 changed files with 4402 additions and 452 deletions
@@ -168,11 +168,6 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
@@ -604,36 +599,6 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean, wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified) ) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: ChessGameEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: LiveChessGameChallengeEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: LiveChessGameAcceptEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: LiveChessMoveEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: LiveChessGameEndEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consumeRegularEvent( fun consumeRegularEvent(
event: Event, event: Event,
relay: NormalizedRelayUrl?, relay: NormalizedRelayUrl?,
@@ -2965,11 +2930,6 @@ object LocalCache : ILocalCache, ICacheProvider {
is CalendarDateSlotEvent -> consume(event, relay, wasVerified) is CalendarDateSlotEvent -> consume(event, relay, wasVerified)
is CalendarTimeSlotEvent -> consume(event, relay, wasVerified) is CalendarTimeSlotEvent -> consume(event, relay, wasVerified)
is CalendarRSVPEvent -> consume(event, relay, wasVerified) is CalendarRSVPEvent -> consume(event, relay, wasVerified)
is ChessGameEvent -> consume(event, relay, wasVerified)
is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified)
is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified)
is LiveChessMoveEvent -> consume(event, relay, wasVerified)
is LiveChessGameEndEvent -> consume(event, relay, wasVerified)
is ChannelCreateEvent -> consume(event, relay, wasVerified) is ChannelCreateEvent -> consume(event, relay, wasVerified)
is ChannelListEvent -> consume(event, relay, wasVerified) is ChannelListEvent -> consume(event, relay, wasVerified)
is ChannelHideMessageEvent -> consume(event, relay, wasVerified) is ChannelHideMessageEvent -> consume(event, relay, wasVerified)
@@ -31,7 +31,6 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
@@ -80,7 +79,6 @@ class RelaySubscriptionsCoordinator(
val hashtags = HashtagFilterAssembler(client) val hashtags = HashtagFilterAssembler(client)
val geohashes = GeoHashFilterAssembler(client) val geohashes = GeoHashFilterAssembler(client)
val followPacks = FollowPackFeedFilterAssembler(client) val followPacks = FollowPackFeedFilterAssembler(client)
val chess = ChessFilterAssembler(client)
// active when sending zaps via NWC // active when sending zaps via NWC
val nwc = NWCPaymentFilterAssembler(client) val nwc = NWCPaymentFilterAssembler(client)
@@ -103,7 +101,6 @@ class RelaySubscriptionsCoordinator(
profile, profile,
hashtags, hashtags,
geohashes, geohashes,
chess,
nwc, nwc,
) )
@@ -0,0 +1,251 @@
/**
* 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.ui.screen.loggedIn.chess
import com.vitorpamplona.amethyst.commons.chess.ChessEventPublisher
import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetcher
import com.vitorpamplona.amethyst.commons.chess.IUserMetadataProvider
import com.vitorpamplona.amethyst.commons.chess.RelayGameSummary
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvents
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
/**
* Android implementation of ChessEventPublisher.
* Wraps account.signAndComputeBroadcast() for event publishing.
*/
class AndroidChessPublisher(
private val account: Account,
) : ChessEventPublisher {
override suspend fun publishChallenge(
gameId: String,
playerColor: Color,
opponentPubkey: String?,
timeControl: String?,
): Boolean =
try {
val template =
LiveChessGameChallengeEvent.build(
gameId = gameId,
playerColor = playerColor,
opponentPubkey = opponentPubkey,
timeControl = timeControl,
)
account.signAndComputeBroadcast(template)
true
} catch (e: Exception) {
false
}
override suspend fun publishAccept(
gameId: String,
challengeEventId: String,
challengerPubkey: String,
): Boolean =
try {
val template =
LiveChessGameAcceptEvent.build(
gameId = gameId,
challengeEventId = challengeEventId,
challengerPubkey = challengerPubkey,
)
account.signAndComputeBroadcast(template)
true
} catch (e: Exception) {
false
}
override suspend fun publishMove(move: ChessMoveEvent): Boolean =
try {
val template =
LiveChessMoveEvent.build(
gameId = move.gameId,
moveNumber = move.moveNumber,
san = move.san,
fen = move.fen,
opponentPubkey = move.opponentPubkey,
)
account.signAndComputeBroadcast(template)
true
} catch (e: Exception) {
false
}
override suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean =
try {
val template =
LiveChessGameEndEvent.build(
gameId = gameEnd.gameId,
result = gameEnd.result,
termination = gameEnd.termination,
winnerPubkey = gameEnd.winnerPubkey,
opponentPubkey = gameEnd.opponentPubkey,
pgn = gameEnd.pgn ?: "",
)
account.signAndComputeBroadcast(template)
true
} catch (e: Exception) {
false
}
override suspend fun publishDrawOffer(
gameId: String,
opponentPubkey: String,
message: String?,
): Boolean =
try {
val template =
LiveChessDrawOfferEvent.build(
gameId = gameId,
opponentPubkey = opponentPubkey,
message = message ?: "",
)
account.signAndComputeBroadcast(template)
true
} catch (e: Exception) {
false
}
override fun getWriteRelayCount(): Int = account.outboxRelays.flow.value.size
}
/**
* Android implementation of ChessRelayFetcher.
* Uses LocalCache for game state (hybrid approach during migration).
*/
class AndroidRelayFetcher(
private val account: Account,
) : ChessRelayFetcher {
override suspend fun fetchGameEvents(gameId: String): ChessGameEvents {
// Query LocalCache for all game events
val challengeNotes =
LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note ->
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
event.gameId() == gameId
}
val challengeEvent = challengeNotes.firstOrNull()?.event as? LiveChessGameChallengeEvent
val acceptNotes =
LocalCache.addressables.filterIntoSet(LiveChessGameAcceptEvent.KIND) { _, note ->
val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false
event.gameId() == gameId
}
val acceptEvent = acceptNotes.firstOrNull()?.event as? LiveChessGameAcceptEvent
val moveNotes =
LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note ->
val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
event.gameId() == gameId
}
val moveEvents = moveNotes.mapNotNull { it.event as? LiveChessMoveEvent }
val endNotes =
LocalCache.addressables.filterIntoSet(LiveChessGameEndEvent.KIND) { _, note ->
val event = note.event as? LiveChessGameEndEvent ?: return@filterIntoSet false
event.gameId() == gameId
}
val endEvent = endNotes.firstOrNull()?.event as? LiveChessGameEndEvent
return ChessGameEvents(
challenge = challengeEvent,
accept = acceptEvent,
moves = moveEvents,
end = endEvent,
drawOffers = emptyList(), // TODO: Fetch draw offers
)
}
override suspend fun fetchChallenges(): List<LiveChessGameChallengeEvent> {
val challengeNotes =
LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note ->
note.event is LiveChessGameChallengeEvent
}
return challengeNotes.mapNotNull { it.event as? LiveChessGameChallengeEvent }
}
override suspend fun fetchRecentGames(): List<RelayGameSummary> {
// Query LocalCache for recent active games
// Group moves by game ID and create summaries
val moveNotes =
LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note ->
note.event is LiveChessMoveEvent
}
val gameIds = moveNotes.mapNotNull { (it.event as? LiveChessMoveEvent)?.gameId() }.distinct()
return gameIds.mapNotNull { gameId ->
val events = fetchGameEvents(gameId)
val challenge = events.challenge ?: return@mapNotNull null
val accept = events.accept
// Determine white/black pubkeys
val challengerColor = challenge.playerColor() ?: Color.WHITE
val whitePubkey =
if (challengerColor == Color.WHITE) {
challenge.pubKey
} else {
accept?.pubKey ?: return@mapNotNull null
}
val blackPubkey =
if (challengerColor == Color.BLACK) {
challenge.pubKey
} else {
accept?.pubKey ?: return@mapNotNull null
}
val lastMove = events.moves.maxByOrNull { it.createdAt }
RelayGameSummary(
gameId = gameId,
whitePubkey = whitePubkey,
blackPubkey = blackPubkey,
moveCount = events.moves.size,
lastMoveTime = lastMove?.createdAt ?: challenge.createdAt,
isActive = events.end == null,
)
}
}
}
/**
* Android implementation of IUserMetadataProvider.
* Wraps LocalCache.getOrCreateUser() for user metadata lookup.
*/
class AndroidMetadataProvider : IUserMetadataProvider {
override fun getDisplayName(pubkey: String): String {
val user = LocalCache.getOrCreateUser(pubkey)
return user.info?.bestName()
?: user.pubkeyDisplayHex()
}
override fun getPictureUrl(pubkey: String): String? {
val user = LocalCache.getOrCreateUser(pubkey)
return user.info?.profilePicture()
}
}
@@ -23,21 +23,32 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -82,14 +93,29 @@ fun ChessGameScreen(
) )
val activeGames by chessViewModel.activeGames.collectAsState() val activeGames by chessViewModel.activeGames.collectAsState()
val spectatingGames by chessViewModel.spectatingGames.collectAsState()
val error by chessViewModel.error.collectAsState() val error by chessViewModel.error.collectAsState()
val chessStatus by chessViewModel.chessStatus.collectAsState()
var isLoading by remember { mutableStateOf(true) } var isLoading by remember { mutableStateOf(true) }
var loadedGameState by remember { mutableStateOf<LiveChessGameState?>(null) } var loadedGameState by remember { mutableStateOf<LiveChessGameState?>(null) }
var loadAttempted by remember { mutableStateOf(false) } var loadAttempted by remember { mutableStateOf(false) }
var showRelaySettings by remember { mutableStateOf(false) }
// Try to load game from cache if not in activeGames // Get relay information
LaunchedEffect(gameId, activeGames) { val outboxRelays by accountViewModel.account.outboxRelays.flow
val existingState = activeGames[gameId] .collectAsState()
val writeRelays =
remember(outboxRelays) {
outboxRelays.map { it.toString() }
}
// Subscribe to chess events when game screen is visible
// This is critical - without it, no new events will arrive from relays
ChessSubscription(accountViewModel, chessViewModel)
// Try to load game from cache if not in activeGames or spectatingGames
LaunchedEffect(gameId, activeGames, spectatingGames) {
val existingState = activeGames[gameId] ?: spectatingGames[gameId]
if (existingState != null) { if (existingState != null) {
loadedGameState = existingState loadedGameState = existingState
isLoading = false isLoading = false
@@ -103,9 +129,9 @@ fun ChessGameScreen(
} }
} }
// Update state when activeGames changes (e.g., after refresh loads new moves) // Update state when games change (e.g., after refresh loads new moves)
LaunchedEffect(activeGames[gameId]) { LaunchedEffect(activeGames[gameId], spectatingGames[gameId]) {
activeGames[gameId]?.let { (activeGames[gameId] ?: spectatingGames[gameId])?.let {
loadedGameState = it loadedGameState = it
} }
} }
@@ -118,21 +144,47 @@ fun ChessGameScreen(
} }
} }
val gameState = loadedGameState ?: activeGames[gameId] val gameState = loadedGameState ?: activeGames[gameId] ?: spectatingGames[gameId]
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( Column {
title = { Text("Chess Game") }, TopAppBar(
navigationIcon = { title = { Text("Chess Game") },
IconButton(onClick = { nav.popBack() }) { navigationIcon = {
Icon( IconButton(onClick = { nav.popBack() }) {
imageVector = Icons.AutoMirrored.Filled.ArrowBack, Icon(
contentDescription = "Back", imageVector = Icons.AutoMirrored.Filled.ArrowBack,
) contentDescription = "Back",
} )
}, }
) },
actions = {
IconButton(onClick = { showRelaySettings = true }) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Relay Settings",
)
}
},
)
// Status banner below top bar
ChessStatusBanner(
status = chessStatus,
onTap = {
when (chessStatus) {
is ChessStatus.MoveFailed -> {
// Could implement retry logic here
}
is ChessStatus.Desynced -> {
chessViewModel.forceRefresh()
}
else -> { }
}
},
)
}
}, },
) { paddingValues -> ) { paddingValues ->
when { when {
@@ -214,4 +266,112 @@ fun ChessGameScreen(
} }
} }
} }
// Relay settings bottom sheet
if (showRelaySettings) {
ModalBottomSheet(
onDismissRequest = { showRelaySettings = false },
sheetState = rememberModalBottomSheetState(),
) {
RelaySettingsSheet(
writeRelays = writeRelays,
gameId = gameId,
)
}
}
}
/**
* Bottom sheet showing relay settings for the chess game
*/
@Composable
private fun RelaySettingsSheet(
writeRelays: List<String>,
gameId: String,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text(
text = "Chess Game Relays",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Moves are broadcast to these relays:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
if (writeRelays.isEmpty()) {
Text(
text = "No write relays configured",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(writeRelays) { relay ->
RelayItem(relayUrl = relay, isConnected = true)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Game ID",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = gameId,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(32.dp))
}
}
@Composable
private fun RelayItem(
relayUrl: String,
isConnected: Boolean,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Circle,
contentDescription = if (isConnected) "Connected" else "Disconnected",
tint = if (isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
Text(
text = relayUrl.removePrefix("wss://").removePrefix("ws://"),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
}
} }
@@ -31,24 +31,30 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -71,6 +77,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -92,15 +99,27 @@ fun ChessLobbyScreen(
) )
val activeGames by chessViewModel.activeGames.collectAsState() val activeGames by chessViewModel.activeGames.collectAsState()
val spectatingGames by chessViewModel.spectatingGames.collectAsState()
val publicGames by chessViewModel.publicGames.collectAsState()
val challenges by chessViewModel.challenges.collectAsState() val challenges by chessViewModel.challenges.collectAsState()
val error by chessViewModel.error.collectAsState() val error by chessViewModel.error.collectAsState()
val chessStatus by chessViewModel.chessStatus.collectAsState()
val selectedGameId by chessViewModel.selectedGameId.collectAsState() val selectedGameId by chessViewModel.selectedGameId.collectAsState()
val userPubkey = accountViewModel.account.userProfile().pubkeyHex val userPubkey = accountViewModel.account.userProfile().pubkeyHex
var showNewGameDialog by remember { mutableStateOf(false) } var showNewGameDialog by remember { mutableStateOf(false) }
var showRelaySettings by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Get relay information for settings display
val inboxRelays by accountViewModel.account.notificationRelays.flow
.collectAsState()
val outboxRelays by accountViewModel.account.outboxRelays.flow
.collectAsState()
val globalRelays by accountViewModel.account.defaultGlobalRelays.flow
.collectAsState()
// Subscribe to chess events when screen is visible // Subscribe to chess events when screen is visible
ChessSubscription(accountViewModel, chessViewModel) ChessSubscription(accountViewModel, chessViewModel)
@@ -139,6 +158,14 @@ fun ChessLobbyScreen(
) )
} }
}, },
actions = {
IconButton(onClick = { showRelaySettings = true }) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Relay Settings",
)
}
},
) )
}, },
floatingActionButton = { floatingActionButton = {
@@ -170,6 +197,13 @@ fun ChessLobbyScreen(
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) { ) {
// Broadcast status banner
ChessStatusBanner(
status = chessStatus,
onTap = { /* no-op in lobby */ },
modifier = Modifier.padding(bottom = 8.dp),
)
// Error display // Error display
error?.let { errorMsg -> error?.let { errorMsg ->
Card( Card(
@@ -193,6 +227,8 @@ fun ChessLobbyScreen(
ChessLobbyContent( ChessLobbyContent(
challenges = challenges, challenges = challenges,
activeGames = activeGames, activeGames = activeGames,
spectatingGames = spectatingGames,
publicGames = publicGames,
userPubkey = userPubkey, userPubkey = userPubkey,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onAcceptChallenge = { note -> onAcceptChallenge = { note ->
@@ -201,6 +237,10 @@ fun ChessLobbyScreen(
onSelectGame = { gameId -> onSelectGame = { gameId ->
nav.nav(Route.ChessGame(gameId)) nav.nav(Route.ChessGame(gameId))
}, },
onWatchGame = { gameId ->
chessViewModel.loadGameAsSpectator(gameId)
nav.nav(Route.ChessGame(gameId))
},
) )
} }
} }
@@ -216,18 +256,41 @@ fun ChessLobbyScreen(
}, },
) )
} }
// Relay settings bottom sheet
if (showRelaySettings) {
ModalBottomSheet(
onDismissRequest = { showRelaySettings = false },
sheetState = rememberModalBottomSheetState(),
) {
ChessRelaySettingsSheet(
inboxRelays = inboxRelays.map { it.toString() },
outboxRelays = outboxRelays.map { it.toString() },
globalRelays = globalRelays.map { it.toString() },
challengeCount = challenges.size,
publicGameCount = publicGames.size,
)
}
}
} }
@Composable @Composable
fun ChessLobbyContent( fun ChessLobbyContent(
challenges: List<Note>, challenges: List<Note>,
activeGames: Map<String, LiveChessGameState>, activeGames: Map<String, LiveChessGameState>,
spectatingGames: Map<String, LiveChessGameState>,
publicGames: List<PublicGameInfo>,
userPubkey: String, userPubkey: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onAcceptChallenge: (Note) -> Unit, onAcceptChallenge: (Note) -> Unit,
onSelectGame: (String) -> Unit, onSelectGame: (String) -> Unit,
onWatchGame: (String) -> Unit,
) { ) {
if (activeGames.isEmpty() && challenges.isEmpty()) { val hasContent =
activeGames.isNotEmpty() || spectatingGames.isNotEmpty() ||
publicGames.isNotEmpty() || challenges.isNotEmpty()
if (!hasContent) {
// Empty state // Empty state
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -255,18 +318,18 @@ fun ChessLobbyContent(
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
// Active games section // Active games section (user is participant)
if (activeGames.isNotEmpty()) { if (activeGames.isNotEmpty()) {
item { item {
Text( Text(
"Active Games", "Your Games",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp), modifier = Modifier.padding(vertical = 8.dp),
) )
} }
items(activeGames.entries.toList(), key = { it.key }) { (gameId, state) -> items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) ->
ActiveGameCard( ActiveGameCard(
gameId = gameId, gameId = gameId,
opponentPubkey = state.opponentPubkey, opponentPubkey = state.opponentPubkey,
@@ -277,6 +340,28 @@ fun ChessLobbyContent(
} }
} }
// Games user is spectating
if (spectatingGames.isNotEmpty()) {
item {
Spacer(Modifier.height(16.dp))
Text(
"Watching",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp),
)
}
items(spectatingGames.entries.toList(), key = { "spectating-${it.key}" }) { (gameId, state) ->
SpectatingGameCard(
gameId = gameId,
gameState = state,
accountViewModel = accountViewModel,
onClick = { onSelectGame(gameId) },
)
}
}
// Incoming challenges // Incoming challenges
val incomingChallenges = val incomingChallenges =
challenges.filter { challenges.filter {
@@ -294,7 +379,7 @@ fun ChessLobbyContent(
) )
} }
items(incomingChallenges, key = { it.idHex }) { note -> items(incomingChallenges, key = { "incoming-${it.idHex}" }) { note ->
ChallengeCard( ChallengeCard(
note = note, note = note,
isIncoming = true, isIncoming = true,
@@ -304,31 +389,7 @@ fun ChessLobbyContent(
} }
} }
// User's outgoing challenges // Open challenges from others (can join)
val outgoingChallenges =
challenges.filter {
it.author?.pubkeyHex == userPubkey
}
if (outgoingChallenges.isNotEmpty()) {
item {
Spacer(Modifier.height(16.dp))
Text(
"Your Challenges",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp),
)
}
items(outgoingChallenges, key = { it.idHex }) { note ->
OutgoingChallengeCard(
note = note,
accountViewModel = accountViewModel,
)
}
}
// Open challenges from others
val openChallenges = val openChallenges =
challenges.filter { challenges.filter {
val event = it.event as? LiveChessGameChallengeEvent val event = it.event as? LiveChessGameChallengeEvent
@@ -345,7 +406,7 @@ fun ChessLobbyContent(
) )
} }
items(openChallenges, key = { it.idHex }) { note -> items(openChallenges, key = { "open-${it.idHex}" }) { note ->
ChallengeCard( ChallengeCard(
note = note, note = note,
isIncoming = false, isIncoming = false,
@@ -355,6 +416,51 @@ fun ChessLobbyContent(
} }
} }
// Live games to watch (public games user is not part of)
if (publicGames.isNotEmpty()) {
item {
Spacer(Modifier.height(16.dp))
Text(
"Live Games",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp),
)
}
items(publicGames, key = { "public-${it.gameId}" }) { game ->
PublicGameCard(
game = game,
accountViewModel = accountViewModel,
onWatch = { onWatchGame(game.gameId) },
)
}
}
// User's outgoing challenges (last, as they're just waiting)
val outgoingChallenges =
challenges.filter {
it.author?.pubkeyHex == userPubkey
}
if (outgoingChallenges.isNotEmpty()) {
item {
Spacer(Modifier.height(16.dp))
Text(
"Your Challenges",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp),
)
}
items(outgoingChallenges, key = { "outgoing-${it.idHex}" }) { note ->
OutgoingChallengeCard(
note = note,
accountViewModel = accountViewModel,
)
}
}
// Bottom padding for FAB // Bottom padding for FAB
item { item {
Spacer(Modifier.height(80.dp)) Spacer(Modifier.height(80.dp))
@@ -375,6 +481,12 @@ private fun ActiveGameCard(
accountViewModel.checkGetOrCreateUser(opponentPubkey)?.toBestDisplayName() ?: opponentPubkey.take(8) accountViewModel.checkGetOrCreateUser(opponentPubkey)?.toBestDisplayName() ?: opponentPubkey.take(8)
} }
// Extract human-readable game name if available
val gameName =
remember(gameId) {
ChessGameNameGenerator.extractDisplayName(gameId) ?: gameId.take(12)
}
Card( Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
border = border =
@@ -391,14 +503,14 @@ private fun ActiveGameCard(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
"vs $displayName", gameName,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
) )
Text( Text(
"Game: ${gameId.take(12)}...", "vs $displayName",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
@@ -515,3 +627,277 @@ private fun OutgoingChallengeCard(
} }
} }
} }
@Composable
private fun SpectatingGameCard(
gameId: String,
gameState: LiveChessGameState,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val moveCount =
gameState.moveHistory
.collectAsState()
.value.size
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
border = BorderStroke(1.dp, Color(0xFF9C27B0)), // Purple for spectating
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
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 = Color(0xFF9C27B0),
fontWeight = FontWeight.Medium,
)
}
}
}
@Composable
private fun PublicGameCard(
game: PublicGameInfo,
accountViewModel: AccountViewModel,
onWatch: () -> Unit,
) {
val whiteName =
remember(game.whitePubkey) {
accountViewModel.checkGetOrCreateUser(game.whitePubkey)?.toBestDisplayName() ?: game.whitePubkey.take(8)
}
val blackName =
remember(game.blackPubkey) {
accountViewModel.checkGetOrCreateUser(game.blackPubkey)?.toBestDisplayName() ?: game.blackPubkey.take(8)
}
Card(
modifier = Modifier.fillMaxWidth(),
border = BorderStroke(1.dp, Color(0xFF2196F3)), // Blue for live games
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"$whiteName vs $blackName",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
"${game.moveCount} moves",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(onClick = onWatch) {
Text("Watch")
}
}
}
}
/**
* Bottom sheet showing relay settings and debug info for chess subscriptions
*/
@Composable
private fun ChessRelaySettingsSheet(
inboxRelays: List<String>,
outboxRelays: List<String>,
globalRelays: List<String>,
challengeCount: Int,
publicGameCount: Int,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text(
text = "Chess Relay Settings",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(16.dp))
// Stats section
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "$challengeCount",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Challenges",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "$publicGameCount",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Live Games",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Inbox relays (where challenges TO you are fetched)
Text(
text = "Inbox Relays (${inboxRelays.size})",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Text(
text = "Personal challenges are fetched from here",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
if (inboxRelays.isEmpty()) {
Text(
text = "No inbox relays configured",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
} else {
inboxRelays.take(5).forEach { relay ->
RelayRow(relayUrl = relay)
}
if (inboxRelays.size > 5) {
Text(
text = "+${inboxRelays.size - 5} more",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Global relays (where open challenges are fetched)
Text(
text = "Global Relays (${globalRelays.size})",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Text(
text = "Open challenges and public games are fetched from here",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
if (globalRelays.isEmpty()) {
Text(
text = "No global relays configured - open challenges won't load!",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
} else {
globalRelays.take(5).forEach { relay ->
RelayRow(relayUrl = relay)
}
if (globalRelays.size > 5) {
Text(
text = "+${globalRelays.size - 5} more",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Outbox relays (where your challenges are published)
Text(
text = "Outbox Relays (${outboxRelays.size})",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Text(
text = "Your challenges and moves are published here",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
if (outboxRelays.isEmpty()) {
Text(
text = "No outbox relays configured",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
} else {
outboxRelays.take(5).forEach { relay ->
RelayRow(relayUrl = relay)
}
if (outboxRelays.size > 5) {
Text(
text = "+${outboxRelays.size - 5} more",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
@Composable
private fun RelayRow(relayUrl: String) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "Connected",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(12.dp),
)
Text(
text = relayUrl.removePrefix("wss://").removePrefix("ws://"),
style = MaterialTheme.typography.bodySmall,
)
}
}
@@ -25,75 +25,61 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionState
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter 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.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/** /**
* Default "since" window for challenges (24 hours) * Subscribe to chess events when the Chess screen is active.
* Challenges older than this are considered expired anyway *
*/ * Always uses global relays to ensure we see all chess events,
private const val CHALLENGE_WINDOW_SECONDS = 24 * 60 * 60L * regardless of the user's feed filter setting (Global/Follows).
/**
* Default "since" window for game events when no EOSE cache exists (7 days)
* This prevents loading ancient game history on first connection
*/
private const val GAME_EVENT_WINDOW_SECONDS = 7 * 24 * 60 * 60L
/**
* Subscribe to chess events when the Chess tab is active.
* Respects the Global/Follows filter from the discovery top bar.
*/ */
@Composable @Composable
fun ChessSubscription( fun ChessSubscription(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
chessViewModel: ChessViewModel, chessViewModel: ChessViewModel,
) { ) {
// Observe the current filter selection (Global, All Follows, etc.)
val filterSelection by accountViewModel.account.settings.defaultDiscoveryFollowList
.collectAsStateWithLifecycle()
val isGlobal = filterSelection == GLOBAL_FOLLOWS
// Get active game IDs from the view model for game-specific subscriptions // Get active game IDs from the view model for game-specific subscriptions
val activeGames by chessViewModel.activeGames.collectAsStateWithLifecycle() val activeGames by chessViewModel.activeGames.collectAsStateWithLifecycle()
val activeGameIds = activeGames.keys val spectatingGames by chessViewModel.spectatingGames.collectAsStateWithLifecycle()
val activeGameIds = activeGames.keys + spectatingGames.keys
// Extract opponent pubkeys for move filtering
val opponentPubkeys =
remember(activeGames) {
activeGames.values.map { it.opponentPubkey }.toSet()
}
val state = val state =
remember(accountViewModel.account, isGlobal, activeGameIds) { remember(accountViewModel.account, activeGameIds, opponentPubkeys) {
ChessQueryState( ChessQueryState(
userPubkey = accountViewModel.account.userProfile().pubkeyHex, userPubkey = accountViewModel.account.userProfile().pubkeyHex,
// Use notification inbox relays - where others send events TO us // Use notification inbox relays - where others send events TO us
inboxRelays = accountViewModel.account.notificationRelays.flow.value, inboxRelays = accountViewModel.account.notificationRelays.flow.value,
// Use proxy relays for global, outbox relays for follows // Always use global relays for chess - we want to see ALL games
globalRelays = globalRelays = accountViewModel.account.defaultGlobalRelays.flow.value,
if (isGlobal) { // Always treat as global for chess
accountViewModel.account.defaultGlobalRelays.flow.value isGlobal = true,
} else {
accountViewModel.account.followOutboxesOrProxy.flow.value
},
isGlobal = isGlobal,
activeGameIds = activeGameIds, activeGameIds = activeGameIds,
opponentPubkeys = opponentPubkeys,
) )
} }
// Note: Chess subscription is now managed internally by ChessLobbyLogic.
// This composable is kept for backward compatibility but the actual
// subscription is handled by the ViewModel's ChessLobbyLogic instance.
// TODO: Step 7 will integrate this with the new ChessSubscriptionController
DisposableEffect(state) { DisposableEffect(state) {
accountViewModel.dataSources().chess.subscribe(state)
chessViewModel.refreshChallenges() chessViewModel.refreshChallenges()
onDispose { onDispose {
accountViewModel.dataSources().chess.unsubscribe(state) // Subscription cleanup handled by ViewModel
} }
} }
} }
@@ -107,6 +93,7 @@ data class ChessQueryState(
val globalRelays: Set<NormalizedRelayUrl>, val globalRelays: Set<NormalizedRelayUrl>,
val isGlobal: Boolean, val isGlobal: Boolean,
val activeGameIds: Set<String> = emptySet(), val activeGameIds: Set<String> = emptySet(),
val opponentPubkeys: Set<String> = emptySet(),
) )
/** /**
@@ -143,128 +130,33 @@ class ChessFeedFilterSubAssembler(
} }
/** /**
* Create relay filters for chess events. * Create relay filters for chess events using the shared [ChessFilterBuilder].
* *
* Creates filters based on the Global/Follows setting: * Following jesterui's approach:
* - Global: Fetch all chess events from global relays * 1. Fetch ALL challenges from ALL connected relays (no author/tag restriction)
* - Follows: Fetch from followed users' outbox relays * 2. Filter client-side based on user's preferences (Global/Follows)
* 3. Game-specific subscriptions for active games
* *
* Also always fetches challenges directed at the user from inbox relays. * This ensures we see:
* * - Open challenges from anyone
* Improvements over basic implementation: * - Challenges directed at us
* 1. Default "since" timestamps to avoid loading very old events on first connection * - Public games to spectate
* 2. Separate challenge filter (shorter window) from game events (longer window)
* 3. Game-specific subscriptions for active games using #a tag references
* 4. Grouped filters by relay to reduce subscription overhead
*/ */
fun filterChessEvents( fun filterChessEvents(
key: ChessQueryState, key: ChessQueryState,
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter> { ): List<RelayBasedFilter> {
val filters = mutableListOf<RelayBasedFilter>() // Convert Android ChessQueryState to shared ChessSubscriptionState
val now = TimeUtils.now() val state =
ChessSubscriptionState(
// Challenge kinds only (for lobby display) userPubkey = key.userPubkey,
val challengeKinds = relays = key.inboxRelays + key.globalRelays,
listOf( activeGameIds = key.activeGameIds,
LiveChessGameChallengeEvent.KIND, opponentPubkeys = key.opponentPubkeys,
LiveChessGameAcceptEvent.KIND,
) )
// Move/end kinds (for active games) // Use shared filter builder for consistent behavior with Desktop
val gameEventKinds = return ChessFilterBuilder.buildAllFilters(state) { relay ->
listOf( since?.get(relay)?.time
LiveChessMoveEvent.KIND,
LiveChessGameEndEvent.KIND,
)
// All chess kinds
val allChessKinds = challengeKinds + gameEventKinds
// ========================================
// Filter 1: Personal challenges from inbox relays
// Uses 24h window for challenges (they expire anyway)
// ========================================
val inboxFilter =
Filter(
kinds = allChessKinds,
tags = mapOf("p" to listOf(key.userPubkey)),
limit = 100,
)
for (relay in key.inboxRelays) {
val sinceTime = since?.get(relay)?.time ?: (now - CHALLENGE_WINDOW_SECONDS)
filters.add(
RelayBasedFilter(
relay = relay,
filter = inboxFilter.copy(since = sinceTime),
),
)
} }
// ========================================
// Filter 2: General challenges from global/outbox relays
// Uses 24h window to show recent open challenges
// ========================================
val globalChallengeFilter =
Filter(
kinds = challengeKinds,
limit = 50,
)
for (relay in key.globalRelays) {
val sinceTime = since?.get(relay)?.time ?: (now - CHALLENGE_WINDOW_SECONDS)
filters.add(
RelayBasedFilter(
relay = relay,
filter = globalChallengeFilter.copy(since = sinceTime),
),
)
}
// ========================================
// Filter 3: Active game events
// Uses longer window (7 days) or no window for active games
// Follows jesterui pattern: subscribe to game-specific events via #d tag
// ========================================
if (key.activeGameIds.isNotEmpty()) {
// Create filters for each active game's events
// Using #d tag to match the game_id in addressable events
val gameSpecificFilter =
Filter(
kinds = gameEventKinds,
tags = mapOf("d" to key.activeGameIds.toList()),
limit = 200, // More moves per game
)
// Query all relays for active game events (no since - need full history)
val allRelays = key.inboxRelays + key.globalRelays
for (relay in allRelays) {
filters.add(
RelayBasedFilter(
relay = relay,
filter = gameSpecificFilter,
),
)
}
} else {
// No active games - use general game event filter with window
val generalGameFilter =
Filter(
kinds = gameEventKinds,
limit = 100,
)
for (relay in key.globalRelays) {
val sinceTime = since?.get(relay)?.time ?: (now - GAME_EVENT_WINDOW_SECONDS)
filters.add(
RelayBasedFilter(
relay = relay,
filter = generalGameFilter.copy(since = sinceTime),
),
)
}
}
return filters
} }
@@ -29,14 +29,17 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.quartz.nip64Chess.ChessEngine import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.GameResult
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.PieceType
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -44,7 +47,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID
/** /**
* ViewModel for managing chess game state, challenges, and event publishing * ViewModel for managing chess game state, challenges, and event publishing
@@ -64,11 +66,19 @@ class ChessViewModel(
const val CLEANUP_INTERVAL_MS = 5 * 60 * 1000L const val CLEANUP_INTERVAL_MS = 5 * 60 * 1000L
} }
// Active games being played // Active games being played (user is a participant)
private val _activeGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap()) private val _activeGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap())
val activeGames: StateFlow<Map<String, LiveChessGameState>> = _activeGames.asStateFlow() val activeGames: StateFlow<Map<String, LiveChessGameState>> = _activeGames.asStateFlow()
// Pending challenges (incoming and outgoing) // Public games we can spectate (user is NOT a participant)
private val _publicGames = MutableStateFlow<List<PublicGameInfo>>(emptyList())
val publicGames: StateFlow<List<PublicGameInfo>> = _publicGames.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()
// Pending challenges (ALL non-expired challenges)
private val _challenges = MutableStateFlow<List<Note>>(emptyList()) private val _challenges = MutableStateFlow<List<Note>>(emptyList())
val challenges: StateFlow<List<Note>> = _challenges.asStateFlow() val challenges: StateFlow<List<Note>> = _challenges.asStateFlow()
@@ -88,6 +98,14 @@ class ChessViewModel(
private val _pendingRetries = MutableStateFlow<Map<String, RetryOperation>>(emptyMap()) private val _pendingRetries = MutableStateFlow<Map<String, RetryOperation>>(emptyMap())
val pendingRetries: StateFlow<Map<String, RetryOperation>> = _pendingRetries.asStateFlow() val pendingRetries: StateFlow<Map<String, RetryOperation>> = _pendingRetries.asStateFlow()
// Chess status for UI feedback (broadcasting, syncing, etc.)
private val _chessStatus = MutableStateFlow<ChessStatus>(ChessStatus.Idle)
val chessStatus: StateFlow<ChessStatus> = _chessStatus.asStateFlow()
// Connected relays for display
private val _connectedRelays = MutableStateFlow<List<String>>(emptyList())
val connectedRelays: StateFlow<List<String>> = _connectedRelays.asStateFlow()
// Polling delegate for periodic refresh // Polling delegate for periodic refresh
private val pollingDelegate = private val pollingDelegate =
ChessPollingDelegate( ChessPollingDelegate(
@@ -101,6 +119,9 @@ class ChessViewModel(
}, },
) )
// Track games currently being created to prevent race conditions (like Desktop)
private val gamesBeingCreated = mutableSetOf<String>()
init { init {
subscribeToChessEvents() subscribeToChessEvents()
// Start polling - will do initial fetch // Start polling - will do initial fetch
@@ -201,6 +222,11 @@ class ChessViewModel(
* Handle game acceptance event * Handle game acceptance event
*/ */
private fun handleGameAccepted(event: LiveChessGameAcceptEvent) { private fun handleGameAccepted(event: LiveChessGameAcceptEvent) {
val gameId = event.gameId() ?: return
// Skip if game already exists (prevents overwrite)
if (_activeGames.value.containsKey(gameId)) return
// Check if this is acceptance of our challenge // Check if this is acceptance of our challenge
if (event.challengerPubkey() == account.signer.pubKey) { if (event.challengerPubkey() == account.signer.pubKey) {
startGameFromAcceptance(event) startGameFromAcceptance(event)
@@ -267,6 +293,14 @@ class ChessViewModel(
val gameId = generateGameId() val gameId = generateGameId()
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val relayCount = acc.outboxRelays.flow.value.size
_chessStatus.value =
ChessStatus.BroadcastingMove(
san = "Challenge",
successCount = 0,
totalRelays = relayCount,
)
val success = val success =
retryWithBackoff("challenge-$gameId") { retryWithBackoff("challenge-$gameId") {
val template = val template =
@@ -281,8 +315,23 @@ class ChessViewModel(
} }
if (success) { if (success) {
_chessStatus.value =
ChessStatus.MoveSuccess(
san = "Challenge",
relayCount = relayCount,
)
_error.value = null _error.value = null
refreshChallenges()
delay(3000)
if (_chessStatus.value is ChessStatus.MoveSuccess) {
_chessStatus.value = ChessStatus.Idle
}
} else { } else {
_chessStatus.value =
ChessStatus.MoveFailed(
san = "Challenge",
error = "Failed after $MAX_RETRIES attempts",
)
_error.value = "Failed to create challenge after $MAX_RETRIES attempts" _error.value = "Failed to create challenge after $MAX_RETRIES attempts"
} }
} }
@@ -297,6 +346,11 @@ class ChessViewModel(
challengerPubkey: String, challengerPubkey: String,
playerColor: Color, playerColor: Color,
) { ) {
// Mark as being created to prevent duplicate from relay echo
synchronized(gamesBeingCreated) {
if (!gamesBeingCreated.add(gameId)) return // Already being created
}
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val template = val template =
@@ -308,6 +362,16 @@ class ChessViewModel(
account.signAndComputeBroadcast(template) account.signAndComputeBroadcast(template)
// Check if game was already created by relay echo while we were broadcasting
if (_activeGames.value.containsKey(gameId)) {
synchronized(gamesBeingCreated) {
gamesBeingCreated.remove(gameId)
}
_selectedGameId.value = gameId
_error.value = null
return@launch
}
// Create game state // Create game state
val engine = ChessEngine() val engine = ChessEngine()
engine.reset() engine.reset()
@@ -322,10 +386,16 @@ class ChessViewModel(
) )
_activeGames.value = _activeGames.value + (gameId to gameState) _activeGames.value = _activeGames.value + (gameId to gameState)
synchronized(gamesBeingCreated) {
gamesBeingCreated.remove(gameId)
}
pollingDelegate.addGameId(gameId) pollingDelegate.addGameId(gameId)
_selectedGameId.value = gameId _selectedGameId.value = gameId
_error.value = null _error.value = null
} catch (e: Exception) { } catch (e: Exception) {
synchronized(gamesBeingCreated) {
gamesBeingCreated.remove(gameId)
}
_error.value = "Failed to accept challenge: ${e.message}" _error.value = "Failed to accept challenge: ${e.message}"
} }
} }
@@ -338,6 +408,13 @@ class ChessViewModel(
val challengeEvent = challengeNote.event as? LiveChessGameChallengeEvent ?: return val challengeEvent = challengeNote.event as? LiveChessGameChallengeEvent ?: return
val gameId = challengeEvent.gameId() ?: return val gameId = challengeEvent.gameId() ?: return
// Skip if game already exists (prevents overwrite)
if (_activeGames.value.containsKey(gameId)) {
_selectedGameId.value = gameId
return
}
val challengerPubkey = challengeEvent.pubKey val challengerPubkey = challengeEvent.pubKey
val challengerColor = challengeEvent.playerColor() ?: Color.WHITE val challengerColor = challengeEvent.playerColor() ?: Color.WHITE
val playerColor = challengerColor.opposite() val playerColor = challengerColor.opposite()
@@ -353,6 +430,13 @@ class ChessViewModel(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val gameId = acceptEvent.gameId() ?: return@launch val gameId = acceptEvent.gameId() ?: return@launch
// Skip if game already exists or is being created (prevents overwrite)
if (_activeGames.value.containsKey(gameId)) return@launch
synchronized(gamesBeingCreated) {
if (!gamesBeingCreated.add(gameId)) return@launch // Already being created
}
val opponentPubkey = acceptEvent.pubKey val opponentPubkey = acceptEvent.pubKey
// Find our challenge to get player color // Find our challenge to get player color
@@ -376,6 +460,9 @@ class ChessViewModel(
) )
_activeGames.value = _activeGames.value + (gameId to gameState) _activeGames.value = _activeGames.value + (gameId to gameState)
synchronized(gamesBeingCreated) {
gamesBeingCreated.remove(gameId)
}
pollingDelegate.addGameId(gameId) pollingDelegate.addGameId(gameId)
_selectedGameId.value = gameId _selectedGameId.value = gameId
} }
@@ -390,12 +477,36 @@ class ChessViewModel(
to: String, to: String,
) { ) {
val gameState = _activeGames.value[gameId] ?: return val gameState = _activeGames.value[gameId] ?: return
val moveResult = gameState.makeMove(from, to)
// Parse promotion from 'to' if present (e.g., "e8q" -> square="e8", promotion=QUEEN)
val (targetSquare, promotion) = parsePromotionFromTarget(to)
val moveResult = gameState.makeMove(from, targetSquare, promotion)
if (moveResult != null) { if (moveResult != null) {
publishMove(gameId, moveResult) publishMove(gameId, moveResult)
} }
} }
/**
* Parse promotion piece from target square string.
* e.g., "e8q" -> ("e8", QUEEN), "e4" -> ("e4", null)
*/
private fun parsePromotionFromTarget(to: String): Pair<String, PieceType?> {
if (to.length == 3) {
val square = to.substring(0, 2)
val promotion =
when (to[2].lowercaseChar()) {
'q' -> PieceType.QUEEN
'r' -> PieceType.ROOK
'b' -> PieceType.BISHOP
'n' -> PieceType.KNIGHT
else -> null
}
return square to promotion
}
return to to null
}
/** /**
* Publish a move for the current game (full version with ChessMoveEvent) * Publish a move for the current game (full version with ChessMoveEvent)
*/ */
@@ -405,6 +516,15 @@ class ChessViewModel(
) { ) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
// Update status to broadcasting
val relayCount = account.outboxRelays.flow.value.size
_chessStatus.value =
ChessStatus.BroadcastingMove(
san = moveEvent.san,
successCount = 0,
totalRelays = relayCount,
)
val template = val template =
LiveChessMoveEvent.build( LiveChessMoveEvent.build(
gameId = moveEvent.gameId, gameId = moveEvent.gameId,
@@ -415,8 +535,33 @@ class ChessViewModel(
) )
account.signAndComputeBroadcast(template) account.signAndComputeBroadcast(template)
// Update status to success
_chessStatus.value =
ChessStatus.MoveSuccess(
san = moveEvent.san,
relayCount = relayCount,
)
// Clear status after delay
delay(3000)
if (_chessStatus.value is ChessStatus.MoveSuccess) {
val gameState = _activeGames.value[gameId]
_chessStatus.value =
if (gameState?.isPlayerTurn() == false) {
ChessStatus.WaitingForOpponent
} else {
ChessStatus.Idle
}
}
_error.value = null _error.value = null
} catch (e: Exception) { } catch (e: Exception) {
_chessStatus.value =
ChessStatus.MoveFailed(
san = moveEvent.san,
error = e.message ?: "Unknown error",
)
_error.value = "Failed to publish move: ${e.message}" _error.value = "Failed to publish move: ${e.message}"
} }
} }
@@ -508,28 +653,241 @@ class ChessViewModel(
/** /**
* Refresh challenges from local cache * Refresh challenges from local cache
*
* Following jesterui pattern: fetch ALL non-expired challenges,
* let UI filter by category (incoming, outgoing, open)
*
* NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables
*/ */
fun refreshChallenges() { fun refreshChallenges() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val userPubkey = account.signer.pubKey val userPubkey = account.signer.pubKey
val now = TimeUtils.now() val now = TimeUtils.now()
// Query LocalCache for chess challenge events // Query LocalCache.addressables for chess challenge events (kind 30064)
// Chess events are addressable events, not regular notes!
val challengeNotes = val challengeNotes =
LocalCache.notes.filterIntoSet { _, note -> LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note ->
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
// Check if challenge is not expired // Check if challenge is not expired (24h)
val createdAt = event.createdAt val createdAt = event.createdAt
if ((now - createdAt) >= CHALLENGE_EXPIRY_SECONDS) return@filterIntoSet false if ((now - createdAt) >= CHALLENGE_EXPIRY_SECONDS) return@filterIntoSet false
// Include challenges directed at us, or open challenges (not from us) // Check if game isn't already active (accepted)
val gameId = event.gameId() ?: return@filterIntoSet false
if (_activeGames.value.containsKey(gameId)) return@filterIntoSet false
// Include all challenges:
// - Directed at us
// - Open challenges (no specific opponent)
// - Our own challenges (waiting for accept)
val opponentPubkey = event.opponentPubkey() val opponentPubkey = event.opponentPubkey()
opponentPubkey == userPubkey || (opponentPubkey == null && event.pubKey != userPubkey) || event.pubKey == userPubkey val isDirectedAtUs = opponentPubkey == userPubkey
val isOpenChallenge = opponentPubkey == null
val isFromUs = event.pubKey == userPubkey
isDirectedAtUs || isOpenChallenge || isFromUs
} }
_challenges.value = challengeNotes.toList() _challenges.value = challengeNotes.toList()
updateBadgeCount() updateBadgeCount()
// Also refresh public games
refreshPublicGames()
}
}
/**
* Refresh list of public games that can be spectated
*
* NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables
*/
private fun refreshPublicGames() {
val userPubkey = account.signer.pubKey
val now = TimeUtils.now()
// Find all games where both challenge and accept exist
// Game ID -> (ChallengeEvent, AcceptEvent)
val gameData = mutableMapOf<String, Pair<LiveChessGameChallengeEvent?, LiveChessGameAcceptEvent?>>()
// Collect challenges from addressables (kind 30064)
LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note ->
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
val gameId = event.gameId() ?: return@filterIntoSet false
gameData[gameId] = (event to gameData[gameId]?.second)
false // Don't need to collect, just iterate
}
// Collect accepts from addressables (kind 30065)
LocalCache.addressables.filterIntoSet(LiveChessGameAcceptEvent.KIND) { _, note ->
val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false
val gameId = event.gameId() ?: return@filterIntoSet false
gameData[gameId] = (gameData[gameId]?.first to event)
false
}
// Filter to games that are active and user is NOT a participant
val publicGames = mutableListOf<PublicGameInfo>()
for ((gameId, data) in gameData) {
val (challenge, accept) = data
if (challenge == null || accept == null) continue
// Skip if user is a participant
val challengerPubkey = challenge.pubKey
val acceptorPubkey = accept.pubKey
if (challengerPubkey == userPubkey || acceptorPubkey == userPubkey) continue
// Skip if already in our active/spectating games
if (_activeGames.value.containsKey(gameId) || _spectatingGames.value.containsKey(gameId)) continue
// Determine white/black based on challenger color
val challengerColor = challenge.playerColor() ?: Color.WHITE
val (whitePubkey, blackPubkey) =
if (challengerColor == Color.WHITE) {
challengerPubkey to acceptorPubkey
} else {
acceptorPubkey to challengerPubkey
}
// Count moves and get last move time from addressables (kind 30066)
var moveCount = 0
var lastMoveTime = accept.createdAt
LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note ->
val moveEvent = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
if (moveEvent.gameId() != gameId) return@filterIntoSet false
moveCount++
if (moveEvent.createdAt > lastMoveTime) {
lastMoveTime = moveEvent.createdAt
}
false
}
// Skip inactive games (no moves in 24 hours)
if ((now - lastMoveTime) > CHALLENGE_EXPIRY_SECONDS) continue
publicGames.add(
PublicGameInfo(
gameId = gameId,
whitePubkey = whitePubkey,
blackPubkey = blackPubkey,
moveCount = moveCount,
lastMoveTime = lastMoveTime,
),
)
}
// Sort by most recent activity
_publicGames.value = publicGames.sortedByDescending { it.lastMoveTime }
}
/**
* Load a game as spectator (watch-only mode)
* Returns the game state or null if loading failed
*
* NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables
*/
fun loadGameAsSpectator(gameId: String): LiveChessGameState? {
// Check if already spectating
_spectatingGames.value[gameId]?.let { return it }
// Check if we're actually a participant (shouldn't load as spectator)
_activeGames.value[gameId]?.let {
_error.value = "You are a participant in this game"
return null
}
val userPubkey = account.signer.pubKey
// Find challenge and accept events from addressables
val challengeNotes =
LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note ->
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
event.gameId() == gameId
}
val acceptNotes =
LocalCache.addressables.filterIntoSet(LiveChessGameAcceptEvent.KIND) { _, note ->
val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false
event.gameId() == gameId
}
val challengeEvent = challengeNotes.firstOrNull()?.event as? LiveChessGameChallengeEvent
val acceptEvent = acceptNotes.firstOrNull()?.event as? LiveChessGameAcceptEvent
if (challengeEvent == null) {
_error.value = "Challenge not found for game"
return null
}
if (acceptEvent == null) {
_error.value = "Game not started yet - waiting for opponent"
return null
}
// Determine white/black
val challengerPubkey = challengeEvent.pubKey
val acceptorPubkey = acceptEvent.pubKey
val challengerColor = challengeEvent.playerColor() ?: Color.WHITE
val (whitePubkey, blackPubkey) =
if (challengerColor == Color.WHITE) {
challengerPubkey to acceptorPubkey
} else {
acceptorPubkey to challengerPubkey
}
// Build engine state by replaying moves from addressables
val engine = ChessEngine()
engine.reset()
val moveNotes =
LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note ->
val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
event.gameId() == gameId
}
val sortedMoves =
moveNotes
.mapNotNull { it.event as? LiveChessMoveEvent }
.sortedBy { it.moveNumber() ?: Int.MAX_VALUE }
for (moveEvent in sortedMoves) {
val san = moveEvent.san() ?: continue
try {
engine.makeMove(san)
} catch (e: Exception) {
_error.value = "Error loading move $san: ${e.message}"
}
}
// Create spectator game state - always view from white's perspective
val gameState =
LiveChessGameState(
gameId = gameId,
playerPubkey = userPubkey,
opponentPubkey = blackPubkey, // Opponent relative to spectator view (white)
playerColor = Color.WHITE, // Spectators see from white's perspective
engine = engine,
isSpectator = true,
)
// Add to spectating games
_spectatingGames.value = _spectatingGames.value + (gameId to gameState)
pollingDelegate.addGameId(gameId)
_error.value = null
return gameState
}
/**
* Stop spectating a game
*/
fun stopSpectating(gameId: String) {
if (_spectatingGames.value.containsKey(gameId)) {
_spectatingGames.value = _spectatingGames.value - gameId
pollingDelegate.removeGameId(gameId)
} }
} }
@@ -629,32 +987,31 @@ class ChessViewModel(
} }
/** /**
* Generate unique game ID * Generate unique game ID with human-readable component
*/ */
private fun generateGameId(): String { private fun generateGameId(): String = ChessGameNameGenerator.generateGameId(TimeUtils.now())
val timestamp = TimeUtils.now()
val random = UUID.randomUUID().toString().take(8)
return "chess-$timestamp-$random"
}
/** /**
* Refresh game state from LocalCache for specific game IDs * Refresh game state from LocalCache for specific game IDs
* Called periodically by polling delegate * Called periodically by polling delegate
*
* NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables
*/ */
private suspend fun refreshGamesFromCache(gameIds: Set<String>) { private suspend fun refreshGamesFromCache(gameIds: Set<String>) {
val userPubkey = account.signer.pubKey val userPubkey = account.signer.pubKey
for (gameId in gameIds) { for (gameId in gameIds) {
// Find moves for this game // Find moves for this game from addressables (kind 30066)
val moveNotes = val moveNotes =
LocalCache.notes.filterIntoSet { _, note -> LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note ->
val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
event.gameId() == gameId event.gameId() == gameId
} }
val gameState = _activeGames.value[gameId] // Check active games first
if (gameState != null) { val activeGameState = _activeGames.value[gameId]
// Apply any new moves if (activeGameState != null) {
// Apply any new moves from opponent
moveNotes moveNotes
.mapNotNull { it.event as? LiveChessMoveEvent } .mapNotNull { it.event as? LiveChessMoveEvent }
.filter { it.pubKey != userPubkey } // Only opponent moves .filter { it.pubKey != userPubkey } // Only opponent moves
@@ -663,9 +1020,29 @@ class ChessViewModel(
val san = moveEvent.san() ?: return@forEach val san = moveEvent.san() ?: return@forEach
val fen = moveEvent.fen() ?: return@forEach val fen = moveEvent.fen() ?: return@forEach
val moveNumber = moveEvent.moveNumber() val moveNumber = moveEvent.moveNumber()
gameState.applyOpponentMove(san, fen, moveNumber) activeGameState.applyOpponentMove(san, fen, moveNumber)
} }
} }
// Check spectating games - apply ALL moves (from both players)
val spectatingGameState = _spectatingGames.value[gameId]
if (spectatingGameState != null) {
moveNotes
.mapNotNull { it.event as? LiveChessMoveEvent }
.sortedBy { it.moveNumber() }
.forEach { moveEvent ->
val san = moveEvent.san() ?: return@forEach
val fen = moveEvent.fen() ?: return@forEach
val moveNumber = moveEvent.moveNumber()
spectatingGameState.applyOpponentMove(san, fen, moveNumber)
}
}
// Game is being polled but not yet active — accept event may have arrived since
// initial load. Retry loading from cache to pick up late-arriving accept events.
if (activeGameState == null && spectatingGameState == null) {
loadGameFromCache(gameId)
}
} }
updateBadgeCount() updateBadgeCount()
@@ -675,6 +1052,8 @@ class ChessViewModel(
* Load or rebuild game state from LocalCache for a specific gameId * Load or rebuild game state from LocalCache for a specific gameId
* Used when navigating to a game screen * Used when navigating to a game screen
* *
* NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables
*
* @return GameLoadResult with either the game state or an error reason * @return GameLoadResult with either the game state or an error reason
*/ */
fun loadGameFromCache(gameId: String): LiveChessGameState? { fun loadGameFromCache(gameId: String): LiveChessGameState? {
@@ -683,17 +1062,17 @@ class ChessViewModel(
val userPubkey = account.signer.pubKey val userPubkey = account.signer.pubKey
// Find the challenge event for this game // Find the challenge event for this game from addressables (kind 30064)
val challengeNotes = val challengeNotes =
LocalCache.notes.filterIntoSet { _, note -> LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note ->
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
event.gameId() == gameId event.gameId() == gameId
} }
val challengeNote = challengeNotes.firstOrNull() val challengeNote = challengeNotes.firstOrNull()
// Find accept event for this game // Find accept event for this game from addressables (kind 30065)
val acceptNotes = val acceptNotes =
LocalCache.notes.filterIntoSet { _, note -> LocalCache.addressables.filterIntoSet(LiveChessGameAcceptEvent.KIND) { _, note ->
val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false
event.gameId() == gameId event.gameId() == gameId
} }
@@ -724,9 +1103,10 @@ class ChessViewModel(
challengerColor.opposite() to challengerPubkey challengerColor.opposite() to challengerPubkey
} }
challengerPubkey == userPubkey && acceptorPubkey == null -> { challengerPubkey == userPubkey && acceptorPubkey == null -> {
// We created challenge but no one accepted yet // We created challenge but no one accepted yet - show it anyway
_error.value = "Waiting for opponent to accept challenge" // Use targeted opponent or empty string for open challenges
return null val targetedOpponent = challengeEvent.opponentPubkey() ?: ""
challengerColor to targetedOpponent
} }
else -> { else -> {
_error.value = "You are not a participant in this game" _error.value = "You are not a participant in this game"
@@ -738,9 +1118,9 @@ class ChessViewModel(
val engine = ChessEngine() val engine = ChessEngine()
engine.reset() engine.reset()
// Find and apply all moves in order // Find and apply all moves in order from addressables (kind 30066)
val moveNotes = val moveNotes =
LocalCache.notes.filterIntoSet { _, note -> LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note ->
val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
event.gameId() == gameId event.gameId() == gameId
} }
@@ -767,6 +1147,10 @@ class ChessViewModel(
} }
} }
// Only pending if we're the challenger, no accept event found, AND no moves exist
// (moves prove the game was accepted even if accept event isn't in cache)
val isPendingChallenge = challengerPubkey == userPubkey && acceptorPubkey == null && sortedMoves.isEmpty()
val gameState = val gameState =
LiveChessGameState( LiveChessGameState(
gameId = gameId, gameId = gameId,
@@ -774,13 +1158,28 @@ class ChessViewModel(
opponentPubkey = opponentPubkey, opponentPubkey = opponentPubkey,
playerColor = playerColor, playerColor = playerColor,
engine = engine, engine = engine,
isPendingChallenge = isPendingChallenge,
) )
// Mark loaded moves as received to prevent re-application during refresh // Mark loaded moves as received to prevent re-application during refresh
gameState.markMovesAsReceived(loadedMoveNumbers) gameState.markMovesAsReceived(loadedMoveNumbers)
// Add to active games // Check for end event (kind 30067) — game may have been resigned/finished
_activeGames.value = _activeGames.value + (gameId to gameState) val endNotes =
LocalCache.addressables.filterIntoSet(LiveChessGameEndEvent.KIND) { _, note ->
val event = note.event as? LiveChessGameEndEvent ?: return@filterIntoSet false
event.gameId() == gameId
}
val endEvent = endNotes.firstOrNull()?.event as? LiveChessGameEndEvent
if (endEvent != null) {
val result = GameResult.parse(endEvent.result() ?: "*")
gameState.markAsFinished(result)
}
// Only add to active games if not pending (pending challenges are view-only)
if (!isPendingChallenge) {
_activeGames.value = _activeGames.value + (gameId to gameState)
}
// Update polling delegate with this game // Update polling delegate with this game
pollingDelegate.addGameId(gameId) pollingDelegate.addGameId(gameId)
@@ -808,3 +1207,14 @@ data class RetryOperation(
val currentAttempt: Int, val currentAttempt: Int,
val maxAttempts: Int, val maxAttempts: Int,
) )
/**
* Info about a public game that can be spectated
*/
data class PublicGameInfo(
val gameId: String,
val whitePubkey: String,
val blackPubkey: String,
val moveCount: Int,
val lastMoveTime: Long,
)
@@ -0,0 +1,632 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvents
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.UUID
/**
* Interface for platform-specific chess event publishing
*/
interface ChessEventPublisher {
suspend fun publishChallenge(
gameId: String,
playerColor: Color,
opponentPubkey: String?,
timeControl: String?,
): Boolean
suspend fun publishAccept(
gameId: String,
challengeEventId: String,
challengerPubkey: String,
): Boolean
suspend fun publishMove(move: ChessMoveEvent): Boolean
suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean
suspend fun publishDrawOffer(
gameId: String,
opponentPubkey: String,
message: String?,
): Boolean
fun getWriteRelayCount(): Int
}
/**
* Relay-first fetcher interface. Platforms provide one-shot relay queries.
*
* Every fetch does: one-shot REQ → collect events → EOSE → close.
* No caching — relays are the single source of truth.
*/
interface ChessRelayFetcher {
/** Fetch all events for a specific game */
suspend fun fetchGameEvents(gameId: String): ChessGameEvents
/** Fetch recent challenge events */
suspend fun fetchChallenges(): List<LiveChessGameChallengeEvent>
/** Fetch recent public game summaries for spectating */
suspend fun fetchRecentGames(): List<RelayGameSummary>
}
/**
* Summary of a game found on relays (for lobby display / spectating)
*/
data class RelayGameSummary(
val gameId: String,
val whitePubkey: String,
val blackPubkey: String,
val moveCount: Int,
val lastMoveTime: Long,
val isActive: Boolean,
)
/**
* Shared chess lobby logic — relay-first architecture.
*
* Both Android and Desktop use this identically.
* Platform-specific code only implements:
* - ChessEventPublisher: sign + broadcast events
* - ChessRelayFetcher: one-shot relay queries
* - IUserMetadataProvider: display names / avatars
*/
class ChessLobbyLogic(
private val userPubkey: String,
private val publisher: ChessEventPublisher,
private val fetcher: ChessRelayFetcher,
private val metadataProvider: IUserMetadataProvider,
private val scope: CoroutineScope,
pollingConfig: ChessPollingConfig = ChessPollingDefaults.android,
) {
val state = ChessLobbyState(userPubkey, scope)
private val pollingDelegate =
ChessPollingDelegate(
config = pollingConfig,
scope = scope,
onRefreshGames = { gameIds -> refreshGames(gameIds) },
onRefreshChallenges = { refreshChallenges() },
onCleanup = { cleanupExpiredChallenges() },
)
// ========================================
// Lifecycle
// ========================================
fun startPolling() = pollingDelegate.start()
fun stopPolling() = pollingDelegate.stop()
fun forceRefresh() = pollingDelegate.refreshNow()
// ========================================
// Incoming event routing (real-time / optimistic)
// ========================================
/**
* Route an incoming relay event to the appropriate handler.
* Called by platform subscription callbacks for real-time updates.
*/
fun handleIncomingEvent(event: Event) {
when (event) {
is LiveChessGameChallengeEvent -> handleChallenge(event)
is LiveChessGameAcceptEvent -> handleAccept(event)
is LiveChessMoveEvent -> handleMove(event)
is LiveChessGameEndEvent -> handleGameEnd(event)
is LiveChessDrawOfferEvent -> handleDrawOffer(event)
}
}
private fun handleChallenge(event: LiveChessGameChallengeEvent) {
val gameId = event.gameId() ?: return
val challengerColor = event.playerColor() ?: Color.WHITE
val challenge =
ChessChallenge(
eventId = event.id,
gameId = gameId,
challengerPubkey = event.pubKey,
challengerDisplayName = metadataProvider.getDisplayName(event.pubKey),
challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey),
opponentPubkey = event.opponentPubkey(),
challengerColor = challengerColor,
createdAt = event.createdAt,
)
state.addChallenge(challenge)
}
private fun handleAccept(event: LiveChessGameAcceptEvent) {
val gameId = event.gameId() ?: return
// If this is an accept for our challenge, auto-load the game
val ourChallenge = state.outgoingChallenges().find { it.gameId == gameId }
if (ourChallenge != null) {
handleGameAccepted(gameId)
}
}
private fun handleMove(event: LiveChessMoveEvent) {
val gameId = event.gameId() ?: return
val san = event.san() ?: return
val fen = event.fen() ?: return
val moveNumber = event.moveNumber()
val gameState = state.getGameState(gameId) ?: return
// Only apply opponent moves optimistically
if (event.pubKey != userPubkey) {
gameState.applyOpponentMove(san, fen, moveNumber)
}
}
private fun handleGameEnd(event: LiveChessGameEndEvent) {
val gameId = event.gameId() ?: return
val gameState = state.getGameState(gameId) ?: return
val resultStr = event.result()
val result =
when (resultStr) {
"1-0" -> com.vitorpamplona.quartz.nip64Chess.GameResult.WHITE_WINS
"0-1" -> com.vitorpamplona.quartz.nip64Chess.GameResult.BLACK_WINS
"1/2-1/2" -> com.vitorpamplona.quartz.nip64Chess.GameResult.DRAW
else -> return
}
gameState.markAsFinished(result)
state.moveToCompleted(gameId, result.notation, event.termination())
pollingDelegate.removeGameId(gameId)
}
private fun handleDrawOffer(event: LiveChessDrawOfferEvent) {
val gameId = event.gameId() ?: return
val gameState = state.getGameState(gameId) ?: return
if (event.pubKey != userPubkey) {
gameState.receiveDrawOffer(event.pubKey)
}
}
// ========================================
// Challenge operations
// ========================================
fun createChallenge(
opponentPubkey: String? = null,
playerColor: Color = Color.WHITE,
timeControl: String? = null,
) {
val gameId = generateGameId()
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(
ChessBroadcastStatus.Broadcasting(
san = "Challenge",
successCount = 0,
totalRelays = publisher.getWriteRelayCount(),
),
)
val success = retryWithBackoff { publisher.publishChallenge(gameId, playerColor, opponentPubkey, timeControl) }
if (success) {
state.setBroadcastStatus(
ChessBroadcastStatus.Success("Challenge", publisher.getWriteRelayCount()),
)
delay(2000)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
state.setError(null)
} else {
state.setBroadcastStatus(
ChessBroadcastStatus.Failed("Challenge", "Failed to publish"),
)
state.setError("Failed to create challenge")
}
}
}
fun acceptChallenge(challenge: ChessChallenge) {
scope.launch(Dispatchers.Default) {
val success =
retryWithBackoff {
publisher.publishAccept(
gameId = challenge.gameId,
challengeEventId = challenge.eventId,
challengerPubkey = challenge.challengerPubkey,
)
}
if (success) {
val playerColor = challenge.challengerColor.opposite()
val gameState =
ChessGameLoader.createNewGame(
gameId = challenge.gameId,
playerPubkey = userPubkey,
opponentPubkey = challenge.challengerPubkey,
playerColor = playerColor,
)
state.addActiveGame(challenge.gameId, gameState)
pollingDelegate.addGameId(challenge.gameId)
state.selectGame(challenge.gameId)
state.setError(null)
} else {
state.setError("Failed to accept challenge")
}
}
}
/**
* When we detect our challenge was accepted, load game from relays.
*/
fun handleGameAccepted(gameId: String) {
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
val events = fetcher.fetchGameEvents(gameId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
state.addActiveGame(gameId, result.liveState)
pollingDelegate.addGameId(gameId)
state.selectGame(gameId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
state.setError(null)
}
is LoadGameResult.Error -> {
state.setError("Failed to load game: ${result.message}")
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
}
}
}
}
// ========================================
// Game operations
// ========================================
fun publishMove(
gameId: String,
from: String,
to: String,
) {
val gameState = state.getGameState(gameId) ?: return
if (state.isSpectating(gameId)) {
state.setError("Cannot move while spectating")
return
}
val moveResult = gameState.makeMove(from, to) ?: return
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(
ChessBroadcastStatus.Broadcasting(
san = moveResult.san,
successCount = 0,
totalRelays = publisher.getWriteRelayCount(),
),
)
val success = retryWithBackoff { publisher.publishMove(moveResult) }
if (success) {
state.setBroadcastStatus(
ChessBroadcastStatus.Success(moveResult.san, publisher.getWriteRelayCount()),
)
delay(3000)
val currentState = state.getGameState(gameId)
state.setBroadcastStatus(
if (currentState?.isPlayerTurn() == false) {
ChessBroadcastStatus.WaitingForOpponent
} else {
ChessBroadcastStatus.Idle
},
)
state.setError(null)
} else {
state.setBroadcastStatus(
ChessBroadcastStatus.Failed(moveResult.san, "Failed to publish move"),
)
state.setError("Failed to publish move")
}
}
}
fun resign(gameId: String) {
val gameState = state.getGameState(gameId) ?: return
if (state.isSpectating(gameId)) {
state.setError("Cannot resign while spectating")
return
}
scope.launch(Dispatchers.Default) {
val endData = gameState.resign()
val success = retryWithBackoff { publisher.publishGameEnd(endData) }
if (success) {
state.moveToCompleted(gameId, endData.result.notation, endData.termination.name.lowercase())
pollingDelegate.removeGameId(gameId)
state.setError(null)
} else {
state.setError("Failed to resign")
}
}
}
fun offerDraw(gameId: String) {
val gameState = state.getGameState(gameId) ?: return
if (state.isSpectating(gameId)) {
state.setError("Cannot offer draw while spectating")
return
}
scope.launch(Dispatchers.Default) {
val drawOffer = gameState.offerDraw()
val success =
retryWithBackoff {
publisher.publishDrawOffer(
gameId = drawOffer.gameId,
opponentPubkey = drawOffer.opponentPubkey,
message = drawOffer.message,
)
}
if (success) {
state.setError(null)
} else {
state.setError("Failed to offer draw")
}
}
}
fun acceptDraw(gameId: String) {
val gameState = state.getGameState(gameId) ?: return
val endData = gameState.acceptDraw() ?: return
scope.launch(Dispatchers.Default) {
val success = retryWithBackoff { publisher.publishGameEnd(endData) }
if (success) {
state.moveToCompleted(gameId, endData.result.notation, "draw_agreement")
pollingDelegate.removeGameId(gameId)
state.setError(null)
} else {
state.setError("Failed to accept draw")
}
}
}
fun declineDraw(gameId: String) {
val gameState = state.getGameState(gameId) ?: return
gameState.declineDraw()
}
fun claimAbandonmentVictory(gameId: String) {
val gameState = state.getGameState(gameId) ?: return
val endData = gameState.claimAbandonmentVictory() ?: return
scope.launch(Dispatchers.Default) {
val success = retryWithBackoff { publisher.publishGameEnd(endData) }
if (success) {
state.moveToCompleted(gameId, endData.result.notation, "abandonment")
pollingDelegate.removeGameId(gameId)
state.setError(null)
} else {
state.setError("Failed to claim abandonment victory")
}
}
}
// ========================================
// Spectator mode
// ========================================
fun loadGameAsSpectator(gameId: String) {
scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
val events = fetcher.fetchGameEvents(gameId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
state.addSpectatingGame(gameId, result.liveState)
pollingDelegate.addGameId(gameId)
state.selectGame(gameId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
state.setError(null)
}
is LoadGameResult.Error -> {
state.setError("Failed to load game: ${result.message}")
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
}
}
}
}
fun loadGame(gameId: String) {
scope.launch(Dispatchers.Default) {
if (state.getGameState(gameId) != null) {
state.selectGame(gameId)
return@launch
}
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
val events = fetcher.fetchGameEvents(gameId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
if (result.liveState.isSpectator) {
state.addSpectatingGame(gameId, result.liveState)
} else {
state.addActiveGame(gameId, result.liveState)
}
pollingDelegate.addGameId(gameId)
state.selectGame(gameId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
state.setError(null)
}
is LoadGameResult.Error -> {
state.setError("Failed to load game: ${result.message}")
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
}
}
}
}
// ========================================
// Relay-first refresh (periodic reconstruction)
// ========================================
/**
* Full reconstruction refresh for active games.
* One-shot REQ → transient collector → reconstruct → diff + update.
*/
private suspend fun refreshGames(gameIds: Set<String>) {
for (gameId in gameIds) {
refreshGame(gameId)
}
}
private suspend fun refreshGame(gameId: String) {
val events = fetcher.fetchGameEvents(gameId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
state.replaceGameState(gameId, result.liveState)
}
is LoadGameResult.Error -> {
// Don't overwrite error for periodic refresh failures
}
}
}
private suspend fun refreshChallenges() {
val challengeEvents = fetcher.fetchChallenges()
val challenges =
challengeEvents.mapNotNull { event ->
val gameId = event.gameId() ?: return@mapNotNull null
val challengerColor = event.playerColor() ?: Color.WHITE
ChessChallenge(
eventId = event.id,
gameId = gameId,
challengerPubkey = event.pubKey,
challengerDisplayName = metadataProvider.getDisplayName(event.pubKey),
challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey),
opponentPubkey = event.opponentPubkey(),
challengerColor = challengerColor,
createdAt = event.createdAt,
)
}
state.updateChallenges(challenges)
// Also refresh public games
val recentGames = fetcher.fetchRecentGames()
val publicGames =
recentGames.map { summary ->
PublicGame(
gameId = summary.gameId,
whitePubkey = summary.whitePubkey,
whiteDisplayName = metadataProvider.getDisplayName(summary.whitePubkey),
blackPubkey = summary.blackPubkey,
blackDisplayName = metadataProvider.getDisplayName(summary.blackPubkey),
moveCount = summary.moveCount,
lastMoveTime = summary.lastMoveTime,
isActive = summary.isActive,
)
}
state.updatePublicGames(publicGames)
}
private fun cleanupExpiredChallenges() {
val now = TimeUtils.now()
val validChallenges =
state.challenges.value.filter { challenge ->
(now - challenge.createdAt) < CHALLENGE_EXPIRY_SECONDS
}
state.updateChallenges(validChallenges)
}
// ========================================
// Utilities
// ========================================
private fun generateGameId(): String {
val timestamp = TimeUtils.now()
val random = UUID.randomUUID().toString().take(8)
return "chess-$timestamp-$random"
}
/**
* Retry a publish operation with exponential backoff.
* Returns true if any attempt succeeds.
*/
private suspend fun retryWithBackoff(
maxRetries: Int = 3,
initialDelayMs: Long = 1000,
action: suspend () -> Boolean,
): Boolean {
var delayMs = initialDelayMs
repeat(maxRetries) { attempt ->
if (action()) return true
if (attempt < maxRetries - 1) {
delay(delayMs)
delayMs *= 2
}
}
return false
}
fun clearError() {
state.setError(null)
}
fun selectGame(gameId: String?) {
state.selectGame(gameId)
}
}
@@ -0,0 +1,367 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Challenge expiry: 24 hours
*/
const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L
/**
* Represents a chess challenge that can be displayed in the lobby
*/
@Immutable
data class ChessChallenge(
val eventId: String,
val gameId: String,
val challengerPubkey: String,
val challengerDisplayName: String?,
val challengerAvatarUrl: String?,
val opponentPubkey: String?,
val challengerColor: Color,
val createdAt: Long,
) {
/** Whether this is an open challenge anyone can accept */
val isOpen: Boolean get() = opponentPubkey == null
/** Whether this challenge is directed at a specific user */
fun isDirectedAt(pubkey: String): Boolean = opponentPubkey == pubkey
/** Whether this challenge was created by a specific user */
fun isFrom(pubkey: String): Boolean = challengerPubkey == pubkey
}
/**
* Represents a public game that can be spectated
*/
@Immutable
data class PublicGame(
val gameId: String,
val whitePubkey: String,
val whiteDisplayName: String?,
val blackPubkey: String,
val blackDisplayName: String?,
val moveCount: Int,
val lastMoveTime: Long,
val isActive: Boolean,
)
/**
* Represents a completed game for history display
*/
@Immutable
data class CompletedGame(
val gameId: String,
val whitePubkey: String,
val whiteDisplayName: String?,
val blackPubkey: String,
val blackDisplayName: String?,
val result: String,
val termination: String?,
val moveCount: Int,
val completedAt: Long,
) {
/** Whether user won this game */
fun didUserWin(userPubkey: String): Boolean =
when (result) {
"1-0" -> whitePubkey == userPubkey
"0-1" -> blackPubkey == userPubkey
else -> false
}
/** Whether this was a draw */
val isDraw: Boolean get() = result == "1/2-1/2"
}
/**
* Chess status for UI feedback
*/
@Immutable
sealed class ChessBroadcastStatus {
data object Idle : ChessBroadcastStatus()
data class Broadcasting(
val san: String,
val successCount: Int,
val totalRelays: Int,
) : ChessBroadcastStatus() {
val progress: Float get() = if (totalRelays > 0) successCount.toFloat() / totalRelays else 0f
}
data class Success(
val san: String,
val relayCount: Int,
) : ChessBroadcastStatus()
data class Failed(
val san: String,
val error: String,
) : ChessBroadcastStatus()
data object WaitingForOpponent : ChessBroadcastStatus()
data class Syncing(
val progress: Float = 0f,
) : ChessBroadcastStatus()
data class Desynced(
val message: String,
) : ChessBroadcastStatus()
}
/**
* Shared chess lobby state that can be used by both Android and Desktop
*/
class ChessLobbyState(
private val userPubkey: String,
private val scope: CoroutineScope,
) {
// Active games where user is a participant
private val _activeGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap())
val activeGames: StateFlow<Map<String, LiveChessGameState>> = _activeGames.asStateFlow()
// Public games that can be spectated
private val _publicGames = MutableStateFlow<List<PublicGame>>(emptyList())
val publicGames: StateFlow<List<PublicGame>> = _publicGames.asStateFlow()
// All challenges (filtered by UI based on type)
private val _challenges = MutableStateFlow<List<ChessChallenge>>(emptyList())
val challenges: StateFlow<List<ChessChallenge>> = _challenges.asStateFlow()
// Spectating games (user is watching but not playing)
private val _spectatingGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap())
val spectatingGames: StateFlow<Map<String, LiveChessGameState>> = _spectatingGames.asStateFlow()
// Completed games history
private val _completedGames = MutableStateFlow<List<CompletedGame>>(emptyList())
val completedGames: StateFlow<List<CompletedGame>> = _completedGames.asStateFlow()
// Broadcast status
private val _broadcastStatus = MutableStateFlow<ChessBroadcastStatus>(ChessBroadcastStatus.Idle)
val broadcastStatus: StateFlow<ChessBroadcastStatus> = _broadcastStatus.asStateFlow()
// Error state
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
// Selected game ID for navigation
private val _selectedGameId = MutableStateFlow<String?>(null)
val selectedGameId: StateFlow<String?> = _selectedGameId.asStateFlow()
// Badge count (incoming challenges + your turn games)
val badgeCount: Int
get() {
val incomingChallenges = _challenges.value.count { it.isDirectedAt(userPubkey) }
val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() }
return incomingChallenges + yourTurnGames
}
// ========================================
// Derived state for UI sections
// ========================================
/** Challenges directed at the user */
fun incomingChallenges(): List<ChessChallenge> = _challenges.value.filter { it.isDirectedAt(userPubkey) }
/** Challenges created by the user */
fun outgoingChallenges(): List<ChessChallenge> = _challenges.value.filter { it.isFrom(userPubkey) }
/** Open challenges from others that user can join */
fun openChallenges(): List<ChessChallenge> = _challenges.value.filter { it.isOpen && !it.isFrom(userPubkey) }
// ========================================
// State updates
// ========================================
fun updateChallenges(challenges: List<ChessChallenge>) {
_challenges.value = challenges
}
fun addChallenge(challenge: ChessChallenge) {
_challenges.update { current ->
if (current.any { it.eventId == challenge.eventId || it.gameId == challenge.gameId }) {
current
} else {
current + challenge
}
}
}
fun removeChallenge(gameId: String) {
_challenges.update { current ->
current.filter { it.gameId != gameId }
}
}
fun updatePublicGames(games: List<PublicGame>) {
_publicGames.value = games
}
fun addActiveGame(
gameId: String,
state: LiveChessGameState,
) {
_activeGames.update { it + (gameId to state) }
// Remove from challenges if present
removeChallenge(gameId)
}
fun removeActiveGame(gameId: String) {
_activeGames.update { it - gameId }
}
fun updateActiveGame(
gameId: String,
update: (LiveChessGameState) -> LiveChessGameState,
) {
_activeGames.update { current ->
current[gameId]?.let { state ->
current + (gameId to update(state))
} ?: current
}
}
/**
* Replace game state entirely after full reconstruction from relays.
* Preserves game location (active vs spectating).
*/
fun replaceGameState(
gameId: String,
newState: LiveChessGameState,
) {
if (_activeGames.value.containsKey(gameId)) {
_activeGames.update { it + (gameId to newState) }
} else if (_spectatingGames.value.containsKey(gameId)) {
_spectatingGames.update { it + (gameId to newState) }
}
}
/**
* Move a game from active/spectating to completed.
* Display names are optional; UI can look them up later if needed.
*/
fun moveToCompleted(
gameId: String,
result: String,
termination: String?,
whiteDisplayName: String? = null,
blackDisplayName: String? = null,
) {
val existingState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId]
existingState?.let { gameState ->
// Derive white/black pubkeys from player color
val whitePubkey =
if (gameState.playerColor == Color.WHITE) {
gameState.playerPubkey
} else {
gameState.opponentPubkey
}
val blackPubkey =
if (gameState.playerColor == Color.BLACK) {
gameState.playerPubkey
} else {
gameState.opponentPubkey
}
val completed =
CompletedGame(
gameId = gameId,
whitePubkey = whitePubkey,
whiteDisplayName = whiteDisplayName,
blackPubkey = blackPubkey,
blackDisplayName = blackDisplayName,
result = result,
termination = termination,
moveCount = gameState.moveHistory.value.size,
completedAt =
com.vitorpamplona.quartz.utils.TimeUtils
.now(),
)
_completedGames.update { current ->
// Avoid duplicates
if (current.any { it.gameId == gameId }) {
current
} else {
listOf(completed) + current
}
}
// Remove from active/spectating
_activeGames.update { it - gameId }
_spectatingGames.update { it - gameId }
// Clear selection if this game was selected
if (_selectedGameId.value == gameId) {
_selectedGameId.value = null
}
}
}
fun addSpectatingGame(
gameId: String,
state: LiveChessGameState,
) {
_spectatingGames.update { it + (gameId to state) }
}
fun removeSpectatingGame(gameId: String) {
_spectatingGames.update { it - gameId }
}
fun setBroadcastStatus(status: ChessBroadcastStatus) {
_broadcastStatus.value = status
}
fun setError(error: String?) {
_error.value = error
}
fun selectGame(gameId: String?) {
_selectedGameId.value = gameId
}
fun getGameState(gameId: String): LiveChessGameState? = _activeGames.value[gameId] ?: _spectatingGames.value[gameId]
fun isUserParticipant(gameId: String): Boolean = _activeGames.value.containsKey(gameId)
fun isSpectating(gameId: String): Boolean = _spectatingGames.value.containsKey(gameId)
fun clearAll() {
_activeGames.value = emptyMap()
_publicGames.value = emptyList()
_challenges.value = emptyList()
_spectatingGames.value = emptyMap()
_completedGames.value = emptyList()
_broadcastStatus.value = ChessBroadcastStatus.Idle
_error.value = null
_selectedGameId.value = null
}
}
@@ -0,0 +1,93 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap
/**
* One-shot relay fetch helper for chess events.
*
* Follows the existing INostrClient + IRequestListener + Channel pattern
* from quartz (see NostrClientSingleDownloadExt.kt).
*
* Each fetch opens a subscription, collects events until EOSE from all relays,
* then closes and returns the collected events. The subscription is transient —
* no state is cached between fetches.
*/
class ChessRelayFetchHelper(
private val client: INostrClient,
) {
/**
* Fetch events matching filters from relays, waiting for EOSE.
*
* @param filters Map of relay → filter list (same format as INostrClient.openReqSubscription)
* @param timeoutMs Max time to wait for all relays to send EOSE
* @return Deduplicated list of events received before timeout/EOSE
*/
suspend fun fetchEvents(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000,
): List<Event> {
if (filters.isEmpty()) return emptyList()
val events = ConcurrentHashMap<String, Event>()
val relayCount = filters.keys.size
val eoseReceived = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>()
val allEose = CompletableDeferred<Unit>()
val subId = newSubId()
val listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
events[event.id] = event
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eoseReceived.add(relay)
if (eoseReceived.size >= relayCount) {
allEose.complete(Unit)
}
}
}
client.openReqSubscription(subId, filters, listener)
withTimeoutOrNull(timeoutMs) { allEose.await() }
client.close(subId)
return events.values.toList()
}
}
@@ -0,0 +1,33 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess
/**
* Platform-specific metadata provider for enriching chess challenges with display info.
*
* Android: wraps LocalCache.users[pubkey].info
* Desktop: wraps UserMetadataCache
*/
interface IUserMetadataProvider {
fun getDisplayName(pubkey: String): String
fun getPictureUrl(pubkey: String): String?
}
@@ -24,6 +24,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -31,8 +32,11 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -69,15 +73,27 @@ fun InteractiveChessBoard(
boardSize: Dp = 400.dp, boardSize: Dp = 400.dp,
flipped: Boolean = false, flipped: Boolean = false,
playerColor: ChessColor? = null, // null = allow both (for local play) playerColor: ChessColor? = null, // null = allow both (for local play)
positionVersion: Int = 0, // External trigger for position refresh (e.g. moveHistory.size)
onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> }, onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> },
) { ) {
// Track move count to trigger recomposition when board changes // Track local move count + external version to trigger recomposition when board changes
var moveCount by remember { mutableStateOf(0) } var localMoveCount by remember { mutableStateOf(0) }
val position = remember(engine, moveCount) { engine.getPosition() } val effectiveVersion = localMoveCount + positionVersion
val sideToMove = remember(engine, moveCount) { engine.getSideToMove() } val position = remember(engine, effectiveVersion) { engine.getPosition() }
val sideToMove = remember(engine, effectiveVersion) { engine.getSideToMove() }
var selectedSquare by remember { mutableStateOf<Pair<Int, Int>?>(null) } var selectedSquare by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var legalMoves by remember { mutableStateOf<List<String>>(emptyList()) } var legalMoves by remember { mutableStateOf<List<String>>(emptyList()) }
// Pawn promotion state
var pendingPromotion by remember { mutableStateOf<PendingPromotion?>(null) }
// Clear stale selection when position changes externally (e.g. opponent move)
LaunchedEffect(positionVersion) {
selectedSquare = null
legalMoves = emptyList()
pendingPromotion = null
}
// Can only interact if it's your turn (or playerColor is null for local play) // Can only interact if it's your turn (or playerColor is null for local play)
val canInteract = playerColor == null || sideToMove == playerColor val canInteract = playerColor == null || sideToMove == playerColor
@@ -87,66 +103,118 @@ fun InteractiveChessBoard(
val rankRange = if (flipped) (0..7) else (7 downTo 0) val rankRange = if (flipped) (0..7) else (7 downTo 0)
val fileRange = if (flipped) (7 downTo 0) else (0..7) val fileRange = if (flipped) (7 downTo 0) else (0..7)
Column(modifier = modifier.size(boardSize)) { Box(modifier = modifier.size(boardSize)) {
for (rank in rankRange) { Column(modifier = Modifier.fillMaxSize()) {
Row { for (rank in rankRange) {
for (file in fileRange) { Row {
val piece = position.pieceAt(file, rank) for (file in fileRange) {
val isLightSquare = (rank + file) % 2 == 0 val piece = position.pieceAt(file, rank)
val square = fileRankToSquare(file, rank) val isLightSquare = (rank + file) % 2 == 0
val isSelected = selectedSquare == (file to rank) val square = fileRankToSquare(file, rank)
val isLegalMove = legalMoves.contains(square) val isSelected = selectedSquare == (file to rank)
val isLegalMove = legalMoves.contains(square)
val showCoord = if (flipped) rank == 7 else rank == 0 val showCoord = if (flipped) rank == 7 else rank == 0
InteractiveChessSquare( InteractiveChessSquare(
piece = piece, piece = piece,
isLight = isLightSquare, isLight = isLightSquare,
size = squareSize, size = squareSize,
isSelected = isSelected, isSelected = isSelected,
isLegalMove = isLegalMove, isLegalMove = isLegalMove,
showCoordinate = showCoord, showCoordinate = showCoord,
file = file, file = file,
rank = rank, rank = rank,
onClick = { onClick = {
if (!canInteract) return@InteractiveChessSquare if (!canInteract || pendingPromotion != null) return@InteractiveChessSquare
if (selectedSquare != null) { if (selectedSquare != null) {
// Attempt to make move - validate first, don't actually make it // Attempt to make move - validate first, don't actually make it
val from = fileRankToSquare(selectedSquare!!.first, selectedSquare!!.second) val from = fileRankToSquare(selectedSquare!!.first, selectedSquare!!.second)
val to = square val to = square
if (legalMoves.contains(to)) { if (legalMoves.contains(to)) {
// Valid move - callback will make the actual move // Check if this is a pawn promotion move
selectedSquare = null val fromRank = selectedSquare!!.second
legalMoves = emptyList() val toRank = rank
// Pass empty san - callback will get it from makeMove result val movingPiece = position.pieceAt(selectedSquare!!.first, fromRank)
onMoveMade(from, to, "") val isPromotion =
moveCount++ // Trigger recomposition after move is made 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 { } else {
// Not a legal move target - try selecting this square instead // Select piece - only allow selecting player's pieces
val validColor = playerColor ?: sideToMove val validColor = playerColor ?: sideToMove
if (piece != null && piece.color == validColor) { if (piece != null && piece.color == validColor) {
selectedSquare = file to rank selectedSquare = file to rank
legalMoves = engine.getLegalMovesFrom(square) legalMoves = engine.getLegalMovesFrom(square)
} else {
selectedSquare = null
legalMoves = emptyList()
} }
} }
} else { },
// Select piece - only allow selecting player's pieces )
val validColor = playerColor ?: sideToMove }
if (piece != null && piece.color == validColor) {
selectedSquare = file to rank
legalMoves = engine.getLegalMovesFrom(square)
}
}
},
)
} }
} }
} }
// Promotion dialog overlay
pendingPromotion?.let { promo ->
PromotionPicker(
color = promo.color,
squareSize = squareSize,
onPieceSelected = { pieceType ->
// Make the move with promotion
val promotionSuffix =
when (pieceType) {
PieceType.QUEEN -> "q"
PieceType.ROOK -> "r"
PieceType.BISHOP -> "b"
PieceType.KNIGHT -> "n"
else -> "q"
}
selectedSquare = null
legalMoves = emptyList()
pendingPromotion = null
// Include promotion in the 'to' square for the callback
onMoveMade(promo.from, promo.to + promotionSuffix, "")
localMoveCount++
},
onDismiss = {
pendingPromotion = null
selectedSquare = null
legalMoves = emptyList()
},
)
}
} }
} }
@@ -254,3 +322,71 @@ private fun ChessPiece.toImageVector(): ImageVector {
PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn
} }
} }
/**
* Data class for pending pawn promotion
*/
private data class PendingPromotion(
val from: String,
val to: String,
val file: Int,
val rank: Int,
val color: ChessColor,
)
/**
* Promotion piece picker overlay
*/
@Composable
private fun PromotionPicker(
color: ChessColor,
squareSize: Dp,
onPieceSelected: (PieceType) -> Unit,
onDismiss: () -> Unit,
) {
val isWhite = color == ChessColor.WHITE
val promotionPieces =
listOf(
PieceType.QUEEN to if (isWhite) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen,
PieceType.ROOK to if (isWhite) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook,
PieceType.BISHOP to if (isWhite) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop,
PieceType.KNIGHT to if (isWhite) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight,
)
// Semi-transparent overlay
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.clickable { onDismiss() },
contentAlignment = Alignment.Center,
) {
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.clickable { /* prevent dismiss */ },
) {
Row(
modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
promotionPieces.forEach { (pieceType, icon) ->
Box(
modifier =
Modifier
.size(squareSize)
.background(Color(0xFFF0D9B5), RoundedCornerShape(4.dp))
.clickable { onPieceSelected(pieceType) },
contentAlignment = Alignment.Center,
) {
Image(
imageVector = icon,
contentDescription = pieceType.name,
modifier = Modifier.fillMaxSize().padding(4.dp),
)
}
}
}
}
}
}
@@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min import androidx.compose.ui.unit.min
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import com.vitorpamplona.quartz.nip64Chess.ChessEngine import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
import com.vitorpamplona.quartz.nip64Chess.Color import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
@@ -189,6 +190,9 @@ fun LiveChessGameScreen(
val currentPosition by gameState.currentPosition.collectAsState() val currentPosition by gameState.currentPosition.collectAsState()
val moveHistory by gameState.moveHistory.collectAsState() val moveHistory by gameState.moveHistory.collectAsState()
// Pending challenges and spectators cannot make moves
val canMakeMoves = !gameState.isSpectator && !gameState.isPendingChallenge
BoxWithConstraints( BoxWithConstraints(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
) { ) {
@@ -212,15 +216,23 @@ fun LiveChessGameScreen(
opponentName = opponentName, opponentName = opponentName,
playerColor = gameState.playerColor, playerColor = gameState.playerColor,
currentTurn = currentPosition.activeColor, currentTurn = currentPosition.activeColor,
isSpectator = gameState.isSpectator,
isPendingChallenge = gameState.isPendingChallenge,
) )
// Interactive chess board - flip when playing black // Interactive chess board - flip when playing black (spectators see from white's view)
// Auto-sized to fit available space // Auto-sized to fit available space
InteractiveChessBoard( InteractiveChessBoard(
engine = gameState.engine, engine = gameState.engine,
boardSize = boardSize, boardSize = boardSize,
flipped = gameState.playerColor == Color.BLACK, flipped = !gameState.isSpectator && gameState.playerColor == Color.BLACK,
onMoveMade = onMoveMade, positionVersion = moveHistory.size,
onMoveMade =
if (canMakeMoves) {
onMoveMade
} else {
{ _, _, _ -> } // No-op for spectators and pending challenges
},
) )
// Move history (scrollable) - observed from state flow // Move history (scrollable) - observed from state flow
@@ -228,11 +240,12 @@ fun LiveChessGameScreen(
moves = moveHistory, moves = moveHistory,
) )
// Game controls // Show appropriate controls based on game state
GameControls( when {
onResign = onResign, gameState.isPendingChallenge -> PendingChallengeInfo()
onOfferDraw = onOfferDraw, gameState.isSpectator -> SpectatorInfo()
) else -> GameControls(onResign = onResign, onOfferDraw = onOfferDraw)
}
} }
} }
} }
@@ -291,6 +304,7 @@ fun LiveChessGameScreen(
engine = engine, engine = engine,
boardSize = boardSize, boardSize = boardSize,
flipped = playerColor == Color.BLACK, flipped = playerColor == Color.BLACK,
positionVersion = engine.getMoveHistory().size,
onMoveMade = onMoveMade, onMoveMade = onMoveMade,
) )
@@ -317,45 +331,147 @@ private fun GameInfoHeader(
opponentName: String, opponentName: String,
playerColor: Color, playerColor: Color,
currentTurn: Color, currentTurn: Color,
isSpectator: Boolean = false,
isPendingChallenge: Boolean = false,
) { ) {
// Extract human-readable game name if available
val gameName =
remember(gameId) {
ChessGameNameGenerator.extractDisplayName(gameId)
}
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, 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(
text = "Game: ${gameId.take(8)}...", text = if (gameName != null) gameId.take(16) else "Game: ${gameId.take(8)}...",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text( when {
text = "vs $opponentName", isPendingChallenge -> {
style = MaterialTheme.typography.titleMedium, Text(
fontWeight = FontWeight.Bold, text = "Challenge Pending",
) style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.secondary,
)
Text( Text(
text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}", text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
val turnText = Text(
if (currentTurn == playerColor) { text = if (opponentName.isNotEmpty()) "Waiting for $opponentName" else "Open challenge",
"Your turn" style = MaterialTheme.typography.bodyMedium,
} else { color = MaterialTheme.colorScheme.onSurfaceVariant,
"Opponent's turn" )
} }
isSpectator -> {
Text(
text = "Spectating",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.tertiary,
)
val turnText = "${if (currentTurn == Color.WHITE) "White" else "Black"}'s turn"
Text(
text = turnText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> {
Text(
text = "vs $opponentName",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}",
style = MaterialTheme.typography.bodyMedium,
)
val turnText =
if (currentTurn == playerColor) {
"Your turn"
} else {
"Opponent's turn"
}
Text(
text = turnText,
style = MaterialTheme.typography.bodyMedium,
color =
if (currentTurn == playerColor) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
fontWeight = if (currentTurn == playerColor) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
}
/**
* Info banner shown when spectating a game
*/
@Composable
private fun SpectatorInfo() {
Box(
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
RoundedCornerShape(8.dp),
).padding(12.dp),
contentAlignment = Alignment.Center,
) {
Text( Text(
text = turnText, text = "Watching game - spectator mode",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = color = MaterialTheme.colorScheme.onTertiaryContainer,
if (currentTurn == playerColor) { )
MaterialTheme.colorScheme.primary }
} else { }
MaterialTheme.colorScheme.onSurfaceVariant
}, /**
fontWeight = if (currentTurn == playerColor) FontWeight.Bold else FontWeight.Normal, * Info banner shown when viewing a pending challenge
*/
@Composable
private fun PendingChallengeInfo() {
Box(
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
RoundedCornerShape(8.dp),
).padding(12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "Waiting for opponent to accept challenge",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
) )
} }
} }
@@ -0,0 +1,292 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess.subscription
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Shared filter builder for chess subscriptions.
* Used by both Android and Desktop to ensure identical subscription behavior.
*
* Builds 4 types of filters:
* 1. Personal filters - Events tagged with user's pubkey
* 2. Challenge filters - All challenges (like jesterui pattern)
* 3. Active game filters - Game-specific move/end subscriptions
* 4. Recent game filters - For spectating discovery (7 day window)
*/
object ChessFilterBuilder {
/** Challenge and accept event kinds (for lobby display) */
private val CHALLENGE_KINDS =
listOf(
LiveChessGameChallengeEvent.KIND,
LiveChessGameAcceptEvent.KIND,
)
/** Move and end event kinds (for active games) */
private val GAME_EVENT_KINDS =
listOf(
LiveChessMoveEvent.KIND,
LiveChessGameEndEvent.KIND,
)
/** All chess event kinds */
private val ALL_CHESS_KINDS = CHALLENGE_KINDS + GAME_EVENT_KINDS
/**
* Build all chess filters for a given subscription state.
* This is the main entry point - platforms call this to get filters.
*
* @param state The current subscription state with user info and active games
* @param sinceForRelay Function to get cached EOSE time for a relay (null if no cache)
* @return List of relay-specific filters to subscribe with
*/
fun buildAllFilters(
state: ChessSubscriptionState,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
): List<RelayBasedFilter> {
val filters = mutableListOf<RelayBasedFilter>()
val now = TimeUtils.now()
// Filter 1: Personal events (challenges to us, moves in our games)
filters.addAll(
buildPersonalFilters(state.userPubkey, state.relays, sinceForRelay, now),
)
// Filter 2: All challenges (for lobby display)
filters.addAll(
buildChallengeFilters(state.relays, sinceForRelay, now),
)
// Filter 3: Active game subscriptions (game-specific)
if (state.hasActiveGames) {
filters.addAll(
buildActiveGameFilters(
state.allGameIds,
state.userPubkey,
state.opponentPubkeys,
state.relays,
),
)
}
// Filter 4: Recent game events (for spectating discovery)
filters.addAll(
buildRecentGameFilters(state.relays, sinceForRelay, now),
)
return filters
}
/**
* Personal events - tagged with user's pubkey.
* Catches: challenges directed at us, moves in our games, game ends.
*/
fun buildPersonalFilters(
userPubkey: String,
relays: Set<NormalizedRelayUrl>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
now: Long = TimeUtils.now(),
): List<RelayBasedFilter> {
val filter =
Filter(
kinds = ALL_CHESS_KINDS,
tags = mapOf("p" to listOf(userPubkey)),
limit = 100,
)
return relays.map { relay ->
val sinceTime =
sinceForRelay(relay)
?: (now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS)
RelayBasedFilter(
relay = relay,
filter = filter.copy(since = sinceTime),
)
}
}
/**
* All challenge events (like jesterui pattern).
* No author/tag restriction - fetch everything, filter client-side.
* This ensures we see open challenges and public games.
*/
fun buildChallengeFilters(
relays: Set<NormalizedRelayUrl>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
now: Long = TimeUtils.now(),
): List<RelayBasedFilter> {
val filter =
Filter(
kinds = CHALLENGE_KINDS,
limit = 100,
)
return relays.map { relay ->
val sinceTime =
sinceForRelay(relay)
?: (now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS)
RelayBasedFilter(
relay = relay,
filter = filter.copy(since = sinceTime),
)
}
}
/**
* Active game events - game-specific subscriptions.
* These filters ensure moves are received for games the user is playing.
*
* Note: Move d-tags are "gameId-moveNumber" so we can't filter by #d for moves.
* We use two strategies:
* 1. Filter by authors (opponent pubkeys) - catches moves they make
* 2. Filter by #p tag (opponent tagged us) - catches moves tagged with us
*
* End events use gameId as d-tag so we can filter those directly.
*/
fun buildActiveGameFilters(
gameIds: Set<String>,
userPubkey: String,
opponentPubkeys: Set<String>,
relays: Set<NormalizedRelayUrl>,
): List<RelayBasedFilter> {
if (gameIds.isEmpty()) return emptyList()
println("[ChessFilterBuilder] Building filters for ${gameIds.size} games, ${opponentPubkeys.size} opponents: $opponentPubkeys")
val filters = mutableListOf<RelayBasedFilter>()
// End events: filter by d-tag (gameId)
val endFilter =
Filter(
kinds = listOf(LiveChessGameEndEvent.KIND),
tags = mapOf("d" to gameIds.toList()),
limit = 50,
)
// Move events: filter by p-tag (opponent tagged us)
// This catches all moves for games where we're a participant
val moveFilterByTag =
Filter(
kinds = listOf(LiveChessMoveEvent.KIND),
tags = mapOf("p" to listOf(userPubkey)),
limit = 200,
)
relays.forEach { relay ->
filters.add(RelayBasedFilter(relay = relay, filter = endFilter))
filters.add(RelayBasedFilter(relay = relay, filter = moveFilterByTag))
}
// Move events: filter by authors (opponent pubkeys)
// This directly catches moves made by our opponents
if (opponentPubkeys.isNotEmpty()) {
println("[ChessFilterBuilder] Adding author filter for opponents: $opponentPubkeys")
val moveFilterByAuthor =
Filter(
kinds = listOf(LiveChessMoveEvent.KIND),
authors = opponentPubkeys.toList(),
limit = 200,
)
relays.forEach { relay ->
filters.add(RelayBasedFilter(relay = relay, filter = moveFilterByAuthor))
}
} else {
println("[ChessFilterBuilder] WARNING: No opponent pubkeys provided!")
}
return filters
}
/**
* Recent game events for spectating discovery.
* Uses longer time window (7 days) to catch ongoing games.
*/
fun buildRecentGameFilters(
relays: Set<NormalizedRelayUrl>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
now: Long = TimeUtils.now(),
): List<RelayBasedFilter> {
val filter =
Filter(
kinds = GAME_EVENT_KINDS,
limit = 100,
)
return relays.map { relay ->
val sinceTime =
sinceForRelay(relay)
?: (now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS)
RelayBasedFilter(
relay = relay,
filter = filter.copy(since = sinceTime),
)
}
}
// ===================================================
// Simple filters for one-shot fetches (ChessRelayFetcher)
// ===================================================
/**
* Filter for all events related to a specific game.
* Used for one-shot fetch when loading a game.
*/
fun gameEventsFilter(gameId: String): Filter =
Filter(
kinds = ALL_CHESS_KINDS + listOf(com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent.KIND),
tags = mapOf("d" to listOf(gameId)),
limit = 500,
)
/**
* Filter for challenge events in the last 24 hours.
* Used for one-shot fetch to populate lobby.
*/
fun challengesFilter(userPubkey: String): Filter {
val now = TimeUtils.now()
return Filter(
kinds = CHALLENGE_KINDS,
since = now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS,
limit = 100,
)
}
/**
* Filter for recent game activity for spectating discovery.
* Fetches move events from the last 7 days.
*/
fun recentGamesFilter(): Filter {
val now = TimeUtils.now()
return Filter(
kinds = ALL_CHESS_KINDS,
since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS,
limit = 200,
)
}
}
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess.subscription
import kotlinx.coroutines.flow.StateFlow
/**
* Interface for platform-specific chess subscription management.
*
* Implementations handle the actual relay subscription mechanics while
* using [ChessFilterBuilder] for consistent filter construction.
*
* Key responsibilities:
* - Subscribe/unsubscribe to chess events on relays
* - Update filters when active games change
* - Track EOSE (End of Stored Events) per relay for efficient re-subscription
*/
interface ChessSubscriptionController {
/** Current subscription state, null if not subscribed */
val currentState: StateFlow<ChessSubscriptionState?>
/**
* Subscribe to chess events with the given state.
* Replaces any existing subscription.
*/
fun subscribe(state: ChessSubscriptionState)
/** Unsubscribe from all chess events */
fun unsubscribe()
/**
* Update active game IDs and refresh subscription filters.
* This is the key method for dynamic subscription updates.
*
* When a game starts or ends, call this to update the filters
* so moves are properly received for active games.
*
* @param activeGameIds Game IDs the user is actively playing
* @param spectatingGameIds Game IDs the user is watching (optional)
*/
fun updateActiveGames(
activeGameIds: Set<String>,
spectatingGameIds: Set<String> = emptySet(),
)
/**
* Force refresh all filters from relays.
* Clears EOSE cache so full history is re-fetched.
*/
fun forceRefresh()
}
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess.subscription
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Immutable state for chess relay subscriptions.
* Used to generate subscription filters and track subscription identity.
*
* When any of these values change, a new subscription should be created
* with updated filters.
*/
@Immutable
data class ChessSubscriptionState(
/** User's public key (hex) for filtering personal events */
val userPubkey: String,
/** Set of relays to subscribe to */
val relays: Set<NormalizedRelayUrl>,
/** Game IDs the user is actively playing */
val activeGameIds: Set<String> = emptySet(),
/** Game IDs the user is spectating */
val spectatingGameIds: Set<String> = emptySet(),
/** Opponent pubkeys for active games (to filter their moves) */
val opponentPubkeys: Set<String> = emptySet(),
) {
/**
* Unique subscription ID that changes when game IDs change.
* Used for:
* - Subscription deduplication
* - EOSE cache keying
* - Detecting when filters need to be refreshed
*/
fun subscriptionId(): String =
buildString {
append("chess-")
append(userPubkey.take(8))
if (allGameIds.isNotEmpty()) {
append("-g")
append(allGameIds.sorted().hashCode())
}
}
/** All game IDs requiring move subscriptions (active + spectating) */
val allGameIds: Set<String>
get() = activeGameIds + spectatingGameIds
/** True if user has any active or spectating games */
val hasActiveGames: Boolean
get() = allGameIds.isNotEmpty()
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess.subscription
/**
* Shared time window constants for chess subscriptions.
* Used by both Android and Desktop to ensure consistent behavior.
*/
object ChessTimeWindows {
/**
* 24 hours - challenges older than this are considered expired.
* Used for challenge and accept event filters.
*/
const val CHALLENGE_WINDOW_SECONDS = 24 * 60 * 60L
/**
* 7 days - default lookback for game events when no EOSE cache exists.
* This prevents loading ancient game history on first connection
* while still allowing recovery of recent games.
*/
const val GAME_EVENT_WINDOW_SECONDS = 7 * 24 * 60 * 60L
}
@@ -69,9 +69,10 @@ import com.vitorpamplona.amethyst.commons.data.UserMetadataCache
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.createChessSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createChessSubscriptionWithGames
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
/** /**
@@ -91,14 +92,26 @@ fun ChessScreen(
val relayStatuses by relayManager.relayStatuses.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState()
val refreshKey by viewModel.refreshKey.collectAsState() val refreshKey by viewModel.refreshKey.collectAsState()
val isLoading by viewModel.isLoading.collectAsState() val isLoading by viewModel.isLoading.collectAsState()
val activeGames by viewModel.activeGames.collectAsState()
// Subscribe to chess events from relays (re-subscribes when refreshKey changes) // Extract opponent pubkeys from active games for move filtering
rememberSubscription(relayStatuses, account, refreshKey, relayManager = relayManager) { val opponentPubkeys =
remember(activeGames) {
val pubkeys = activeGames.values.map { it.opponentPubkey }.toSet()
println("[ChessScreen] Active games: ${activeGames.keys}, Opponent pubkeys: $pubkeys")
pubkeys
}
// Subscribe to chess events from relays
// Re-subscribes when relays, refreshKey, or active games change
rememberSubscription(relayStatuses, account, refreshKey, activeGames.keys, opponentPubkeys, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) { if (configuredRelays.isNotEmpty()) {
createChessSubscription( createChessSubscriptionWithGames(
relays = configuredRelays, relays = configuredRelays,
userPubkey = account.pubKeyHex, userPubkey = account.pubKeyHex,
activeGameIds = activeGames.keys,
opponentPubkeys = opponentPubkeys,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
viewModel.handleIncomingEvent(event) viewModel.handleIncomingEvent(event)
}, },
@@ -128,7 +141,6 @@ fun ChessScreen(
} }
} }
val activeGames by viewModel.activeGames.collectAsState()
val challenges by viewModel.challenges.collectAsState() val challenges by viewModel.challenges.collectAsState()
val completedGames by viewModel.completedGames.collectAsState() val completedGames by viewModel.completedGames.collectAsState()
// Observe metadata changes to trigger recomposition // Observe metadata changes to trigger recomposition
@@ -280,7 +292,7 @@ private fun ChessLobby(
) )
} }
items(activeGames.entries.toList(), key = { it.key }) { (gameId, state) -> items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) ->
ActiveGameCard( ActiveGameCard(
gameId = gameId, gameId = gameId,
opponentPubkey = state.opponentPubkey, opponentPubkey = state.opponentPubkey,
@@ -375,7 +387,10 @@ private fun ChessLobby(
) )
} }
items(completedGames.take(10), key = { it.gameId }) { game -> items(
completedGames.distinctBy { it.gameId }.take(10),
key = { "completed-${it.gameId}-${it.completedAt}" },
) { game ->
CompletedGameCard( CompletedGameCard(
game = game, game = game,
userPubkey = userPubkey, userPubkey = userPubkey,
@@ -428,6 +443,12 @@ private fun ActiveGameCard(
isYourTurn: Boolean, isYourTurn: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
// Extract human-readable game name if available
val gameName =
remember(gameId) {
ChessGameNameGenerator.extractDisplayName(gameId) ?: gameId.take(12)
}
Card( Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
border = border =
@@ -453,14 +474,14 @@ private fun ActiveGameCard(
) )
Column { Column {
Text( Text(
"vs $opponentName", gameName,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
) )
Text( Text(
"Game: ${gameId.take(12)}...", "vs $opponentName",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@@ -685,6 +706,7 @@ private fun DesktopChessGameLayout(
boardSize = 520.dp, boardSize = 520.dp,
flipped = playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK, flipped = playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK,
playerColor = playerColor, playerColor = playerColor,
positionVersion = moveHistory.size,
onMoveMade = onMoveMade, onMoveMade = onMoveMade,
) )
} }
@@ -698,6 +720,12 @@ private fun DesktopChessGameLayout(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
// Extract human-readable game name
val gameName =
remember(gameId) {
ChessGameNameGenerator.extractDisplayName(gameId)
}
// Game info card // Game info card
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -706,11 +734,21 @@ private fun DesktopChessGameLayout(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( // Show readable game name if available
"Game Info", if (gameName != null) {
style = MaterialTheme.typography.titleMedium, Text(
fontWeight = FontWeight.Bold, gameName,
) style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
"Game Info",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -0,0 +1,432 @@
/**
* 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.desktop.chess
import com.vitorpamplona.amethyst.commons.chess.ChessEventPublisher
import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetchHelper
import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetcher
import com.vitorpamplona.amethyst.commons.chess.IUserMetadataProvider
import com.vitorpamplona.amethyst.commons.chess.RelayGameSummary
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder
import com.vitorpamplona.amethyst.commons.data.UserMetadataCache
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvents
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
/**
* Desktop implementation of ChessEventPublisher.
* Wraps account.signer.sign() + relayManager.broadcastToAll() for event publishing.
*/
class DesktopChessPublisher(
private val account: AccountState.LoggedIn,
private val relayManager: DesktopRelayConnectionManager,
) : ChessEventPublisher {
override suspend fun publishChallenge(
gameId: String,
playerColor: Color,
opponentPubkey: String?,
timeControl: String?,
): Boolean =
try {
val template =
LiveChessGameChallengeEvent.build(
gameId = gameId,
playerColor = playerColor,
opponentPubkey = opponentPubkey,
timeControl = timeControl,
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
true
} catch (e: Exception) {
false
}
override suspend fun publishAccept(
gameId: String,
challengeEventId: String,
challengerPubkey: String,
): Boolean =
try {
val template =
LiveChessGameAcceptEvent.build(
gameId = gameId,
challengeEventId = challengeEventId,
challengerPubkey = challengerPubkey,
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
true
} catch (e: Exception) {
false
}
override suspend fun publishMove(move: ChessMoveEvent): Boolean =
try {
val template =
LiveChessMoveEvent.build(
gameId = move.gameId,
moveNumber = move.moveNumber,
san = move.san,
fen = move.fen,
opponentPubkey = move.opponentPubkey,
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
true
} catch (e: Exception) {
false
}
override suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean =
try {
val template =
LiveChessGameEndEvent.build(
gameId = gameEnd.gameId,
result = gameEnd.result,
termination = gameEnd.termination,
winnerPubkey = gameEnd.winnerPubkey,
opponentPubkey = gameEnd.opponentPubkey,
pgn = gameEnd.pgn ?: "",
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
true
} catch (e: Exception) {
false
}
override suspend fun publishDrawOffer(
gameId: String,
opponentPubkey: String,
message: String?,
): Boolean =
try {
val template =
LiveChessDrawOfferEvent.build(
gameId = gameId,
opponentPubkey = opponentPubkey,
message = message ?: "",
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
true
} catch (e: Exception) {
false
}
override fun getWriteRelayCount(): Int = relayManager.connectedRelays.value.size
}
/**
* Desktop implementation of ChessRelayFetcher.
* Uses ChessRelayFetchHelper for one-shot relay queries.
*/
class DesktopRelayFetcher(
private val relayManager: DesktopRelayConnectionManager,
private val userPubkey: String,
) : ChessRelayFetcher {
private val fetchHelper = ChessRelayFetchHelper(relayManager.client)
override suspend fun fetchGameEvents(gameId: String): ChessGameEvents {
val filters = ChessFilterBuilder.gameEventsFilter(gameId)
val relayFilters = relayManager.connectedRelays.value.associateWith { listOf(filters) }
val events = fetchHelper.fetchEvents(relayFilters)
val challengeEvent =
events
.filterIsInstance<LiveChessGameChallengeEvent>()
.firstOrNull { it.gameId() == gameId }
?: events
.filter { it.kind == LiveChessGameChallengeEvent.KIND }
.mapNotNull { event ->
LiveChessGameChallengeEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
).takeIf { it.gameId() == gameId }
}.firstOrNull()
val acceptEvent =
events
.filterIsInstance<LiveChessGameAcceptEvent>()
.firstOrNull { it.gameId() == gameId }
?: events
.filter { it.kind == LiveChessGameAcceptEvent.KIND }
.mapNotNull { event ->
LiveChessGameAcceptEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
).takeIf { it.gameId() == gameId }
}.firstOrNull()
val moveEvents =
events
.filterIsInstance<LiveChessMoveEvent>()
.filter { it.gameId() == gameId }
.ifEmpty {
events
.filter { it.kind == LiveChessMoveEvent.KIND }
.mapNotNull { event ->
LiveChessMoveEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
).takeIf { it.gameId() == gameId }
}
}
val endEvent =
events
.filterIsInstance<LiveChessGameEndEvent>()
.firstOrNull { it.gameId() == gameId }
?: events
.filter { it.kind == LiveChessGameEndEvent.KIND }
.mapNotNull { event ->
LiveChessGameEndEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
).takeIf { it.gameId() == gameId }
}.firstOrNull()
val drawOffers =
events
.filterIsInstance<LiveChessDrawOfferEvent>()
.filter { it.gameId() == gameId }
.ifEmpty {
events
.filter { it.kind == LiveChessDrawOfferEvent.KIND }
.mapNotNull { event ->
LiveChessDrawOfferEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
).takeIf { it.gameId() == gameId }
}
}
return ChessGameEvents(
challenge = challengeEvent,
accept = acceptEvent,
moves = moveEvents,
end = endEvent,
drawOffers = drawOffers,
)
}
override suspend fun fetchChallenges(): List<LiveChessGameChallengeEvent> {
val filters = ChessFilterBuilder.challengesFilter(userPubkey)
val relayFilters = relayManager.connectedRelays.value.associateWith { listOf(filters) }
val events = fetchHelper.fetchEvents(relayFilters)
return events
.filterIsInstance<LiveChessGameChallengeEvent>()
.ifEmpty {
events
.filter { it.kind == LiveChessGameChallengeEvent.KIND }
.map { event ->
LiveChessGameChallengeEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
}
}
}
override suspend fun fetchRecentGames(): List<RelayGameSummary> {
val filters = ChessFilterBuilder.recentGamesFilter()
val relayFilters = relayManager.connectedRelays.value.associateWith { listOf(filters) }
val events = fetchHelper.fetchEvents(relayFilters)
// Group by game ID and create summaries
val gameIds =
events
.filter { it.kind == LiveChessMoveEvent.KIND }
.mapNotNull { event ->
val move =
if (event is LiveChessMoveEvent) {
event
} else {
LiveChessMoveEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
}
move.gameId()
}.distinct()
return gameIds.mapNotNull { gameId ->
// Find challenge and accept for this game
val challenge =
events
.filter { it.kind == LiveChessGameChallengeEvent.KIND }
.mapNotNull { event ->
val e =
if (event is LiveChessGameChallengeEvent) {
event
} else {
LiveChessGameChallengeEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
}
e.takeIf { it.gameId() == gameId }
}.firstOrNull() ?: return@mapNotNull null
val accept =
events
.filter { it.kind == LiveChessGameAcceptEvent.KIND }
.mapNotNull { event ->
val e =
if (event is LiveChessGameAcceptEvent) {
event
} else {
LiveChessGameAcceptEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
}
e.takeIf { it.gameId() == gameId }
}.firstOrNull()
val challengerColor = challenge.playerColor() ?: Color.WHITE
val whitePubkey =
if (challengerColor == Color.WHITE) {
challenge.pubKey
} else {
accept?.pubKey ?: return@mapNotNull null
}
val blackPubkey =
if (challengerColor == Color.BLACK) {
challenge.pubKey
} else {
accept?.pubKey ?: return@mapNotNull null
}
val moves =
events
.filter { it.kind == LiveChessMoveEvent.KIND }
.mapNotNull { event ->
val m =
if (event is LiveChessMoveEvent) {
event
} else {
LiveChessMoveEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
}
m.takeIf { it.gameId() == gameId }
}
val endEvent =
events
.filter { it.kind == LiveChessGameEndEvent.KIND }
.mapNotNull { event ->
val e =
if (event is LiveChessGameEndEvent) {
event
} else {
LiveChessGameEndEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
}
e.takeIf { it.gameId() == gameId }
}.firstOrNull()
val lastMove = moves.maxByOrNull { it.createdAt }
RelayGameSummary(
gameId = gameId,
whitePubkey = whitePubkey,
blackPubkey = blackPubkey,
moveCount = moves.size,
lastMoveTime = lastMove?.createdAt ?: challenge.createdAt,
isActive = endEvent == null,
)
}
}
}
/**
* Desktop implementation of IUserMetadataProvider.
* Wraps UserMetadataCache for user metadata lookup.
*/
class DesktopMetadataProvider(
private val metadataCache: UserMetadataCache,
) : IUserMetadataProvider {
override fun getDisplayName(pubkey: String): String = metadataCache.getDisplayName(pubkey)
override fun getPictureUrl(pubkey: String): String? = metadataCache.getPictureUrl(pubkey)
}
@@ -20,12 +20,16 @@
*/ */
package com.vitorpamplona.amethyst.desktop.chess package com.vitorpamplona.amethyst.desktop.chess
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDelegate
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionController
import com.vitorpamplona.amethyst.commons.data.UserMetadataCache import com.vitorpamplona.amethyst.commons.data.UserMetadataCache
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip64Chess.ChessEngine import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.GameResult import com.vitorpamplona.quartz.nip64Chess.GameResult
@@ -36,6 +40,7 @@ import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.PieceType
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -44,7 +49,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID
/** /**
* Desktop ViewModel for managing chess game state and event publishing. * Desktop ViewModel for managing chess game state and event publishing.
@@ -106,18 +110,59 @@ class DesktopChessViewModel(
private val pendingAccepts = mutableMapOf<String, LiveChessGameAcceptEvent>() private val pendingAccepts = mutableMapOf<String, LiveChessGameAcceptEvent>()
// Track event IDs we've already processed to avoid duplicates // Track event IDs we've already processed to avoid duplicates
private val processedEventIds = mutableSetOf<String>() // Uses ConcurrentHashMap for thread-safe check-and-add
private val processedEventIds =
java.util.concurrent.ConcurrentHashMap
.newKeySet<String>()
// Track games currently being created to prevent race conditions // Track games currently being created to prevent race conditions
private val gamesBeingCreated = mutableSetOf<String>() private val gamesBeingCreated = mutableSetOf<String>()
// Subscription controller for dynamic filter updates
private var subscriptionController: ChessSubscriptionController? = null
// Polling delegate for periodic refresh (fallback when relay events are missed)
private val pollingDelegate =
ChessPollingDelegate(
config = ChessPollingDefaults.desktop,
scope = scope,
onRefreshGames = { gameIds -> refreshGamesFromRelay(gameIds) },
onRefreshChallenges = { /* Subscriptions handle challenges */ },
onCleanup = { cleanupExpiredChallenges() },
)
init {
// Start polling for move updates
pollingDelegate.start()
}
/**
* Set the subscription controller for dynamic filter updates.
* Should be called after creating the ViewModel.
*/
fun setSubscriptionController(controller: ChessSubscriptionController) {
subscriptionController = controller
}
/**
* Notify subscription controller and polling delegate of active game changes.
* Call this whenever _activeGames is modified.
*/
private fun notifyActiveGamesChanged() {
val gameIds = _activeGames.value.keys
subscriptionController?.updateActiveGames(
activeGameIds = gameIds,
spectatingGameIds = emptySet(),
)
pollingDelegate.setActiveGameIds(gameIds)
}
/** /**
* Process incoming chess event from relay subscription * Process incoming chess event from relay subscription
*/ */
fun handleIncomingEvent(event: Event) { fun handleIncomingEvent(event: Event) {
// Skip already processed events // Skip already processed events (atomic check-and-add)
if (processedEventIds.contains(event.id)) return if (!processedEventIds.add(event.id)) return
processedEventIds.add(event.id)
// Check by kind since events may not be cast to specific types // Check by kind since events may not be cast to specific types
when (event.kind) { when (event.kind) {
@@ -230,8 +275,7 @@ class DesktopChessViewModel(
} }
private fun handleIncomingMove(event: LiveChessMoveEvent) { private fun handleIncomingMove(event: LiveChessMoveEvent) {
// Don't process our own moves println("[Chess] Received move event: gameId=${event.gameId()}, san=${event.san()}, moveNum=${event.moveNumber()}, from=${event.pubKey.take(8)}")
if (event.pubKey == account.pubKeyHex) return
val gameId = event.gameId() ?: return val gameId = event.gameId() ?: return
val san = event.san() ?: return val san = event.san() ?: return
@@ -241,14 +285,25 @@ class DesktopChessViewModel(
val gameState = _activeGames.value[gameId] val gameState = _activeGames.value[gameId]
if (gameState == null) { if (gameState == null) {
// Game doesn't exist yet - buffer the move for later // Game doesn't exist yet - buffer the move for later (including our own for FEN sync)
println("[Chess] Game $gameId not found, buffering move $san (fen for sync)")
val moveList = pendingMoves.getOrPut(gameId) { mutableListOf() } val moveList = pendingMoves.getOrPut(gameId) { mutableListOf() }
moveList.add(Triple(san, fen, moveNumber)) moveList.add(Triple(san, fen, moveNumber))
return return
} }
if (event.pubKey != gameState.opponentPubkey) return // Don't process our own moves for active games (already applied locally)
if (event.pubKey == account.pubKeyHex) {
println("[Chess] Skipping own move (already applied locally)")
return
}
if (event.pubKey != gameState.opponentPubkey) {
println("[Chess] Move not from opponent (expected ${gameState.opponentPubkey.take(8)}, got ${event.pubKey.take(8)})")
return
}
println("[Chess] Applying opponent move: $san (move #$moveNumber)")
gameState.applyOpponentMove(san, fen, moveNumber) gameState.applyOpponentMove(san, fen, moveNumber)
updateBadgeCount() updateBadgeCount()
} }
@@ -283,9 +338,10 @@ class DesktopChessViewModel(
private fun handleGameEnded(event: LiveChessGameEndEvent) { private fun handleGameEnded(event: LiveChessGameEndEvent) {
val gameId = event.gameId() ?: return val gameId = event.gameId() ?: return
// Store game in history before removing // Store game in history before removing (skip if already in completed list)
val alreadyCompleted = _completedGames.value.any { it.gameId == gameId }
val gameState = _activeGames.value[gameId] val gameState = _activeGames.value[gameId]
if (gameState != null) { if (gameState != null && !alreadyCompleted) {
val completedGame = val completedGame =
CompletedGame( CompletedGame(
gameId = gameId, gameId = gameId,
@@ -303,6 +359,7 @@ class DesktopChessViewModel(
// Remove from active games // Remove from active games
if (_activeGames.value.containsKey(gameId)) { if (_activeGames.value.containsKey(gameId)) {
_activeGames.value = _activeGames.value - gameId _activeGames.value = _activeGames.value - gameId
notifyActiveGamesChanged()
} }
// Clean up challenge references // Clean up challenge references
@@ -460,6 +517,7 @@ class DesktopChessViewModel(
) )
_activeGames.value = _activeGames.value + (gameId to gameState) _activeGames.value = _activeGames.value + (gameId to gameState)
notifyActiveGamesChanged()
gamesBeingCreated.remove(gameId) gamesBeingCreated.remove(gameId)
_selectedGameId.value = gameId _selectedGameId.value = gameId
_challenges.value = _challenges.value.filter { it.id != challengeEvent.id } _challenges.value = _challenges.value.filter { it.id != challengeEvent.id }
@@ -480,9 +538,18 @@ class DesktopChessViewModel(
val challengerPubkey = acceptEvent.challengerPubkey() ?: return@launch val challengerPubkey = acceptEvent.challengerPubkey() ?: return@launch
val accepterPubkey = acceptEvent.pubKey val accepterPubkey = acceptEvent.pubKey
println("[Chess] startGameFromAcceptance: gameId=$gameId, isChallenger=$isChallenger")
println("[Chess] Pending moves in buffer: ${pendingMoves.keys}")
// Skip if game already exists or is being created // Skip if game already exists or is being created
if (_activeGames.value.containsKey(gameId)) return@launch if (_activeGames.value.containsKey(gameId)) {
if (!gamesBeingCreated.add(gameId)) return@launch // Returns false if already present println("[Chess] Game $gameId already exists, skipping")
return@launch
}
if (!gamesBeingCreated.add(gameId)) {
println("[Chess] Game $gameId already being created, skipping")
return@launch // Returns false if already present
}
val opponentPubkey: String val opponentPubkey: String
val playerColor: Color val playerColor: Color
@@ -514,11 +581,14 @@ class DesktopChessViewModel(
engine = engine, engine = engine,
) )
println("[Chess] Adding game $gameId to active games, opponent=$opponentPubkey")
_activeGames.value = _activeGames.value + (gameId to gameState) _activeGames.value = _activeGames.value + (gameId to gameState)
notifyActiveGamesChanged()
_challenges.value = _challenges.value.filter { it.gameId() != gameId } _challenges.value = _challenges.value.filter { it.gameId() != gameId }
gamesBeingCreated.remove(gameId) gamesBeingCreated.remove(gameId)
// Apply any pending moves that arrived before the game was created // Apply any pending moves that arrived before the game was created
println("[Chess] About to apply pending moves for $gameId, buffer has: ${pendingMoves[gameId]?.size ?: 0} moves")
applyPendingMoves(gameId, gameState) applyPendingMoves(gameId, gameState)
} }
} }
@@ -527,13 +597,37 @@ class DesktopChessViewModel(
gameId: String, gameId: String,
gameState: LiveChessGameState, gameState: LiveChessGameState,
) { ) {
val moves = pendingMoves.remove(gameId) ?: return val moves = pendingMoves.remove(gameId)
if (moves == null) {
println("[Chess] No pending moves for game $gameId")
return
}
// Sort by move number if available, then apply in order println("[Chess] Processing ${moves.size} pending moves for game $gameId")
// Sort by move number to find the latest move
val sortedMoves = moves.sortedBy { it.third ?: Int.MAX_VALUE } val sortedMoves = moves.sortedBy { it.third ?: Int.MAX_VALUE }
for ((san, fen, moveNumber) in sortedMoves) { if (sortedMoves.isEmpty()) return
gameState.applyOpponentMove(san, fen, moveNumber)
// Find the move with highest move number - its FEN has the current board state
val latestMove = sortedMoves.maxByOrNull { it.third ?: 0 }
if (latestMove != null) {
val (san, fen, moveNumber) = latestMove
println("[Chess] Syncing to latest position from move #$moveNumber: $san")
println("[Chess] FEN: $fen")
// Use forceResync to set the board to the correct position
gameState.forceResync(fen)
// Mark all received move numbers as processed to avoid duplicates
sortedMoves.forEach { (_, _, num) ->
if (num != null) {
gameState.markMovesAsReceived(setOf(num))
}
}
println("[Chess] Board synced to position after ${sortedMoves.size} moves")
} }
updateBadgeCount() updateBadgeCount()
@@ -548,12 +642,36 @@ class DesktopChessViewModel(
to: String, to: String,
) { ) {
val gameState = _activeGames.value[gameId] ?: return val gameState = _activeGames.value[gameId] ?: return
val moveResult = gameState.makeMove(from, to)
// Parse promotion from 'to' if present (e.g., "e8q" -> square="e8", promotion=QUEEN)
val (targetSquare, promotion) = parsePromotionFromTarget(to)
val moveResult = gameState.makeMove(from, targetSquare, promotion)
if (moveResult != null) { if (moveResult != null) {
publishMoveEvent(gameId, moveResult) publishMoveEvent(gameId, moveResult)
} }
} }
/**
* Parse promotion piece from target square string.
* e.g., "e8q" -> ("e8", QUEEN), "e4" -> ("e4", null)
*/
private fun parsePromotionFromTarget(to: String): Pair<String, PieceType?> {
if (to.length == 3) {
val square = to.substring(0, 2)
val promotion =
when (to[2].lowercaseChar()) {
'q' -> PieceType.QUEEN
'r' -> PieceType.ROOK
'b' -> PieceType.BISHOP
'n' -> PieceType.KNIGHT
else -> null
}
return square to promotion
}
return to to null
}
private fun publishMoveEvent( private fun publishMoveEvent(
gameId: String, gameId: String,
moveEvent: ChessMoveEvent, moveEvent: ChessMoveEvent,
@@ -606,20 +724,24 @@ class DesktopChessViewModel(
} }
if (success) { if (success) {
// Store in history // Store in history (skip if already completed)
val completedGame = val alreadyCompleted = _completedGames.value.any { it.gameId == gameId }
CompletedGame( if (!alreadyCompleted) {
gameId = gameId, val completedGame =
opponentPubkey = gameState.opponentPubkey, CompletedGame(
playerColor = gameState.playerColor, gameId = gameId,
result = endData.result.notation, opponentPubkey = gameState.opponentPubkey,
termination = endData.termination.name.lowercase(), playerColor = gameState.playerColor,
winnerPubkey = endData.winnerPubkey, result = endData.result.notation,
completedAt = TimeUtils.now(), termination = endData.termination.name.lowercase(),
moveCount = gameState.moveHistory.value.size, winnerPubkey = endData.winnerPubkey,
) completedAt = TimeUtils.now(),
_completedGames.value = listOf(completedGame) + _completedGames.value moveCount = gameState.moveHistory.value.size,
)
_completedGames.value = listOf(completedGame) + _completedGames.value
}
_activeGames.value = _activeGames.value - gameId _activeGames.value = _activeGames.value - gameId
notifyActiveGamesChanged()
_error.value = null _error.value = null
} else { } else {
_error.value = "Failed to resign" _error.value = "Failed to resign"
@@ -685,20 +807,24 @@ class DesktopChessViewModel(
} }
if (success) { if (success) {
// Store in history and remove from active // Store in history and remove from active (skip if already completed)
val completedGame = val alreadyCompleted = _completedGames.value.any { it.gameId == gameId }
CompletedGame( if (!alreadyCompleted) {
gameId = gameId, val completedGame =
opponentPubkey = gameState.opponentPubkey, CompletedGame(
playerColor = gameState.playerColor, gameId = gameId,
result = GameResult.DRAW.notation, opponentPubkey = gameState.opponentPubkey,
termination = GameTermination.DRAW_AGREEMENT.name.lowercase(), playerColor = gameState.playerColor,
winnerPubkey = null, result = GameResult.DRAW.notation,
completedAt = TimeUtils.now(), termination = GameTermination.DRAW_AGREEMENT.name.lowercase(),
moveCount = gameState.moveHistory.value.size, winnerPubkey = null,
) completedAt = TimeUtils.now(),
_completedGames.value = listOf(completedGame) + _completedGames.value moveCount = gameState.moveHistory.value.size,
)
_completedGames.value = listOf(completedGame) + _completedGames.value
}
_activeGames.value = _activeGames.value - gameId _activeGames.value = _activeGames.value - gameId
notifyActiveGamesChanged()
_error.value = null _error.value = null
} else { } else {
_error.value = "Failed to accept draw" _error.value = "Failed to accept draw"
@@ -754,6 +880,30 @@ class DesktopChessViewModel(
_isLoading.value = false _isLoading.value = false
} }
/**
* Refresh game states - polling callback.
* Triggers a subscription refresh to catch any missed events.
* The fixed filter (with opponent authors) will fetch opponent moves.
*/
private suspend fun refreshGamesFromRelay(gameIds: Set<String>) {
if (gameIds.isEmpty()) return
// Trigger subscription controller refresh to re-fetch with current filters
// The filter now includes opponent pubkeys as authors, so it will catch their moves
subscriptionController?.forceRefresh()
}
/**
* Clean up expired challenges (older than 24 hours)
*/
private fun cleanupExpiredChallenges() {
val now = TimeUtils.now()
_challenges.value =
_challenges.value.filter { challenge ->
(now - challenge.createdAt) < CHALLENGE_EXPIRY_SECONDS
}
}
private fun updateBadgeCount() { private fun updateBadgeCount() {
val incomingChallenges = _challenges.value.count { it.opponentPubkey() == account.pubKeyHex } val incomingChallenges = _challenges.value.count { it.opponentPubkey() == account.pubKeyHex }
val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() } val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() }
@@ -776,11 +926,7 @@ class DesktopChessViewModel(
return false return false
} }
private fun generateGameId(): String { private fun generateGameId(): String = ChessGameNameGenerator.generateGameId(TimeUtils.now())
val timestamp = TimeUtils.now()
val random = UUID.randomUUID().toString().take(8)
return "chess-$timestamp-$random"
}
} }
/** /**
@@ -95,6 +95,19 @@ object FilterBuilders {
authors = pubKeyHexList, authors = pubKeyHexList,
) )
/**
* Creates a filter for user metadata (kind 0) from multiple authors.
* Alias for userMetadataBatch for API compatibility.
*
* @param pubKeys List of author public keys (hex-encoded, 64 chars each)
* @return Filter for user metadata
*/
fun userMetadataMultiple(pubKeys: List<String>): Filter =
Filter(
kinds = listOf(0),
authors = pubKeys,
)
/** /**
* Creates a filter for contact list (kind 3) from a specific author. * Creates a filter for contact list (kind 3) from a specific author.
* *
+287
View File
@@ -0,0 +1,287 @@
# Chess Engine Refactoring
## Context
Chess feature had inconsistent state reconstruction:
- **Android**: Replays moves from `LocalCache.addressables` (can get stale/desync)
- **Desktop**: Buffers `pendingMoves` and syncs via `forceResync(fen)` (fragile)
Both ViewModels (~1200 + ~956 lines) duplicate logic that now exists in shared components.
## Completed Work
### Shared Components (Done)
| File | Location | Purpose |
|------|----------|---------|
| `ChessStateReconstructor` | `quartz/commonMain/` | Deterministic event→state reconstruction |
| `ChessStateReconstructorTest` | `quartz/jvmAndroidTest/` | 22 integration tests |
| `ChessEventCollector` | `commons/commonMain/` | Thread-safe event aggregation with dedup |
| `ChessGameLoader` | `commons/commonMain/` | Converts ReconstructionResult → LiveChessGameState |
| `ChessLobbyLogic` | `commons/commonMain/` | Shared business logic (challenges, moves, polling) |
| `ChessLobbyState` | `commons/commonMain/` | Shared UI state (StateFlows for games, challenges, status) |
| `ChessBroadcastStatus` | `commons/commonMain/` | Shared broadcast status sealed class |
| `ChessPollingDelegate` | `commons/commonMain/` | Configurable periodic refresh |
| `ChessFilterBuilder` | `commons/commonMain/subscription/` | Shared relay filter construction |
| `ChessSubscriptionController` | `commons/commonMain/subscription/` | Platform subscription interface |
| `ChessStatusBanner` | `amethyst/` (Android only) | Android broadcast status UI |
### Test Coverage
- Game lifecycle, move ordering/dedup, game end conditions
- Viewer perspectives, draw offers, castling, determinism
---
## Target Architecture
```
BOTH PLATFORMS IDENTICAL:
VM (~150 lines) → ChessLobbyLogic
│
├─ publish: ChessEventPublisher (platform impl)
│
└─ refresh (periodic):
one-shot REQ to relays
↓
transient ChessEventCollector (use-once)
↓
ChessStateReconstructor.reconstruct()
↓
LiveChessGameState → UI
(collector discarded, no cache)
```
**Key principle**: No cache. Relays are the ONLY source of truth.
Every refresh cycle:
1. One-shot REQ to relays for all game events
2. Transient collector → reconstruct → get state
3. Diff against UI state, update if changed
4. Discard collector
Real-time subscription events apply moves **optimistically** to `LiveChessGameState`. Periodic full-reconstruction corrects any drift.
---
## Resolved Decisions
### 1. No LocalCache for Chess
**Decision**: Chess events must NOT enter LocalCache. Chess is inherently remote-only.
**How to stop writes**: Remove chess event handlers from `LocalCache.justConsumeInnerInner()` (lines 2956-2960 in `LocalCache.kt`). These are:
```kotlin
is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified) // REMOVE
is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified) // REMOVE
is LiveChessMoveEvent -> consume(event, relay, wasVerified) // REMOVE
is LiveChessGameEndEvent -> consume(event, relay, wasVerified) // REMOVE
```
Also remove chess DataSource from `RelaySubscriptionsCoordinator` (line 83: `val chess = ChessFilterAssembler(client)`). Chess manages its own relay subscriptions independently.
### 2. One-Shot Relay Fetch (in commons)
**Decision**: Create a shared one-shot fetch helper in commons using existing `IRequestListener` + `Channel` pattern.
**Existing pattern** (proven in production):
- `quartz/.../accessories/NostrClientSingleDownloadExt.kt` — single event download
- `quartz/.../accessories/NostrClientSendAndWaitExt.kt` — multi-relay wait
**Design**: The fetcher takes an `INostrClient` (available on both platforms) and uses the existing `IRequestListener` callback → `Channel` → `withTimeoutOrNull` pattern:
```kotlin
// commons/commonMain - shared one-shot fetch
class ChessRelayFetchHelper(private val client: INostrClient) {
suspend fun fetchEvents(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000,
): List<Event> {
val events = mutableListOf<Event>()
val eoseReceived = CompletableDeferred<Unit>()
val subId = UUID.randomUUID().toString().take(8)
val listener = object : IRequestListener {
override fun onEvent(event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
events.add(event)
}
override fun onEose(relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
eoseReceived.complete(Unit)
}
}
client.openReqSubscription(subId, filters, listener)
withTimeoutOrNull(timeoutMs) { eoseReceived.await() }
client.close(subId)
return events
}
}
```
Platform adapters inject their `INostrClient`:
- **Android**: `account.client` (or equivalent from relay pool)
- **Desktop**: `relayManager.client`
### 3. Metadata Provider (interface in commons)
**Decision**: Shared `IUserMetadataProvider` interface in commons. Platform-specific implementations.
Android uses `LocalCache.users[pubkey].info`, Desktop uses `UserMetadataCache`. Both implement:
```kotlin
// commons/commonMain
interface IUserMetadataProvider {
fun getDisplayName(pubkey: String): String
fun getPictureUrl(pubkey: String): String?
}
```
`ChessChallenge` gets enriched with display fields by calling provider at construction time.
### 4. Challenge Type Migration
**Decision**: Enrich `ChessChallenge` with display fields (displayName, avatarUrl) so it replaces both `Note` (Android) and `LiveChessGameChallengeEvent` (Desktop) in UI code.
---
## Implementation Steps
### Step 1: Stop chess events entering LocalCache
**Files:**
- `amethyst/.../model/LocalCache.kt` — remove chess `when` branches (lines ~2956-2960)
- `amethyst/.../service/relayClient/RelaySubscriptionsCoordinator.kt` — remove `val chess` DataSource
### Step 2: Create one-shot relay fetch helper in commons
**File:** `commons/src/commonMain/.../chess/ChessRelayFetchHelper.kt` (new)
Uses `INostrClient` + `IRequestListener` + `Channel` pattern. Shared by both platforms.
### Step 3: Create IUserMetadataProvider in commons
**File:** `commons/src/commonMain/.../chess/IUserMetadataProvider.kt` (new)
### Step 4: Rewrite ChessLobbyLogic (relay-first)
**File:** `commons/src/commonMain/.../chess/ChessLobbyLogic.kt`
Replace `ChessEventFetcher` with `ChessRelayFetcher` (wraps `ChessRelayFetchHelper`):
```kotlin
interface ChessRelayFetcher {
suspend fun fetchGameEvents(gameId: String): ChessGameEvents
suspend fun fetchChallenges(): List<LiveChessGameChallengeEvent>
suspend fun fetchRecentGames(): List<RelayGameSummary>
}
```
Rewrite refresh cycle:
```kotlin
private suspend fun refreshGame(gameId: String) {
val events = relayFetcher.fetchGameEvents(gameId) // one-shot from relays
val result = ChessStateReconstructor.reconstruct(events, userPubkey)
when (result) {
is ReconstructionResult.Success ->
state.replaceGameState(gameId, ChessGameLoader.toLiveGameState(result, userPubkey))
is ReconstructionResult.Error ->
state.setError("Game $gameId: ${result.message}")
}
}
```
Add:
- `handleIncomingEvent(event)` — optimistic real-time event routing
- `handleGameAccepted()` + `startGameFromAcceptance()`
- `acceptDraw()`, `declineDraw()`, `claimAbandonmentVictory()`
- `retryWithBackoff()` for publish operations
- Subscription controller integration
### Step 5: Enhance ChessLobbyState
**File:** `commons/src/commonMain/.../chess/ChessLobbyState.kt`
Add:
- `replaceGameState(gameId, newState)` — for full reconstruction updates
- `completedGames: StateFlow<List<CompletedGame>>`
- Enrich `ChessChallenge` with `displayName`, `avatarUrl`
### Step 6: Platform adapters
**Android:** `amethyst/.../chess/AndroidChessAdapter.kt` (new)
- `AndroidChessPublisher` — wraps `account.signAndComputeBroadcast()`
- `AndroidRelayFetcher` — wraps `ChessRelayFetchHelper(account.client)`
- `AndroidMetadataProvider` — wraps `LocalCache.users[pubkey].info`
**Desktop:** `desktopApp/.../chess/DesktopChessAdapter.kt` (new)
- `DesktopChessPublisher` — wraps `account.signer.sign()` + `relayManager.broadcastToAll()`
- `DesktopRelayFetcher` — wraps `ChessRelayFetchHelper(relayManager.client)`
- `DesktopMetadataProvider` — wraps `UserMetadataCache`
### Step 7: Rewrite Android ChessViewModel
**File:** `amethyst/.../chess/ChessViewModel.kt` (1220 → ~150 lines)
Thin wrapper: delegates to `ChessLobbyLogic`, exposes state, routes `LocalCache.live.newEventBundles` for real-time events.
Delete: `RetryOperation`, `PublicGameInfo`, all LocalCache queries, all handle* methods, `ChessStatus`.
### Step 8: Rewrite Desktop DesktopChessViewModel
**File:** `desktopApp/.../chess/DesktopChessViewModel.kt` (956 → ~150 lines)
Thin wrapper: delegates to `ChessLobbyLogic`, keeps `UserMetadataCache` platform-specific.
Delete: `pendingMoves`, `pendingAccepts`, `challengesByGameId`, `processedEventIds`, `gamesBeingCreated`, all handle* methods, `applyPendingMoves`, `CompletedGame`.
### Step 9: Create shared BroadcastBanner
**File:** `commons/src/commonMain/.../chess/ChessBroadcastBanner.kt` (new)
Extract from Android's `ChessStatusBanner.kt`, use `ChessBroadcastStatus` from commons.
### Step 10: Update UI consumers
**Android:**
- `ChessGameScreen.kt` / `ChessLobbyScreen.kt`: `ChessChallenge` instead of `Note`
- Remove `ChessStatusBanner.kt`, use shared `ChessBroadcastBanner`
**Desktop:**
- `ChessScreen.kt`: `ChessChallenge` instead of `LiveChessGameChallengeEvent`
- Add `ChessBroadcastBanner`
---
## Files Modified
| File | Action | Notes |
|------|--------|-------|
| `LocalCache.kt` | Edit | Remove chess event handlers |
| `RelaySubscriptionsCoordinator.kt` | Edit | Remove chess DataSource |
| `commons/.../ChessRelayFetchHelper.kt` | **New** | One-shot relay query helper |
| `commons/.../IUserMetadataProvider.kt` | **New** | Metadata interface |
| `commons/.../ChessLobbyLogic.kt` | Rewrite | Relay-first, event routing, reconstructor |
| `commons/.../ChessLobbyState.kt` | Enhance | replaceGameState, completedGames, enriched ChessChallenge |
| `commons/.../ChessBroadcastBanner.kt` | **New** | Shared broadcast status UI |
| `amethyst/.../AndroidChessAdapter.kt` | **New** | Publisher + fetcher + metadata |
| `amethyst/.../ChessViewModel.kt` | Rewrite | 1220→150 lines |
| `amethyst/.../ChessStatusBanner.kt` | Delete | Replaced by shared |
| `amethyst/.../ChessGameScreen.kt` | Update | Challenge type + banner |
| `amethyst/.../ChessLobbyScreen.kt` | Update | Challenge type |
| `desktopApp/.../DesktopChessAdapter.kt` | **New** | Publisher + fetcher + metadata |
| `desktopApp/.../DesktopChessViewModel.kt` | Rewrite | 956→150 lines |
| `desktopApp/.../ChessScreen.kt` | Update | Challenge type + banner |
---
## Verification
1. `./gradlew :quartz:build`
2. `./gradlew :commons:build`
3. `./gradlew :amethyst:compileDebugKotlin`
4. `./gradlew :desktopApp:compileKotlin`
5. `./gradlew :quartz:jvmAndroidTest` — ChessStateReconstructor tests
6. `./gradlew :desktopApp:run` — manual test: create challenge, play moves
@@ -21,7 +21,7 @@
package com.vitorpamplona.quartz.nip64Chess package com.vitorpamplona.quartz.nip64Chess
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
@@ -48,7 +48,7 @@ class LiveChessGameChallengeEvent(
tags: Array<Array<String>>, tags: Array<Array<String>>,
content: String, content: String,
sig: HexKey, sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object { companion object {
const val KIND = 30064 const val KIND = 30064
@@ -100,7 +100,7 @@ class LiveChessGameAcceptEvent(
tags: Array<Array<String>>, tags: Array<Array<String>>,
content: String, content: String,
sig: HexKey, sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object { companion object {
const val KIND = 30065 const val KIND = 30065
@@ -148,7 +148,7 @@ class LiveChessMoveEvent(
tags: Array<Array<String>>, tags: Array<Array<String>>,
content: String, content: String,
sig: HexKey, sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object { companion object {
const val KIND = 30066 const val KIND = 30066
@@ -162,7 +162,8 @@ class LiveChessMoveEvent(
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveChessMoveEvent>.() -> Unit = {}, initializer: TagArrayBuilder<LiveChessMoveEvent>.() -> Unit = {},
) = eventTemplate(KIND, comment, createdAt) { ) = eventTemplate(KIND, comment, createdAt) {
add(arrayOf("d", gameId)) add(arrayOf("d", "$gameId-$moveNumber"))
add(arrayOf("game_id", gameId))
add(arrayOf("move_number", moveNumber.toString())) add(arrayOf("move_number", moveNumber.toString()))
add(arrayOf("san", san)) add(arrayOf("san", san))
add(arrayOf("fen", fen)) add(arrayOf("fen", fen))
@@ -172,7 +173,7 @@ class LiveChessMoveEvent(
} }
} }
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "game_id" }?.get(1)
fun moveNumber(): Int? = fun moveNumber(): Int? =
tags tags
@@ -211,7 +212,7 @@ class LiveChessGameEndEvent(
tags: Array<Array<String>>, tags: Array<Array<String>>,
content: String, content: String,
sig: HexKey, sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object { companion object {
const val KIND = 30067 const val KIND = 30067
@@ -268,7 +269,7 @@ class LiveChessDrawOfferEvent(
tags: Array<Array<String>>, tags: Array<Array<String>>,
content: String, content: String,
sig: HexKey, sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object { companion object {
const val KIND = 30068 const val KIND = 30068
@@ -40,6 +40,8 @@ class LiveChessGameState(
val playerColor: Color, val playerColor: Color,
val engine: ChessEngine, val engine: ChessEngine,
val createdAt: Long = TimeUtils.now(), val createdAt: Long = TimeUtils.now(),
val isSpectator: Boolean = false,
val isPendingChallenge: Boolean = false,
) { ) {
companion object { companion object {
// Abandon timeout: 24 hours without a move // Abandon timeout: 24 hours without a move
@@ -92,8 +94,9 @@ class LiveChessGameState(
/** /**
* Check if it's the player's turn * Check if it's the player's turn
* Spectators never have a turn
*/ */
fun isPlayerTurn(): Boolean = engine.getSideToMove() == playerColor fun isPlayerTurn(): Boolean = !isSpectator && engine.getSideToMove() == playerColor
/** /**
* Make a move (called when player makes a move on the board) * Make a move (called when player makes a move on the board)
@@ -129,9 +132,12 @@ class LiveChessGameState(
checkGameEnd() checkGameEnd()
// Return move event to publish // Return move event to publish
// Use ply count (half-move count) for moveNumber so each move gets a unique
// d-tag in the Nostr addressable event. The full-move counter from chesslib
// repeats for White/Black in the same move pair, causing d-tag collisions.
return ChessMoveEvent( return ChessMoveEvent(
gameId = gameId, gameId = gameId,
moveNumber = result.position.moveNumber, moveNumber = _moveHistory.value.size,
san = result.san, san = result.san,
fen = engine.getFen(), fen = engine.getFen(),
opponentPubkey = opponentPubkey, opponentPubkey = opponentPubkey,
@@ -403,6 +409,14 @@ class LiveChessGameState(
""".trimIndent() """.trimIndent()
} }
/**
* Mark game as finished with the given result.
* Used when loading a game from cache that already has an end event.
*/
fun markAsFinished(result: GameResult) {
_gameStatus.value = GameStatus.Finished(result)
}
/** /**
* Reset game to initial position * Reset game to initial position
*/ */
@@ -113,6 +113,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
@@ -193,6 +194,7 @@ class EventFactory {
LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig) LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig)
LiveChessMoveEvent.KIND -> LiveChessMoveEvent(id, pubKey, createdAt, tags, content, sig) LiveChessMoveEvent.KIND -> LiveChessMoveEvent(id, pubKey, createdAt, tags, content, sig)
LiveChessGameEndEvent.KIND -> LiveChessGameEndEvent(id, pubKey, createdAt, tags, content, sig) LiveChessGameEndEvent.KIND -> LiveChessGameEndEvent(id, pubKey, createdAt, tags, content, sig)
LiveChessDrawOfferEvent.KIND -> LiveChessDrawOfferEvent(id, pubKey, createdAt, tags, content, sig)
ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig) ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -27,32 +27,38 @@ import com.github.bhlangonijr.chesslib.Square
import com.github.bhlangonijr.chesslib.move.Move import com.github.bhlangonijr.chesslib.move.Move
/** /**
* JVM/Android implementation of ChessEngine using kchesslib * JVM/Android implementation of ChessEngine using kchesslib.
*
* Maintains its own SAN history because chesslib's Move.toString() returns
* UCI/coordinate notation (e.g. "e2e4") rather than proper SAN (e.g. "e4").
*/ */
actual class ChessEngine { actual class ChessEngine {
private val board = Board() private val board = Board()
private val sanHistory = mutableListOf<String>()
actual fun getFen(): String = board.fen actual fun getFen(): String = board.fen
actual fun loadFen(fen: String) { actual fun loadFen(fen: String) {
board.loadFromFen(fen) board.loadFromFen(fen)
sanHistory.clear()
} }
actual fun reset() { actual fun reset() {
board.loadFromFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") board.loadFromFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
sanHistory.clear()
} }
actual fun makeMove(san: String): MoveResult = actual fun makeMove(san: String): MoveResult =
try { try {
val move = board.doMove(san) if (board.doMove(san)) {
if (move != null) { // Input is already SAN from Nostr events, store directly
sanHistory.add(san)
MoveResult( MoveResult(
success = true, success = true,
san = san, san = san,
position = boardToPosition(), position = boardToPosition(),
) )
} else { } else {
board.undoMove()
MoveResult( MoveResult(
success = false, success = false,
error = "Invalid move: $san", error = "Invalid move: $san",
@@ -86,8 +92,9 @@ actual class ChessEngine {
val move = Move(fromSquare, toSquare, promotionPiece) val move = Move(fromSquare, toSquare, promotionPiece)
if (board.legalMoves().contains(move)) { if (board.legalMoves().contains(move)) {
val san = computeSan(move)
board.doMove(move) board.doMove(move)
val san = move.toString() // kchesslib converts to SAN sanHistory.add(san)
MoveResult( MoveResult(
success = true, success = true,
san = san, san = san,
@@ -119,8 +126,7 @@ actual class ChessEngine {
actual fun isLegalMove(san: String): Boolean = actual fun isLegalMove(san: String): Boolean =
try { try {
val move = board.doMove(san) if (board.doMove(san)) {
if (move != null) {
board.undoMove() board.undoMove()
true true
} else { } else {
@@ -162,6 +168,9 @@ actual class ChessEngine {
actual fun undoMove() { actual fun undoMove() {
board.undoMove() board.undoMove()
if (sanHistory.isNotEmpty()) {
sanHistory.removeAt(sanHistory.lastIndex)
}
} }
actual fun getPosition(): ChessPosition = boardToPosition() actual fun getPosition(): ChessPosition = boardToPosition()
@@ -172,11 +181,115 @@ actual class ChessEngine {
Side.BLACK -> Color.BLACK Side.BLACK -> Color.BLACK
} }
actual fun getMoveHistory(): List<String> { actual fun getMoveHistory(): List<String> = sanHistory.toList()
// kchesslib stores move history
return board.backup.mapNotNull { it?.move?.toString() } /**
* Compute proper SAN notation for a move BEFORE it is applied to the board.
* Handles pieces, pawns, castling, captures, promotion, disambiguation, check/checkmate.
*/
private fun computeSan(move: Move): String {
val fromSquare = move.from
val toSquare = move.to
val piece = board.getPiece(fromSquare)
val pt = piece.pieceType ?: return move.toString()
val promotionPiece = move.promotion ?: Piece.NONE
// Castling
if (pt == com.github.bhlangonijr.chesslib.PieceType.KING) {
val fileDiff = toSquare.file.ordinal - fromSquare.file.ordinal
if (fileDiff == 2) {
val suffix = checkSuffixAfterMove(move)
return "O-O$suffix"
}
if (fileDiff == -2) {
val suffix = checkSuffixAfterMove(move)
return "O-O-O$suffix"
}
}
val sb = StringBuilder()
val epTarget = board.enPassantTarget
val isCapture =
board.getPiece(toSquare) != Piece.NONE ||
(
pt == com.github.bhlangonijr.chesslib.PieceType.PAWN &&
epTarget != null && epTarget != Square.NONE && toSquare == epTarget
)
if (pt != com.github.bhlangonijr.chesslib.PieceType.PAWN) {
sb.append(sanSymbol(pt))
// Disambiguation: check if other pieces of same type can reach the same square
val ambiguous =
board.legalMoves().filterNotNull().filter {
it.to == toSquare &&
board.getPiece(it.from).pieceType == pt &&
it.from != fromSquare
}
if (ambiguous.isNotEmpty()) {
val sameFile = ambiguous.any { it.from.file == fromSquare.file }
val sameRank = ambiguous.any { it.from.rank == fromSquare.rank }
when {
!sameFile -> sb.append(fileChar(fromSquare))
!sameRank -> sb.append(rankChar(fromSquare))
else -> {
sb.append(fileChar(fromSquare))
sb.append(rankChar(fromSquare))
}
}
}
} else if (isCapture) {
// Pawn captures include the source file
sb.append(fileChar(fromSquare))
}
if (isCapture) sb.append('x')
sb.append(toSquare.toString().lowercase())
// Promotion
if (promotionPiece != Piece.NONE) {
sb.append('=')
val promType = promotionPiece.pieceType
if (promType != null) sb.append(sanSymbol(promType))
}
// Check/checkmate suffix
sb.append(checkSuffixAfterMove(move))
return sb.toString()
} }
/**
* Temporarily apply a move to check for check/checkmate, then undo.
*/
private fun checkSuffixAfterMove(move: Move): String {
board.doMove(move)
val suffix =
when {
board.isMated -> "#"
board.isKingAttacked -> "+"
else -> ""
}
board.undoMove()
return suffix
}
private fun sanSymbol(pt: com.github.bhlangonijr.chesslib.PieceType): String =
when (pt) {
com.github.bhlangonijr.chesslib.PieceType.KING -> "K"
com.github.bhlangonijr.chesslib.PieceType.QUEEN -> "Q"
com.github.bhlangonijr.chesslib.PieceType.ROOK -> "R"
com.github.bhlangonijr.chesslib.PieceType.BISHOP -> "B"
com.github.bhlangonijr.chesslib.PieceType.KNIGHT -> "N"
else -> ""
}
private fun fileChar(square: Square): Char = 'a' + square.file.ordinal
private fun rankChar(square: Square): Char = '1' + square.rank.ordinal
/** /**
* Convert kchesslib Board to our ChessPosition model * Convert kchesslib Board to our ChessPosition model
*/ */