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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-02-02 10:00:08 +02:00
parent e0d0e33408
commit 42b33ca04c
26 changed files with 4402 additions and 452 deletions
@@ -168,11 +168,6 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
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.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
@@ -604,36 +599,6 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
) = 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(
event: Event,
relay: NormalizedRelayUrl?,
@@ -2965,11 +2930,6 @@ object LocalCache : ILocalCache, ICacheProvider {
is CalendarDateSlotEvent -> consume(event, relay, wasVerified)
is CalendarTimeSlotEvent -> 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 ChannelListEvent -> 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.publicChannels.datasource.ChannelFilterAssembler
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.discover.datasource.DiscoveryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
@@ -80,7 +79,6 @@ class RelaySubscriptionsCoordinator(
val hashtags = HashtagFilterAssembler(client)
val geohashes = GeoHashFilterAssembler(client)
val followPacks = FollowPackFeedFilterAssembler(client)
val chess = ChessFilterAssembler(client)
// active when sending zaps via NWC
val nwc = NWCPaymentFilterAssembler(client)
@@ -103,7 +101,6 @@ class RelaySubscriptionsCoordinator(
profile,
hashtags,
geohashes,
chess,
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
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.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -82,14 +93,29 @@ fun ChessGameScreen(
)
val activeGames by chessViewModel.activeGames.collectAsState()
val spectatingGames by chessViewModel.spectatingGames.collectAsState()
val error by chessViewModel.error.collectAsState()
val chessStatus by chessViewModel.chessStatus.collectAsState()
var isLoading by remember { mutableStateOf(true) }
var loadedGameState by remember { mutableStateOf<LiveChessGameState?>(null) }
var loadAttempted by remember { mutableStateOf(false) }
var showRelaySettings by remember { mutableStateOf(false) }
// Try to load game from cache if not in activeGames
LaunchedEffect(gameId, activeGames) {
val existingState = activeGames[gameId]
// Get relay information
val outboxRelays by accountViewModel.account.outboxRelays.flow
.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) {
loadedGameState = existingState
isLoading = false
@@ -103,9 +129,9 @@ fun ChessGameScreen(
}
}
// Update state when activeGames changes (e.g., after refresh loads new moves)
LaunchedEffect(activeGames[gameId]) {
activeGames[gameId]?.let {
// Update state when games change (e.g., after refresh loads new moves)
LaunchedEffect(activeGames[gameId], spectatingGames[gameId]) {
(activeGames[gameId] ?: spectatingGames[gameId])?.let {
loadedGameState = it
}
}
@@ -118,21 +144,47 @@ fun ChessGameScreen(
}
}
val gameState = loadedGameState ?: activeGames[gameId]
val gameState = loadedGameState ?: activeGames[gameId] ?: spectatingGames[gameId]
Scaffold(
topBar = {
TopAppBar(
title = { Text("Chess Game") },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
)
}
},
)
Column {
TopAppBar(
title = { Text("Chess Game") },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
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 ->
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.height
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.automirrored.filled.ArrowBack
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.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.routes.Route
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.LiveChessGameState
import kotlinx.coroutines.delay
@@ -92,15 +99,27 @@ fun ChessLobbyScreen(
)
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 error by chessViewModel.error.collectAsState()
val chessStatus by chessViewModel.chessStatus.collectAsState()
val selectedGameId by chessViewModel.selectedGameId.collectAsState()
val userPubkey = accountViewModel.account.userProfile().pubkeyHex
var showNewGameDialog by remember { mutableStateOf(false) }
var showRelaySettings by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
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
ChessSubscription(accountViewModel, chessViewModel)
@@ -139,6 +158,14 @@ fun ChessLobbyScreen(
)
}
},
actions = {
IconButton(onClick = { showRelaySettings = true }) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Relay Settings",
)
}
},
)
},
floatingActionButton = {
@@ -170,6 +197,13 @@ fun ChessLobbyScreen(
.fillMaxSize()
.padding(horizontal = 16.dp),
) {
// Broadcast status banner
ChessStatusBanner(
status = chessStatus,
onTap = { /* no-op in lobby */ },
modifier = Modifier.padding(bottom = 8.dp),
)
// Error display
error?.let { errorMsg ->
Card(
@@ -193,6 +227,8 @@ fun ChessLobbyScreen(
ChessLobbyContent(
challenges = challenges,
activeGames = activeGames,
spectatingGames = spectatingGames,
publicGames = publicGames,
userPubkey = userPubkey,
accountViewModel = accountViewModel,
onAcceptChallenge = { note ->
@@ -201,6 +237,10 @@ fun ChessLobbyScreen(
onSelectGame = { 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
fun ChessLobbyContent(
challenges: List<Note>,
activeGames: Map<String, LiveChessGameState>,
spectatingGames: Map<String, LiveChessGameState>,
publicGames: List<PublicGameInfo>,
userPubkey: String,
accountViewModel: AccountViewModel,
onAcceptChallenge: (Note) -> 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
Box(
modifier = Modifier.fillMaxSize(),
@@ -255,18 +318,18 @@ fun ChessLobbyContent(
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Active games section
// Active games section (user is participant)
if (activeGames.isNotEmpty()) {
item {
Text(
"Active Games",
"Your Games",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
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(
gameId = gameId,
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
val incomingChallenges =
challenges.filter {
@@ -294,7 +379,7 @@ fun ChessLobbyContent(
)
}
items(incomingChallenges, key = { it.idHex }) { note ->
items(incomingChallenges, key = { "incoming-${it.idHex}" }) { note ->
ChallengeCard(
note = note,
isIncoming = true,
@@ -304,31 +389,7 @@ fun ChessLobbyContent(
}
}
// User's outgoing challenges
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
// Open challenges from others (can join)
val openChallenges =
challenges.filter {
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(
note = note,
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
item {
Spacer(Modifier.height(80.dp))
@@ -375,6 +481,12 @@ private fun ActiveGameCard(
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(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
border =
@@ -391,14 +503,14 @@ private fun ActiveGameCard(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"vs $displayName",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
gameName,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
"Game: ${gameId.take(12)}...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
"vs $displayName",
style = MaterialTheme.typography.bodyMedium,
)
}
@@ -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.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder
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.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
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
/**
* Default "since" window for challenges (24 hours)
* Challenges older than this are considered expired anyway
*/
private const val CHALLENGE_WINDOW_SECONDS = 24 * 60 * 60L
/**
* 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.
* Subscribe to chess events when the Chess screen is active.
*
* Always uses global relays to ensure we see all chess events,
* regardless of the user's feed filter setting (Global/Follows).
*/
@Composable
fun ChessSubscription(
accountViewModel: AccountViewModel,
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
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 =
remember(accountViewModel.account, isGlobal, activeGameIds) {
remember(accountViewModel.account, activeGameIds, opponentPubkeys) {
ChessQueryState(
userPubkey = accountViewModel.account.userProfile().pubkeyHex,
// Use notification inbox relays - where others send events TO us
inboxRelays = accountViewModel.account.notificationRelays.flow.value,
// Use proxy relays for global, outbox relays for follows
globalRelays =
if (isGlobal) {
accountViewModel.account.defaultGlobalRelays.flow.value
} else {
accountViewModel.account.followOutboxesOrProxy.flow.value
},
isGlobal = isGlobal,
// Always use global relays for chess - we want to see ALL games
globalRelays = accountViewModel.account.defaultGlobalRelays.flow.value,
// Always treat as global for chess
isGlobal = true,
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) {
accountViewModel.dataSources().chess.subscribe(state)
chessViewModel.refreshChallenges()
onDispose {
accountViewModel.dataSources().chess.unsubscribe(state)
// Subscription cleanup handled by ViewModel
}
}
}
@@ -107,6 +93,7 @@ data class ChessQueryState(
val globalRelays: Set<NormalizedRelayUrl>,
val isGlobal: Boolean,
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:
* - Global: Fetch all chess events from global relays
* - Follows: Fetch from followed users' outbox relays
* Following jesterui's approach:
* 1. Fetch ALL challenges from ALL connected relays (no author/tag restriction)
* 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.
*
* Improvements over basic implementation:
* 1. Default "since" timestamps to avoid loading very old events on first connection
* 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
* This ensures we see:
* - Open challenges from anyone
* - Challenges directed at us
* - Public games to spectate
*/
fun filterChessEvents(
key: ChessQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val filters = mutableListOf<RelayBasedFilter>()
val now = TimeUtils.now()
// Challenge kinds only (for lobby display)
val challengeKinds =
listOf(
LiveChessGameChallengeEvent.KIND,
LiveChessGameAcceptEvent.KIND,
// Convert Android ChessQueryState to shared ChessSubscriptionState
val state =
ChessSubscriptionState(
userPubkey = key.userPubkey,
relays = key.inboxRelays + key.globalRelays,
activeGameIds = key.activeGameIds,
opponentPubkeys = key.opponentPubkeys,
)
// Move/end kinds (for active games)
val gameEventKinds =
listOf(
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),
),
)
// Use shared filter builder for consistent behavior with Desktop
return ChessFilterBuilder.buildAllFilters(state) { relay ->
since?.get(relay)?.time
}
// ========================================
// 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.filterIntoSet
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.GameResult
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.LiveChessGameState
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.PieceType
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -44,7 +47,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.UUID
/**
* ViewModel for managing chess game state, challenges, and event publishing
@@ -64,11 +66,19 @@ class ChessViewModel(
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())
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())
val challenges: StateFlow<List<Note>> = _challenges.asStateFlow()
@@ -88,6 +98,14 @@ class ChessViewModel(
private val _pendingRetries = MutableStateFlow<Map<String, RetryOperation>>(emptyMap())
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
private val pollingDelegate =
ChessPollingDelegate(
@@ -101,6 +119,9 @@ class ChessViewModel(
},
)
// Track games currently being created to prevent race conditions (like Desktop)
private val gamesBeingCreated = mutableSetOf<String>()
init {
subscribeToChessEvents()
// Start polling - will do initial fetch
@@ -201,6 +222,11 @@ class ChessViewModel(
* Handle game acceptance event
*/
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
if (event.challengerPubkey() == account.signer.pubKey) {
startGameFromAcceptance(event)
@@ -267,6 +293,14 @@ class ChessViewModel(
val gameId = generateGameId()
viewModelScope.launch(Dispatchers.IO) {
val relayCount = acc.outboxRelays.flow.value.size
_chessStatus.value =
ChessStatus.BroadcastingMove(
san = "Challenge",
successCount = 0,
totalRelays = relayCount,
)
val success =
retryWithBackoff("challenge-$gameId") {
val template =
@@ -281,8 +315,23 @@ class ChessViewModel(
}
if (success) {
_chessStatus.value =
ChessStatus.MoveSuccess(
san = "Challenge",
relayCount = relayCount,
)
_error.value = null
refreshChallenges()
delay(3000)
if (_chessStatus.value is ChessStatus.MoveSuccess) {
_chessStatus.value = ChessStatus.Idle
}
} else {
_chessStatus.value =
ChessStatus.MoveFailed(
san = "Challenge",
error = "Failed after $MAX_RETRIES attempts",
)
_error.value = "Failed to create challenge after $MAX_RETRIES attempts"
}
}
@@ -297,6 +346,11 @@ class ChessViewModel(
challengerPubkey: String,
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) {
try {
val template =
@@ -308,6 +362,16 @@ class ChessViewModel(
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
val engine = ChessEngine()
engine.reset()
@@ -322,10 +386,16 @@ class ChessViewModel(
)
_activeGames.value = _activeGames.value + (gameId to gameState)
synchronized(gamesBeingCreated) {
gamesBeingCreated.remove(gameId)
}
pollingDelegate.addGameId(gameId)
_selectedGameId.value = gameId
_error.value = null
} catch (e: Exception) {
synchronized(gamesBeingCreated) {
gamesBeingCreated.remove(gameId)
}
_error.value = "Failed to accept challenge: ${e.message}"
}
}
@@ -338,6 +408,13 @@ class ChessViewModel(
val challengeEvent = challengeNote.event as? LiveChessGameChallengeEvent ?: 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 challengerColor = challengeEvent.playerColor() ?: Color.WHITE
val playerColor = challengerColor.opposite()
@@ -353,6 +430,13 @@ class ChessViewModel(
viewModelScope.launch(Dispatchers.IO) {
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
// Find our challenge to get player color
@@ -376,6 +460,9 @@ class ChessViewModel(
)
_activeGames.value = _activeGames.value + (gameId to gameState)
synchronized(gamesBeingCreated) {
gamesBeingCreated.remove(gameId)
}
pollingDelegate.addGameId(gameId)
_selectedGameId.value = gameId
}
@@ -390,12 +477,36 @@ class ChessViewModel(
to: String,
) {
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) {
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)
*/
@@ -405,6 +516,15 @@ class ChessViewModel(
) {
viewModelScope.launch(Dispatchers.IO) {
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 =
LiveChessMoveEvent.build(
gameId = moveEvent.gameId,
@@ -415,8 +535,33 @@ class ChessViewModel(
)
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
} catch (e: Exception) {
_chessStatus.value =
ChessStatus.MoveFailed(
san = moveEvent.san,
error = e.message ?: "Unknown error",
)
_error.value = "Failed to publish move: ${e.message}"
}
}
@@ -508,28 +653,241 @@ class ChessViewModel(
/**
* 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() {
viewModelScope.launch(Dispatchers.IO) {
val userPubkey = account.signer.pubKey
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 =
LocalCache.notes.filterIntoSet { _, note ->
LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note ->
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
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()
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()
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 {
val timestamp = TimeUtils.now()
val random = UUID.randomUUID().toString().take(8)
return "chess-$timestamp-$random"
}
private fun generateGameId(): String = ChessGameNameGenerator.generateGameId(TimeUtils.now())
/**
* Refresh game state from LocalCache for specific game IDs
* Called periodically by polling delegate
*
* NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables
*/
private suspend fun refreshGamesFromCache(gameIds: Set<String>) {
val userPubkey = account.signer.pubKey
for (gameId in gameIds) {
// Find moves for this game
// Find moves for this game from addressables (kind 30066)
val moveNotes =
LocalCache.notes.filterIntoSet { _, note ->
LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note ->
val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
event.gameId() == gameId
}
val gameState = _activeGames.value[gameId]
if (gameState != null) {
// Apply any new moves
// Check active games first
val activeGameState = _activeGames.value[gameId]
if (activeGameState != null) {
// Apply any new moves from opponent
moveNotes
.mapNotNull { it.event as? LiveChessMoveEvent }
.filter { it.pubKey != userPubkey } // Only opponent moves
@@ -663,9 +1020,29 @@ class ChessViewModel(
val san = moveEvent.san() ?: return@forEach
val fen = moveEvent.fen() ?: return@forEach
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()
@@ -675,6 +1052,8 @@ class ChessViewModel(
* Load or rebuild game state from LocalCache for a specific gameId
* 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
*/
fun loadGameFromCache(gameId: String): LiveChessGameState? {
@@ -683,17 +1062,17 @@ class ChessViewModel(
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 =
LocalCache.notes.filterIntoSet { _, note ->
LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note ->
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
event.gameId() == gameId
}
val challengeNote = challengeNotes.firstOrNull()
// Find accept event for this game
// Find accept event for this game from addressables (kind 30065)
val acceptNotes =
LocalCache.notes.filterIntoSet { _, note ->
LocalCache.addressables.filterIntoSet(LiveChessGameAcceptEvent.KIND) { _, note ->
val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false
event.gameId() == gameId
}
@@ -724,9 +1103,10 @@ class ChessViewModel(
challengerColor.opposite() to challengerPubkey
}
challengerPubkey == userPubkey && acceptorPubkey == null -> {
// We created challenge but no one accepted yet
_error.value = "Waiting for opponent to accept challenge"
return null
// We created challenge but no one accepted yet - show it anyway
// Use targeted opponent or empty string for open challenges
val targetedOpponent = challengeEvent.opponentPubkey() ?: ""
challengerColor to targetedOpponent
}
else -> {
_error.value = "You are not a participant in this game"
@@ -738,9 +1118,9 @@ class ChessViewModel(
val engine = ChessEngine()
engine.reset()
// Find and apply all moves in order
// Find and apply all moves in order from addressables (kind 30066)
val moveNotes =
LocalCache.notes.filterIntoSet { _, note ->
LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note ->
val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
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 =
LiveChessGameState(
gameId = gameId,
@@ -774,13 +1158,28 @@ class ChessViewModel(
opponentPubkey = opponentPubkey,
playerColor = playerColor,
engine = engine,
isPendingChallenge = isPendingChallenge,
)
// Mark loaded moves as received to prevent re-application during refresh
gameState.markMovesAsReceived(loadedMoveNumbers)
// Add to active games
_activeGames.value = _activeGames.value + (gameId to gameState)
// Check for end event (kind 30067) — game may have been resigned/finished
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
pollingDelegate.addGameId(gameId)
@@ -808,3 +1207,14 @@ data class RetryOperation(
val currentAttempt: 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.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -31,8 +32,11 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -69,15 +73,27 @@ fun InteractiveChessBoard(
boardSize: Dp = 400.dp,
flipped: Boolean = false,
playerColor: ChessColor? = null, // null = allow both (for local play)
positionVersion: Int = 0, // External trigger for position refresh (e.g. moveHistory.size)
onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> },
) {
// Track move count to trigger recomposition when board changes
var moveCount by remember { mutableStateOf(0) }
val position = remember(engine, moveCount) { engine.getPosition() }
val sideToMove = remember(engine, moveCount) { engine.getSideToMove() }
// Track local move count + external version to trigger recomposition when board changes
var localMoveCount by remember { mutableStateOf(0) }
val effectiveVersion = localMoveCount + positionVersion
val position = remember(engine, effectiveVersion) { engine.getPosition() }
val sideToMove = remember(engine, effectiveVersion) { engine.getSideToMove() }
var selectedSquare by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var legalMoves by remember { mutableStateOf<List<String>>(emptyList()) }
// Pawn promotion state
var pendingPromotion by remember { mutableStateOf<PendingPromotion?>(null) }
// Clear stale selection when position changes externally (e.g. opponent move)
LaunchedEffect(positionVersion) {
selectedSquare = null
legalMoves = emptyList()
pendingPromotion = null
}
// Can only interact if it's your turn (or playerColor is null for local play)
val canInteract = playerColor == null || sideToMove == playerColor
@@ -87,66 +103,118 @@ fun InteractiveChessBoard(
val rankRange = if (flipped) (0..7) else (7 downTo 0)
val fileRange = if (flipped) (7 downTo 0) else (0..7)
Column(modifier = modifier.size(boardSize)) {
for (rank in rankRange) {
Row {
for (file in fileRange) {
val piece = position.pieceAt(file, rank)
val isLightSquare = (rank + file) % 2 == 0
val square = fileRankToSquare(file, rank)
val isSelected = selectedSquare == (file to rank)
val isLegalMove = legalMoves.contains(square)
Box(modifier = modifier.size(boardSize)) {
Column(modifier = Modifier.fillMaxSize()) {
for (rank in rankRange) {
Row {
for (file in fileRange) {
val piece = position.pieceAt(file, rank)
val isLightSquare = (rank + file) % 2 == 0
val square = fileRankToSquare(file, rank)
val isSelected = selectedSquare == (file to rank)
val isLegalMove = legalMoves.contains(square)
val showCoord = if (flipped) rank == 7 else rank == 0
val showCoord = if (flipped) rank == 7 else rank == 0
InteractiveChessSquare(
piece = piece,
isLight = isLightSquare,
size = squareSize,
isSelected = isSelected,
isLegalMove = isLegalMove,
showCoordinate = showCoord,
file = file,
rank = rank,
onClick = {
if (!canInteract) return@InteractiveChessSquare
InteractiveChessSquare(
piece = piece,
isLight = isLightSquare,
size = squareSize,
isSelected = isSelected,
isLegalMove = isLegalMove,
showCoordinate = showCoord,
file = file,
rank = rank,
onClick = {
if (!canInteract || pendingPromotion != null) return@InteractiveChessSquare
if (selectedSquare != null) {
// Attempt to make move - validate first, don't actually make it
val from = fileRankToSquare(selectedSquare!!.first, selectedSquare!!.second)
val to = square
if (selectedSquare != null) {
// Attempt to make move - validate first, don't actually make it
val from = fileRankToSquare(selectedSquare!!.first, selectedSquare!!.second)
val to = square
if (legalMoves.contains(to)) {
// Valid move - callback will make the actual move
selectedSquare = null
legalMoves = emptyList()
// Pass empty san - callback will get it from makeMove result
onMoveMade(from, to, "")
moveCount++ // Trigger recomposition after move is made
if (legalMoves.contains(to)) {
// Check if this is a pawn promotion move
val fromRank = selectedSquare!!.second
val toRank = rank
val movingPiece = position.pieceAt(selectedSquare!!.first, fromRank)
val isPromotion =
movingPiece?.type == PieceType.PAWN &&
(toRank == 7 || toRank == 0)
if (isPromotion) {
// Show promotion dialog
pendingPromotion =
PendingPromotion(
from = from,
to = to,
file = file,
rank = toRank,
color = sideToMove,
)
} else {
// Valid move - callback will make the actual move
selectedSquare = null
legalMoves = emptyList()
// Pass empty san - callback will get it from makeMove result
onMoveMade(from, to, "")
localMoveCount++ // Trigger recomposition after move is made
}
} else {
// Not a legal move target - try selecting this square instead
val validColor = playerColor ?: sideToMove
if (piece != null && piece.color == validColor) {
selectedSquare = file to rank
legalMoves = engine.getLegalMovesFrom(square)
} else {
selectedSquare = null
legalMoves = emptyList()
}
}
} else {
// Not a legal move target - try selecting this square instead
// Select piece - only allow selecting player's pieces
val validColor = playerColor ?: sideToMove
if (piece != null && piece.color == validColor) {
selectedSquare = file to rank
legalMoves = engine.getLegalMovesFrom(square)
} else {
selectedSquare = null
legalMoves = emptyList()
}
}
} else {
// Select piece - only allow selecting player's pieces
val validColor = playerColor ?: sideToMove
if (piece != null && piece.color == validColor) {
selectedSquare = file to rank
legalMoves = engine.getLegalMovesFrom(square)
}
}
},
)
},
)
}
}
}
}
// Promotion dialog overlay
pendingPromotion?.let { promo ->
PromotionPicker(
color = promo.color,
squareSize = squareSize,
onPieceSelected = { pieceType ->
// Make the move with promotion
val promotionSuffix =
when (pieceType) {
PieceType.QUEEN -> "q"
PieceType.ROOK -> "r"
PieceType.BISHOP -> "b"
PieceType.KNIGHT -> "n"
else -> "q"
}
selectedSquare = null
legalMoves = emptyList()
pendingPromotion = null
// Include promotion in the 'to' square for the callback
onMoveMade(promo.from, promo.to + promotionSuffix, "")
localMoveCount++
},
onDismiss = {
pendingPromotion = null
selectedSquare = null
legalMoves = emptyList()
},
)
}
}
}
@@ -254,3 +322,71 @@ private fun ChessPiece.toImageVector(): ImageVector {
PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn
}
}
/**
* Data class for pending pawn promotion
*/
private data class PendingPromotion(
val from: String,
val to: String,
val file: Int,
val rank: Int,
val color: ChessColor,
)
/**
* Promotion piece picker overlay
*/
@Composable
private fun PromotionPicker(
color: ChessColor,
squareSize: Dp,
onPieceSelected: (PieceType) -> Unit,
onDismiss: () -> Unit,
) {
val isWhite = color == ChessColor.WHITE
val promotionPieces =
listOf(
PieceType.QUEEN to if (isWhite) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen,
PieceType.ROOK to if (isWhite) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook,
PieceType.BISHOP to if (isWhite) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop,
PieceType.KNIGHT to if (isWhite) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight,
)
// Semi-transparent overlay
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.clickable { onDismiss() },
contentAlignment = Alignment.Center,
) {
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.clickable { /* prevent dismiss */ },
) {
Row(
modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
promotionPieces.forEach { (pieceType, icon) ->
Box(
modifier =
Modifier
.size(squareSize)
.background(Color(0xFFF0D9B5), RoundedCornerShape(4.dp))
.clickable { onPieceSelected(pieceType) },
contentAlignment = Alignment.Center,
) {
Image(
imageVector = icon,
contentDescription = pieceType.name,
modifier = Modifier.fillMaxSize().padding(4.dp),
)
}
}
}
}
}
}
@@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
@@ -189,6 +190,9 @@ fun LiveChessGameScreen(
val currentPosition by gameState.currentPosition.collectAsState()
val moveHistory by gameState.moveHistory.collectAsState()
// Pending challenges and spectators cannot make moves
val canMakeMoves = !gameState.isSpectator && !gameState.isPendingChallenge
BoxWithConstraints(
modifier = modifier.fillMaxSize(),
) {
@@ -212,15 +216,23 @@ fun LiveChessGameScreen(
opponentName = opponentName,
playerColor = gameState.playerColor,
currentTurn = currentPosition.activeColor,
isSpectator = gameState.isSpectator,
isPendingChallenge = gameState.isPendingChallenge,
)
// Interactive chess board - flip when playing black
// Interactive chess board - flip when playing black (spectators see from white's view)
// Auto-sized to fit available space
InteractiveChessBoard(
engine = gameState.engine,
boardSize = boardSize,
flipped = gameState.playerColor == Color.BLACK,
onMoveMade = onMoveMade,
flipped = !gameState.isSpectator && gameState.playerColor == Color.BLACK,
positionVersion = moveHistory.size,
onMoveMade =
if (canMakeMoves) {
onMoveMade
} else {
{ _, _, _ -> } // No-op for spectators and pending challenges
},
)
// Move history (scrollable) - observed from state flow
@@ -228,11 +240,12 @@ fun LiveChessGameScreen(
moves = moveHistory,
)
// Game controls
GameControls(
onResign = onResign,
onOfferDraw = onOfferDraw,
)
// Show appropriate controls based on game state
when {
gameState.isPendingChallenge -> PendingChallengeInfo()
gameState.isSpectator -> SpectatorInfo()
else -> GameControls(onResign = onResign, onOfferDraw = onOfferDraw)
}
}
}
}
@@ -291,6 +304,7 @@ fun LiveChessGameScreen(
engine = engine,
boardSize = boardSize,
flipped = playerColor == Color.BLACK,
positionVersion = engine.getMoveHistory().size,
onMoveMade = onMoveMade,
)
@@ -317,45 +331,147 @@ private fun GameInfoHeader(
opponentName: String,
playerColor: Color,
currentTurn: Color,
isSpectator: Boolean = false,
isPendingChallenge: Boolean = false,
) {
// Extract human-readable game name if available
val gameName =
remember(gameId) {
ChessGameNameGenerator.extractDisplayName(gameId)
}
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Show readable name prominently if available
if (gameName != null) {
Text(
text = gameName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
Text(
text = "Game: ${gameId.take(8)}...",
text = if (gameName != null) gameId.take(16) else "Game: ${gameId.take(8)}...",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "vs $opponentName",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
when {
isPendingChallenge -> {
Text(
text = "Challenge Pending",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.secondary,
)
Text(
text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}",
style = MaterialTheme.typography.bodyMedium,
)
val turnText =
if (currentTurn == playerColor) {
"Your turn"
} else {
"Opponent's turn"
Text(
text = if (opponentName.isNotEmpty()) "Waiting for $opponentName" else "Open challenge",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
isSpectator -> {
Text(
text = "Spectating",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.tertiary,
)
val turnText = "${if (currentTurn == Color.WHITE) "White" else "Black"}'s turn"
Text(
text = turnText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> {
Text(
text = "vs $opponentName",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}",
style = MaterialTheme.typography.bodyMedium,
)
val turnText =
if (currentTurn == playerColor) {
"Your turn"
} else {
"Opponent's turn"
}
Text(
text = turnText,
style = MaterialTheme.typography.bodyMedium,
color =
if (currentTurn == playerColor) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
fontWeight = if (currentTurn == playerColor) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
}
/**
* Info banner shown when spectating a game
*/
@Composable
private fun SpectatorInfo() {
Box(
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
RoundedCornerShape(8.dp),
).padding(12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = turnText,
text = "Watching game - spectator mode",
style = MaterialTheme.typography.bodyMedium,
color =
if (currentTurn == playerColor) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
fontWeight = if (currentTurn == playerColor) FontWeight.Bold else FontWeight.Normal,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
/**
* Info banner shown when viewing a pending challenge
*/
@Composable
private fun PendingChallengeInfo() {
Box(
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
RoundedCornerShape(8.dp),
).padding(12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "Waiting for opponent to accept challenge",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
@@ -0,0 +1,292 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess.subscription
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Shared filter builder for chess subscriptions.
* Used by both Android and Desktop to ensure identical subscription behavior.
*
* Builds 4 types of filters:
* 1. Personal filters - Events tagged with user's pubkey
* 2. Challenge filters - All challenges (like jesterui pattern)
* 3. Active game filters - Game-specific move/end subscriptions
* 4. Recent game filters - For spectating discovery (7 day window)
*/
object ChessFilterBuilder {
/** Challenge and accept event kinds (for lobby display) */
private val CHALLENGE_KINDS =
listOf(
LiveChessGameChallengeEvent.KIND,
LiveChessGameAcceptEvent.KIND,
)
/** Move and end event kinds (for active games) */
private val GAME_EVENT_KINDS =
listOf(
LiveChessMoveEvent.KIND,
LiveChessGameEndEvent.KIND,
)
/** All chess event kinds */
private val ALL_CHESS_KINDS = CHALLENGE_KINDS + GAME_EVENT_KINDS
/**
* Build all chess filters for a given subscription state.
* This is the main entry point - platforms call this to get filters.
*
* @param state The current subscription state with user info and active games
* @param sinceForRelay Function to get cached EOSE time for a relay (null if no cache)
* @return List of relay-specific filters to subscribe with
*/
fun buildAllFilters(
state: ChessSubscriptionState,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
): List<RelayBasedFilter> {
val filters = mutableListOf<RelayBasedFilter>()
val now = TimeUtils.now()
// Filter 1: Personal events (challenges to us, moves in our games)
filters.addAll(
buildPersonalFilters(state.userPubkey, state.relays, sinceForRelay, now),
)
// Filter 2: All challenges (for lobby display)
filters.addAll(
buildChallengeFilters(state.relays, sinceForRelay, now),
)
// Filter 3: Active game subscriptions (game-specific)
if (state.hasActiveGames) {
filters.addAll(
buildActiveGameFilters(
state.allGameIds,
state.userPubkey,
state.opponentPubkeys,
state.relays,
),
)
}
// Filter 4: Recent game events (for spectating discovery)
filters.addAll(
buildRecentGameFilters(state.relays, sinceForRelay, now),
)
return filters
}
/**
* Personal events - tagged with user's pubkey.
* Catches: challenges directed at us, moves in our games, game ends.
*/
fun buildPersonalFilters(
userPubkey: String,
relays: Set<NormalizedRelayUrl>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
now: Long = TimeUtils.now(),
): List<RelayBasedFilter> {
val filter =
Filter(
kinds = ALL_CHESS_KINDS,
tags = mapOf("p" to listOf(userPubkey)),
limit = 100,
)
return relays.map { relay ->
val sinceTime =
sinceForRelay(relay)
?: (now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS)
RelayBasedFilter(
relay = relay,
filter = filter.copy(since = sinceTime),
)
}
}
/**
* All challenge events (like jesterui pattern).
* No author/tag restriction - fetch everything, filter client-side.
* This ensures we see open challenges and public games.
*/
fun buildChallengeFilters(
relays: Set<NormalizedRelayUrl>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
now: Long = TimeUtils.now(),
): List<RelayBasedFilter> {
val filter =
Filter(
kinds = CHALLENGE_KINDS,
limit = 100,
)
return relays.map { relay ->
val sinceTime =
sinceForRelay(relay)
?: (now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS)
RelayBasedFilter(
relay = relay,
filter = filter.copy(since = sinceTime),
)
}
}
/**
* Active game events - game-specific subscriptions.
* These filters ensure moves are received for games the user is playing.
*
* Note: Move d-tags are "gameId-moveNumber" so we can't filter by #d for moves.
* We use two strategies:
* 1. Filter by authors (opponent pubkeys) - catches moves they make
* 2. Filter by #p tag (opponent tagged us) - catches moves tagged with us
*
* End events use gameId as d-tag so we can filter those directly.
*/
fun buildActiveGameFilters(
gameIds: Set<String>,
userPubkey: String,
opponentPubkeys: Set<String>,
relays: Set<NormalizedRelayUrl>,
): List<RelayBasedFilter> {
if (gameIds.isEmpty()) return emptyList()
println("[ChessFilterBuilder] Building filters for ${gameIds.size} games, ${opponentPubkeys.size} opponents: $opponentPubkeys")
val filters = mutableListOf<RelayBasedFilter>()
// End events: filter by d-tag (gameId)
val endFilter =
Filter(
kinds = listOf(LiveChessGameEndEvent.KIND),
tags = mapOf("d" to gameIds.toList()),
limit = 50,
)
// Move events: filter by p-tag (opponent tagged us)
// This catches all moves for games where we're a participant
val moveFilterByTag =
Filter(
kinds = listOf(LiveChessMoveEvent.KIND),
tags = mapOf("p" to listOf(userPubkey)),
limit = 200,
)
relays.forEach { relay ->
filters.add(RelayBasedFilter(relay = relay, filter = endFilter))
filters.add(RelayBasedFilter(relay = relay, filter = moveFilterByTag))
}
// Move events: filter by authors (opponent pubkeys)
// This directly catches moves made by our opponents
if (opponentPubkeys.isNotEmpty()) {
println("[ChessFilterBuilder] Adding author filter for opponents: $opponentPubkeys")
val moveFilterByAuthor =
Filter(
kinds = listOf(LiveChessMoveEvent.KIND),
authors = opponentPubkeys.toList(),
limit = 200,
)
relays.forEach { relay ->
filters.add(RelayBasedFilter(relay = relay, filter = moveFilterByAuthor))
}
} else {
println("[ChessFilterBuilder] WARNING: No opponent pubkeys provided!")
}
return filters
}
/**
* Recent game events for spectating discovery.
* Uses longer time window (7 days) to catch ongoing games.
*/
fun buildRecentGameFilters(
relays: Set<NormalizedRelayUrl>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
now: Long = TimeUtils.now(),
): List<RelayBasedFilter> {
val filter =
Filter(
kinds = GAME_EVENT_KINDS,
limit = 100,
)
return relays.map { relay ->
val sinceTime =
sinceForRelay(relay)
?: (now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS)
RelayBasedFilter(
relay = relay,
filter = filter.copy(since = sinceTime),
)
}
}
// ===================================================
// Simple filters for one-shot fetches (ChessRelayFetcher)
// ===================================================
/**
* Filter for all events related to a specific game.
* Used for one-shot fetch when loading a game.
*/
fun gameEventsFilter(gameId: String): Filter =
Filter(
kinds = ALL_CHESS_KINDS + listOf(com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent.KIND),
tags = mapOf("d" to listOf(gameId)),
limit = 500,
)
/**
* Filter for challenge events in the last 24 hours.
* Used for one-shot fetch to populate lobby.
*/
fun challengesFilter(userPubkey: String): Filter {
val now = TimeUtils.now()
return Filter(
kinds = CHALLENGE_KINDS,
since = now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS,
limit = 100,
)
}
/**
* Filter for recent game activity for spectating discovery.
* Fetches move events from the last 7 days.
*/
fun recentGamesFilter(): Filter {
val now = TimeUtils.now()
return Filter(
kinds = ALL_CHESS_KINDS,
since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS,
limit = 200,
)
}
}
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess.subscription
import kotlinx.coroutines.flow.StateFlow
/**
* Interface for platform-specific chess subscription management.
*
* Implementations handle the actual relay subscription mechanics while
* using [ChessFilterBuilder] for consistent filter construction.
*
* Key responsibilities:
* - Subscribe/unsubscribe to chess events on relays
* - Update filters when active games change
* - Track EOSE (End of Stored Events) per relay for efficient re-subscription
*/
interface ChessSubscriptionController {
/** Current subscription state, null if not subscribed */
val currentState: StateFlow<ChessSubscriptionState?>
/**
* Subscribe to chess events with the given state.
* Replaces any existing subscription.
*/
fun subscribe(state: ChessSubscriptionState)
/** Unsubscribe from all chess events */
fun unsubscribe()
/**
* Update active game IDs and refresh subscription filters.
* This is the key method for dynamic subscription updates.
*
* When a game starts or ends, call this to update the filters
* so moves are properly received for active games.
*
* @param activeGameIds Game IDs the user is actively playing
* @param spectatingGameIds Game IDs the user is watching (optional)
*/
fun updateActiveGames(
activeGameIds: Set<String>,
spectatingGameIds: Set<String> = emptySet(),
)
/**
* Force refresh all filters from relays.
* Clears EOSE cache so full history is re-fetched.
*/
fun forceRefresh()
}
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess.subscription
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Immutable state for chess relay subscriptions.
* Used to generate subscription filters and track subscription identity.
*
* When any of these values change, a new subscription should be created
* with updated filters.
*/
@Immutable
data class ChessSubscriptionState(
/** User's public key (hex) for filtering personal events */
val userPubkey: String,
/** Set of relays to subscribe to */
val relays: Set<NormalizedRelayUrl>,
/** Game IDs the user is actively playing */
val activeGameIds: Set<String> = emptySet(),
/** Game IDs the user is spectating */
val spectatingGameIds: Set<String> = emptySet(),
/** Opponent pubkeys for active games (to filter their moves) */
val opponentPubkeys: Set<String> = emptySet(),
) {
/**
* Unique subscription ID that changes when game IDs change.
* Used for:
* - Subscription deduplication
* - EOSE cache keying
* - Detecting when filters need to be refreshed
*/
fun subscriptionId(): String =
buildString {
append("chess-")
append(userPubkey.take(8))
if (allGameIds.isNotEmpty()) {
append("-g")
append(allGameIds.sorted().hashCode())
}
}
/** All game IDs requiring move subscriptions (active + spectating) */
val allGameIds: Set<String>
get() = activeGameIds + spectatingGameIds
/** True if user has any active or spectating games */
val hasActiveGames: Boolean
get() = allGameIds.isNotEmpty()
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chess.subscription
/**
* Shared time window constants for chess subscriptions.
* Used by both Android and Desktop to ensure consistent behavior.
*/
object ChessTimeWindows {
/**
* 24 hours - challenges older than this are considered expired.
* Used for challenge and accept event filters.
*/
const val CHALLENGE_WINDOW_SECONDS = 24 * 60 * 60L
/**
* 7 days - default lookback for game events when no EOSE cache exists.
* This prevents loading ancient game history on first connection
* while still allowing recovery of recent games.
*/
const val GAME_EVENT_WINDOW_SECONDS = 7 * 24 * 60 * 60L
}
@@ -69,9 +69,10 @@ import com.vitorpamplona.amethyst.commons.data.UserMetadataCache
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.account.AccountState
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.rememberSubscription
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
/**
@@ -91,14 +92,26 @@ fun ChessScreen(
val relayStatuses by relayManager.relayStatuses.collectAsState()
val refreshKey by viewModel.refreshKey.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val activeGames by viewModel.activeGames.collectAsState()
// Subscribe to chess events from relays (re-subscribes when refreshKey changes)
rememberSubscription(relayStatuses, account, refreshKey, relayManager = relayManager) {
// Extract opponent pubkeys from active games for move filtering
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
if (configuredRelays.isNotEmpty()) {
createChessSubscription(
createChessSubscriptionWithGames(
relays = configuredRelays,
userPubkey = account.pubKeyHex,
activeGameIds = activeGames.keys,
opponentPubkeys = opponentPubkeys,
onEvent = { event, _, _, _ ->
viewModel.handleIncomingEvent(event)
},
@@ -128,7 +141,6 @@ fun ChessScreen(
}
}
val activeGames by viewModel.activeGames.collectAsState()
val challenges by viewModel.challenges.collectAsState()
val completedGames by viewModel.completedGames.collectAsState()
// 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(
gameId = gameId,
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(
game = game,
userPubkey = userPubkey,
@@ -428,6 +443,12 @@ private fun ActiveGameCard(
isYourTurn: Boolean,
onClick: () -> Unit,
) {
// Extract human-readable game name if available
val gameName =
remember(gameId) {
ChessGameNameGenerator.extractDisplayName(gameId) ?: gameId.take(12)
}
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
border =
@@ -453,14 +474,14 @@ private fun ActiveGameCard(
)
Column {
Text(
"vs $opponentName",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
gameName,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
"Game: ${gameId.take(12)}...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
"vs $opponentName",
style = MaterialTheme.typography.bodyMedium,
)
}
}
@@ -685,6 +706,7 @@ private fun DesktopChessGameLayout(
boardSize = 520.dp,
flipped = playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK,
playerColor = playerColor,
positionVersion = moveHistory.size,
onMoveMade = onMoveMade,
)
}
@@ -698,6 +720,12 @@ private fun DesktopChessGameLayout(
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Extract human-readable game name
val gameName =
remember(gameId) {
ChessGameNameGenerator.extractDisplayName(gameId)
}
// Game info card
Card(
modifier = Modifier.fillMaxWidth(),
@@ -706,11 +734,21 @@ private fun DesktopChessGameLayout(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Game Info",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
// Show readable game name if available
if (gameName != null) {
Text(
gameName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
"Game Info",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
Row(
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
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.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color
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.LiveChessGameState
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.PieceType
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -44,7 +49,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.UUID
/**
* Desktop ViewModel for managing chess game state and event publishing.
@@ -106,18 +110,59 @@ class DesktopChessViewModel(
private val pendingAccepts = mutableMapOf<String, LiveChessGameAcceptEvent>()
// 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
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
*/
fun handleIncomingEvent(event: Event) {
// Skip already processed events
if (processedEventIds.contains(event.id)) return
processedEventIds.add(event.id)
// Skip already processed events (atomic check-and-add)
if (!processedEventIds.add(event.id)) return
// Check by kind since events may not be cast to specific types
when (event.kind) {
@@ -230,8 +275,7 @@ class DesktopChessViewModel(
}
private fun handleIncomingMove(event: LiveChessMoveEvent) {
// Don't process our own moves
if (event.pubKey == account.pubKeyHex) return
println("[Chess] Received move event: gameId=${event.gameId()}, san=${event.san()}, moveNum=${event.moveNumber()}, from=${event.pubKey.take(8)}")
val gameId = event.gameId() ?: return
val san = event.san() ?: return
@@ -241,14 +285,25 @@ class DesktopChessViewModel(
val gameState = _activeGames.value[gameId]
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() }
moveList.add(Triple(san, fen, moveNumber))
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)
updateBadgeCount()
}
@@ -283,9 +338,10 @@ class DesktopChessViewModel(
private fun handleGameEnded(event: LiveChessGameEndEvent) {
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]
if (gameState != null) {
if (gameState != null && !alreadyCompleted) {
val completedGame =
CompletedGame(
gameId = gameId,
@@ -303,6 +359,7 @@ class DesktopChessViewModel(
// Remove from active games
if (_activeGames.value.containsKey(gameId)) {
_activeGames.value = _activeGames.value - gameId
notifyActiveGamesChanged()
}
// Clean up challenge references
@@ -460,6 +517,7 @@ class DesktopChessViewModel(
)
_activeGames.value = _activeGames.value + (gameId to gameState)
notifyActiveGamesChanged()
gamesBeingCreated.remove(gameId)
_selectedGameId.value = gameId
_challenges.value = _challenges.value.filter { it.id != challengeEvent.id }
@@ -480,9 +538,18 @@ class DesktopChessViewModel(
val challengerPubkey = acceptEvent.challengerPubkey() ?: return@launch
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
if (_activeGames.value.containsKey(gameId)) return@launch
if (!gamesBeingCreated.add(gameId)) return@launch // Returns false if already present
if (_activeGames.value.containsKey(gameId)) {
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 playerColor: Color
@@ -514,11 +581,14 @@ class DesktopChessViewModel(
engine = engine,
)
println("[Chess] Adding game $gameId to active games, opponent=$opponentPubkey")
_activeGames.value = _activeGames.value + (gameId to gameState)
notifyActiveGamesChanged()
_challenges.value = _challenges.value.filter { it.gameId() != gameId }
gamesBeingCreated.remove(gameId)
// 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)
}
}
@@ -527,13 +597,37 @@ class DesktopChessViewModel(
gameId: String,
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 }
for ((san, fen, moveNumber) in sortedMoves) {
gameState.applyOpponentMove(san, fen, moveNumber)
if (sortedMoves.isEmpty()) return
// 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()
@@ -548,12 +642,36 @@ class DesktopChessViewModel(
to: String,
) {
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) {
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(
gameId: String,
moveEvent: ChessMoveEvent,
@@ -606,20 +724,24 @@ class DesktopChessViewModel(
}
if (success) {
// Store in history
val completedGame =
CompletedGame(
gameId = gameId,
opponentPubkey = gameState.opponentPubkey,
playerColor = gameState.playerColor,
result = endData.result.notation,
termination = endData.termination.name.lowercase(),
winnerPubkey = endData.winnerPubkey,
completedAt = TimeUtils.now(),
moveCount = gameState.moveHistory.value.size,
)
_completedGames.value = listOf(completedGame) + _completedGames.value
// Store in history (skip if already completed)
val alreadyCompleted = _completedGames.value.any { it.gameId == gameId }
if (!alreadyCompleted) {
val completedGame =
CompletedGame(
gameId = gameId,
opponentPubkey = gameState.opponentPubkey,
playerColor = gameState.playerColor,
result = endData.result.notation,
termination = endData.termination.name.lowercase(),
winnerPubkey = endData.winnerPubkey,
completedAt = TimeUtils.now(),
moveCount = gameState.moveHistory.value.size,
)
_completedGames.value = listOf(completedGame) + _completedGames.value
}
_activeGames.value = _activeGames.value - gameId
notifyActiveGamesChanged()
_error.value = null
} else {
_error.value = "Failed to resign"
@@ -685,20 +807,24 @@ class DesktopChessViewModel(
}
if (success) {
// Store in history and remove from active
val completedGame =
CompletedGame(
gameId = gameId,
opponentPubkey = gameState.opponentPubkey,
playerColor = gameState.playerColor,
result = GameResult.DRAW.notation,
termination = GameTermination.DRAW_AGREEMENT.name.lowercase(),
winnerPubkey = null,
completedAt = TimeUtils.now(),
moveCount = gameState.moveHistory.value.size,
)
_completedGames.value = listOf(completedGame) + _completedGames.value
// Store in history and remove from active (skip if already completed)
val alreadyCompleted = _completedGames.value.any { it.gameId == gameId }
if (!alreadyCompleted) {
val completedGame =
CompletedGame(
gameId = gameId,
opponentPubkey = gameState.opponentPubkey,
playerColor = gameState.playerColor,
result = GameResult.DRAW.notation,
termination = GameTermination.DRAW_AGREEMENT.name.lowercase(),
winnerPubkey = null,
completedAt = TimeUtils.now(),
moveCount = gameState.moveHistory.value.size,
)
_completedGames.value = listOf(completedGame) + _completedGames.value
}
_activeGames.value = _activeGames.value - gameId
notifyActiveGamesChanged()
_error.value = null
} else {
_error.value = "Failed to accept draw"
@@ -754,6 +880,30 @@ class DesktopChessViewModel(
_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() {
val incomingChallenges = _challenges.value.count { it.opponentPubkey() == account.pubKeyHex }
val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() }
@@ -776,11 +926,7 @@ class DesktopChessViewModel(
return false
}
private fun generateGameId(): String {
val timestamp = TimeUtils.now()
val random = UUID.randomUUID().toString().take(8)
return "chess-$timestamp-$random"
}
private fun generateGameId(): String = ChessGameNameGenerator.generateGameId(TimeUtils.now())
}
/**
@@ -95,6 +95,19 @@ object FilterBuilders {
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.
*
+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
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.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
@@ -48,7 +48,7 @@ class LiveChessGameChallengeEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30064
@@ -100,7 +100,7 @@ class LiveChessGameAcceptEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30065
@@ -148,7 +148,7 @@ class LiveChessMoveEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30066
@@ -162,7 +162,8 @@ class LiveChessMoveEvent(
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveChessMoveEvent>.() -> Unit = {},
) = 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("san", san))
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? =
tags
@@ -211,7 +212,7 @@ class LiveChessGameEndEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30067
@@ -268,7 +269,7 @@ class LiveChessDrawOfferEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30068
@@ -40,6 +40,8 @@ class LiveChessGameState(
val playerColor: Color,
val engine: ChessEngine,
val createdAt: Long = TimeUtils.now(),
val isSpectator: Boolean = false,
val isPendingChallenge: Boolean = false,
) {
companion object {
// Abandon timeout: 24 hours without a move
@@ -92,8 +94,9 @@ class LiveChessGameState(
/**
* 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)
@@ -129,9 +132,12 @@ class LiveChessGameState(
checkGameEnd()
// 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(
gameId = gameId,
moveNumber = result.position.moveNumber,
moveNumber = _moveHistory.value.size,
san = result.san,
fen = engine.getFen(),
opponentPubkey = opponentPubkey,
@@ -403,6 +409,14 @@ class LiveChessGameState(
""".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
*/
@@ -113,6 +113,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
@@ -193,6 +194,7 @@ class EventFactory {
LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig)
LiveChessMoveEvent.KIND -> LiveChessMoveEvent(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)
ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(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
/**
* 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 {
private val board = Board()
private val sanHistory = mutableListOf<String>()
actual fun getFen(): String = board.fen
actual fun loadFen(fen: String) {
board.loadFromFen(fen)
sanHistory.clear()
}
actual fun reset() {
board.loadFromFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
sanHistory.clear()
}
actual fun makeMove(san: String): MoveResult =
try {
val move = board.doMove(san)
if (move != null) {
if (board.doMove(san)) {
// Input is already SAN from Nostr events, store directly
sanHistory.add(san)
MoveResult(
success = true,
san = san,
position = boardToPosition(),
)
} else {
board.undoMove()
MoveResult(
success = false,
error = "Invalid move: $san",
@@ -86,8 +92,9 @@ actual class ChessEngine {
val move = Move(fromSquare, toSquare, promotionPiece)
if (board.legalMoves().contains(move)) {
val san = computeSan(move)
board.doMove(move)
val san = move.toString() // kchesslib converts to SAN
sanHistory.add(san)
MoveResult(
success = true,
san = san,
@@ -119,8 +126,7 @@ actual class ChessEngine {
actual fun isLegalMove(san: String): Boolean =
try {
val move = board.doMove(san)
if (move != null) {
if (board.doMove(san)) {
board.undoMove()
true
} else {
@@ -162,6 +168,9 @@ actual class ChessEngine {
actual fun undoMove() {
board.undoMove()
if (sanHistory.isNotEmpty()) {
sanHistory.removeAt(sanHistory.lastIndex)
}
}
actual fun getPosition(): ChessPosition = boardToPosition()
@@ -172,11 +181,115 @@ actual class ChessEngine {
Side.BLACK -> Color.BLACK
}
actual fun getMoveHistory(): List<String> {
// kchesslib stores move history
return board.backup.mapNotNull { it?.move?.toString() }
actual fun getMoveHistory(): List<String> = sanHistory.toList()
/**
* 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
*/