refactor impl
This commit is contained in:
+3
@@ -31,6 +31,7 @@ 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
|
||||
@@ -79,6 +80,7 @@ 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)
|
||||
@@ -101,6 +103,7 @@ class RelaySubscriptionsCoordinator(
|
||||
profile,
|
||||
hashtags,
|
||||
geohashes,
|
||||
chess,
|
||||
nwc,
|
||||
)
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
|
||||
@@ -138,6 +139,7 @@ fun AppNavigation(
|
||||
composable<Route.Video> { VideoScreen(accountViewModel, nav) }
|
||||
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
|
||||
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
|
||||
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
|
||||
|
||||
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
|
||||
|
||||
+9
@@ -471,6 +471,15 @@ fun ListContent(
|
||||
route = Route.Drafts,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.route_chess,
|
||||
icon = R.drawable.ic_chess,
|
||||
iconReference = 1,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Chess,
|
||||
)
|
||||
|
||||
IconRowRelays(
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
|
||||
@@ -40,6 +40,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object Notification : Route()
|
||||
|
||||
@Serializable object Chess : Route()
|
||||
|
||||
@Serializable object Search : Route()
|
||||
|
||||
@Serializable object SecurityFilters : Route()
|
||||
@@ -316,6 +318,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
|
||||
dest.hasRoute<Route.Video>() -> entry.toRoute<Route.Video>()
|
||||
dest.hasRoute<Route.Discover>() -> entry.toRoute<Route.Discover>()
|
||||
dest.hasRoute<Route.Notification>() -> entry.toRoute<Route.Notification>()
|
||||
dest.hasRoute<Route.Chess>() -> entry.toRoute<Route.Chess>()
|
||||
dest.hasRoute<Route.Search>() -> entry.toRoute<Route.Search>()
|
||||
dest.hasRoute<Route.SecurityFilters>() -> entry.toRoute<Route.SecurityFilters>()
|
||||
dest.hasRoute<Route.PrivacyOptions>() -> entry.toRoute<Route.PrivacyOptions>()
|
||||
|
||||
@@ -144,7 +144,7 @@ class TopNavFilterState(
|
||||
),
|
||||
)
|
||||
|
||||
val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow, muteListFollow)
|
||||
val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)
|
||||
|
||||
fun mergePeopleLists(
|
||||
peopleLists: List<AddressableNote>,
|
||||
@@ -258,7 +258,7 @@ class TopNavFilterState(
|
||||
checkNotInMainThread()
|
||||
emit(
|
||||
listOf(
|
||||
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow),
|
||||
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow),
|
||||
myLivePeopleListsFlow,
|
||||
myLiveKind3FollowsFlow,
|
||||
listOf(muteListFollow),
|
||||
@@ -274,7 +274,7 @@ class TopNavFilterState(
|
||||
checkNotInMainThread()
|
||||
emit(
|
||||
listOf(
|
||||
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow),
|
||||
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow),
|
||||
myLivePeopleListsFlow,
|
||||
listOf(muteListFollow),
|
||||
).flatten().toImmutableList(),
|
||||
@@ -413,9 +413,6 @@ val DEFAULT_FEED_KINDS =
|
||||
WikiNoteEvent.KIND,
|
||||
NipTextEvent.KIND,
|
||||
InteractiveStoryPrologueEvent.KIND,
|
||||
ChessGameEvent.KIND,
|
||||
LiveChessGameChallengeEvent.KIND,
|
||||
LiveChessGameEndEvent.KIND,
|
||||
)
|
||||
|
||||
val DEFAULT_COMMUNITY_FEEDS =
|
||||
|
||||
+115
-10
@@ -21,17 +21,31 @@
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -40,6 +54,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
||||
|
||||
/**
|
||||
* Wrapper screen for live chess game
|
||||
@@ -50,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
* @param accountViewModel Account view model
|
||||
* @param nav Navigation interface
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChessGameScreen(
|
||||
gameId: String,
|
||||
@@ -66,11 +82,78 @@ fun ChessGameScreen(
|
||||
)
|
||||
|
||||
val activeGames by chessViewModel.activeGames.collectAsState()
|
||||
val gameState = activeGames[gameId]
|
||||
val error by chessViewModel.error.collectAsState()
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var loadedGameState by remember { mutableStateOf<LiveChessGameState?>(null) }
|
||||
var loadAttempted by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold { paddingValues ->
|
||||
if (gameState == null) {
|
||||
// Game not found - show error
|
||||
// Try to load game from cache if not in activeGames
|
||||
LaunchedEffect(gameId, activeGames) {
|
||||
val existingState = activeGames[gameId]
|
||||
if (existingState != null) {
|
||||
loadedGameState = existingState
|
||||
isLoading = false
|
||||
loadAttempted = true
|
||||
} else if (!loadAttempted) {
|
||||
// Try to load from LocalCache
|
||||
loadAttempted = true
|
||||
val loaded = chessViewModel.loadGameFromCache(gameId)
|
||||
loadedGameState = loaded
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// Update state when activeGames changes (e.g., after refresh loads new moves)
|
||||
LaunchedEffect(activeGames[gameId]) {
|
||||
activeGames[gameId]?.let {
|
||||
loadedGameState = it
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling when screen is visible
|
||||
DisposableEffect(Unit) {
|
||||
chessViewModel.startPolling()
|
||||
onDispose {
|
||||
// Don't stop polling - let ViewModel manage it
|
||||
}
|
||||
}
|
||||
|
||||
val gameState = loadedGameState ?: activeGames[gameId]
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Chess Game") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { nav.popBack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
when {
|
||||
isLoading -> {
|
||||
// Loading state
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text("Loading game...")
|
||||
}
|
||||
}
|
||||
}
|
||||
gameState == null -> {
|
||||
// Game not found - show error with back button
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -88,18 +171,39 @@ fun ChessGameScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Show specific error if available
|
||||
Text(
|
||||
text = "This game may have ended or the ID is incorrect.",
|
||||
text = error ?: "This game may have ended or is waiting for opponent.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Game ID: ${gameId.take(16)}...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(onClick = { nav.popBack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
Text("Go Back")
|
||||
}
|
||||
} else {
|
||||
// Show game
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Show game with proper padding for status bar
|
||||
// Use state-observing version for automatic refresh on polling updates
|
||||
LiveChessGameScreen(
|
||||
engine = gameState.engine,
|
||||
playerColor = gameState.playerColor,
|
||||
gameId = gameState.gameId,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
gameState = gameState,
|
||||
opponentName = gameState.opponentPubkey.take(8), // TODO: Resolve to display name
|
||||
onMoveMade = { from, to, san ->
|
||||
chessViewModel.publishMove(gameId, from, to)
|
||||
@@ -109,4 +213,5 @@ fun ChessGameScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+517
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* 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 androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.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.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.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.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog
|
||||
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.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Chess lobby screen showing challenges and active games
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChessLobbyScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val chessViewModel: ChessViewModel =
|
||||
viewModel(
|
||||
key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}",
|
||||
factory = ChessViewModelFactory(accountViewModel.account),
|
||||
)
|
||||
|
||||
val activeGames by chessViewModel.activeGames.collectAsState()
|
||||
val challenges by chessViewModel.challenges.collectAsState()
|
||||
val error by chessViewModel.error.collectAsState()
|
||||
val selectedGameId by chessViewModel.selectedGameId.collectAsState()
|
||||
val userPubkey = accountViewModel.account.userProfile().pubkeyHex
|
||||
|
||||
var showNewGameDialog by remember { mutableStateOf(false) }
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Subscribe to chess events when screen is visible
|
||||
ChessSubscription(accountViewModel, chessViewModel)
|
||||
|
||||
// Clear any stale game selection on mount
|
||||
LaunchedEffect(Unit) {
|
||||
chessViewModel.selectGame(null)
|
||||
}
|
||||
|
||||
// Auto-navigate when a game is selected (e.g., after accepting a challenge)
|
||||
LaunchedEffect(selectedGameId, activeGames) {
|
||||
selectedGameId?.let { gameId ->
|
||||
if (activeGames.containsKey(gameId)) {
|
||||
nav.nav(Route.ChessGame(gameId))
|
||||
chessViewModel.selectGame(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling when screen is visible
|
||||
DisposableEffect(Unit) {
|
||||
chessViewModel.startPolling()
|
||||
onDispose {
|
||||
chessViewModel.stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.route_chess)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { nav.popBack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { showNewGameDialog = true },
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "New Game")
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = {
|
||||
scope.launch {
|
||||
isRefreshing = true
|
||||
chessViewModel.forceRefresh()
|
||||
delay(500) // Brief delay for visual feedback
|
||||
isRefreshing = false
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
// Error display
|
||||
error?.let { errorMsg ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(errorMsg, color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
OutlinedButton(onClick = { chessViewModel.clearError() }) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main content
|
||||
ChessLobbyContent(
|
||||
challenges = challenges,
|
||||
activeGames = activeGames,
|
||||
userPubkey = userPubkey,
|
||||
accountViewModel = accountViewModel,
|
||||
onAcceptChallenge = { note ->
|
||||
chessViewModel.acceptChallenge(note)
|
||||
},
|
||||
onSelectGame = { gameId ->
|
||||
nav.nav(Route.ChessGame(gameId))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New game dialog
|
||||
if (showNewGameDialog) {
|
||||
NewChessGameDialog(
|
||||
onDismiss = { showNewGameDialog = false },
|
||||
onCreateGame = { opponentPubkey, color ->
|
||||
chessViewModel.createChallenge(opponentPubkey, color)
|
||||
showNewGameDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChessLobbyContent(
|
||||
challenges: List<Note>,
|
||||
activeGames: Map<String, LiveChessGameState>,
|
||||
userPubkey: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
onAcceptChallenge: (Note) -> Unit,
|
||||
onSelectGame: (String) -> Unit,
|
||||
) {
|
||||
if (activeGames.isEmpty() && challenges.isEmpty()) {
|
||||
// Empty state
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
"No games or challenges",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Create a new game to get started",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Active games section
|
||||
if (activeGames.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Active Games",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
items(activeGames.entries.toList(), key = { it.key }) { (gameId, state) ->
|
||||
ActiveGameCard(
|
||||
gameId = gameId,
|
||||
opponentPubkey = state.opponentPubkey,
|
||||
isYourTurn = state.isPlayerTurn(),
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = { onSelectGame(gameId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Incoming challenges
|
||||
val incomingChallenges =
|
||||
challenges.filter {
|
||||
val event = it.event as? LiveChessGameChallengeEvent
|
||||
event?.opponentPubkey() == userPubkey
|
||||
}
|
||||
if (incomingChallenges.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Incoming Challenges",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
items(incomingChallenges, key = { it.idHex }) { note ->
|
||||
ChallengeCard(
|
||||
note = note,
|
||||
isIncoming = true,
|
||||
accountViewModel = accountViewModel,
|
||||
onAccept = { onAcceptChallenge(note) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
val openChallenges =
|
||||
challenges.filter {
|
||||
val event = it.event as? LiveChessGameChallengeEvent
|
||||
event?.opponentPubkey() == null && it.author?.pubkeyHex != userPubkey
|
||||
}
|
||||
if (openChallenges.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Open Challenges",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
items(openChallenges, key = { it.idHex }) { note ->
|
||||
ChallengeCard(
|
||||
note = note,
|
||||
isIncoming = false,
|
||||
accountViewModel = accountViewModel,
|
||||
onAccept = { onAcceptChallenge(note) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom padding for FAB
|
||||
item {
|
||||
Spacer(Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveGameCard(
|
||||
gameId: String,
|
||||
opponentPubkey: String,
|
||||
isYourTurn: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val displayName =
|
||||
remember(opponentPubkey) {
|
||||
accountViewModel.checkGetOrCreateUser(opponentPubkey)?.toBestDisplayName() ?: opponentPubkey.take(8)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
border =
|
||||
if (isYourTurn) {
|
||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"vs $displayName",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"Game: ${gameId.take(12)}...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
if (isYourTurn) "Your turn" else "Waiting...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isYourTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChallengeCard(
|
||||
note: Note,
|
||||
isIncoming: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
onAccept: () -> Unit,
|
||||
) {
|
||||
val event = note.event as? LiveChessGameChallengeEvent ?: return
|
||||
val challengerPubkey = note.author?.pubkeyHex ?: return
|
||||
val playerColor = event.playerColor()
|
||||
|
||||
val displayName =
|
||||
remember(challengerPubkey) {
|
||||
accountViewModel.checkGetOrCreateUser(challengerPubkey)?.toBestDisplayName() ?: challengerPubkey.take(8)
|
||||
}
|
||||
|
||||
val borderColor =
|
||||
if (isIncoming) {
|
||||
Color(0xFFFF9800) // Orange for incoming
|
||||
} else {
|
||||
Color(0xFF4CAF50) // Green for open
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
border = BorderStroke(2.dp, borderColor),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
if (isIncoming) "Challenge from $displayName" else "Open challenge by $displayName",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"Challenger plays ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Button(onClick = onAccept) {
|
||||
Text("Accept")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OutgoingChallengeCard(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val event = note.event as? LiveChessGameChallengeEvent ?: return
|
||||
val opponentPubkey = event.opponentPubkey()
|
||||
val playerColor = event.playerColor()
|
||||
|
||||
val displayName =
|
||||
remember(opponentPubkey) {
|
||||
opponentPubkey?.let {
|
||||
accountViewModel.checkGetOrCreateUser(it)?.toBestDisplayName() ?: it.take(8)
|
||||
}
|
||||
}
|
||||
|
||||
// Not clickable - waiting for opponent to accept
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
if (displayName != null) {
|
||||
"Challenge to $displayName"
|
||||
} else {
|
||||
"Open challenge (awaiting opponent)"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
"Waiting...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 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 androidx.compose.runtime.Composable
|
||||
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.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.
|
||||
*/
|
||||
@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 state =
|
||||
remember(accountViewModel.account, isGlobal, activeGameIds) {
|
||||
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,
|
||||
activeGameIds = activeGameIds,
|
||||
)
|
||||
}
|
||||
|
||||
DisposableEffect(state) {
|
||||
accountViewModel.dataSources().chess.subscribe(state)
|
||||
chessViewModel.refreshChallenges()
|
||||
onDispose {
|
||||
accountViewModel.dataSources().chess.unsubscribe(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query state for chess subscription
|
||||
*/
|
||||
data class ChessQueryState(
|
||||
val userPubkey: String,
|
||||
val inboxRelays: Set<NormalizedRelayUrl>,
|
||||
val globalRelays: Set<NormalizedRelayUrl>,
|
||||
val isGlobal: Boolean,
|
||||
val activeGameIds: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Filter assembler for chess events
|
||||
*/
|
||||
class ChessFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<ChessQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
ChessFeedFilterSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-assembler that creates the actual relay filters
|
||||
*/
|
||||
class ChessFeedFilterSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<ChessQueryState>,
|
||||
) : PerUniqueIdEoseManager<ChessQueryState, String>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: ChessQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> = filterChessEvents(key, since)
|
||||
|
||||
override fun id(key: ChessQueryState): String = "chess-${key.userPubkey}-${key.isGlobal}-${key.activeGameIds.hashCode()}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Create relay filters for chess events.
|
||||
*
|
||||
* Creates filters based on the Global/Follows setting:
|
||||
* - Global: Fetch all chess events from global relays
|
||||
* - Follows: Fetch from followed users' outbox relays
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
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,
|
||||
)
|
||||
|
||||
// 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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 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
|
||||
}
|
||||
+384
-26
@@ -22,12 +22,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDelegate
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
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.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
|
||||
@@ -35,6 +39,7 @@ import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -47,6 +52,18 @@ import java.util.UUID
|
||||
class ChessViewModel(
|
||||
private val account: Account,
|
||||
) : ViewModel() {
|
||||
companion object {
|
||||
// Challenge expiry: 24 hours
|
||||
const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L
|
||||
|
||||
// Retry configuration
|
||||
const val MAX_RETRIES = 3
|
||||
const val RETRY_DELAY_MS = 1000L
|
||||
|
||||
// Cleanup interval: check every 5 minutes
|
||||
const val CLEANUP_INTERVAL_MS = 5 * 60 * 1000L
|
||||
}
|
||||
|
||||
// Active games being played
|
||||
private val _activeGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap())
|
||||
val activeGames: StateFlow<Map<String, LiveChessGameState>> = _activeGames.asStateFlow()
|
||||
@@ -67,9 +84,78 @@ class ChessViewModel(
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
// Pending retry operations
|
||||
private val _pendingRetries = MutableStateFlow<Map<String, RetryOperation>>(emptyMap())
|
||||
val pendingRetries: StateFlow<Map<String, RetryOperation>> = _pendingRetries.asStateFlow()
|
||||
|
||||
// Polling delegate for periodic refresh
|
||||
private val pollingDelegate =
|
||||
ChessPollingDelegate(
|
||||
config = ChessPollingDefaults.android,
|
||||
scope = viewModelScope,
|
||||
onRefreshGames = { gameIds -> refreshGamesFromCache(gameIds) },
|
||||
onRefreshChallenges = { refreshChallenges() },
|
||||
onCleanup = {
|
||||
cleanupExpiredChallenges()
|
||||
checkAbandonedGames()
|
||||
},
|
||||
)
|
||||
|
||||
init {
|
||||
refreshChallenges()
|
||||
subscribeToChessEvents()
|
||||
// Start polling - will do initial fetch
|
||||
startPolling()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for chess events
|
||||
*/
|
||||
fun startPolling() {
|
||||
pollingDelegate.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling (call when screen is not visible on Android)
|
||||
*/
|
||||
fun stopPolling() {
|
||||
pollingDelegate.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force immediate refresh
|
||||
*/
|
||||
fun forceRefresh() {
|
||||
pollingDelegate.refreshNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove expired challenges
|
||||
*/
|
||||
private fun cleanupExpiredChallenges() {
|
||||
val now = TimeUtils.now()
|
||||
val validChallenges =
|
||||
_challenges.value.filter { note ->
|
||||
val event = note.event as? LiveChessGameChallengeEvent
|
||||
val createdAt = event?.createdAt ?: 0
|
||||
(now - createdAt) < CHALLENGE_EXPIRY_SECONDS
|
||||
}
|
||||
|
||||
if (validChallenges.size != _challenges.value.size) {
|
||||
_challenges.value = validChallenges
|
||||
updateBadgeCount()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for abandoned games and notify
|
||||
*/
|
||||
private fun checkAbandonedGames() {
|
||||
_activeGames.value.forEach { (gameId, state) ->
|
||||
if (state.isAbandoned()) {
|
||||
// Game is abandoned, could auto-claim or notify user
|
||||
_error.value = "Game $gameId appears abandoned. You can claim victory."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,8 +191,9 @@ class ChessViewModel(
|
||||
|
||||
val san = event.san() ?: return
|
||||
val fen = event.fen() ?: return
|
||||
val moveNumber = event.moveNumber()
|
||||
|
||||
gameState.applyOpponentMove(san, fen)
|
||||
gameState.applyOpponentMove(san, fen, moveNumber)
|
||||
updateBadgeCount()
|
||||
}
|
||||
|
||||
@@ -129,6 +216,7 @@ class ChessViewModel(
|
||||
// Remove from active games if present
|
||||
if (_activeGames.value.containsKey(gameId)) {
|
||||
_activeGames.value = _activeGames.value - gameId
|
||||
pollingDelegate.removeGameId(gameId)
|
||||
updateBadgeCount()
|
||||
}
|
||||
}
|
||||
@@ -140,11 +228,26 @@ class ChessViewModel(
|
||||
note: Note,
|
||||
event: LiveChessGameChallengeEvent,
|
||||
) {
|
||||
// Add to challenges if directed at us or is open
|
||||
val gameId = event.gameId() ?: return
|
||||
|
||||
// Skip if this game is already active
|
||||
if (_activeGames.value.containsKey(gameId)) return
|
||||
|
||||
// Add to challenges if directed at us, from us, or is open
|
||||
val opponentPubkey = event.opponentPubkey()
|
||||
if (opponentPubkey == null || opponentPubkey == account.signer.pubKey) {
|
||||
val isFromUs = event.pubKey == account.signer.pubKey
|
||||
val isDirectedAtUs = opponentPubkey == account.signer.pubKey
|
||||
val isOpenChallenge = opponentPubkey == null
|
||||
|
||||
if (isFromUs || isDirectedAtUs || isOpenChallenge) {
|
||||
val currentChallenges = _challenges.value.toMutableList()
|
||||
if (currentChallenges.none { it.idHex == note.idHex }) {
|
||||
// Deduplicate by both event ID and game ID
|
||||
val isDuplicateEvent = currentChallenges.any { it.idHex == note.idHex }
|
||||
val isDuplicateGame =
|
||||
currentChallenges.any {
|
||||
(it.event as? LiveChessGameChallengeEvent)?.gameId() == gameId
|
||||
}
|
||||
if (!isDuplicateEvent && !isDuplicateGame) {
|
||||
currentChallenges.add(note)
|
||||
_challenges.value = currentChallenges
|
||||
updateBadgeCount()
|
||||
@@ -153,7 +256,7 @@ class ChessViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chess challenge (open or directed)
|
||||
* Create a new chess challenge (open or directed) with retry
|
||||
*/
|
||||
fun createChallenge(
|
||||
opponentPubkey: String? = null,
|
||||
@@ -161,11 +264,11 @@ class ChessViewModel(
|
||||
timeControl: String? = null,
|
||||
) {
|
||||
val acc = account
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val gameId = generateGameId()
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val success =
|
||||
retryWithBackoff("challenge-$gameId") {
|
||||
val template =
|
||||
LiveChessGameChallengeEvent.build(
|
||||
gameId = gameId,
|
||||
@@ -175,10 +278,12 @@ class ChessViewModel(
|
||||
)
|
||||
|
||||
acc.signAndComputeBroadcast(template)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
_error.value = null
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Failed to create challenge: ${e.message}"
|
||||
} else {
|
||||
_error.value = "Failed to create challenge after $MAX_RETRIES attempts"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,6 +322,7 @@ class ChessViewModel(
|
||||
)
|
||||
|
||||
_activeGames.value = _activeGames.value + (gameId to gameState)
|
||||
pollingDelegate.addGameId(gameId)
|
||||
_selectedGameId.value = gameId
|
||||
_error.value = null
|
||||
} catch (e: Exception) {
|
||||
@@ -270,6 +376,7 @@ class ChessViewModel(
|
||||
)
|
||||
|
||||
_activeGames.value = _activeGames.value + (gameId to gameState)
|
||||
pollingDelegate.addGameId(gameId)
|
||||
_selectedGameId.value = gameId
|
||||
}
|
||||
}
|
||||
@@ -362,7 +469,7 @@ class ChessViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer/accept draw
|
||||
* Offer a draw to opponent - sends a draw offer event that opponent can accept or decline
|
||||
*/
|
||||
fun offerDraw(gameId: String) {
|
||||
val acc = account
|
||||
@@ -370,22 +477,16 @@ class ChessViewModel(
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val endData = gameState.offerDraw()
|
||||
val drawOffer = gameState.offerDraw()
|
||||
|
||||
val template =
|
||||
LiveChessGameEndEvent.build(
|
||||
gameId = endData.gameId,
|
||||
result = endData.result,
|
||||
termination = endData.termination,
|
||||
winnerPubkey = endData.winnerPubkey,
|
||||
opponentPubkey = endData.opponentPubkey,
|
||||
pgn = endData.pgn ?: "",
|
||||
LiveChessDrawOfferEvent.build(
|
||||
gameId = drawOffer.gameId,
|
||||
opponentPubkey = drawOffer.opponentPubkey,
|
||||
message = drawOffer.message ?: "",
|
||||
)
|
||||
|
||||
acc.signAndComputeBroadcast(template)
|
||||
|
||||
// Remove from active games
|
||||
_activeGames.value = _activeGames.value - gameId
|
||||
_error.value = null
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Failed to offer draw: ${e.message}"
|
||||
@@ -408,10 +509,26 @@ class ChessViewModel(
|
||||
/**
|
||||
* Refresh challenges from local cache
|
||||
*/
|
||||
private fun refreshChallenges() {
|
||||
fun refreshChallenges() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// TODO: Subscribe to challenge events via relay filter
|
||||
// For now, this is a placeholder
|
||||
val userPubkey = account.signer.pubKey
|
||||
val now = TimeUtils.now()
|
||||
|
||||
// Query LocalCache for chess challenge events
|
||||
val challengeNotes =
|
||||
LocalCache.notes.filterIntoSet { _, note ->
|
||||
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
|
||||
|
||||
// Check if challenge is not expired
|
||||
val createdAt = event.createdAt
|
||||
if ((now - createdAt) >= CHALLENGE_EXPIRY_SECONDS) return@filterIntoSet false
|
||||
|
||||
// Include challenges directed at us, or open challenges (not from us)
|
||||
val opponentPubkey = event.opponentPubkey()
|
||||
opponentPubkey == userPubkey || (opponentPubkey == null && event.pubKey != userPubkey) || event.pubKey == userPubkey
|
||||
}
|
||||
|
||||
_challenges.value = challengeNotes.toList()
|
||||
updateBadgeCount()
|
||||
}
|
||||
}
|
||||
@@ -441,6 +558,76 @@ class ChessViewModel(
|
||||
_error.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim victory for an abandoned game
|
||||
*/
|
||||
fun claimAbandonmentVictory(gameId: String) {
|
||||
val gameState = _activeGames.value[gameId] ?: return
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val endData = gameState.claimAbandonmentVictory() ?: return@launch
|
||||
|
||||
val success =
|
||||
retryWithBackoff("abandon-$gameId") {
|
||||
val template =
|
||||
LiveChessGameEndEvent.build(
|
||||
gameId = endData.gameId,
|
||||
result = endData.result,
|
||||
termination = endData.termination,
|
||||
winnerPubkey = endData.winnerPubkey,
|
||||
opponentPubkey = endData.opponentPubkey,
|
||||
pgn = endData.pgn ?: "",
|
||||
)
|
||||
|
||||
account.signAndComputeBroadcast(template)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
_activeGames.value = _activeGames.value - gameId
|
||||
_error.value = null
|
||||
} else {
|
||||
_error.value = "Failed to claim victory"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force resync a game to opponent's position
|
||||
*/
|
||||
fun forceResync(
|
||||
gameId: String,
|
||||
fen: String,
|
||||
) {
|
||||
val gameState = _activeGames.value[gameId] ?: return
|
||||
gameState.forceResync(fen)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry an operation with exponential backoff
|
||||
*/
|
||||
private suspend fun retryWithBackoff(
|
||||
operationId: String,
|
||||
operation: suspend () -> Unit,
|
||||
): Boolean {
|
||||
var attempt = 0
|
||||
while (attempt < MAX_RETRIES) {
|
||||
try {
|
||||
_pendingRetries.value =
|
||||
_pendingRetries.value + (operationId to RetryOperation(operationId, attempt + 1, MAX_RETRIES))
|
||||
operation()
|
||||
_pendingRetries.value = _pendingRetries.value - operationId
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
attempt++
|
||||
if (attempt < MAX_RETRIES) {
|
||||
delay(RETRY_DELAY_MS * attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
_pendingRetries.value = _pendingRetries.value - operationId
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique game ID
|
||||
*/
|
||||
@@ -449,4 +636,175 @@ class ChessViewModel(
|
||||
val random = UUID.randomUUID().toString().take(8)
|
||||
return "chess-$timestamp-$random"
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh game state from LocalCache for specific game IDs
|
||||
* Called periodically by polling delegate
|
||||
*/
|
||||
private suspend fun refreshGamesFromCache(gameIds: Set<String>) {
|
||||
val userPubkey = account.signer.pubKey
|
||||
|
||||
for (gameId in gameIds) {
|
||||
// Find moves for this game
|
||||
val moveNotes =
|
||||
LocalCache.notes.filterIntoSet { _, 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
|
||||
moveNotes
|
||||
.mapNotNull { it.event as? LiveChessMoveEvent }
|
||||
.filter { it.pubKey != userPubkey } // Only opponent moves
|
||||
.sortedBy { it.moveNumber() }
|
||||
.forEach { moveEvent ->
|
||||
val san = moveEvent.san() ?: return@forEach
|
||||
val fen = moveEvent.fen() ?: return@forEach
|
||||
val moveNumber = moveEvent.moveNumber()
|
||||
gameState.applyOpponentMove(san, fen, moveNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateBadgeCount()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load or rebuild game state from LocalCache for a specific gameId
|
||||
* Used when navigating to a game screen
|
||||
*
|
||||
* @return GameLoadResult with either the game state or an error reason
|
||||
*/
|
||||
fun loadGameFromCache(gameId: String): LiveChessGameState? {
|
||||
// Check if already loaded
|
||||
_activeGames.value[gameId]?.let { return it }
|
||||
|
||||
val userPubkey = account.signer.pubKey
|
||||
|
||||
// Find the challenge event for this game
|
||||
val challengeNotes =
|
||||
LocalCache.notes.filterIntoSet { _, note ->
|
||||
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
|
||||
event.gameId() == gameId
|
||||
}
|
||||
val challengeNote = challengeNotes.firstOrNull()
|
||||
|
||||
// Find accept event for this game
|
||||
val acceptNotes =
|
||||
LocalCache.notes.filterIntoSet { _, note ->
|
||||
val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false
|
||||
event.gameId() == gameId
|
||||
}
|
||||
val acceptNote = acceptNotes.firstOrNull()
|
||||
|
||||
// Need challenge to understand the game
|
||||
val challengeEvent = challengeNote?.event as? LiveChessGameChallengeEvent
|
||||
if (challengeEvent == null) {
|
||||
_error.value = "Challenge event not found for game $gameId"
|
||||
return null
|
||||
}
|
||||
|
||||
val acceptEvent = acceptNote?.event as? LiveChessGameAcceptEvent
|
||||
|
||||
// Determine if we're a participant
|
||||
val challengerPubkey = challengeEvent.pubKey
|
||||
val acceptorPubkey = acceptEvent?.pubKey
|
||||
val challengerColor = challengeEvent.playerColor() ?: Color.WHITE
|
||||
|
||||
val (playerColor, opponentPubkey) =
|
||||
when {
|
||||
challengerPubkey == userPubkey && acceptorPubkey != null -> {
|
||||
// We created the challenge, someone accepted
|
||||
challengerColor to acceptorPubkey
|
||||
}
|
||||
acceptorPubkey == userPubkey -> {
|
||||
// We accepted someone's challenge
|
||||
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
|
||||
}
|
||||
else -> {
|
||||
_error.value = "You are not a participant in this game"
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Build game state
|
||||
val engine = ChessEngine()
|
||||
engine.reset()
|
||||
|
||||
// Find and apply all moves in order
|
||||
val moveNotes =
|
||||
LocalCache.notes.filterIntoSet { _, 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 }
|
||||
|
||||
// Track move numbers we've loaded
|
||||
val loadedMoveNumbers = mutableSetOf<Int>()
|
||||
|
||||
for (moveEvent in sortedMoves) {
|
||||
val san = moveEvent.san() ?: continue
|
||||
val moveNumber = moveEvent.moveNumber()
|
||||
try {
|
||||
engine.makeMove(san)
|
||||
if (moveNumber != null) {
|
||||
loadedMoveNumbers.add(moveNumber)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error loading move $san: ${e.message}"
|
||||
// Continue trying to load other moves
|
||||
}
|
||||
}
|
||||
|
||||
val gameState =
|
||||
LiveChessGameState(
|
||||
gameId = gameId,
|
||||
playerPubkey = userPubkey,
|
||||
opponentPubkey = opponentPubkey,
|
||||
playerColor = playerColor,
|
||||
engine = engine,
|
||||
)
|
||||
|
||||
// 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)
|
||||
|
||||
// Update polling delegate with this game
|
||||
pollingDelegate.addGameId(gameId)
|
||||
|
||||
// Clear any error since we loaded successfully
|
||||
_error.value = null
|
||||
|
||||
return gameState
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when ViewModel is cleared
|
||||
*/
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
pollingDelegate.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a pending retry operation for UI feedback
|
||||
*/
|
||||
data class RetryOperation(
|
||||
val id: String,
|
||||
val currentAttempt: Int,
|
||||
val maxAttempts: Int,
|
||||
)
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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 androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
|
||||
/**
|
||||
* Simple floating action button for creating new chess game challenges.
|
||||
* Just triggers onClick - the caller handles the dialog.
|
||||
*/
|
||||
@Composable
|
||||
fun NewChessGameButton(onClick: () -> Unit) {
|
||||
FloatingActionButton(
|
||||
onClick = onClick,
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = "New Chess Game",
|
||||
modifier = Size26Modifier,
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
+24
-17
@@ -123,7 +123,7 @@ fun DiscoverScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val tabs by
|
||||
val feedTabs by
|
||||
remember(accountViewModel) {
|
||||
mutableStateOf(
|
||||
listOf(
|
||||
@@ -181,7 +181,7 @@ fun DiscoverScreen(
|
||||
)
|
||||
}
|
||||
|
||||
val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size }
|
||||
val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { feedTabs.size }
|
||||
|
||||
WatchAccountForDiscoveryScreen(
|
||||
discoveryFollowSetsFeedContentState = discoveryFollowSetsFeedContentState,
|
||||
@@ -204,13 +204,13 @@ fun DiscoverScreen(
|
||||
|
||||
DiscoveryFilterAssemblerSubscription(accountViewModel.dataSources().discovery, accountViewModel)
|
||||
|
||||
DiscoverPages(pagerState, tabs, accountViewModel, nav)
|
||||
DiscoverPages(pagerState, feedTabs, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverPages(
|
||||
pagerState: PagerState,
|
||||
tabs: ImmutableList<TabItem>,
|
||||
feedTabs: ImmutableList<TabItem>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
@@ -228,7 +228,7 @@ private fun DiscoverPages(
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
tabs.forEachIndexed { index, tab ->
|
||||
feedTabs.forEachIndexed { index, tab ->
|
||||
Tab(
|
||||
selected = pagerState.currentPage == index,
|
||||
text = { Text(text = stringRes(tab.resource)) },
|
||||
@@ -241,38 +241,44 @@ private fun DiscoverPages(
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.Discover, accountViewModel) { route ->
|
||||
if (route == Route.Discover) {
|
||||
tabs[pagerState.currentPage].feedState.sendToTop()
|
||||
val currentPage = pagerState.currentPage
|
||||
if (currentPage >= 0 && currentPage < feedTabs.size) {
|
||||
feedTabs[currentPage].feedState.sendToTop()
|
||||
}
|
||||
} else {
|
||||
nav.newStack(route)
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingButton = {
|
||||
if (tabs[pagerState.currentPage].resource == R.string.discover_marketplace) {
|
||||
val currentPage = pagerState.currentPage
|
||||
if (currentPage >= 0 && currentPage < feedTabs.size && feedTabs[currentPage].resource == R.string.discover_marketplace) {
|
||||
NewProductButton(accountViewModel, nav)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
HorizontalPager(state = pagerState, contentPadding = it) { page ->
|
||||
RefresheableBox(tabs[page].feedState, true) {
|
||||
if (tabs[page].useGridLayout) {
|
||||
SaveableGridFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState ->
|
||||
if (page >= 0 && page < feedTabs.size) {
|
||||
val tab = feedTabs[page]
|
||||
RefresheableBox(tab.feedState, true) {
|
||||
if (tab.useGridLayout) {
|
||||
SaveableGridFeedContentState(tab.feedState, scrollStateKey = tab.scrollStateKey) { listState ->
|
||||
RenderDiscoverFeed(
|
||||
feedContentState = tabs[page].feedState,
|
||||
routeForLastRead = tabs[page].routeForLastRead,
|
||||
forceEventKind = tabs[page].forceEventKind,
|
||||
feedContentState = tab.feedState,
|
||||
routeForLastRead = tab.routeForLastRead,
|
||||
forceEventKind = tab.forceEventKind,
|
||||
listState = listState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SaveableFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState ->
|
||||
SaveableFeedContentState(tab.feedState, scrollStateKey = tab.scrollStateKey) { listState ->
|
||||
RenderDiscoverFeed(
|
||||
feedContentState = tabs[page].feedState,
|
||||
routeForLastRead = tabs[page].routeForLastRead,
|
||||
forceEventKind = tabs[page].forceEventKind,
|
||||
feedContentState = tab.feedState,
|
||||
routeForLastRead = tab.routeForLastRead,
|
||||
forceEventKind = tab.forceEventKind,
|
||||
listState = listState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
@@ -282,6 +288,7 @@ private fun DiscoverPages(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF9C9C9C"
|
||||
android:pathData="M19,22H5v-2h14v2M17.16,8.26A8.94,8.94 0,0 0,19 3H15.03A5.24,5.24 0,0 1,14.05 4.89L10.08,4.89A5.24,5.24 0,0 1,9.1 3H5A8.94,8.94 0,0 0,6.84 8.26L6.84,18h10.32v-9.74ZM7.5,6c0.28,0 0.5,0.22 0.5,0.5S7.78,7 7.5,7 7,6.78 7,6.5 7.22,6 7.5,6M9,12 L9,9h2v3H9M13,12v-3h2v3H13Z"/>
|
||||
</vector>
|
||||
@@ -1191,6 +1191,7 @@
|
||||
<string name="route_notifications">Notifications</string>
|
||||
<string name="route_global">Global</string>
|
||||
<string name="route_video">Shorts</string>
|
||||
<string name="route_chess">Chess</string>
|
||||
<string name="route_security_filters">Security Filters</string>
|
||||
|
||||
<string name="new_post">New Post</string>
|
||||
|
||||
+17
-16
@@ -20,23 +20,25 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.chess
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessPiece
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessPosition
|
||||
import com.vitorpamplona.quartz.nip64Chess.PieceType
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
|
||||
@@ -103,13 +105,12 @@ private fun ChessSquare(
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Display piece using Unicode chess symbols
|
||||
// Display piece using CBurnett ImageVector icons
|
||||
piece?.let {
|
||||
Text(
|
||||
text = it.toUnicode(),
|
||||
fontSize = (size.value * 0.6).sp,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
Image(
|
||||
imageVector = it.toImageVector(),
|
||||
contentDescription = "${it.color} ${it.type}",
|
||||
modifier = Modifier.fillMaxSize().padding(2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,16 +131,16 @@ private fun ChessSquare(
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to convert ChessPiece to Unicode chess symbol
|
||||
* Extension function to convert ChessPiece to CBurnett ImageVector
|
||||
*/
|
||||
private fun com.vitorpamplona.quartz.nip64Chess.ChessPiece.toUnicode(): String {
|
||||
private fun ChessPiece.toImageVector(): ImageVector {
|
||||
val white = color == ChessColor.WHITE
|
||||
return when (type) {
|
||||
PieceType.KING -> if (white) "♔" else "♚"
|
||||
PieceType.QUEEN -> if (white) "♕" else "♛"
|
||||
PieceType.ROOK -> if (white) "♖" else "♜"
|
||||
PieceType.BISHOP -> if (white) "♗" else "♝"
|
||||
PieceType.KNIGHT -> if (white) "♘" else "♞"
|
||||
PieceType.PAWN -> if (white) "♙" else "♟"
|
||||
PieceType.KING -> if (white) ChessPieceVectors.WhiteKing else ChessPieceVectors.BlackKing
|
||||
PieceType.QUEEN -> if (white) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen
|
||||
PieceType.ROOK -> if (white) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook
|
||||
PieceType.BISHOP -> if (white) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop
|
||||
PieceType.KNIGHT -> if (white) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight
|
||||
PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn
|
||||
}
|
||||
}
|
||||
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.chess
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Configuration for chess event polling
|
||||
*/
|
||||
data class ChessPollingConfig(
|
||||
/** Interval for polling active games (ms) */
|
||||
val activeGamePollInterval: Long = 10_000L,
|
||||
/** Interval for polling challenges (ms) */
|
||||
val challengePollInterval: Long = 30_000L,
|
||||
/** Whether to continue polling in background */
|
||||
val pollInBackground: Boolean = false,
|
||||
/** Challenge expiry time (seconds) */
|
||||
val challengeExpirySeconds: Long = 24 * 60 * 60L,
|
||||
/** Cleanup interval (ms) */
|
||||
val cleanupInterval: Long = 5 * 60 * 1000L,
|
||||
)
|
||||
|
||||
/**
|
||||
* Platform-specific defaults
|
||||
*/
|
||||
object ChessPollingDefaults {
|
||||
/** Android: shorter intervals, no background polling */
|
||||
val android =
|
||||
ChessPollingConfig(
|
||||
activeGamePollInterval = 10_000L,
|
||||
challengePollInterval = 30_000L,
|
||||
pollInBackground = false,
|
||||
)
|
||||
|
||||
/** Desktop: can poll in background */
|
||||
val desktop =
|
||||
ChessPollingConfig(
|
||||
activeGamePollInterval = 10_000L,
|
||||
challengePollInterval = 30_000L,
|
||||
pollInBackground = true,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate for managing chess event polling
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* class ChessViewModel(...) : ViewModel() {
|
||||
* private val pollingDelegate = ChessPollingDelegate(
|
||||
* config = ChessPollingDefaults.android,
|
||||
* scope = viewModelScope,
|
||||
* onRefreshGames = { gameIds -> refreshGamesFromCache(gameIds) },
|
||||
* onRefreshChallenges = { refreshChallengesFromCache() },
|
||||
* )
|
||||
*
|
||||
* fun startPolling() = pollingDelegate.start()
|
||||
* fun stopPolling() = pollingDelegate.stop()
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class ChessPollingDelegate(
|
||||
private val config: ChessPollingConfig,
|
||||
private val scope: CoroutineScope,
|
||||
private val onRefreshGames: suspend (Set<String>) -> Unit,
|
||||
private val onRefreshChallenges: suspend () -> Unit,
|
||||
private val onCleanup: suspend () -> Unit = {},
|
||||
) {
|
||||
private var gamePollingJob: Job? = null
|
||||
private var challengePollingJob: Job? = null
|
||||
private var cleanupJob: Job? = null
|
||||
|
||||
private val _isPolling = MutableStateFlow(false)
|
||||
val isPolling: StateFlow<Boolean> = _isPolling.asStateFlow()
|
||||
|
||||
private val activeGameIdsFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/**
|
||||
* Update the set of game IDs to poll for
|
||||
*/
|
||||
fun setActiveGameIds(gameIds: Set<String>) {
|
||||
activeGameIdsFlow.value = gameIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a game ID to poll for
|
||||
*/
|
||||
fun addGameId(gameId: String) {
|
||||
activeGameIdsFlow.value = activeGameIdsFlow.value + gameId
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a game ID from polling
|
||||
*/
|
||||
fun removeGameId(gameId: String) {
|
||||
activeGameIdsFlow.value = activeGameIdsFlow.value - gameId
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for chess events
|
||||
*/
|
||||
fun start() {
|
||||
if (_isPolling.value) return
|
||||
_isPolling.value = true
|
||||
|
||||
// Poll for active games
|
||||
gamePollingJob =
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
val gameIds = activeGameIdsFlow.value
|
||||
if (gameIds.isNotEmpty()) {
|
||||
onRefreshGames(gameIds)
|
||||
}
|
||||
delay(config.activeGamePollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll for challenges
|
||||
challengePollingJob =
|
||||
scope.launch {
|
||||
// Initial fetch
|
||||
onRefreshChallenges()
|
||||
|
||||
while (isActive) {
|
||||
delay(config.challengePollInterval)
|
||||
onRefreshChallenges()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup job
|
||||
cleanupJob =
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(config.cleanupInterval)
|
||||
onCleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling
|
||||
*/
|
||||
fun stop() {
|
||||
_isPolling.value = false
|
||||
gamePollingJob?.cancel()
|
||||
challengePollingJob?.cancel()
|
||||
cleanupJob?.cancel()
|
||||
gamePollingJob = null
|
||||
challengePollingJob = null
|
||||
cleanupJob = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause polling (e.g., when app goes to background on Android)
|
||||
*/
|
||||
fun pause() {
|
||||
if (!config.pollInBackground) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume polling (e.g., when app comes to foreground on Android)
|
||||
*/
|
||||
fun resume() {
|
||||
if (!_isPolling.value) {
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force an immediate refresh
|
||||
*/
|
||||
fun refreshNow() {
|
||||
scope.launch {
|
||||
onRefreshChallenges()
|
||||
val gameIds = activeGameIdsFlow.value
|
||||
if (gameIds.isNotEmpty()) {
|
||||
onRefreshGames(gameIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for chess event sources that can be refreshed
|
||||
*/
|
||||
interface ChessEventSource {
|
||||
/**
|
||||
* Fetch/refresh challenges from the event source
|
||||
*/
|
||||
suspend fun fetchChallenges(): List<ChessChallengeData>
|
||||
|
||||
/**
|
||||
* Fetch/refresh game state for specific game IDs
|
||||
*/
|
||||
suspend fun fetchGameUpdates(gameIds: Set<String>): Map<String, ChessGameUpdate>
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class representing a chess challenge
|
||||
*/
|
||||
data class ChessChallengeData(
|
||||
val id: String,
|
||||
val gameId: String,
|
||||
val challengerPubkey: String,
|
||||
val opponentPubkey: String?,
|
||||
val challengerColor: com.vitorpamplona.quartz.nip64Chess.Color,
|
||||
val createdAt: Long,
|
||||
val isExpired: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Data class representing a game update
|
||||
*/
|
||||
data class ChessGameUpdate(
|
||||
val gameId: String,
|
||||
val moves: List<ChessMoveData>,
|
||||
val isEnded: Boolean = false,
|
||||
val endReason: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Data class representing a chess move
|
||||
*/
|
||||
data class ChessMoveData(
|
||||
val san: String,
|
||||
val fen: String,
|
||||
val moveNumber: Int,
|
||||
val playerPubkey: String,
|
||||
val timestamp: Long,
|
||||
)
|
||||
+1018
File diff suppressed because it is too large
Load Diff
+52
-29
@@ -20,16 +20,17 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.chess
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -40,12 +41,13 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessPiece
|
||||
import com.vitorpamplona.quartz.nip64Chess.PieceType
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
|
||||
|
||||
@@ -55,55 +57,76 @@ import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
|
||||
* @param engine Chess engine for move validation and legal moves
|
||||
* @param modifier Modifier for the board
|
||||
* @param boardSize Total size of the board in dp
|
||||
* @param flipped If true, renders from black's perspective (rank 1 at top)
|
||||
* @param playerColor The color the player is playing as (only allows moving these pieces)
|
||||
* @param onMoveMade Callback when a valid move is made, receives (from, to, san)
|
||||
* NOTE: This callback should make the actual move - this component only validates
|
||||
*/
|
||||
@Composable
|
||||
fun InteractiveChessBoard(
|
||||
engine: ChessEngine,
|
||||
modifier: Modifier = Modifier,
|
||||
boardSize: Dp = 400.dp,
|
||||
flipped: Boolean = false,
|
||||
playerColor: ChessColor? = null, // null = allow both (for local play)
|
||||
onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> },
|
||||
) {
|
||||
val position = remember(engine) { engine.getPosition() }
|
||||
// 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() }
|
||||
var selectedSquare by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
var legalMoves by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
|
||||
// Can only interact if it's your turn (or playerColor is null for local play)
|
||||
val canInteract = playerColor == null || sideToMove == playerColor
|
||||
|
||||
val squareSize = boardSize / 8
|
||||
|
||||
// Rank iteration order based on perspective
|
||||
val rankRange = if (flipped) (0..7) else (7 downTo 0)
|
||||
val fileRange = if (flipped) (7 downTo 0) else (0..7)
|
||||
|
||||
Column(modifier = modifier.size(boardSize)) {
|
||||
// Render ranks 8 down to 1 (from white's perspective)
|
||||
for (rank in 7 downTo 0) {
|
||||
for (rank in rankRange) {
|
||||
Row {
|
||||
for (file in 0..7) {
|
||||
for (file in fileRange) {
|
||||
val piece = position.pieceAt(file, rank)
|
||||
val isLightSquare = (rank + file) % 2 == 0
|
||||
val square = fileRankToSquare(file, rank)
|
||||
val isSelected = selectedSquare == (file to rank)
|
||||
val isLegalMove = legalMoves.contains(square)
|
||||
|
||||
val showCoord = if (flipped) rank == 7 else rank == 0
|
||||
|
||||
InteractiveChessSquare(
|
||||
piece = piece,
|
||||
isLight = isLightSquare,
|
||||
size = squareSize,
|
||||
isSelected = isSelected,
|
||||
isLegalMove = isLegalMove,
|
||||
showCoordinate = rank == 0,
|
||||
showCoordinate = showCoord,
|
||||
file = file,
|
||||
rank = rank,
|
||||
onClick = {
|
||||
if (!canInteract) return@InteractiveChessSquare
|
||||
|
||||
if (selectedSquare != null) {
|
||||
// Attempt to make move
|
||||
// Attempt to make move - validate first, don't actually make it
|
||||
val from = fileRankToSquare(selectedSquare!!.first, selectedSquare!!.second)
|
||||
val to = square
|
||||
val result = engine.makeMove(from, to)
|
||||
|
||||
if (result.success) {
|
||||
onMoveMade(from, to, result.san ?: "$from$to")
|
||||
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
|
||||
} else {
|
||||
// If move failed, try selecting this square instead
|
||||
if (piece != null && piece.color == engine.getSideToMove()) {
|
||||
// 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 {
|
||||
@@ -112,8 +135,9 @@ fun InteractiveChessBoard(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Select piece
|
||||
if (piece != null && piece.color == engine.getSideToMove()) {
|
||||
// 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)
|
||||
}
|
||||
@@ -164,13 +188,12 @@ private fun InteractiveChessSquare(
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Display piece using Unicode chess symbols
|
||||
// Display piece using CBurnett ImageVector icons
|
||||
piece?.let {
|
||||
Text(
|
||||
text = it.toUnicode(),
|
||||
fontSize = (size.value * 0.6).sp,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
Image(
|
||||
imageVector = it.toImageVector(),
|
||||
contentDescription = "${it.color} ${it.type}",
|
||||
modifier = Modifier.fillMaxSize().padding(2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -218,16 +241,16 @@ private fun fileRankToSquare(
|
||||
): String = "${('a' + file)}${rank + 1}"
|
||||
|
||||
/**
|
||||
* Extension function to convert ChessPiece to Unicode chess symbol
|
||||
* Extension function to convert ChessPiece to CBurnett ImageVector
|
||||
*/
|
||||
private fun com.vitorpamplona.quartz.nip64Chess.ChessPiece.toUnicode(): String {
|
||||
private fun ChessPiece.toImageVector(): ImageVector {
|
||||
val white = color == ChessColor.WHITE
|
||||
return when (type) {
|
||||
PieceType.KING -> if (white) "♔" else "♚"
|
||||
PieceType.QUEEN -> if (white) "♕" else "♛"
|
||||
PieceType.ROOK -> if (white) "♖" else "♜"
|
||||
PieceType.BISHOP -> if (white) "♗" else "♝"
|
||||
PieceType.KNIGHT -> if (white) "♘" else "♞"
|
||||
PieceType.PAWN -> if (white) "♙" else "♟"
|
||||
PieceType.KING -> if (white) ChessPieceVectors.WhiteKing else ChessPieceVectors.BlackKing
|
||||
PieceType.QUEEN -> if (white) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen
|
||||
PieceType.ROOK -> if (white) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook
|
||||
PieceType.BISHOP -> if (white) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop
|
||||
PieceType.KNIGHT -> if (white) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight
|
||||
PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn
|
||||
}
|
||||
}
|
||||
|
||||
+173
-9
@@ -20,14 +20,21 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.chess
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -35,6 +42,7 @@ import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -43,9 +51,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.Color
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
||||
|
||||
/**
|
||||
* Dialog for creating a new chess game challenge
|
||||
@@ -156,6 +166,81 @@ fun NewChessGameDialog(
|
||||
/**
|
||||
* Complete live chess game UI with board, controls, and game info
|
||||
*
|
||||
* This version observes LiveChessGameState flows for automatic UI updates
|
||||
* when polling refreshes the game state.
|
||||
*
|
||||
* @param modifier Modifier for the root layout
|
||||
* @param gameState Live chess game state (observed for updates)
|
||||
* @param opponentName Opponent's display name
|
||||
* @param onMoveMade Callback when player makes a move (from, to, san)
|
||||
* @param onResign Callback when player resigns
|
||||
* @param onOfferDraw Callback when player offers draw
|
||||
*/
|
||||
@Composable
|
||||
fun LiveChessGameScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
gameState: LiveChessGameState,
|
||||
opponentName: String,
|
||||
onMoveMade: (from: String, to: String, san: String) -> Unit,
|
||||
onResign: () -> Unit,
|
||||
onOfferDraw: () -> Unit,
|
||||
) {
|
||||
// Observe state flows for automatic recomposition on updates
|
||||
val currentPosition by gameState.currentPosition.collectAsState()
|
||||
val moveHistory by gameState.moveHistory.collectAsState()
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
// Calculate board size based on available space
|
||||
// Leave room for header (~100dp), history (~60dp), controls (~60dp), and padding
|
||||
val availableWidth = maxWidth - 32.dp // Account for horizontal padding
|
||||
val availableHeight = maxHeight - 250.dp // Account for other UI elements
|
||||
val boardSize = min(availableWidth, availableHeight).coerceAtLeast(200.dp)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Game info - use currentPosition.activeColor for turn display
|
||||
GameInfoHeader(
|
||||
gameId = gameState.gameId,
|
||||
opponentName = opponentName,
|
||||
playerColor = gameState.playerColor,
|
||||
currentTurn = currentPosition.activeColor,
|
||||
)
|
||||
|
||||
// Interactive chess board - flip when playing black
|
||||
// Auto-sized to fit available space
|
||||
InteractiveChessBoard(
|
||||
engine = gameState.engine,
|
||||
boardSize = boardSize,
|
||||
flipped = gameState.playerColor == Color.BLACK,
|
||||
onMoveMade = onMoveMade,
|
||||
)
|
||||
|
||||
// Move history (scrollable) - observed from state flow
|
||||
MoveHistoryDisplay(
|
||||
moves = moveHistory,
|
||||
)
|
||||
|
||||
// Game controls
|
||||
GameControls(
|
||||
onResign = onResign,
|
||||
onOfferDraw = onOfferDraw,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy version that takes engine directly (for backwards compatibility)
|
||||
*
|
||||
* @param modifier Modifier for the root layout
|
||||
* @param engine Chess engine instance
|
||||
* @param playerColor Color the player is playing as
|
||||
* @param gameId Unique game identifier
|
||||
@@ -166,6 +251,7 @@ fun NewChessGameDialog(
|
||||
*/
|
||||
@Composable
|
||||
fun LiveChessGameScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
engine: ChessEngine,
|
||||
playerColor: Color,
|
||||
gameId: String,
|
||||
@@ -174,13 +260,22 @@ fun LiveChessGameScreen(
|
||||
onResign: () -> Unit,
|
||||
onOfferDraw: () -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
// Calculate board size based on available space
|
||||
// Leave room for header (~100dp), history (~60dp), controls (~60dp), and padding
|
||||
val availableWidth = maxWidth - 32.dp // Account for horizontal padding
|
||||
val availableHeight = maxHeight - 250.dp // Account for other UI elements
|
||||
val boardSize = min(availableWidth, availableHeight).coerceAtLeast(200.dp)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Game info
|
||||
GameInfoHeader(
|
||||
@@ -190,14 +285,16 @@ fun LiveChessGameScreen(
|
||||
currentTurn = engine.getSideToMove(),
|
||||
)
|
||||
|
||||
// Interactive chess board
|
||||
// Interactive chess board - flip when playing black
|
||||
// Auto-sized to fit available space
|
||||
InteractiveChessBoard(
|
||||
engine = engine,
|
||||
boardSize = 400.dp,
|
||||
boardSize = boardSize,
|
||||
flipped = playerColor == Color.BLACK,
|
||||
onMoveMade = onMoveMade,
|
||||
)
|
||||
|
||||
// Move history
|
||||
// Move history (scrollable)
|
||||
MoveHistoryDisplay(
|
||||
moves = engine.getMoveHistory(),
|
||||
)
|
||||
@@ -208,6 +305,7 @@ fun LiveChessGameScreen(
|
||||
onOfferDraw = onOfferDraw,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,25 +361,91 @@ private fun GameInfoHeader(
|
||||
}
|
||||
|
||||
/**
|
||||
* Display move history in SAN notation
|
||||
* Display move history in SAN notation with move numbers
|
||||
* Shows in a horizontally scrollable container
|
||||
*/
|
||||
@Composable
|
||||
private fun MoveHistoryDisplay(moves: List<String>) {
|
||||
if (moves.isNotEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "Moves:",
|
||||
text = "Move History",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
RoundedCornerShape(8.dp),
|
||||
).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
if (moves.isEmpty()) {
|
||||
Text(
|
||||
text = moves.joinToString(" "),
|
||||
text = "No moves yet",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.CenterStart),
|
||||
)
|
||||
} else {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.horizontalScroll(scrollState)
|
||||
.align(Alignment.CenterStart),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Group moves into pairs (white, black)
|
||||
moves.chunked(2).forEachIndexed { index, movePair ->
|
||||
// Move number
|
||||
Text(
|
||||
text = "${index + 1}.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// White's move
|
||||
Text(
|
||||
text = movePair[0],
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier =
|
||||
Modifier
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surface,
|
||||
RoundedCornerShape(4.dp),
|
||||
).padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
)
|
||||
|
||||
// Black's move (if exists)
|
||||
if (movePair.size > 1) {
|
||||
Text(
|
||||
text = movePair[1],
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier =
|
||||
Modifier
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
RoundedCornerShape(4.dp),
|
||||
).padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.data
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Simple data class for cached user display info
|
||||
*/
|
||||
data class UserDisplayInfo(
|
||||
val pubkey: String,
|
||||
val displayName: String?,
|
||||
val pictureUrl: String?,
|
||||
val nip05: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Lightweight user metadata cache for UI display.
|
||||
* Can be used by any feature (chess, chat, etc.) that needs user info.
|
||||
*
|
||||
* Thread-safe via StateFlow updates.
|
||||
*/
|
||||
class UserMetadataCache {
|
||||
private val _metadata = MutableStateFlow<Map<String, UserMetadata>>(emptyMap())
|
||||
val metadata: StateFlow<Map<String, UserMetadata>> = _metadata.asStateFlow()
|
||||
|
||||
private val _pubkeysNeeded = MutableStateFlow<Set<String>>(emptySet())
|
||||
val pubkeysNeeded: StateFlow<Set<String>> = _pubkeysNeeded.asStateFlow()
|
||||
|
||||
/**
|
||||
* Add or update metadata for a pubkey
|
||||
*/
|
||||
fun put(
|
||||
pubkey: String,
|
||||
userMetadata: UserMetadata,
|
||||
) {
|
||||
_metadata.value = _metadata.value + (pubkey to userMetadata)
|
||||
_pubkeysNeeded.value = _pubkeysNeeded.value - pubkey
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a pubkey (may be null if not cached)
|
||||
*/
|
||||
fun get(pubkey: String): UserMetadata? = _metadata.value[pubkey]
|
||||
|
||||
/**
|
||||
* Check if metadata is cached for a pubkey
|
||||
*/
|
||||
fun contains(pubkey: String): Boolean = _metadata.value.containsKey(pubkey)
|
||||
|
||||
/**
|
||||
* Request metadata for a pubkey (adds to needed set if not cached)
|
||||
*/
|
||||
fun request(pubkey: String) {
|
||||
if (!contains(pubkey) && pubkey !in _pubkeysNeeded.value) {
|
||||
_pubkeysNeeded.value = _pubkeysNeeded.value + pubkey
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request metadata for multiple pubkeys
|
||||
*/
|
||||
fun requestAll(pubkeys: Collection<String>) {
|
||||
val newNeeded = pubkeys.filter { !contains(it) && it !in _pubkeysNeeded.value }
|
||||
if (newNeeded.isNotEmpty()) {
|
||||
_pubkeysNeeded.value = _pubkeysNeeded.value + newNeeded
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a pubkey, falling back to truncated pubkey
|
||||
*/
|
||||
fun getDisplayName(pubkey: String): String {
|
||||
val meta = get(pubkey)
|
||||
return meta?.bestName() ?: formatPubkeyShort(pubkey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile picture URL for a pubkey
|
||||
*/
|
||||
fun getPictureUrl(pubkey: String): String? = get(pubkey)?.profilePicture()
|
||||
|
||||
/**
|
||||
* Get display info for a pubkey (for UI binding)
|
||||
*/
|
||||
fun getDisplayInfo(pubkey: String): UserDisplayInfo {
|
||||
val meta = get(pubkey)
|
||||
return UserDisplayInfo(
|
||||
pubkey = pubkey,
|
||||
displayName = meta?.bestName(),
|
||||
pictureUrl = meta?.profilePicture(),
|
||||
nip05 = meta?.nip05,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear metadata that was requested but hasn't been fetched
|
||||
* (call when pubkeys are no longer needed)
|
||||
*/
|
||||
fun clearNeeded(pubkeys: Collection<String>) {
|
||||
_pubkeysNeeded.value = _pubkeysNeeded.value - pubkeys.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached metadata
|
||||
*/
|
||||
fun clear() {
|
||||
_metadata.value = emptyMap()
|
||||
_pubkeysNeeded.value = emptySet()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Format pubkey for display (npub-style truncation)
|
||||
*/
|
||||
fun formatPubkeyShort(pubkey: String): String =
|
||||
if (pubkey.length > 12) {
|
||||
"${pubkey.take(8)}...${pubkey.takeLast(4)}"
|
||||
} else {
|
||||
pubkey
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Article
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
@@ -82,6 +83,7 @@ import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
|
||||
@@ -120,6 +122,8 @@ sealed class DesktopScreen {
|
||||
|
||||
object Notifications : DesktopScreen()
|
||||
|
||||
object Chess : DesktopScreen()
|
||||
|
||||
object MyProfile : DesktopScreen()
|
||||
|
||||
data class UserProfile(
|
||||
@@ -415,6 +419,13 @@ fun MainContent(
|
||||
onClick = { onScreenChange(DesktopScreen.Notifications) },
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Extension, contentDescription = "Chess") },
|
||||
label = { Text("Chess") },
|
||||
selected = currentScreen == DesktopScreen.Chess,
|
||||
onClick = { onScreenChange(DesktopScreen.Chess) },
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
|
||||
label = { Text("Profile") },
|
||||
@@ -514,6 +525,14 @@ fun MainContent(
|
||||
NotificationsScreen(relayManager, account, subscriptionsCoordinator)
|
||||
}
|
||||
|
||||
DesktopScreen.Chess -> {
|
||||
ChessScreen(
|
||||
relayManager = relayManager,
|
||||
account = account,
|
||||
onBack = { onScreenChange(DesktopScreen.Feed) },
|
||||
)
|
||||
}
|
||||
|
||||
DesktopScreen.MyProfile -> {
|
||||
UserProfileScreen(
|
||||
pubKeyHex = account.pubKeyHex,
|
||||
|
||||
+903
@@ -0,0 +1,903 @@
|
||||
/**
|
||||
* 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 androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.chess.InteractiveChessBoard
|
||||
import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog
|
||||
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.createMetadataListSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||
|
||||
/**
|
||||
* Desktop chess screen with challenge list and game view
|
||||
*/
|
||||
@Composable
|
||||
fun ChessScreen(
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
account: AccountState.LoggedIn,
|
||||
onBack: () -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val viewModel =
|
||||
remember(account.pubKeyHex) {
|
||||
DesktopChessViewModel(account, relayManager, scope)
|
||||
}
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val refreshKey by viewModel.refreshKey.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
|
||||
// Subscribe to chess events from relays (re-subscribes when refreshKey changes)
|
||||
rememberSubscription(relayStatuses, account, refreshKey, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
createChessSubscription(
|
||||
relays = configuredRelays,
|
||||
userPubkey = account.pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
viewModel.handleIncomingEvent(event)
|
||||
},
|
||||
onEose = { _, _ ->
|
||||
viewModel.onLoadComplete()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to user metadata for pubkeys that need it
|
||||
val pubkeysNeeded by viewModel.userMetadataCache.pubkeysNeeded.collectAsState()
|
||||
rememberSubscription(relayStatuses, pubkeysNeeded, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) {
|
||||
createMetadataListSubscription(
|
||||
relays = configuredRelays,
|
||||
pubKeys = pubkeysNeeded.toList(),
|
||||
onEvent = { event, _, _, _ ->
|
||||
viewModel.handleIncomingEvent(event)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val activeGames by viewModel.activeGames.collectAsState()
|
||||
val challenges by viewModel.challenges.collectAsState()
|
||||
val completedGames by viewModel.completedGames.collectAsState()
|
||||
// Observe metadata changes to trigger recomposition
|
||||
val userMetadata by viewModel.userMetadataCache.metadata.collectAsState()
|
||||
val selectedGameId by viewModel.selectedGameId.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
var showNewGameDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (selectedGameId != null) {
|
||||
IconButton(onClick = { viewModel.selectGame(null) }) {
|
||||
Icon(Icons.Default.ArrowBack, "Back to list")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
if (selectedGameId != null) "Live Game" else "Chess",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedGameId == null) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Refresh button
|
||||
IconButton(
|
||||
onClick = { viewModel.refresh() },
|
||||
enabled = !isLoading,
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Default.Refresh, "Refresh")
|
||||
}
|
||||
}
|
||||
|
||||
// New Game button
|
||||
if (!account.isReadOnly) {
|
||||
Button(onClick = { showNewGameDialog = true }) {
|
||||
Icon(Icons.Default.Add, "New Game")
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("New Game")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error display
|
||||
error?.let { errorMsg ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(errorMsg, color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
OutlinedButton(onClick = { viewModel.clearError() }) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main content
|
||||
if (selectedGameId != null) {
|
||||
val gameState = viewModel.getGameState(selectedGameId!!)
|
||||
if (gameState != null) {
|
||||
DesktopChessGameLayout(
|
||||
gameState = gameState,
|
||||
opponentName = viewModel.userMetadataCache.getDisplayName(gameState.opponentPubkey),
|
||||
opponentPicture = viewModel.userMetadataCache.getPictureUrl(gameState.opponentPubkey),
|
||||
onMoveMade = { from, to, _ ->
|
||||
viewModel.publishMove(gameState.gameId, from, to)
|
||||
},
|
||||
onResign = { viewModel.resign(gameState.gameId) },
|
||||
onOfferDraw = { viewModel.offerDraw(gameState.gameId) },
|
||||
onAcceptDraw = { viewModel.acceptDraw(gameState.gameId) },
|
||||
onDeclineDraw = { viewModel.declineDraw(gameState.gameId) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ChessLobby(
|
||||
challenges = challenges,
|
||||
activeGames = activeGames,
|
||||
completedGames = completedGames,
|
||||
userPubkey = account.pubKeyHex,
|
||||
isLoading = isLoading,
|
||||
metadataCache = viewModel.userMetadataCache,
|
||||
onAcceptChallenge = { viewModel.acceptChallenge(it) },
|
||||
onSelectGame = { viewModel.selectGame(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// New game dialog
|
||||
if (showNewGameDialog) {
|
||||
NewChessGameDialog(
|
||||
onDismiss = { showNewGameDialog = false },
|
||||
onCreateGame = { opponentPubkey, color ->
|
||||
viewModel.createChallenge(opponentPubkey, color)
|
||||
showNewGameDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chess lobby showing challenges and active games
|
||||
*/
|
||||
@Composable
|
||||
private fun ChessLobby(
|
||||
challenges: List<LiveChessGameChallengeEvent>,
|
||||
activeGames: Map<String, com.vitorpamplona.quartz.nip64Chess.LiveChessGameState>,
|
||||
completedGames: List<CompletedGame>,
|
||||
isLoading: Boolean,
|
||||
userPubkey: String,
|
||||
metadataCache: UserMetadataCache,
|
||||
onAcceptChallenge: (LiveChessGameChallengeEvent) -> Unit,
|
||||
onSelectGame: (String) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Active games section
|
||||
if (activeGames.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Active Games",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
items(activeGames.entries.toList(), key = { it.key }) { (gameId, state) ->
|
||||
ActiveGameCard(
|
||||
gameId = gameId,
|
||||
opponentPubkey = state.opponentPubkey,
|
||||
opponentName = metadataCache.getDisplayName(state.opponentPubkey),
|
||||
opponentPicture = metadataCache.getPictureUrl(state.opponentPubkey),
|
||||
isYourTurn = state.isPlayerTurn(),
|
||||
onClick = { onSelectGame(gameId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Incoming challenges
|
||||
val incomingChallenges = challenges.filter { it.opponentPubkey() == userPubkey }
|
||||
if (incomingChallenges.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Incoming Challenges",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
items(incomingChallenges, key = { it.id }) { challenge ->
|
||||
ChallengeCard(
|
||||
challenge = challenge,
|
||||
challengerName = metadataCache.getDisplayName(challenge.pubKey),
|
||||
challengerPicture = metadataCache.getPictureUrl(challenge.pubKey),
|
||||
isIncoming = true,
|
||||
onAccept = { onAcceptChallenge(challenge) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// User's outgoing challenges
|
||||
val outgoingChallenges = challenges.filter { it.pubKey == 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.id }) { challenge ->
|
||||
val opponentPubkey = challenge.opponentPubkey()
|
||||
OutgoingChallengeCard(
|
||||
challenge = challenge,
|
||||
opponentName = opponentPubkey?.let { metadataCache.getDisplayName(it) },
|
||||
opponentPicture = opponentPubkey?.let { metadataCache.getPictureUrl(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Open challenges from others
|
||||
val openChallenges = challenges.filter { it.opponentPubkey() == null && it.pubKey != userPubkey }
|
||||
if (openChallenges.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Open Challenges",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
items(openChallenges, key = { it.id }) { challenge ->
|
||||
ChallengeCard(
|
||||
challenge = challenge,
|
||||
challengerName = metadataCache.getDisplayName(challenge.pubKey),
|
||||
challengerPicture = metadataCache.getPictureUrl(challenge.pubKey),
|
||||
isIncoming = false,
|
||||
onAccept = { onAcceptChallenge(challenge) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Completed games history
|
||||
if (completedGames.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Recent Games",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
items(completedGames.take(10), key = { it.gameId }) { game ->
|
||||
CompletedGameCard(
|
||||
game = game,
|
||||
userPubkey = userPubkey,
|
||||
opponentName = metadataCache.getDisplayName(game.opponentPubkey),
|
||||
opponentPicture = metadataCache.getPictureUrl(game.opponentPubkey),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state or loading
|
||||
if (activeGames.isEmpty() && challenges.isEmpty()) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Loading games from relays...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"No games or challenges",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Create a new game or refresh to load from relays",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveGameCard(
|
||||
gameId: String,
|
||||
opponentPubkey: String,
|
||||
opponentName: String,
|
||||
opponentPicture: String?,
|
||||
isYourTurn: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
border =
|
||||
if (isYourTurn) {
|
||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
UserAvatar(
|
||||
userHex = opponentPubkey,
|
||||
pictureUrl = opponentPicture,
|
||||
size = 40.dp,
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
"vs $opponentName",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"Game: ${gameId.take(12)}...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
if (isYourTurn) "Your turn" else "Waiting...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isYourTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OutgoingChallengeCard(
|
||||
challenge: LiveChessGameChallengeEvent,
|
||||
opponentName: String?,
|
||||
opponentPicture: String?,
|
||||
) {
|
||||
val opponentPubkey = challenge.opponentPubkey()
|
||||
val playerColor = challenge.playerColor()
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (opponentPubkey != null) {
|
||||
UserAvatar(
|
||||
userHex = opponentPubkey,
|
||||
pictureUrl = opponentPicture,
|
||||
size = 40.dp,
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
if (opponentName != null) {
|
||||
"Challenge to $opponentName"
|
||||
} else {
|
||||
"Open challenge (awaiting opponent)"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"Waiting...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChallengeCard(
|
||||
challenge: LiveChessGameChallengeEvent,
|
||||
challengerName: String,
|
||||
challengerPicture: String?,
|
||||
isIncoming: Boolean,
|
||||
onAccept: () -> Unit,
|
||||
) {
|
||||
val borderColor =
|
||||
if (isIncoming) {
|
||||
Color(0xFFFF9800) // Orange for incoming
|
||||
} else {
|
||||
Color(0xFF4CAF50) // Green for open
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
border = BorderStroke(2.dp, borderColor),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
UserAvatar(
|
||||
userHex = challenge.pubKey,
|
||||
pictureUrl = challengerPicture,
|
||||
size = 40.dp,
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
if (isIncoming) "Challenge from $challengerName" else "Open challenge by $challengerName",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
val playerColor = challenge.playerColor()
|
||||
Text(
|
||||
"Challenger plays ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(onClick = onAccept) {
|
||||
Text("Accept")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompletedGameCard(
|
||||
game: CompletedGame,
|
||||
userPubkey: String,
|
||||
opponentName: String,
|
||||
opponentPicture: String?,
|
||||
) {
|
||||
val resultColor =
|
||||
when (game.didWin(userPubkey)) {
|
||||
true -> Color(0xFF4CAF50) // Green for win
|
||||
false -> Color(0xFFF44336) // Red for loss
|
||||
null -> Color(0xFF9E9E9E) // Gray for draw
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
UserAvatar(
|
||||
userHex = game.opponentPubkey,
|
||||
pictureUrl = opponentPicture,
|
||||
size = 40.dp,
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
"vs $opponentName",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"${game.moveCount} moves - ${game.termination}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
game.resultText(userPubkey),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = resultColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop-optimized chess game layout with board on left, info/controls on right
|
||||
*/
|
||||
@Composable
|
||||
private fun DesktopChessGameLayout(
|
||||
gameState: com.vitorpamplona.quartz.nip64Chess.LiveChessGameState,
|
||||
opponentName: String,
|
||||
opponentPicture: String?,
|
||||
onMoveMade: (from: String, to: String, san: String) -> Unit,
|
||||
onResign: () -> Unit,
|
||||
onOfferDraw: () -> Unit,
|
||||
onAcceptDraw: () -> Unit,
|
||||
onDeclineDraw: () -> Unit,
|
||||
) {
|
||||
// Collect state flows to trigger recomposition on changes
|
||||
val currentPosition by gameState.currentPosition.collectAsState()
|
||||
val moveHistory by gameState.moveHistory.collectAsState()
|
||||
val pendingDrawOffer by gameState.pendingDrawOffer.collectAsState()
|
||||
|
||||
val engine = gameState.engine
|
||||
val playerColor = gameState.playerColor
|
||||
val gameId = gameState.gameId
|
||||
val opponentPubkey = gameState.opponentPubkey
|
||||
val hasOpponentDrawOffer = pendingDrawOffer == opponentPubkey
|
||||
val hasOurDrawOffer = pendingDrawOffer == gameState.playerPubkey
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
// Left side: Chess board
|
||||
Box(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
InteractiveChessBoard(
|
||||
engine = engine,
|
||||
boardSize = 520.dp,
|
||||
flipped = playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK,
|
||||
playerColor = playerColor,
|
||||
onMoveMade = onMoveMade,
|
||||
)
|
||||
}
|
||||
|
||||
// Right side: Game info, moves, controls
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(300.dp)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Game info card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Game Info",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
UserAvatar(
|
||||
userHex = opponentPubkey,
|
||||
pictureUrl = opponentPicture,
|
||||
size = 48.dp,
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
"vs $opponentName",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
"You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Use currentPosition to derive turn (triggers recomposition on move)
|
||||
val currentTurn = currentPosition.activeColor
|
||||
val isYourTurn = currentTurn == playerColor
|
||||
|
||||
Text(
|
||||
if (isYourTurn) "Your turn" else "Opponent's turn",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
|
||||
color =
|
||||
if (isYourTurn) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Move history card
|
||||
if (moveHistory.isNotEmpty()) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Move History",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
// Format moves as numbered pairs
|
||||
val moveText =
|
||||
moveHistory
|
||||
.chunked(2)
|
||||
.mapIndexed { index, pair ->
|
||||
"${index + 1}. ${pair.joinToString(" ")}"
|
||||
}.joinToString("\n")
|
||||
|
||||
Text(
|
||||
moveText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw offer notification (if opponent offered)
|
||||
if (hasOpponentDrawOffer) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = Color(0xFFFF9800).copy(alpha = 0.15f),
|
||||
),
|
||||
border = BorderStroke(2.dp, Color(0xFFFF9800)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Draw Offered",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFFFF9800),
|
||||
)
|
||||
Text(
|
||||
"$opponentName has offered a draw",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = onAcceptDraw,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Accept")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onDeclineDraw,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Decline")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Our draw offer pending notification
|
||||
if (hasOurDrawOffer) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
"Draw offer sent",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"Waiting for $opponentName to respond...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Game controls card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Actions",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onOfferDraw,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !hasOurDrawOffer && !hasOpponentDrawOffer,
|
||||
) {
|
||||
Text(if (hasOurDrawOffer) "Offer Sent" else "Offer Draw")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onResign,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Resign")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Game ID (small footer)
|
||||
Text(
|
||||
"Game: ${gameId.take(16)}...",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+813
@@ -0,0 +1,813 @@
|
||||
/**
|
||||
* 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.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.ChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.nip64Chess.GameResult
|
||||
import com.vitorpamplona.quartz.nip64Chess.GameTermination
|
||||
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.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
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.
|
||||
* Adapts the Android ChessViewModel patterns for Desktop's relay architecture.
|
||||
*/
|
||||
class DesktopChessViewModel(
|
||||
private val account: AccountState.LoggedIn,
|
||||
private val relayManager: DesktopRelayConnectionManager,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
companion object {
|
||||
const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L
|
||||
const val MAX_RETRIES = 3
|
||||
const val RETRY_DELAY_MS = 1000L
|
||||
}
|
||||
|
||||
// Active games being played
|
||||
private val _activeGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap())
|
||||
val activeGames: StateFlow<Map<String, LiveChessGameState>> = _activeGames.asStateFlow()
|
||||
|
||||
// Pending challenges
|
||||
private val _challenges = MutableStateFlow<List<LiveChessGameChallengeEvent>>(emptyList())
|
||||
val challenges: StateFlow<List<LiveChessGameChallengeEvent>> = _challenges.asStateFlow()
|
||||
|
||||
// Badge count
|
||||
private val _badgeCount = MutableStateFlow(0)
|
||||
val badgeCount: StateFlow<Int> = _badgeCount.asStateFlow()
|
||||
|
||||
// Currently selected game
|
||||
private val _selectedGameId = MutableStateFlow<String?>(null)
|
||||
val selectedGameId: StateFlow<String?> = _selectedGameId.asStateFlow()
|
||||
|
||||
// Error state
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
// Loading state
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
// Refresh key - incrementing this triggers re-subscription
|
||||
private val _refreshKey = MutableStateFlow(0)
|
||||
val refreshKey: StateFlow<Int> = _refreshKey.asStateFlow()
|
||||
|
||||
// Completed games history
|
||||
private val _completedGames = MutableStateFlow<List<CompletedGame>>(emptyList())
|
||||
val completedGames: StateFlow<List<CompletedGame>> = _completedGames.asStateFlow()
|
||||
|
||||
// Shared user metadata cache
|
||||
val userMetadataCache = UserMetadataCache()
|
||||
|
||||
// Pending moves waiting for game to be created (gameId -> list of (san, fen, moveNumber))
|
||||
private val pendingMoves = mutableMapOf<String, MutableList<Triple<String, String, Int?>>>()
|
||||
|
||||
// Challenge events indexed by gameId for lookup during accept processing
|
||||
private val challengesByGameId = mutableMapOf<String, LiveChessGameChallengeEvent>()
|
||||
|
||||
// Pending accept events waiting for challenge to arrive (gameId -> accept event)
|
||||
private val pendingAccepts = mutableMapOf<String, LiveChessGameAcceptEvent>()
|
||||
|
||||
// Track event IDs we've already processed to avoid duplicates
|
||||
private val processedEventIds = mutableSetOf<String>()
|
||||
|
||||
// Track games currently being created to prevent race conditions
|
||||
private val gamesBeingCreated = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
// Check by kind since events may not be cast to specific types
|
||||
when (event.kind) {
|
||||
MetadataEvent.KIND -> {
|
||||
handleMetadataEvent(event)
|
||||
return
|
||||
}
|
||||
LiveChessGameChallengeEvent.KIND -> {
|
||||
if (event is LiveChessGameChallengeEvent) {
|
||||
handleNewChallenge(event)
|
||||
} else {
|
||||
// Manually parse if not already the right type
|
||||
val challenge =
|
||||
LiveChessGameChallengeEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
)
|
||||
handleNewChallenge(challenge)
|
||||
}
|
||||
}
|
||||
LiveChessGameAcceptEvent.KIND -> {
|
||||
if (event is LiveChessGameAcceptEvent) {
|
||||
handleGameAccepted(event)
|
||||
} else {
|
||||
val accept =
|
||||
LiveChessGameAcceptEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
)
|
||||
handleGameAccepted(accept)
|
||||
}
|
||||
}
|
||||
LiveChessMoveEvent.KIND -> {
|
||||
if (event is LiveChessMoveEvent) {
|
||||
handleIncomingMove(event)
|
||||
} else {
|
||||
val move =
|
||||
LiveChessMoveEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
)
|
||||
handleIncomingMove(move)
|
||||
}
|
||||
}
|
||||
LiveChessGameEndEvent.KIND -> {
|
||||
if (event is LiveChessGameEndEvent) {
|
||||
handleGameEnded(event)
|
||||
} else {
|
||||
val end =
|
||||
LiveChessGameEndEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
)
|
||||
handleGameEnded(end)
|
||||
}
|
||||
}
|
||||
LiveChessDrawOfferEvent.KIND -> {
|
||||
if (event is LiveChessDrawOfferEvent) {
|
||||
handleDrawOffer(event)
|
||||
} else {
|
||||
val drawOffer =
|
||||
LiveChessDrawOfferEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
)
|
||||
handleDrawOffer(drawOffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMetadataEvent(event: Event) {
|
||||
try {
|
||||
val metadataEvent =
|
||||
MetadataEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
)
|
||||
val metadata = metadataEvent.contactMetaData()
|
||||
if (metadata != null) {
|
||||
userMetadataCache.put(event.pubKey, metadata)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIncomingMove(event: LiveChessMoveEvent) {
|
||||
// Don't process our own moves
|
||||
if (event.pubKey == account.pubKeyHex) return
|
||||
|
||||
val gameId = event.gameId() ?: return
|
||||
val san = event.san() ?: return
|
||||
val fen = event.fen() ?: return
|
||||
val moveNumber = event.moveNumber()
|
||||
|
||||
val gameState = _activeGames.value[gameId]
|
||||
|
||||
if (gameState == null) {
|
||||
// Game doesn't exist yet - buffer the move for later
|
||||
val moveList = pendingMoves.getOrPut(gameId) { mutableListOf() }
|
||||
moveList.add(Triple(san, fen, moveNumber))
|
||||
return
|
||||
}
|
||||
|
||||
if (event.pubKey != gameState.opponentPubkey) return
|
||||
|
||||
gameState.applyOpponentMove(san, fen, moveNumber)
|
||||
updateBadgeCount()
|
||||
}
|
||||
|
||||
private fun handleGameAccepted(event: LiveChessGameAcceptEvent) {
|
||||
val gameId = event.gameId() ?: return
|
||||
|
||||
// Skip if game already exists
|
||||
if (_activeGames.value.containsKey(gameId)) return
|
||||
|
||||
val challengerPubkey = event.challengerPubkey() ?: return
|
||||
val accepterPubkey = event.pubKey
|
||||
|
||||
// Check if we have the challenge event for color info
|
||||
val challengeEvent = challengesByGameId[gameId]
|
||||
if (challengeEvent == null) {
|
||||
// Challenge hasn't arrived yet - buffer this accept for later
|
||||
if (challengerPubkey == account.pubKeyHex || accepterPubkey == account.pubKeyHex) {
|
||||
pendingAccepts[gameId] = event
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle if user is the challenger or accepter
|
||||
if (challengerPubkey == account.pubKeyHex) {
|
||||
startGameFromAcceptance(event, isChallenger = true)
|
||||
} else if (accepterPubkey == account.pubKeyHex) {
|
||||
startGameFromAcceptance(event, isChallenger = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGameEnded(event: LiveChessGameEndEvent) {
|
||||
val gameId = event.gameId() ?: return
|
||||
|
||||
// Store game in history before removing
|
||||
val gameState = _activeGames.value[gameId]
|
||||
if (gameState != null) {
|
||||
val completedGame =
|
||||
CompletedGame(
|
||||
gameId = gameId,
|
||||
opponentPubkey = gameState.opponentPubkey,
|
||||
playerColor = gameState.playerColor,
|
||||
result = event.result() ?: "?",
|
||||
termination = event.termination() ?: "unknown",
|
||||
winnerPubkey = event.winnerPubkey(),
|
||||
completedAt = event.createdAt,
|
||||
moveCount = gameState.moveHistory.value.size,
|
||||
)
|
||||
_completedGames.value = listOf(completedGame) + _completedGames.value
|
||||
}
|
||||
|
||||
// Remove from active games
|
||||
if (_activeGames.value.containsKey(gameId)) {
|
||||
_activeGames.value = _activeGames.value - gameId
|
||||
}
|
||||
|
||||
// Clean up challenge references
|
||||
_challenges.value = _challenges.value.filter { it.gameId() != gameId }
|
||||
challengesByGameId.remove(gameId)
|
||||
pendingAccepts.remove(gameId)
|
||||
pendingMoves.remove(gameId)
|
||||
|
||||
updateBadgeCount()
|
||||
}
|
||||
|
||||
private fun handleDrawOffer(event: LiveChessDrawOfferEvent) {
|
||||
// Only process draw offers from opponent
|
||||
if (event.pubKey == account.pubKeyHex) return
|
||||
|
||||
val gameId = event.gameId() ?: return
|
||||
val gameState = _activeGames.value[gameId] ?: return
|
||||
|
||||
// Verify this is from our opponent in this game
|
||||
if (event.pubKey != gameState.opponentPubkey) return
|
||||
|
||||
// Pass to game state to track
|
||||
gameState.receiveDrawOffer(event.pubKey)
|
||||
updateBadgeCount()
|
||||
}
|
||||
|
||||
private fun handleNewChallenge(event: LiveChessGameChallengeEvent) {
|
||||
val gameId = event.gameId() ?: return
|
||||
val opponentPubkey = event.opponentPubkey()
|
||||
val isUserChallenge = event.pubKey == account.pubKeyHex
|
||||
val isOpenChallenge = opponentPubkey == null
|
||||
val isDirectedAtUser = opponentPubkey == account.pubKeyHex
|
||||
|
||||
// Request metadata for challenger
|
||||
userMetadataCache.request(event.pubKey)
|
||||
opponentPubkey?.let { userMetadataCache.request(it) }
|
||||
|
||||
// Always store in lookup map for accept event processing (even if game exists)
|
||||
challengesByGameId[gameId] = event
|
||||
|
||||
// Skip if game already exists (challenge was already accepted)
|
||||
if (_activeGames.value.containsKey(gameId)) return
|
||||
|
||||
// Check if there's a pending accept waiting for this challenge
|
||||
val pendingAccept = pendingAccepts.remove(gameId)
|
||||
if (pendingAccept != null) {
|
||||
// Now we can properly process the accept
|
||||
val isChallenger = event.pubKey == account.pubKeyHex
|
||||
val isAccepter = pendingAccept.pubKey == account.pubKeyHex
|
||||
if (isChallenger || isAccepter) {
|
||||
startGameFromAcceptance(pendingAccept, isChallenger = isChallenger)
|
||||
return // Don't add to challenges list since game is starting
|
||||
}
|
||||
}
|
||||
|
||||
// Show challenges that are:
|
||||
// - Created by user (their outgoing challenges)
|
||||
// - Open challenges (anyone can accept)
|
||||
// - Directed at user (incoming challenges)
|
||||
if (isUserChallenge || isOpenChallenge || isDirectedAtUser) {
|
||||
val currentChallenges = _challenges.value.toMutableList()
|
||||
// Deduplicate by both event ID and game ID to prevent duplicates from relay broadcasts
|
||||
val isDuplicateEvent = currentChallenges.any { it.id == event.id }
|
||||
val isDuplicateGame = currentChallenges.any { it.gameId() == gameId }
|
||||
if (!isDuplicateEvent && !isDuplicateGame) {
|
||||
currentChallenges.add(event)
|
||||
_challenges.value = currentChallenges
|
||||
updateBadgeCount()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chess challenge
|
||||
*/
|
||||
fun createChallenge(
|
||||
opponentPubkey: String? = null,
|
||||
playerColor: Color = Color.WHITE,
|
||||
timeControl: String? = null,
|
||||
) {
|
||||
val gameId = generateGameId()
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val success =
|
||||
retryWithBackoff {
|
||||
val template =
|
||||
LiveChessGameChallengeEvent.build(
|
||||
gameId = gameId,
|
||||
playerColor = playerColor,
|
||||
opponentPubkey = opponentPubkey,
|
||||
timeControl = timeControl,
|
||||
)
|
||||
|
||||
val signedEvent = account.signer.sign(template)
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
_error.value = null
|
||||
} else {
|
||||
_error.value = "Failed to create challenge after $MAX_RETRIES attempts"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a chess challenge
|
||||
*/
|
||||
fun acceptChallenge(challengeEvent: LiveChessGameChallengeEvent) {
|
||||
val gameId = challengeEvent.gameId() ?: return
|
||||
val challengerPubkey = challengeEvent.pubKey
|
||||
val challengerColor = challengeEvent.playerColor() ?: Color.WHITE
|
||||
val playerColor = challengerColor.opposite()
|
||||
|
||||
// Store in lookup map for consistent access
|
||||
challengesByGameId[gameId] = challengeEvent
|
||||
|
||||
// Mark as being created to prevent duplicate from relay echo
|
||||
if (!gamesBeingCreated.add(gameId)) return // Already being created
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val success =
|
||||
retryWithBackoff {
|
||||
val template =
|
||||
LiveChessGameAcceptEvent.build(
|
||||
gameId = gameId,
|
||||
challengeEventId = challengeEvent.id,
|
||||
challengerPubkey = challengerPubkey,
|
||||
)
|
||||
|
||||
val signedEvent = account.signer.sign(template)
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// Check if game was already created by relay echo while we were broadcasting
|
||||
if (_activeGames.value.containsKey(gameId)) {
|
||||
gamesBeingCreated.remove(gameId)
|
||||
_selectedGameId.value = gameId
|
||||
_challenges.value = _challenges.value.filter { it.id != challengeEvent.id }
|
||||
_error.value = null
|
||||
return@launch
|
||||
}
|
||||
|
||||
val engine = ChessEngine()
|
||||
engine.reset()
|
||||
|
||||
val gameState =
|
||||
LiveChessGameState(
|
||||
gameId = gameId,
|
||||
playerPubkey = account.pubKeyHex,
|
||||
opponentPubkey = challengerPubkey,
|
||||
playerColor = playerColor,
|
||||
engine = engine,
|
||||
)
|
||||
|
||||
_activeGames.value = _activeGames.value + (gameId to gameState)
|
||||
gamesBeingCreated.remove(gameId)
|
||||
_selectedGameId.value = gameId
|
||||
_challenges.value = _challenges.value.filter { it.id != challengeEvent.id }
|
||||
_error.value = null
|
||||
} else {
|
||||
gamesBeingCreated.remove(gameId)
|
||||
_error.value = "Failed to accept challenge"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startGameFromAcceptance(
|
||||
acceptEvent: LiveChessGameAcceptEvent,
|
||||
isChallenger: Boolean,
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val gameId = acceptEvent.gameId() ?: return@launch
|
||||
val challengerPubkey = acceptEvent.challengerPubkey() ?: return@launch
|
||||
val accepterPubkey = acceptEvent.pubKey
|
||||
|
||||
// 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
|
||||
|
||||
val opponentPubkey: String
|
||||
val playerColor: Color
|
||||
|
||||
// Use challengesByGameId for reliable lookup (not affected by UI filtering)
|
||||
val challengeEvent = challengesByGameId[gameId]
|
||||
|
||||
if (isChallenger) {
|
||||
// User is challenger, opponent is accepter
|
||||
opponentPubkey = accepterPubkey
|
||||
playerColor = challengeEvent?.playerColor() ?: Color.WHITE
|
||||
} else {
|
||||
// User is accepter, opponent is challenger
|
||||
opponentPubkey = challengerPubkey
|
||||
// Accepter gets opposite color of challenger
|
||||
val challengerColor = challengeEvent?.playerColor() ?: Color.WHITE
|
||||
playerColor = challengerColor.opposite()
|
||||
}
|
||||
|
||||
val engine = ChessEngine()
|
||||
engine.reset()
|
||||
|
||||
val gameState =
|
||||
LiveChessGameState(
|
||||
gameId = gameId,
|
||||
playerPubkey = account.pubKeyHex,
|
||||
opponentPubkey = opponentPubkey,
|
||||
playerColor = playerColor,
|
||||
engine = engine,
|
||||
)
|
||||
|
||||
_activeGames.value = _activeGames.value + (gameId to gameState)
|
||||
_challenges.value = _challenges.value.filter { it.gameId() != gameId }
|
||||
gamesBeingCreated.remove(gameId)
|
||||
|
||||
// Apply any pending moves that arrived before the game was created
|
||||
applyPendingMoves(gameId, gameState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPendingMoves(
|
||||
gameId: String,
|
||||
gameState: LiveChessGameState,
|
||||
) {
|
||||
val moves = pendingMoves.remove(gameId) ?: return
|
||||
|
||||
// Sort by move number if available, then apply in order
|
||||
val sortedMoves = moves.sortedBy { it.third ?: Int.MAX_VALUE }
|
||||
|
||||
for ((san, fen, moveNumber) in sortedMoves) {
|
||||
gameState.applyOpponentMove(san, fen, moveNumber)
|
||||
}
|
||||
|
||||
updateBadgeCount()
|
||||
}
|
||||
|
||||
/**
|
||||
* Make and publish a move
|
||||
*/
|
||||
fun publishMove(
|
||||
gameId: String,
|
||||
from: String,
|
||||
to: String,
|
||||
) {
|
||||
val gameState = _activeGames.value[gameId] ?: return
|
||||
val moveResult = gameState.makeMove(from, to)
|
||||
if (moveResult != null) {
|
||||
publishMoveEvent(gameId, moveResult)
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishMoveEvent(
|
||||
gameId: String,
|
||||
moveEvent: ChessMoveEvent,
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val success =
|
||||
retryWithBackoff {
|
||||
val template =
|
||||
LiveChessMoveEvent.build(
|
||||
gameId = moveEvent.gameId,
|
||||
moveNumber = moveEvent.moveNumber,
|
||||
san = moveEvent.san,
|
||||
fen = moveEvent.fen,
|
||||
opponentPubkey = moveEvent.opponentPubkey,
|
||||
)
|
||||
|
||||
val signedEvent = account.signer.sign(template)
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
_error.value = "Failed to publish move"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resign from a game
|
||||
*/
|
||||
fun resign(gameId: String) {
|
||||
val gameState = _activeGames.value[gameId] ?: return
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val endData = gameState.resign()
|
||||
|
||||
val success =
|
||||
retryWithBackoff {
|
||||
val template =
|
||||
LiveChessGameEndEvent.build(
|
||||
gameId = endData.gameId,
|
||||
result = endData.result,
|
||||
termination = endData.termination,
|
||||
winnerPubkey = endData.winnerPubkey,
|
||||
opponentPubkey = endData.opponentPubkey,
|
||||
pgn = endData.pgn ?: "",
|
||||
)
|
||||
|
||||
val signedEvent = account.signer.sign(template)
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
}
|
||||
|
||||
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
|
||||
_activeGames.value = _activeGames.value - gameId
|
||||
_error.value = null
|
||||
} else {
|
||||
_error.value = "Failed to resign"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer draw - sends a draw offer event that opponent can accept or decline
|
||||
*/
|
||||
fun offerDraw(gameId: String) {
|
||||
val gameState = _activeGames.value[gameId] ?: return
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val drawOffer = gameState.offerDraw()
|
||||
|
||||
val success =
|
||||
retryWithBackoff {
|
||||
val template =
|
||||
LiveChessDrawOfferEvent.build(
|
||||
gameId = drawOffer.gameId,
|
||||
opponentPubkey = drawOffer.opponentPubkey,
|
||||
message = drawOffer.message ?: "",
|
||||
)
|
||||
|
||||
val signedEvent = account.signer.sign(template)
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
_error.value = "Failed to offer draw"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept opponent's draw offer
|
||||
*/
|
||||
fun acceptDraw(gameId: String) {
|
||||
val gameState = _activeGames.value[gameId] ?: return
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val endData = gameState.acceptDraw()
|
||||
if (endData == null) {
|
||||
_error.value = "No draw offer to accept"
|
||||
return@launch
|
||||
}
|
||||
|
||||
val success =
|
||||
retryWithBackoff {
|
||||
val template =
|
||||
LiveChessGameEndEvent.build(
|
||||
gameId = endData.gameId,
|
||||
result = endData.result,
|
||||
termination = endData.termination,
|
||||
winnerPubkey = endData.winnerPubkey,
|
||||
opponentPubkey = endData.opponentPubkey,
|
||||
pgn = endData.pgn ?: "",
|
||||
)
|
||||
|
||||
val signedEvent = account.signer.sign(template)
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
}
|
||||
|
||||
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
|
||||
_activeGames.value = _activeGames.value - gameId
|
||||
_error.value = null
|
||||
} else {
|
||||
_error.value = "Failed to accept draw"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline opponent's draw offer
|
||||
*/
|
||||
fun declineDraw(gameId: String) {
|
||||
val gameState = _activeGames.value[gameId] ?: return
|
||||
gameState.declineDraw()
|
||||
}
|
||||
|
||||
fun selectGame(gameId: String?) {
|
||||
_selectedGameId.value = gameId
|
||||
}
|
||||
|
||||
fun getGameState(gameId: String): LiveChessGameState? = _activeGames.value[gameId]
|
||||
|
||||
fun clearError() {
|
||||
_error.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a refresh of chess data from relays.
|
||||
* Preserves existing game state (including local moves) while refreshing challenges.
|
||||
*/
|
||||
fun refresh() {
|
||||
_isLoading.value = true
|
||||
|
||||
// Clear challenges - they'll be rebuilt from relay events
|
||||
_challenges.value = emptyList()
|
||||
|
||||
// Clear event tracking caches to allow re-processing
|
||||
processedEventIds.clear()
|
||||
challengesByGameId.clear()
|
||||
pendingAccepts.clear()
|
||||
pendingMoves.clear()
|
||||
gamesBeingCreated.clear()
|
||||
|
||||
// DON'T clear _activeGames - preserve existing game state with local moves
|
||||
// Incoming events will be deduplicated by LiveChessGameState.receivedMoveNumbers
|
||||
|
||||
_refreshKey.value++
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when EOSE is received from relay, indicating initial load complete
|
||||
*/
|
||||
fun onLoadComplete() {
|
||||
_isLoading.value = false
|
||||
}
|
||||
|
||||
private fun updateBadgeCount() {
|
||||
val incomingChallenges = _challenges.value.count { it.opponentPubkey() == account.pubKeyHex }
|
||||
val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() }
|
||||
_badgeCount.value = incomingChallenges + yourTurnGames
|
||||
}
|
||||
|
||||
private suspend fun retryWithBackoff(operation: suspend () -> Unit): Boolean {
|
||||
var attempt = 0
|
||||
while (attempt < MAX_RETRIES) {
|
||||
try {
|
||||
operation()
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
attempt++
|
||||
if (attempt < MAX_RETRIES) {
|
||||
delay(RETRY_DELAY_MS * attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun generateGameId(): String {
|
||||
val timestamp = TimeUtils.now()
|
||||
val random = UUID.randomUUID().toString().take(8)
|
||||
return "chess-$timestamp-$random"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a completed chess game for history display
|
||||
*/
|
||||
data class CompletedGame(
|
||||
val gameId: String,
|
||||
val opponentPubkey: String,
|
||||
val playerColor: Color,
|
||||
val result: String,
|
||||
val termination: String,
|
||||
val winnerPubkey: String?,
|
||||
val completedAt: Long,
|
||||
val moveCount: Int,
|
||||
) {
|
||||
fun didWin(playerPubkey: String): Boolean? =
|
||||
when {
|
||||
result == "1/2-1/2" -> null // Draw
|
||||
winnerPubkey == playerPubkey -> true
|
||||
winnerPubkey != null -> false
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun resultText(playerPubkey: String): String =
|
||||
when (didWin(playerPubkey)) {
|
||||
true -> "Won"
|
||||
false -> "Lost"
|
||||
null -> "Draw"
|
||||
}
|
||||
}
|
||||
+33
@@ -315,3 +315,36 @@ fun createFollowingLongFormFeedSubscription(
|
||||
onEose = onEose,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subscription config for chess events (challenges, moves, etc.).
|
||||
* Subscribes to:
|
||||
* - Open challenges (kind 30064)
|
||||
* - Challenges directed at the user
|
||||
* - Events in games the user is playing
|
||||
*
|
||||
* @param userPubkey The user's public key to filter relevant games
|
||||
* @param limit Maximum number of events to request
|
||||
*/
|
||||
fun createChessSubscription(
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
userPubkey: String,
|
||||
limit: Int = 100,
|
||||
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||
): SubscriptionConfig =
|
||||
SubscriptionConfig(
|
||||
subId = generateSubId("chess-${userPubkey.take(8)}"),
|
||||
filters =
|
||||
listOf(
|
||||
// Open challenges and challenges to user
|
||||
FilterBuilders.chessOpenChallenges(limit = limit),
|
||||
// Events where user is tagged (challenges to them, moves in their games)
|
||||
FilterBuilders.chessEventsForUser(userPubkey, limit = limit),
|
||||
// User's own events (their challenges, moves)
|
||||
FilterBuilders.chessEventsByUser(userPubkey, limit = limit),
|
||||
),
|
||||
relays = relays,
|
||||
onEvent = onEvent,
|
||||
onEose = onEose,
|
||||
)
|
||||
|
||||
+97
@@ -200,6 +200,103 @@ object FilterBuilders {
|
||||
ids = ids,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for chess game challenges (kind 30064).
|
||||
* Includes open challenges (no opponent) and challenges directed at a specific user.
|
||||
*
|
||||
* @param userPubkey The user's public key to filter challenges for
|
||||
* @param limit Maximum number of events to request
|
||||
* @param since Timestamp for events with publication time ≥ this value
|
||||
* @return Filter for chess challenges
|
||||
*/
|
||||
fun chessChallengesToUser(
|
||||
userPubkey: String,
|
||||
limit: Int? = 50,
|
||||
since: Long? = null,
|
||||
): Filter =
|
||||
Filter(
|
||||
kinds = listOf(30064), // LiveChessGameChallengeEvent.KIND
|
||||
tags = mapOf("p" to listOf(userPubkey)),
|
||||
limit = limit,
|
||||
since = since,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for open chess challenges (kind 30064, no specific opponent).
|
||||
*
|
||||
* @param limit Maximum number of events to request
|
||||
* @param since Timestamp for events with publication time ≥ this value
|
||||
* @return Filter for open chess challenges
|
||||
*/
|
||||
fun chessOpenChallenges(
|
||||
limit: Int? = 50,
|
||||
since: Long? = null,
|
||||
): Filter =
|
||||
Filter(
|
||||
kinds = listOf(30064), // LiveChessGameChallengeEvent.KIND
|
||||
limit = limit,
|
||||
since = since,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for all chess events (challenges, accepts, moves, ends, draw offers).
|
||||
* Useful for loading the chess lobby.
|
||||
*
|
||||
* @param limit Maximum number of events to request
|
||||
* @param since Timestamp for events with publication time ≥ this value
|
||||
* @return Filter for all chess events
|
||||
*/
|
||||
fun chessAllEvents(
|
||||
limit: Int? = 100,
|
||||
since: Long? = null,
|
||||
): Filter =
|
||||
Filter(
|
||||
kinds = listOf(30064, 30065, 30066, 30067, 30068),
|
||||
limit = limit,
|
||||
since = since,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for chess events involving a specific user.
|
||||
* Includes challenges to/from user, moves in their games, draw offers, etc.
|
||||
*
|
||||
* @param userPubkey The user's public key
|
||||
* @param limit Maximum number of events to request
|
||||
* @param since Timestamp for events with publication time ≥ this value
|
||||
* @return Filter for chess events involving the user
|
||||
*/
|
||||
fun chessEventsForUser(
|
||||
userPubkey: String,
|
||||
limit: Int? = 100,
|
||||
since: Long? = null,
|
||||
): Filter =
|
||||
Filter(
|
||||
kinds = listOf(30064, 30065, 30066, 30067, 30068),
|
||||
tags = mapOf("p" to listOf(userPubkey)),
|
||||
limit = limit,
|
||||
since = since,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for chess events by the user (their own events).
|
||||
*
|
||||
* @param userPubkey The user's public key
|
||||
* @param limit Maximum number of events to request
|
||||
* @param since Timestamp for events with publication time ≥ this value
|
||||
* @return Filter for chess events by the user
|
||||
*/
|
||||
fun chessEventsByUser(
|
||||
userPubkey: String,
|
||||
limit: Int? = 100,
|
||||
since: Long? = null,
|
||||
): Filter =
|
||||
Filter(
|
||||
kinds = listOf(30064, 30065, 30066, 30067, 30068),
|
||||
authors = listOf(userPubkey),
|
||||
limit = limit,
|
||||
since = since,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for events tagged with specific p-tags (mentioning users).
|
||||
*
|
||||
|
||||
+21
@@ -71,6 +71,27 @@ fun createBatchMetadataSubscription(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subscription config for multiple user metadata (kind 0).
|
||||
* Useful for fetching metadata for a batch of users at once.
|
||||
*/
|
||||
fun createMetadataListSubscription(
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
pubKeys: List<String>,
|
||||
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||
): SubscriptionConfig? {
|
||||
if (pubKeys.isEmpty()) return null
|
||||
|
||||
return SubscriptionConfig(
|
||||
subId = generateSubId("meta-batch-${pubKeys.hashCode()}"),
|
||||
filters = listOf(FilterBuilders.userMetadataMultiple(pubKeys)),
|
||||
relays = relays,
|
||||
onEvent = onEvent,
|
||||
onEose = onEose,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subscription config for user posts (kind 1).
|
||||
*/
|
||||
|
||||
@@ -584,7 +584,8 @@ fun FeedScreen(
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(events, key = { it.id }) { event ->
|
||||
// Use distinctBy to prevent duplicate key crashes from events with same ID
|
||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
FeedNoteCard(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
|
||||
+1
-1
@@ -226,7 +226,7 @@ fun NotificationsScreen(
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(notifications, key = { it.event.id }) { notification ->
|
||||
items(notifications.distinctBy { it.event.id }, key = { it.event.id }) { notification ->
|
||||
NotificationCard(notification)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,7 +423,7 @@ fun ThreadScreen(
|
||||
}
|
||||
|
||||
// Reply notes with level indicators
|
||||
items(replyEvents, key = { it.id }) { event ->
|
||||
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
val level = calculateLevel(event)
|
||||
|
||||
Column(
|
||||
|
||||
+1
-1
@@ -569,7 +569,7 @@ fun UserProfileScreen(
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(events, key = { it.id }) { event ->
|
||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
FeedNoteCard(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
|
||||
@@ -124,7 +124,9 @@ kotlin {
|
||||
implementation(libs.okhttpCoroutines)
|
||||
|
||||
// Chess engine for move validation and legal move generation
|
||||
implementation("io.github.cvb941:kchesslib:1.0.4")
|
||||
// NOTE: 1.0.4+ uses Java 21's removeLast() which crashes on Android API < 34
|
||||
// TODO: Test if 1.0.0 works, or fork library to fix
|
||||
implementation("io.github.cvb941:kchesslib:1.0.0")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,27 @@ data class ChessGameEnd(
|
||||
val pgn: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Draw offer event - offer a draw to opponent
|
||||
* Kind: 30068
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id
|
||||
* - p: opponent pubkey
|
||||
*
|
||||
* Content: Optional message
|
||||
*
|
||||
* Opponent can:
|
||||
* - Accept by sending a GameEnd event with DRAW_AGREEMENT
|
||||
* - Decline implicitly by making their next move
|
||||
* - Decline explicitly (optional, no event needed)
|
||||
*/
|
||||
data class ChessDrawOffer(
|
||||
val gameId: String,
|
||||
val opponentPubkey: String,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Game termination reason
|
||||
*/
|
||||
@@ -132,4 +153,5 @@ object LiveChessEventKinds {
|
||||
const val GAME_ACCEPT = 30065
|
||||
const val CHESS_MOVE = 30066
|
||||
const val GAME_END = 30067
|
||||
const val DRAW_OFFER = 30068
|
||||
}
|
||||
|
||||
+45
@@ -247,3 +247,48 @@ class LiveChessGameEndEvent(
|
||||
|
||||
fun pgn(): String = content
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Chess Draw Offer Event (Kind 30068)
|
||||
*
|
||||
* Offer a draw to opponent. Opponent can accept by sending a game end event
|
||||
* with DRAW_AGREEMENT termination, or decline/ignore by making their next move.
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id
|
||||
* - p: opponent pubkey
|
||||
*
|
||||
* Content: Optional message
|
||||
*/
|
||||
@Immutable
|
||||
class LiveChessDrawOfferEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
companion object {
|
||||
const val KIND = 30068
|
||||
|
||||
fun build(
|
||||
gameId: String,
|
||||
opponentPubkey: String,
|
||||
message: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<LiveChessDrawOfferEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, message, createdAt) {
|
||||
add(arrayOf("d", gameId))
|
||||
add(arrayOf("p", opponentPubkey))
|
||||
alt("Chess draw offer")
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
|
||||
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
|
||||
|
||||
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
|
||||
|
||||
fun message(): String = content
|
||||
}
|
||||
|
||||
+186
-5
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -38,19 +39,57 @@ class LiveChessGameState(
|
||||
val opponentPubkey: String,
|
||||
val playerColor: Color,
|
||||
val engine: ChessEngine,
|
||||
val createdAt: Long = TimeUtils.now(),
|
||||
) {
|
||||
companion object {
|
||||
// Abandon timeout: 24 hours without a move
|
||||
const val ABANDON_TIMEOUT_SECONDS = 24 * 60 * 60L
|
||||
|
||||
// Inactivity warning: 1 hour without a move
|
||||
const val INACTIVITY_WARNING_SECONDS = 60 * 60L
|
||||
}
|
||||
|
||||
private val _gameStatus = MutableStateFlow<GameStatus>(GameStatus.InProgress)
|
||||
val gameStatus: StateFlow<GameStatus> = _gameStatus.asStateFlow()
|
||||
|
||||
private val _currentPosition = MutableStateFlow(engine.getPosition())
|
||||
val currentPosition: StateFlow<ChessPosition> = _currentPosition.asStateFlow()
|
||||
|
||||
private val _moveHistory = MutableStateFlow<List<String>>(emptyList())
|
||||
// Initialize from engine's history so freshly loaded games show all moves
|
||||
private val _moveHistory = MutableStateFlow(engine.getMoveHistory())
|
||||
val moveHistory: StateFlow<List<String>> = _moveHistory.asStateFlow()
|
||||
|
||||
private val _lastError = MutableStateFlow<String?>(null)
|
||||
val lastError: StateFlow<String?> = _lastError.asStateFlow()
|
||||
|
||||
// Track when last activity occurred (move made or received)
|
||||
private val _lastActivityAt = MutableStateFlow(createdAt)
|
||||
val lastActivityAt: StateFlow<Long> = _lastActivityAt.asStateFlow()
|
||||
|
||||
// Track received move numbers to detect duplicates and out-of-order
|
||||
private val receivedMoveNumbers = mutableSetOf<Int>()
|
||||
|
||||
// Pending moves waiting for earlier moves (move number -> move data)
|
||||
private val pendingMoves = mutableMapOf<Int, Pair<String, String>>()
|
||||
|
||||
// Desync detection
|
||||
private val _isDesynced = MutableStateFlow(false)
|
||||
val isDesynced: StateFlow<Boolean> = _isDesynced.asStateFlow()
|
||||
|
||||
// Draw offer tracking - pubkey of who offered the draw (null = no pending offer)
|
||||
private val _pendingDrawOffer = MutableStateFlow<String?>(null)
|
||||
val pendingDrawOffer: StateFlow<String?> = _pendingDrawOffer.asStateFlow()
|
||||
|
||||
/**
|
||||
* Check if there's a pending draw offer from opponent
|
||||
*/
|
||||
fun hasOpponentDrawOffer(): Boolean = _pendingDrawOffer.value == opponentPubkey
|
||||
|
||||
/**
|
||||
* Check if we have an outgoing draw offer
|
||||
*/
|
||||
fun hasOurDrawOffer(): Boolean = _pendingDrawOffer.value == playerPubkey
|
||||
|
||||
/**
|
||||
* Check if it's the player's turn
|
||||
*/
|
||||
@@ -80,8 +119,12 @@ class LiveChessGameState(
|
||||
if (result.success && result.san != null && result.position != null) {
|
||||
_currentPosition.value = result.position
|
||||
_moveHistory.value = engine.getMoveHistory()
|
||||
_lastActivityAt.value = TimeUtils.now()
|
||||
_lastError.value = null
|
||||
|
||||
// Making a move implicitly declines any pending draw offer
|
||||
_pendingDrawOffer.value = null
|
||||
|
||||
// Check for game end conditions
|
||||
checkGameEnd()
|
||||
|
||||
@@ -105,7 +148,22 @@ class LiveChessGameState(
|
||||
fun applyOpponentMove(
|
||||
san: String,
|
||||
fen: String,
|
||||
moveNumber: Int? = null,
|
||||
): Boolean {
|
||||
// Check for duplicate move
|
||||
if (moveNumber != null && receivedMoveNumbers.contains(moveNumber)) {
|
||||
// Already processed this move, ignore
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for out-of-order move
|
||||
val expectedMoveNumber = _moveHistory.value.size + 1
|
||||
if (moveNumber != null && moveNumber > expectedMoveNumber) {
|
||||
// Store for later processing
|
||||
pendingMoves[moveNumber] = san to fen
|
||||
return true
|
||||
}
|
||||
|
||||
if (isPlayerTurn()) {
|
||||
_lastError.value = "Received move but it's not opponent's turn"
|
||||
return false
|
||||
@@ -115,22 +173,37 @@ class LiveChessGameState(
|
||||
val result = engine.makeMove(san)
|
||||
|
||||
if (result.success) {
|
||||
// Mark as received
|
||||
if (moveNumber != null) {
|
||||
receivedMoveNumbers.add(moveNumber)
|
||||
}
|
||||
|
||||
// Verify the resulting FEN matches what opponent sent
|
||||
val currentFen = engine.getFen()
|
||||
if (currentFen != fen) {
|
||||
// Positions don't match - possible desync
|
||||
_lastError.value = "Position mismatch after opponent move"
|
||||
// Positions don't match - desync detected
|
||||
_isDesynced.value = true
|
||||
_lastError.value = "Position mismatch - syncing to opponent's position"
|
||||
// Load the opponent's FEN to stay in sync
|
||||
engine.loadFen(fen)
|
||||
} else {
|
||||
_isDesynced.value = false
|
||||
}
|
||||
|
||||
_currentPosition.value = engine.getPosition()
|
||||
_moveHistory.value = engine.getMoveHistory()
|
||||
_lastActivityAt.value = TimeUtils.now()
|
||||
_lastError.value = null
|
||||
|
||||
// Opponent making a move declines any pending draw offer
|
||||
_pendingDrawOffer.value = null
|
||||
|
||||
// Check for game end conditions
|
||||
checkGameEnd()
|
||||
|
||||
// Process any pending moves that are now valid
|
||||
processPendingMoves()
|
||||
|
||||
return true
|
||||
} else {
|
||||
_lastError.value = "Invalid opponent move: $san"
|
||||
@@ -138,6 +211,79 @@ class LiveChessGameState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process any pending out-of-order moves
|
||||
*/
|
||||
private fun processPendingMoves() {
|
||||
val nextMoveNumber = _moveHistory.value.size + 1
|
||||
val pendingMove = pendingMoves.remove(nextMoveNumber)
|
||||
if (pendingMove != null) {
|
||||
val (san, fen) = pendingMove
|
||||
applyOpponentMove(san, fen, nextMoveNumber)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force resync to a specific FEN (manual recovery)
|
||||
*/
|
||||
fun forceResync(fen: String) {
|
||||
engine.loadFen(fen)
|
||||
_currentPosition.value = engine.getPosition()
|
||||
_moveHistory.value = engine.getMoveHistory()
|
||||
_isDesynced.value = false
|
||||
_lastError.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark move numbers as already received.
|
||||
* Used when loading a game from cache to prevent duplicate move application during refresh.
|
||||
*
|
||||
* @param moveNumbers Set of move numbers that have been loaded
|
||||
*/
|
||||
fun markMovesAsReceived(moveNumbers: Set<Int>) {
|
||||
receivedMoveNumbers.addAll(moveNumbers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if game appears abandoned (opponent hasn't moved in a long time)
|
||||
*/
|
||||
fun isAbandoned(): Boolean {
|
||||
if (_gameStatus.value != GameStatus.InProgress) return false
|
||||
if (isPlayerTurn()) return false // Only check when waiting for opponent
|
||||
|
||||
val elapsed = TimeUtils.now() - _lastActivityAt.value
|
||||
return elapsed > ABANDON_TIMEOUT_SECONDS
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if opponent is inactive (warning threshold)
|
||||
*/
|
||||
fun isOpponentInactive(): Boolean {
|
||||
if (_gameStatus.value != GameStatus.InProgress) return false
|
||||
if (isPlayerTurn()) return false
|
||||
|
||||
val elapsed = TimeUtils.now() - _lastActivityAt.value
|
||||
return elapsed > INACTIVITY_WARNING_SECONDS
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim victory due to abandonment
|
||||
*/
|
||||
fun claimAbandonmentVictory(): ChessGameEnd? {
|
||||
if (!isAbandoned()) return null
|
||||
|
||||
_gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor))
|
||||
|
||||
return ChessGameEnd(
|
||||
gameId = gameId,
|
||||
result = GameResult.getResultForWinner(playerColor),
|
||||
termination = GameTermination.ABANDONMENT,
|
||||
winnerPubkey = playerPubkey,
|
||||
opponentPubkey = opponentPubkey,
|
||||
pgn = generatePGN(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer resignation (player resigns)
|
||||
*/
|
||||
@@ -155,9 +301,37 @@ class LiveChessGameState(
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer or accept draw
|
||||
* Offer a draw to opponent.
|
||||
* Returns the draw offer data to be published to Nostr.
|
||||
*/
|
||||
fun offerDraw(): ChessGameEnd {
|
||||
fun offerDraw(): ChessDrawOffer {
|
||||
_pendingDrawOffer.value = playerPubkey
|
||||
return ChessDrawOffer(
|
||||
gameId = gameId,
|
||||
opponentPubkey = opponentPubkey,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle receiving a draw offer from opponent (via Nostr event)
|
||||
*/
|
||||
fun receiveDrawOffer(fromPubkey: String): Boolean {
|
||||
if (fromPubkey != opponentPubkey) return false
|
||||
_pendingDrawOffer.value = opponentPubkey
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept opponent's draw offer.
|
||||
* Returns game end data to be published to Nostr.
|
||||
*/
|
||||
fun acceptDraw(): ChessGameEnd? {
|
||||
if (_pendingDrawOffer.value != opponentPubkey) {
|
||||
_lastError.value = "No draw offer to accept"
|
||||
return null
|
||||
}
|
||||
|
||||
_pendingDrawOffer.value = null
|
||||
_gameStatus.value = GameStatus.Finished(GameResult.DRAW)
|
||||
|
||||
return ChessGameEnd(
|
||||
@@ -170,6 +344,13 @@ class LiveChessGameState(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline a draw offer (explicit decline, also happens implicitly on move)
|
||||
*/
|
||||
fun declineDraw() {
|
||||
_pendingDrawOffer.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if game has ended (checkmate, stalemate, etc.)
|
||||
*/
|
||||
|
||||
+2
-1
@@ -112,6 +112,7 @@ actual class ChessEngine {
|
||||
val sq = Square.fromValue(square.uppercase())
|
||||
return board
|
||||
.legalMoves()
|
||||
.filterNotNull()
|
||||
.filter { it.from == sq }
|
||||
.map { it.to.toString().lowercase() }
|
||||
}
|
||||
@@ -173,7 +174,7 @@ actual class ChessEngine {
|
||||
|
||||
actual fun getMoveHistory(): List<String> {
|
||||
// kchesslib stores move history
|
||||
return board.backup.map { it.move.toString() }
|
||||
return board.backup.mapNotNull { it?.move?.toString() }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user