From 06accf5831fee93c7a4d7d332f9482ec2f27b25e Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 29 Dec 2025 13:29:10 +0200 Subject: [PATCH] full chess implementation --- .../amethyst/model/nip64Chess/ChessAction.kt | 146 ++++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../amethyst/ui/note/NoteCompose.kt | 20 + .../amethyst/ui/note/types/Chess.kt | 200 ++++++++ .../amethyst/ui/screen/TopNavFilterState.kt | 22 +- .../screen/loggedIn/chess/ChessGameScreen.kt | 112 +++++ .../screen/loggedIn/chess/ChessViewModel.kt | 440 ++++++++++++++++++ .../loggedIn/chess/ChessViewModelFactory.kt | 40 ++ .../ui/screen/loggedIn/home/HomeScreen.kt | 31 +- .../loggedIn/home/NewChessGameButton.kt | 76 +++ amethyst/src/main/res/values/strings.xml | 1 + .../commons/chess/InteractiveChessBoard.kt | 233 ++++++++++ .../amethyst/commons/chess/LiveChessGame.kt | 309 ++++++++++++ docs/live-chess-implementation-status.md | 297 ++++++++++++ quartz/build.gradle.kts | 3 + .../quartz/nip64Chess/ChessEngine.kt | 139 ++++++ .../quartz/nip64Chess/LiveChessEvents.kt | 135 ++++++ .../quartz/nip64Chess/LiveChessGameEvents.kt | 249 ++++++++++ .../quartz/nip64Chess/LiveChessGameState.kt | 255 ++++++++++ .../quartz/utils/EventFactory.kt | 8 + .../nip64Chess/ChessEngine.jvmAndroid.kt | 229 +++++++++ 22 files changed, 2936 insertions(+), 15 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt create mode 100644 commons/src/main/java/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt create mode 100644 commons/src/main/java/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt create mode 100644 docs/live-chess-implementation-status.md create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt new file mode 100644 index 000000000..15f472440 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt @@ -0,0 +1,146 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip64Chess + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.nip64Chess.GameTermination +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent + +/** + * Action class for creating and signing live chess events + */ +class ChessAction { + companion object { + /** + * Create a new chess game challenge + * + * @param gameId Unique identifier for the game + * @param playerColor Color the challenger wants to play + * @param opponentPubkey Optional opponent pubkey (null = open challenge) + * @param timeControl Optional time control (e.g., "10+0") + * @param signer Nostr signer + */ + suspend fun createChallenge( + gameId: String, + playerColor: Color, + opponentPubkey: String? = null, + timeControl: String? = null, + signer: NostrSigner, + ): LiveChessGameChallengeEvent = + signer.sign( + LiveChessGameChallengeEvent.build( + gameId = gameId, + playerColor = playerColor, + opponentPubkey = opponentPubkey, + timeControl = timeControl, + ), + ) + + /** + * Accept a chess game challenge + * + * @param gameId Game identifier from the challenge + * @param challengeEventId Event ID of the challenge + * @param challengerPubkey Pubkey of the challenger + * @param signer Nostr signer + */ + suspend fun acceptChallenge( + gameId: String, + challengeEventId: String, + challengerPubkey: String, + signer: NostrSigner, + ): LiveChessGameAcceptEvent = + signer.sign( + LiveChessGameAcceptEvent.build( + gameId = gameId, + challengeEventId = challengeEventId, + challengerPubkey = challengerPubkey, + ), + ) + + /** + * Publish a move in a live chess game + * + * @param gameId Game identifier + * @param moveNumber Move number (1-based) + * @param san Move in Standard Algebraic Notation + * @param fen Resulting position in FEN notation + * @param opponentPubkey Opponent's pubkey + * @param comment Optional move comment + * @param signer Nostr signer + */ + suspend fun publishMove( + gameId: String, + moveNumber: Int, + san: String, + fen: String, + opponentPubkey: String, + comment: String = "", + signer: NostrSigner, + ): LiveChessMoveEvent = + signer.sign( + LiveChessMoveEvent.build( + gameId = gameId, + moveNumber = moveNumber, + san = san, + fen = fen, + opponentPubkey = opponentPubkey, + comment = comment, + ), + ) + + /** + * End a chess game and publish result + * + * @param gameId Game identifier + * @param result Game result (1-0, 0-1, or 1/2-1/2) + * @param termination How the game ended + * @param winnerPubkey Pubkey of winner (if applicable) + * @param opponentPubkey Opponent's pubkey + * @param pgn Optional PGN of complete game + * @param signer Nostr signer + */ + suspend fun endGame( + gameId: String, + result: GameResult, + termination: GameTermination, + winnerPubkey: String? = null, + opponentPubkey: String, + pgn: String = "", + signer: NostrSigner, + ): LiveChessGameEndEvent = + signer.sign( + LiveChessGameEndEvent.build( + gameId = gameId, + result = result, + termination = termination, + winnerPubkey = winnerPubkey, + opponentPubkey = opponentPubkey, + pgn = pgn, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 78ff36813..7d053fe16 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -74,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen 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.communities.CommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen @@ -174,6 +175,7 @@ fun AppNavigation( composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } + composableFromEndArgs { ChessGameScreen(it.gameId, accountViewModel, nav) } composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 6cffc431e..8c98e47e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -137,6 +137,10 @@ sealed class Route { val geohash: String, ) : Route() + @Serializable data class ChessGame( + val gameId: String, + ) : Route() + @Serializable data class Community( val kind: Int, val pubKeyHex: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 71e305a6b..bf8b7a221 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -115,6 +115,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderLiveChessChallenge +import com.vitorpamplona.amethyst.ui.note.types.RenderLiveChessGameEnd import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status @@ -211,6 +213,8 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -921,6 +925,22 @@ private fun RenderNoteRow( nav, ) } + is LiveChessGameChallengeEvent -> { + RenderLiveChessChallenge( + baseNote, + backgroundColor, + accountViewModel, + nav, + ) + } + is LiveChessGameEndEvent -> { + RenderLiveChessGameEnd( + baseNote, + backgroundColor, + accountViewModel, + nav, + ) + } is ClassifiedsEvent -> { RenderClassifieds( noteEvent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt index f14a4a2f8..8927c0793 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -20,15 +20,38 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +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 androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.chess.ChessGameViewer import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning 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.amethyst.ui.screen.loggedIn.chess.ChessViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent /** * Render NIP-64 Chess Game event (Kind 64) @@ -54,3 +77,180 @@ fun RenderChessGame( ChessGameViewer(pgnContent = event.pgn()) } } + +/** + * Render Live Chess Challenge event (Kind 30064) + * + * Shows a challenge card with Accept/Decline actions for incoming challenges + * or status for sent challenges + */ +@Composable +fun RenderLiveChessChallenge( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = (note.event as? LiveChessGameChallengeEvent) ?: return + val gameId = event.gameId() ?: return + + val chessViewModel: ChessViewModel = + viewModel( + key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}", + factory = ChessViewModelFactory(accountViewModel.account), + ) + + val isOpenChallenge = event.opponentPubkey() == null + val isIncomingChallenge = event.opponentPubkey() == accountViewModel.account.userProfile().pubkeyHex + + val borderColor = + when { + isOpenChallenge -> Color(0xFF4CAF50) // Green for open + isIncomingChallenge -> Color(0xFFFFA726) // Orange for incoming + else -> MaterialTheme.colorScheme.outline // Gray for sent + } + + val icon = + when { + isOpenChallenge -> "🔓" + isIncomingChallenge -> "💌" + else -> "⏳" + } + + val title = + when { + isOpenChallenge -> "Open Challenge" + isIncomingChallenge -> "Challenge from ${note.author?.toBestDisplayName()}" + else -> "Challenge sent to ${event.opponentPubkey()?.take(8)}" + } + + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .border(2.dp, borderColor, MaterialTheme.shapes.medium), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "$icon $title", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = + if (event.playerColor()?.name == "WHITE") "White" else "Black", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + event.timeControl()?.let { + Text( + text = "Time: $it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Action buttons + if (isIncomingChallenge || isOpenChallenge) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton(onClick = { /* TODO: Decline */ }) { + Text("Decline") + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + // Accept challenge and navigate to game + val challengerPubkey = note.author?.pubkeyHex ?: return@Button + val playerColor = + event.playerColor()?.opposite() ?: com.vitorpamplona.quartz.nip64Chess.Color.WHITE + + chessViewModel.acceptChallenge( + challengeEventId = note.idHex, + gameId = gameId, + challengerPubkey = challengerPubkey, + playerColor = playerColor, + ) + + // Navigate to game + nav.nav(Route.ChessGame(gameId)) + }, + ) { + Text("Accept") + } + } + } + } + } +} + +/** + * Render Live Chess Game End event (Kind 30067) + * + * Shows final game result with PGN viewer + */ +@Composable +fun RenderLiveChessGameEnd( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = (note.event as? LiveChessGameEndEvent) ?: return + + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Result header + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "🏆 Game Ended", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Result: ${event.result()}", + style = MaterialTheme.typography.bodyMedium, + ) + event.termination()?.let { + Text( + text = "By: ${it.replace("_", " ")}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + + // Show PGN if available + event.pgn().takeIf { it.isNotBlank() }?.let { pgn -> + ChessGameViewer(pgnContent = pgn) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 8c3170732..ea1ead2ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -54,6 +54,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -128,7 +130,21 @@ class TopNavFilterState( unpackList = listOf(MuteListEvent.blockListFor(account.userProfile().pubkeyHex)), ) - val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow) + val chessFollow = + GlobalFeedDefinition( + code = CHESS, + name = ResourceName(R.string.follow_list_chess), + type = CodeNameType.HARDCODED, + kinds = + listOf( + ChessGameEvent.KIND, // Completed games (Kind 64) + LiveChessGameChallengeEvent.KIND, // Challenges (Kind 30064) + LiveChessGameEndEvent.KIND, // Game endings (Kind 30067) + // Note: LiveChessMoveEvent (Kind 30066) intentionally excluded - too noisy + ), + ) + + val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow, muteListFollow) fun mergePeopleLists( peopleLists: List, @@ -242,7 +258,7 @@ class TopNavFilterState( checkNotInMainThread() emit( listOf( - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow), myLivePeopleListsFlow, myLiveKind3FollowsFlow, listOf(muteListFollow), @@ -258,7 +274,7 @@ class TopNavFilterState( checkNotInMainThread() emit( listOf( - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow), myLivePeopleListsFlow, listOf(muteListFollow), ).flatten().toImmutableList(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt new file mode 100644 index 000000000..7a5a41cf3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt @@ -0,0 +1,112 @@ +/** + * 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.layout.Arrangement +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.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.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 + +/** + * Wrapper screen for live chess game + * + * Connects ChessViewModel to LiveChessGameScreen UI component + * + * @param gameId Unique game identifier + * @param accountViewModel Account view model + * @param nav Navigation interface + */ +@Composable +fun ChessGameScreen( + gameId: String, + 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 gameState = activeGames[gameId] + + Scaffold { paddingValues -> + if (gameState == null) { + // Game not found - show error + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "Game Not Found", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "This game may have ended or the ID is incorrect.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + // Show game + LiveChessGameScreen( + engine = gameState.engine, + playerColor = gameState.playerColor, + gameId = gameState.gameId, + opponentName = gameState.opponentPubkey.take(8), // TODO: Resolve to display name + onMoveMade = { from, to, san -> + chessViewModel.publishMove(gameId, from, to) + }, + onResign = { chessViewModel.resign(gameId) }, + onOfferDraw = { chessViewModel.offerDraw(gameId) }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModel.kt new file mode 100644 index 000000000..c26044413 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModel.kt @@ -0,0 +1,440 @@ +/** + * 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.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.nip64Chess.ChessAction +import com.vitorpamplona.quartz.nip64Chess.ChessEngine +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.nip64Chess.GameTermination +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.UUID + +/** + * ViewModel for managing live chess games + * + * Coordinates between: + * - LiveChessGameState (game logic) + * - Nostr relay (event publishing/subscriptions) + * - UI (game state updates) + */ +@Stable +class ChessViewModel( + private val account: Account, +) : ViewModel() { + private val _activeGames = MutableStateFlow>(emptyMap()) + val activeGames: StateFlow> = _activeGames.asStateFlow() + + private val _badgeCount = MutableStateFlow(0) + val badgeCount: StateFlow = _badgeCount.asStateFlow() + + init { + Log.d("Init", "Starting new ChessViewModel") + } + + /** + * Get a specific game by ID + */ + fun getGame(gameId: String): LiveChessGameState? = _activeGames.value[gameId] + + /** + * Create a new chess game challenge + * + * @param opponentPubkey Opponent's pubkey (null for open challenge) + * @param playerColor Color the player wants to play + * @param timeControl Optional time control + */ + fun createChallenge( + opponentPubkey: String?, + playerColor: Color, + timeControl: String? = null, + ) { + viewModelScope.launch(Dispatchers.IO) { + try { + val gameId = UUID.randomUUID().toString() + + // Create and sign challenge event + val challengeEvent = + ChessAction.createChallenge( + gameId = gameId, + playerColor = playerColor, + opponentPubkey = opponentPubkey, + timeControl = timeControl, + signer = account.signer, + ) + + // Send to relays + account.client.send(challengeEvent, account.outboxRelays.flow.value) + + // Cache locally + account.cache.justConsumeMyOwnEvent(challengeEvent) + + Log.d("Chess", "Challenge created: $gameId") + } catch (e: Exception) { + Log.e("Chess", "Failed to create challenge", e) + } + } + } + + /** + * Accept an incoming challenge and create game state + * + * @param challengeEventId Event ID of the challenge + * @param gameId Game identifier from challenge + * @param challengerPubkey Challenger's pubkey + * @param playerColor Color the accepting player will be + */ + fun acceptChallenge( + challengeEventId: String, + gameId: String, + challengerPubkey: String, + playerColor: Color, + ) { + viewModelScope.launch(Dispatchers.IO) { + try { + // Create and sign acceptance event + val acceptEvent = + ChessAction.acceptChallenge( + gameId = gameId, + challengeEventId = challengeEventId, + challengerPubkey = challengerPubkey, + signer = account.signer, + ) + + // Send to relays + account.client.send(acceptEvent, account.outboxRelays.flow.value) + + // Cache locally + account.cache.justConsumeMyOwnEvent(acceptEvent) + + // Create game state + createGameState(gameId, challengerPubkey, playerColor) + + Log.d("Chess", "Challenge accepted: $gameId") + } catch (e: Exception) { + Log.e("Chess", "Failed to accept challenge", e) + } + } + } + + /** + * Create a new game state (called when challenge is accepted) + */ + private fun createGameState( + gameId: String, + opponentPubkey: String, + playerColor: Color, + ) { + val engine = ChessEngine() + engine.reset() + + val gameState = + LiveChessGameState( + gameId = gameId, + playerPubkey = account.signer.pubKey, + opponentPubkey = opponentPubkey, + playerColor = playerColor, + engine = engine, + ) + + _activeGames.value = _activeGames.value + (gameId to gameState) + + // Subscribe to moves for this game + // TODO: Implement relay subscription for Kind 30066 with d tag = gameId + // This would listen for opponent's moves + + updateBadgeCount() + } + + /** + * Publish a move in an active game + * + * @param gameId Game identifier + * @param from Source square (e.g., "e2") + * @param to Destination square (e.g., "e4") + * @param promotion Optional promotion piece type + */ + fun publishMove( + gameId: String, + from: String, + to: String, + promotion: com.vitorpamplona.quartz.nip64Chess.PieceType? = null, + ) { + viewModelScope.launch(Dispatchers.IO) { + try { + val gameState = _activeGames.value[gameId] ?: return@launch + + // Make move and get event to publish + val moveEvent = gameState.makeMove(from, to, promotion) ?: return@launch + + // Sign and send move event + val signedMoveEvent = + ChessAction.publishMove( + gameId = moveEvent.gameId, + moveNumber = moveEvent.moveNumber, + san = moveEvent.san, + fen = moveEvent.fen, + opponentPubkey = moveEvent.opponentPubkey, + comment = moveEvent.comment ?: "", + signer = account.signer, + ) + + account.client.send(signedMoveEvent, account.outboxRelays.flow.value) + account.cache.justConsumeMyOwnEvent(signedMoveEvent) + + // Check if game ended + checkAndPublishGameEnd(gameId) + + Log.d("Chess", "Move published: ${moveEvent.san}") + } catch (e: Exception) { + Log.e("Chess", "Failed to publish move", e) + } + } + } + + /** + * Handle incoming opponent move + */ + fun handleOpponentMove(moveEvent: LiveChessMoveEvent) { + viewModelScope.launch(Dispatchers.IO) { + try { + val gameId = moveEvent.gameId() ?: return@launch + val gameState = _activeGames.value[gameId] ?: return@launch + + val san = moveEvent.san() ?: return@launch + val fen = moveEvent.fen() ?: return@launch + + gameState.applyOpponentMove(san, fen) + + // Update badge count (it's now your turn) + updateBadgeCount() + + Log.d("Chess", "Opponent move applied: $san") + } catch (e: Exception) { + Log.e("Chess", "Failed to apply opponent move", e) + } + } + } + + /** + * Resign from a game + */ + fun resign(gameId: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + val gameState = _activeGames.value[gameId] ?: return@launch + + val endEvent = gameState.resign() + + val signedEndEvent = + ChessAction.endGame( + gameId = endEvent.gameId, + result = endEvent.result, + termination = endEvent.termination, + winnerPubkey = endEvent.winnerPubkey, + opponentPubkey = endEvent.opponentPubkey, + pgn = endEvent.pgn ?: "", + signer = account.signer, + ) + + account.client.send(signedEndEvent, account.outboxRelays.flow.value) + account.cache.justConsumeMyOwnEvent(signedEndEvent) + + // Remove from active games + _activeGames.value = _activeGames.value - gameId + + updateBadgeCount() + + Log.d("Chess", "Game resigned: $gameId") + } catch (e: Exception) { + Log.e("Chess", "Failed to resign", e) + } + } + } + + /** + * Offer or accept a draw + */ + fun offerDraw(gameId: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + val gameState = _activeGames.value[gameId] ?: return@launch + + val endEvent = gameState.offerDraw() + + val signedEndEvent = + ChessAction.endGame( + gameId = endEvent.gameId, + result = endEvent.result, + termination = endEvent.termination, + winnerPubkey = null, + opponentPubkey = endEvent.opponentPubkey, + pgn = endEvent.pgn ?: "", + signer = account.signer, + ) + + account.client.send(signedEndEvent, account.outboxRelays.flow.value) + account.cache.justConsumeMyOwnEvent(signedEndEvent) + + // Remove from active games + _activeGames.value = _activeGames.value - gameId + + updateBadgeCount() + + Log.d("Chess", "Draw offered/accepted: $gameId") + } catch (e: Exception) { + Log.e("Chess", "Failed to offer draw", e) + } + } + } + + /** + * Check if game ended (checkmate/stalemate) and publish result + */ + private fun checkAndPublishGameEnd(gameId: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + val gameState = _activeGames.value[gameId] ?: return@launch + + if (gameState.gameStatus.value !is com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished) { + return@launch + } + + val finishedStatus = + gameState.gameStatus.value as com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished + + val termination = + when { + gameState.engine.isCheckmate() -> GameTermination.CHECKMATE + gameState.engine.isStalemate() -> GameTermination.STALEMATE + else -> GameTermination.DRAW_AGREEMENT + } + + val winnerPubkey = + when (finishedStatus.result) { + GameResult.WHITE_WINS -> + if (gameState.playerColor == Color.WHITE) { + gameState.playerPubkey + } else { + gameState.opponentPubkey + } + GameResult.BLACK_WINS -> + if (gameState.playerColor == Color.BLACK) { + gameState.playerPubkey + } else { + gameState.opponentPubkey + } + GameResult.DRAW -> null + GameResult.IN_PROGRESS -> null + } + + // Generate PGN from move history + val pgn = buildPGN(gameState, finishedStatus.result) + + val signedEndEvent = + ChessAction.endGame( + gameId = gameId, + result = finishedStatus.result, + termination = termination, + winnerPubkey = winnerPubkey, + opponentPubkey = gameState.opponentPubkey, + pgn = pgn, + signer = account.signer, + ) + + account.client.send(signedEndEvent, account.outboxRelays.flow.value) + account.cache.justConsumeMyOwnEvent(signedEndEvent) + + // Remove from active games + _activeGames.value = _activeGames.value - gameId + + updateBadgeCount() + + Log.d("Chess", "Game ended automatically: $gameId") + } catch (e: Exception) { + Log.e("Chess", "Failed to publish game end", e) + } + } + } + + /** + * Update badge count (incoming challenges + your turn games) + */ + private fun updateBadgeCount() { + val yourTurnCount = + _activeGames.value.values.count { gameState -> + gameState.isPlayerTurn() + } + + // TODO: Add incoming challenges count + // For now, just count "your turn" games + + _badgeCount.value = yourTurnCount + } + + /** + * Build PGN from game state + */ + private fun buildPGN( + gameState: LiveChessGameState, + result: GameResult, + ): String { + val moves = gameState.moveHistory.value + val movePairs = moves.chunked(2) + + val moveText = + movePairs + .mapIndexed { index, pair -> + val moveNum = index + 1 + when (pair.size) { + 2 -> "$moveNum. ${pair[0]} ${pair[1]}" + 1 -> "$moveNum. ${pair[0]}" + else -> "" + } + }.joinToString(" ") + + return """ + [Event "Live Chess Game"] + [Site "Nostr"] + [White "${if (gameState.playerColor == Color.WHITE) gameState.playerPubkey else gameState.opponentPubkey}"] + [Black "${if (gameState.playerColor == Color.BLACK) gameState.playerPubkey else gameState.opponentPubkey}"] + [Result "${result.notation}"] + + $moveText ${result.notation} + """.trimIndent() + } + + override fun onCleared() { + Log.d("Init", "OnCleared: ChessViewModel") + super.onCleared() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt new file mode 100644 index 000000000..0fc24d44f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account + +/** + * Factory for creating ChessViewModel instances + */ +class ChessViewModelFactory( + private val account: Account, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(ChessViewModel::class.java)) { + return ChessViewModel(account) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 06fca2a82..34573d217 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActiviti import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.model.AROUND_ME +import com.vitorpamplona.amethyst.model.CHESS import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -234,21 +235,27 @@ fun HomeScreenFloatingButton( accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - if (list.value == AROUND_ME) { - val location by Amethyst.instance.locationManager.geohashStateFlow - .collectAsStateWithLifecycle() + when (list.value) { + AROUND_ME -> { + val location by Amethyst.instance.locationManager.geohashStateFlow + .collectAsStateWithLifecycle() - when (val myLocation = location) { - is LocationState.LocationResult.Success -> { - NewGeoPostButton(myLocation.geoHash.toString(), accountViewModel, nav) + when (val myLocation = location) { + is LocationState.LocationResult.Success -> { + NewGeoPostButton(myLocation.geoHash.toString(), accountViewModel, nav) + } + + is LocationState.LocationResult.LackPermission -> { } + + is LocationState.LocationResult.Loading -> { } } - - is LocationState.LocationResult.LackPermission -> { } - - is LocationState.LocationResult.Loading -> { } } - } else { - NewNoteButton(nav) + CHESS -> { + NewChessGameButton(accountViewModel, nav) + } + else -> { + NewNoteButton(nav) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt new file mode 100644 index 000000000..b56431d64 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt @@ -0,0 +1,76 @@ +/** + * 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.home + +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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory + +/** + * Floating action button for creating new chess game challenges + */ +@Composable +fun NewChessGameButton( + accountViewModel: AccountViewModel, + nav: INav, +) { + var showDialog by remember { mutableStateOf(false) } + + val chessViewModel: ChessViewModel = + viewModel( + key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}", + factory = ChessViewModelFactory(accountViewModel.account), + ) + + FloatingActionButton( + onClick = { showDialog = true }, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "New Chess Game", + ) + } + + if (showDialog) { + NewChessGameDialog( + onDismiss = { showDialog = false }, + onCreateGame = { opponentPubkey, color -> + chessViewModel.createChallenge( + opponentPubkey = opponentPubkey, + playerColor = color, + ) + showDialog = false + }, + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1798a428c..67f2a44a8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -570,6 +570,7 @@ Follows via Proxy Around Me Global + Chess Mute List Follow Lists diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt new file mode 100644 index 000000000..20b3e5e37 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt @@ -0,0 +1,233 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.foundation.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.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 +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.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.PieceType +import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor + +/** + * Interactive chess board that allows piece selection and moves + * + * @param engine Chess engine for move validation and legal moves + * @param modifier Modifier for the board + * @param boardSize Total size of the board in dp + * @param onMoveMade Callback when a valid move is made, receives (from, to, san) + */ +@Composable +fun InteractiveChessBoard( + engine: ChessEngine, + modifier: Modifier = Modifier, + boardSize: Dp = 400.dp, + onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> }, +) { + val position = remember(engine) { engine.getPosition() } + var selectedSquare by remember { mutableStateOf?>(null) } + var legalMoves by remember { mutableStateOf>(emptyList()) } + + val squareSize = boardSize / 8 + + Column(modifier = modifier.size(boardSize)) { + // Render ranks 8 down to 1 (from white's perspective) + for (rank in 7 downTo 0) { + Row { + for (file in 0..7) { + val piece = position.pieceAt(file, rank) + val isLightSquare = (rank + file) % 2 == 0 + val square = fileRankToSquare(file, rank) + val isSelected = selectedSquare == (file to rank) + val isLegalMove = legalMoves.contains(square) + + InteractiveChessSquare( + piece = piece, + isLight = isLightSquare, + size = squareSize, + isSelected = isSelected, + isLegalMove = isLegalMove, + showCoordinate = rank == 0, + file = file, + rank = rank, + onClick = { + if (selectedSquare != null) { + // Attempt to make move + 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") + selectedSquare = null + legalMoves = emptyList() + } else { + // If move failed, try selecting this square instead + if (piece != null && piece.color == engine.getSideToMove()) { + selectedSquare = file to rank + legalMoves = engine.getLegalMovesFrom(square) + } else { + selectedSquare = null + legalMoves = emptyList() + } + } + } else { + // Select piece + if (piece != null && piece.color == engine.getSideToMove()) { + selectedSquare = file to rank + legalMoves = engine.getLegalMovesFrom(square) + } + } + }, + ) + } + } + } + } +} + +/** + * Single interactive chess square + */ +@Composable +private fun InteractiveChessSquare( + piece: com.vitorpamplona.quartz.nip64Chess.ChessPiece?, + isLight: Boolean, + size: Dp, + isSelected: Boolean, + isLegalMove: Boolean, + showCoordinate: Boolean, + file: Int, + rank: Int, + onClick: () -> Unit, +) { + val backgroundColor = + when { + isSelected -> Color(0xFF7FA650) + isLegalMove -> Color(0xFFAAC75B).copy(alpha = 0.6f) + isLight -> Color(0xFFF0D9B5) + else -> Color(0xFFB58863) + } + + Box( + modifier = + Modifier + .size(size) + .background(backgroundColor) + .clickable { onClick() } + .let { + if (isSelected) { + it.border(2.dp, Color(0xFF4A7536)) + } else { + it + } + }, + contentAlignment = Alignment.Center, + ) { + // Display piece using Unicode chess symbols + piece?.let { + Text( + text = it.toUnicode(), + fontSize = (size.value * 0.6).sp, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + // Show legal move indicator + if (isLegalMove && piece == null) { + Box( + modifier = + Modifier + .size(size * 0.25f) + .background(Color(0xFF7FA650), CircleShape) + .alpha(0.5f), + ) + } else if (isLegalMove && piece != null) { + // Capture indicator - ring around piece + Box( + modifier = + Modifier + .size(size * 0.8f) + .border(3.dp, Color(0xFF7FA650).copy(alpha = 0.5f), CircleShape), + ) + } + + // Show file coordinate (a-h) on bottom rank + if (showCoordinate) { + Text( + text = ('a' + file).toString(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = if (isLight) Color(0xFFB58863) else Color(0xFFF0D9B5), + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(2.dp), + ) + } + } +} + +/** + * Convert file/rank to algebraic notation (e.g., 0,0 -> "a1") + */ +private fun fileRankToSquare( + file: Int, + rank: Int, +): String = "${('a' + file)}${rank + 1}" + +/** + * Extension function to convert ChessPiece to Unicode chess symbol + */ +private fun com.vitorpamplona.quartz.nip64Chess.ChessPiece.toUnicode(): String { + 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 "♟" + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt new file mode 100644 index 000000000..75b21ba90 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt @@ -0,0 +1,309 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.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 +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.vitorpamplona.quartz.nip64Chess.ChessEngine +import com.vitorpamplona.quartz.nip64Chess.Color + +/** + * Dialog for creating a new chess game challenge + * + * @param onDismiss Callback when dialog is dismissed + * @param onCreateGame Callback when game is created (opponentPubkey, playerColor) + */ +@Composable +fun NewChessGameDialog( + onDismiss: () -> Unit, + onCreateGame: (opponentPubkey: String?, playerColor: Color) -> Unit, +) { + var opponentPubkey by remember { mutableStateOf("") } + var selectedColor by remember { mutableStateOf(Color.WHITE) } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "New Chess Game", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + Text( + text = "Create a new chess game challenge", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = opponentPubkey, + onValueChange = { opponentPubkey = it }, + label = { Text("Opponent npub (optional)") }, + placeholder = { Text("Leave blank for open challenge") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Text( + text = "Your color:", + style = MaterialTheme.typography.labelMedium, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { selectedColor = Color.WHITE }, + modifier = Modifier.weight(1f), + ) { + Text( + text = if (selectedColor == Color.WHITE) "✓ White" else "White", + fontWeight = if (selectedColor == Color.WHITE) FontWeight.Bold else FontWeight.Normal, + ) + } + + OutlinedButton( + onClick = { selectedColor = Color.BLACK }, + modifier = Modifier.weight(1f), + ) { + Text( + text = if (selectedColor == Color.BLACK) "✓ Black" else "Black", + fontWeight = if (selectedColor == Color.BLACK) FontWeight.Bold else FontWeight.Normal, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + onCreateGame( + opponentPubkey.ifBlank { null }, + selectedColor, + ) + }, + ) { + Text("Create Game") + } + } + } + } + } +} + +/** + * Complete live chess game UI with board, controls, and game info + * + * @param engine Chess engine instance + * @param playerColor Color the player is playing as + * @param gameId Unique game identifier + * @param opponentName Opponent's display name + * @param onMoveMade Callback when player makes a move (from, to, san) + * @param onResign Callback when player resigns + * @param onOfferDraw Callback when player offers draw + */ +@Composable +fun LiveChessGameScreen( + engine: ChessEngine, + playerColor: Color, + gameId: String, + opponentName: String, + onMoveMade: (from: String, to: String, san: String) -> Unit, + onResign: () -> Unit, + onOfferDraw: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Game info + GameInfoHeader( + gameId = gameId, + opponentName = opponentName, + playerColor = playerColor, + currentTurn = engine.getSideToMove(), + ) + + // Interactive chess board + InteractiveChessBoard( + engine = engine, + boardSize = 400.dp, + onMoveMade = onMoveMade, + ) + + // Move history + MoveHistoryDisplay( + moves = engine.getMoveHistory(), + ) + + // Game controls + GameControls( + onResign = onResign, + onOfferDraw = onOfferDraw, + ) + } +} + +/** + * Game information header showing game ID, opponent, and current turn + */ +@Composable +private fun GameInfoHeader( + gameId: String, + opponentName: String, + playerColor: Color, + currentTurn: Color, +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Game: ${gameId.take(8)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = "vs $opponentName", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodyMedium, + ) + + val turnText = + if (currentTurn == playerColor) { + "Your turn" + } else { + "Opponent's turn" + } + + Text( + text = turnText, + style = MaterialTheme.typography.bodyMedium, + color = + if (currentTurn == playerColor) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + fontWeight = if (currentTurn == playerColor) FontWeight.Bold else FontWeight.Normal, + ) + } +} + +/** + * Display move history in SAN notation + */ +@Composable +private fun MoveHistoryDisplay(moves: List) { + if (moves.isNotEmpty()) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Moves:", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = moves.joinToString(" "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Game control buttons (Resign, Offer Draw) + */ +@Composable +private fun GameControls( + onResign: () -> Unit, + onOfferDraw: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + ) { + OutlinedButton(onClick = onOfferDraw) { + Text("Offer Draw") + } + + OutlinedButton(onClick = onResign) { + Text("Resign") + } + } +} diff --git a/docs/live-chess-implementation-status.md b/docs/live-chess-implementation-status.md new file mode 100644 index 000000000..16e9b617a --- /dev/null +++ b/docs/live-chess-implementation-status.md @@ -0,0 +1,297 @@ +# Live Chess Implementation Status + +## ✅ Completed Implementation + +### 1. Chess Engine & Move Validation +- **kchesslib dependency** added to `quartz/build.gradle.kts:150` +- **ChessEngine** interface with expect/actual pattern: + - `ChessEngine.kt` (commonMain) - Platform-agnostic API + - `ChessEngine.jvmAndroid.kt` (jvmAndroid) - kchesslib implementation +- **Full move validation**: Legal moves, check/checkmate, castling, en passant, pawn promotion +- **FEN import/export**: Board state serialization +- **Move history tracking**: SAN notation + +### 2. Interactive UI Components (commons/) +- **InteractiveChessBoard.kt**: Click-to-move with visual feedback + - Selected piece highlighting (green) + - Legal move indicators (circles/rings) + - Automatic move validation + - Callback system for move events + +- **LiveChessGame.kt**: Complete game UI + - NewChessGameDialog (challenge creation) + - LiveChessGameScreen (full game interface) + - GameInfoHeader (turn indicator, game status) + - MoveHistoryDisplay (SAN move list) + - GameControls (Resign, Offer Draw) + +### 3. Nostr Event Protocol (quartz/nip64Chess/) +**New Event Kinds:** +| Kind | Event | Purpose | +|------|-------|---------| +| 30064 | LiveChessGameChallengeEvent | Create/accept challenges | +| 30065 | LiveChessGameAcceptEvent | Accept challenge | +| 30066 | LiveChessMoveEvent | Individual moves (SAN + FEN) | +| 30067 | LiveChessGameEndEvent | Game result & termination | + +**Event Factory Integration** (EventFactory.kt:190-193): +- All live chess events registered for automatic parsing + +### 4. Game State Management +- **LiveChessGameState.kt**: Coordinates engine, Nostr, and UI + - Turn validation + - Move synchronization + - Automatic end detection (checkmate/stalemate) + - PGN generation + - Position mismatch recovery + +### 5. Feed Integration +**Chess Feed Filter** (TopNavFilterState.kt:130-142): +- Shows Kind 64 (completed games) +- Shows Kind 30064 (challenges - open & directed) +- Shows Kind 30067 (game endings) +- Excludes Kind 30066 (individual moves - too noisy) + +**Event Rendering** (Chess.kt, NoteCompose.kt): +- `RenderChessGame()` - Completed PGN games (Kind 64) +- `RenderLiveChessChallenge()` - Challenge cards with visual states: + - 🔓 **Open challenges** (green border) + - 💌 **Incoming challenges** (orange border) + - ⏳ **Sent challenges** (gray border) +- `RenderLiveChessGameEnd()` - Game results with PGN + +## 🚧 Remaining Work + +### Priority 1: Navigation & Game Flow +**Files to create:** +1. **ChessViewModel.kt** (amethyst/ui/screen/loggedIn/chess/) + ```kotlin + class ChessViewModel(account: Account) { + val activeGames: StateFlow> + val challenges: StateFlow> + val badgeCount: StateFlow // For in-app notifications + + fun createChallenge(opponentPubkey: String?, color: Color) + fun acceptChallenge(challengeEventId: String) + fun publishMove(gameId: String, move: ChessMoveEvent) + } + ``` + +2. **Navigation Routes** (ui/navigation/routes/) + - Add `LiveChessGame(gameId: String)` route + - Wire up from challenge card click + - Pass LiveChessGameState to screen + +3. **ChessGameScreen.kt** (amethyst/ui/screen/loggedIn/chess/) + - Wrapper around `LiveChessGameScreen` composable + - Connects ViewModel to UI + - Handles move publishing callback + +### Priority 2: Event Publishing & Subscriptions +**Files to modify:** +1. **ChessViewModel.kt** - Add relay operations: + ```kotlin + // Subscribe to game events + fun subscribeToGame(gameId: String) { + // Listen for Kind 30066 (moves) with d tag = gameId + // Listen for Kind 30067 (game end) with d tag = gameId + } + + // Publish events + suspend fun publishChallenge(challenge: ChessGameChallenge) { + val event = LiveChessGameChallengeEvent.build(...) + account.sendEvent(event) + } + ``` + +2. **Event Handlers** - Process incoming events: + ```kotlin + fun handleOpponentMove(moveEvent: LiveChessMoveEvent) { + val game = activeGames.find { it.gameId == moveEvent.gameId() } + game?.applyOpponentMove(moveEvent.san(), moveEvent.fen()) + } + ``` + +### Priority 3: FAB & Challenge Creation +**Files to modify:** +1. **HomeScreen.kt** or chess-specific screen + ```kotlin + var showNewGameDialog by remember { mutableStateOf(false) } + + Scaffold( + floatingActionButton = { + if (isChessFeed) { + FloatingActionButton( + onClick = { showNewGameDialog = true } + ) { + Icon(Icons.Default.Add, "New Game") + } + } + } + ) + + if (showNewGameDialog) { + NewChessGameDialog( + onDismiss = { showNewGameDialog = false }, + onCreateGame = { opponentPubkey, color -> + viewModel.createChallenge(opponentPubkey, color) + showNewGameDialog = false + } + ) + } + ``` + +### Priority 4: In-App Badges (Optional) +**Files to create:** +1. **BadgeProvider.kt** (commons/) + ```kotlin + object ChessBadgeProvider { + fun getBadgeCount( + challenges: List, + activeGames: List, + userPubkey: String + ): Int { + val incomingChallenges = challenges.count { + it.opponentPubkey() == userPubkey + } + val yourTurnGames = activeGames.count { it.isPlayerTurn() } + return incomingChallenges + yourTurnGames + } + } + ``` + +2. **Badge Display** - Add to chess filter icon: + ```kotlin + BadgedBox(badge = { Badge { Text("$count") } }) { + Icon(/* chess icon */) + } + ``` + +## 📋 Implementation Checklist + +### Phase 1: Basic Playability +- [ ] Create ChessViewModel +- [ ] Add navigation route for LiveChessGameScreen +- [ ] Create ChessGameScreen wrapper +- [ ] Wire up NewGameDialog → ViewModel.createChallenge() +- [ ] Implement event publishing (challenges, moves, game end) +- [ ] Test: Create challenge, see it in feed + +### Phase 2: Two-Player Functionality +- [ ] Implement relay subscriptions for game events +- [ ] Wire up move synchronization (publish/receive) +- [ ] Handle challenge acceptance flow +- [ ] Test: Play complete game between two accounts + +### Phase 3: Polish +- [ ] Add FAB to chess feed +- [ ] Implement badge counts +- [ ] Add "Accept Challenge" button to incoming challenge cards +- [ ] Add "View Game" navigation from challenge/game-end cards +- [ ] Handle edge cases (network errors, position desync, abandoned games) + +### Phase 4: Persistence (Optional) +- [ ] Create Room entities for active games +- [ ] Store games locally for offline viewing +- [ ] Sync on app start +- [ ] Background service for "your turn" notifications + +## 🎯 UX Decisions Implemented + +Based on user choices: + +1. ✅ **Entry Point**: FAB on Chess feed (pending UI implementation) +2. ✅ **Display**: Visual cards in one feed with borders/icons +3. ✅ **Discovery**: Open challenges shown in Chess feed (green border) +4. ✅ **Tap Action**: Navigate directly to full screen (pending nav) +5. ⏳ **Notifications**: In-app badge for counts (pending implementation) +6. ✅ **Filter Scope**: Meaningful events only (completed games, challenges, endings) +7. ⏳ **Storage**: Local cache + relay sync (pending Room/ViewModel) +8. ✅ **Input**: Click-to-move (already implemented) + +## 🔧 Quick Start for Development + +### Test the Chess Engine: +```kotlin +val engine = ChessEngine() +engine.reset() +val result = engine.makeMove("e2", "e4") +println("Move success: ${result.success}, SAN: ${result.san}") +``` + +### Test the Interactive Board: +```kotlin +@Composable +fun TestScreen() { + val engine = remember { ChessEngine().apply { reset() } } + InteractiveChessBoard( + engine = engine, + onMoveMade = { san -> println("Move made: $san") } + ) +} +``` + +### View Chess Feed: +1. Open Amethyst +2. Select "Chess" from home feed dropdown +3. See completed games (Kind 64) +4. See challenges with colored borders (Kind 30064) +5. See game endings with results (Kind 30067) + +## 📁 File Structure + +``` +AmethystMultiplatform/ +├── quartz/ # Protocol layer (KMP) +│ ├── src/commonMain/kotlin/.../nip64Chess/ +│ │ ├── ChessEngine.kt ✅ Engine interface +│ │ ├── ChessPosition.kt ✅ Board state +│ │ ├── ChessMove.kt ✅ Move representation +│ │ ├── ChessGame.kt ✅ Game model +│ │ ├── PGNParser.kt ✅ PGN parsing (31 tests) +│ │ ├── ChessGameEvent.kt ✅ Kind 64 +│ │ ├── LiveChessEvents.kt ✅ Data classes +│ │ ├── LiveChessGameEvents.kt ✅ Kind 30064-30067 +│ │ └── LiveChessGameState.kt ✅ Game coordinator +│ └── src/jvmAndroid/kotlin/.../nip64Chess/ +│ └── ChessEngine.jvmAndroid.kt ✅ kchesslib impl +│ +├── commons/ # Shared UI (Android/Desktop) +│ └── src/main/java/.../commons/chess/ +│ ├── ChessBoard.kt ✅ Static board +│ ├── InteractiveChessBoard.kt ✅ Click-to-move +│ ├── ChessGameViewer.kt ✅ PGN playback +│ ├── MoveNavigator.kt ✅ Move controls +│ ├── PGNMetadata.kt ✅ Game info +│ └── LiveChessGame.kt ✅ Dialog + screen +│ +├── amethyst/ # Android app +│ └── src/main/java/.../amethyst/ +│ ├── ui/note/types/ +│ │ └── Chess.kt ✅ Event renderers (3 types) +│ ├── ui/note/ +│ │ └── NoteCompose.kt ✅ Event dispatching +│ ├── ui/screen/ +│ │ ├── TopNavFilterState.kt ✅ Chess filter (3 kinds) +│ │ └── loggedIn/chess/ ⏳ TODO: ViewModels, screens +│ └── ui/navigation/routes/ ⏳ TODO: Navigation +│ +└── docs/ + └── live-chess-implementation-status.md ✅ This file +``` + +## 🚀 Next Steps + +**To make chess fully playable**, implement in this order: + +1. **ChessViewModel** - Game state management & event publishing +2. **Navigation** - Route to LiveChessGameScreen +3. **Event Subscriptions** - Listen for opponent moves +4. **FAB** - Add "New Game" button to chess feed +5. **Test** - Play a complete game between two test accounts + +The foundation is complete and tested. The remaining work is "wiring" - connecting the chess engine and UI to Amethyst's existing relay/ViewModel infrastructure. + +--- + +**Status**: Core chess functionality fully implemented. Integration layer partially complete. Estimated 4-6 hours of focused work to reach playable state. diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 11852a57c..d5541b3a1 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -122,6 +122,9 @@ kotlin { // Websockets API implementation(libs.okhttp) implementation(libs.okhttpCoroutines) + + // Chess engine for move validation and legal move generation + implementation("io.github.cvb941:kchesslib:1.0.4") } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.kt new file mode 100644 index 000000000..b0d07c4ff --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.kt @@ -0,0 +1,139 @@ +/** + * 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.quartz.nip64Chess + +import androidx.compose.runtime.Immutable + +/** + * Platform-agnostic chess engine interface for move validation and generation + * Implemented using expect/actual for JVM/Android platforms + */ +expect class ChessEngine() { + /** + * Get the current position as FEN string + */ + fun getFen(): String + + /** + * Load a position from FEN notation + */ + fun loadFen(fen: String) + + /** + * Reset to starting position + */ + fun reset() + + /** + * Make a move using Standard Algebraic Notation (SAN) + * @param san Move in SAN format (e.g., "Nf3", "e4", "O-O") + * @return MoveResult with success status and resulting position + */ + fun makeMove(san: String): MoveResult + + /** + * Make a move from source to destination square + * @param from Source square in algebraic notation (e.g., "e2") + * @param to Destination square in algebraic notation (e.g., "e4") + * @param promotion Optional promotion piece type for pawn promotion + * @return MoveResult with success status and resulting position + */ + fun makeMove( + from: String, + to: String, + promotion: PieceType? = null, + ): MoveResult + + /** + * Get all legal moves from the current position + * @return List of legal moves in SAN notation + */ + fun getLegalMoves(): List + + /** + * Get all legal moves for a specific square + * @param square Square in algebraic notation (e.g., "e2") + * @return List of destination squares that are legal + */ + fun getLegalMovesFrom(square: String): List + + /** + * Check if a move is legal in the current position + * @param san Move in SAN format + * @return true if the move is legal + */ + fun isLegalMove(san: String): Boolean + + /** + * Check if a move from-to is legal + */ + fun isLegalMove( + from: String, + to: String, + promotion: PieceType? = null, + ): Boolean + + /** + * Check if current position is checkmate + */ + fun isCheckmate(): Boolean + + /** + * Check if current position is stalemate + */ + fun isStalemate(): Boolean + + /** + * Check if current position is in check + */ + fun isInCheck(): Boolean + + /** + * Undo the last move + */ + fun undoMove() + + /** + * Get the current position as ChessPosition + */ + fun getPosition(): ChessPosition + + /** + * Get side to move + */ + fun getSideToMove(): Color + + /** + * Get move history in SAN notation + */ + fun getMoveHistory(): List +} + +/** + * Result of attempting a chess move + */ +@Immutable +data class MoveResult( + val success: Boolean, + val san: String? = null, // Move in SAN notation if successful + val position: ChessPosition? = null, // Resulting position if successful + val error: String? = null, // Error message if unsuccessful +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt new file mode 100644 index 000000000..5c6e63fc3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt @@ -0,0 +1,135 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +/* + * Live chess game state and move events + * + * For real-time chess games, we use a different event structure than NIP-64. + * NIP-64 is for completed games in PGN format. Live games need individual + * move events that can be published and subscribed to in real-time. + * + * Event Structure: + * - Game Challenge (Kind 30064): Challenge another player or open challenge + * - Game Accept (Kind 30065): Accept a challenge + * - Chess Move (Kind 30066): Individual move in a live game + * - Game End (Kind 30067): Game result (checkmate, resignation, draw, etc.) + * + * All events use 'd' tag with game_id to group moves from the same game + */ + +/** + * Game challenge event - start a new game + * Kind: 30064 + * + * Tags: + * - d: game_id (unique identifier) + * - p: opponent pubkey (optional, if challenging specific player) + * - player_color: white|black (color challenger wants to play) + * - time_control: time control format (e.g., "10+0", "5+3") + */ +data class ChessGameChallenge( + val gameId: String, + val opponentPubkey: String? = null, // null = open challenge + val playerColor: Color, + val timeControl: String? = null, +) + +/** + * Game acceptance event - accept a challenge + * Kind: 30065 + * + * Tags: + * - d: game_id (same as challenge) + * - e: challenge event ID + * - p: challenger pubkey + */ +data class ChessGameAccept( + val gameId: String, + val challengeEventId: String, + val challengerPubkey: String, +) + +/** + * Chess move event - individual move in a live game + * Kind: 30066 + * + * Tags: + * - d: game_id + * - move_number: move number (1-based) + * - san: move in Standard Algebraic Notation + * - fen: resulting position in FEN notation + * - p: opponent pubkey (for notifications) + * + * Content: Optional move comment or time remaining + */ +data class ChessMoveEvent( + val gameId: String, + val moveNumber: Int, + val san: String, + val fen: String, + val opponentPubkey: String, + val comment: String? = null, +) + +/** + * Game end event - final game result + * Kind: 30067 + * + * Tags: + * - d: game_id + * - result: 1-0|0-1|1/2-1/2 (white wins, black wins, draw) + * - termination: checkmate|resignation|draw_agreement|stalemate|timeout|abandonment + * - winner: pubkey of winner (if applicable) + * - p: opponent pubkey + * + * Content: Optional game summary or final PGN + */ +data class ChessGameEnd( + val gameId: String, + val result: GameResult, + val termination: GameTermination, + val winnerPubkey: String? = null, + val opponentPubkey: String, + val pgn: String? = null, +) + +/** + * Game termination reason + */ +enum class GameTermination { + CHECKMATE, + RESIGNATION, + DRAW_AGREEMENT, + STALEMATE, + TIMEOUT, + ABANDONMENT, +} + +/** + * Event kind constants for live chess + */ +object LiveChessEventKinds { + const val GAME_CHALLENGE = 30064 + const val GAME_ACCEPT = 30065 + const val CHESS_MOVE = 30066 + const val GAME_END = 30067 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt new file mode 100644 index 000000000..5c154e60e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt @@ -0,0 +1,249 @@ +/** + * 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.quartz.nip64Chess + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Live Chess Game Challenge Event (Kind 30064) + * + * Challenge another player to a chess game or create an open challenge. + * This is a parameterized replaceable event. + * + * Tags: + * - d: game_id (unique identifier for this game) + * - p: opponent pubkey (optional, for direct challenges) + * - player_color: "white" or "black" + * - time_control: optional time control (e.g., "10+0", "5+3") + */ +@Immutable +class LiveChessGameChallengeEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30064 + + fun build( + gameId: String, + playerColor: Color, + opponentPubkey: String? = null, + timeControl: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + add(arrayOf("d", gameId)) + add(arrayOf("player_color", if (playerColor == Color.WHITE) "white" else "black")) + opponentPubkey?.let { add(arrayOf("p", it)) } + timeControl?.let { add(arrayOf("time_control", it)) } + alt("Chess game challenge") + initializer() + } + } + + fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + + fun playerColor(): Color? = + tags + .firstOrNull { it.size >= 2 && it[0] == "player_color" } + ?.get(1) + ?.let { if (it == "white") Color.WHITE else Color.BLACK } + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun timeControl(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "time_control" }?.get(1) +} + +/** + * Live Chess Game Accept Event (Kind 30065) + * + * Accept a chess game challenge + * + * Tags: + * - d: game_id (same as challenge) + * - e: challenge event ID + * - p: challenger pubkey + */ +@Immutable +class LiveChessGameAcceptEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30065 + + fun build( + gameId: String, + challengeEventId: String, + challengerPubkey: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + add(arrayOf("d", gameId)) + add(arrayOf("e", challengeEventId)) + add(arrayOf("p", challengerPubkey)) + alt("Chess game acceptance") + initializer() + } + } + + fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + + fun challengeEventId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) + + fun challengerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) +} + +/** + * Live Chess Move Event (Kind 30066) + * + * Individual move in a live chess game + * + * Tags: + * - d: game_id + * - move_number: move number (1-based) + * - san: move in Standard Algebraic Notation + * - fen: resulting position in FEN notation + * - p: opponent pubkey + * + * Content: Optional move comment + */ +@Immutable +class LiveChessMoveEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30066 + + fun build( + gameId: String, + moveNumber: Int, + san: String, + fen: String, + opponentPubkey: String, + comment: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, comment, createdAt) { + add(arrayOf("d", gameId)) + add(arrayOf("move_number", moveNumber.toString())) + add(arrayOf("san", san)) + add(arrayOf("fen", fen)) + add(arrayOf("p", opponentPubkey)) + alt("Chess move: $san") + initializer() + } + } + + fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + + fun moveNumber(): Int? = + tags + .firstOrNull { it.size >= 2 && it[0] == "move_number" } + ?.get(1) + ?.toIntOrNull() + + fun san(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "san" }?.get(1) + + fun fen(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "fen" }?.get(1) + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun comment(): String = content +} + +/** + * Live Chess Game End Event (Kind 30067) + * + * Game result and termination + * + * Tags: + * - d: game_id + * - result: "1-0"|"0-1"|"1/2-1/2" + * - termination: reason for game end + * - winner: pubkey of winner (if applicable) + * - p: opponent pubkey + * + * Content: Optional PGN of complete game + */ +@Immutable +class LiveChessGameEndEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30067 + + fun build( + gameId: String, + result: GameResult, + termination: GameTermination, + winnerPubkey: String? = null, + opponentPubkey: String, + pgn: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, pgn, createdAt) { + add(arrayOf("d", gameId)) + add(arrayOf("result", result.notation)) + add(arrayOf("termination", termination.name.lowercase())) + winnerPubkey?.let { add(arrayOf("winner", it)) } + add(arrayOf("p", opponentPubkey)) + alt("Chess game ended: ${result.notation}") + initializer() + } + } + + fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + + fun result(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "result" }?.get(1) + + fun termination(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "termination" }?.get(1) + + fun winnerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "winner" }?.get(1) + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun pgn(): String = content +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt new file mode 100644 index 000000000..b0249daa4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt @@ -0,0 +1,255 @@ +/** + * 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.quartz.nip64Chess + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Manages state for a live chess game + * + * This class coordinates between: + * - ChessEngine (move validation, board state) + * - Nostr events (publishing moves, receiving opponent moves) + * - UI (game state, turn management) + */ +class LiveChessGameState( + val gameId: String, + val playerPubkey: String, + val opponentPubkey: String, + val playerColor: Color, + val engine: ChessEngine, +) { + private val _gameStatus = MutableStateFlow(GameStatus.InProgress) + val gameStatus: StateFlow = _gameStatus.asStateFlow() + + private val _currentPosition = MutableStateFlow(engine.getPosition()) + val currentPosition: StateFlow = _currentPosition.asStateFlow() + + private val _moveHistory = MutableStateFlow>(emptyList()) + val moveHistory: StateFlow> = _moveHistory.asStateFlow() + + private val _lastError = MutableStateFlow(null) + val lastError: StateFlow = _lastError.asStateFlow() + + /** + * Check if it's the player's turn + */ + fun isPlayerTurn(): Boolean = engine.getSideToMove() == playerColor + + /** + * Make a move (called when player makes a move on the board) + * Returns the move event to be published to Nostr + */ + fun makeMove( + from: String, + to: String, + promotion: PieceType? = null, + ): ChessMoveEvent? { + if (!isPlayerTurn()) { + _lastError.value = "Not your turn" + return null + } + + if (_gameStatus.value != GameStatus.InProgress) { + _lastError.value = "Game is not in progress" + return null + } + + val result = engine.makeMove(from, to, promotion) + + if (result.success && result.san != null && result.position != null) { + _currentPosition.value = result.position + _moveHistory.value = engine.getMoveHistory() + _lastError.value = null + + // Check for game end conditions + checkGameEnd() + + // Return move event to publish + return ChessMoveEvent( + gameId = gameId, + moveNumber = result.position.moveNumber, + san = result.san, + fen = engine.getFen(), + opponentPubkey = opponentPubkey, + ) + } else { + _lastError.value = result.error ?: "Invalid move" + return null + } + } + + /** + * Apply opponent's move (called when receiving move event from Nostr) + */ + fun applyOpponentMove( + san: String, + fen: String, + ): Boolean { + if (isPlayerTurn()) { + _lastError.value = "Received move but it's not opponent's turn" + return false + } + + // Verify the move is valid by trying to make it + val result = engine.makeMove(san) + + if (result.success) { + // 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" + // Load the opponent's FEN to stay in sync + engine.loadFen(fen) + } + + _currentPosition.value = engine.getPosition() + _moveHistory.value = engine.getMoveHistory() + _lastError.value = null + + // Check for game end conditions + checkGameEnd() + + return true + } else { + _lastError.value = "Invalid opponent move: $san" + return false + } + } + + /** + * Offer resignation (player resigns) + */ + fun resign(): ChessGameEnd { + _gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor.opposite())) + + return ChessGameEnd( + gameId = gameId, + result = GameResult.getResultForWinner(playerColor.opposite()), + termination = GameTermination.RESIGNATION, + winnerPubkey = opponentPubkey, + opponentPubkey = opponentPubkey, + pgn = generatePGN(), + ) + } + + /** + * Offer or accept draw + */ + fun offerDraw(): ChessGameEnd { + _gameStatus.value = GameStatus.Finished(GameResult.DRAW) + + return ChessGameEnd( + gameId = gameId, + result = GameResult.DRAW, + termination = GameTermination.DRAW_AGREEMENT, + winnerPubkey = null, + opponentPubkey = opponentPubkey, + pgn = generatePGN(), + ) + } + + /** + * Check if game has ended (checkmate, stalemate, etc.) + */ + private fun checkGameEnd() { + when { + engine.isCheckmate() -> { + val winner = engine.getSideToMove().opposite() + _gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(winner)) + // In a real implementation, you'd publish GameEnd event here + } + engine.isStalemate() -> { + _gameStatus.value = GameStatus.Finished(GameResult.DRAW) + // In a real implementation, you'd publish GameEnd event here + } + } + } + + /** + * Generate PGN for the current game + */ + private fun generatePGN(): String { + val moves = _moveHistory.value + val movePairs = moves.chunked(2) + + val moveText = + movePairs + .mapIndexed { index, pair -> + val moveNum = index + 1 + when (pair.size) { + 2 -> "$moveNum. ${pair[0]} ${pair[1]}" + 1 -> "$moveNum. ${pair[0]}" + else -> "" + } + }.joinToString(" ") + + val result = + when (val status = _gameStatus.value) { + is GameStatus.Finished -> status.result.notation + else -> "*" + } + + return """ + [Event "Live Chess Game"] + [Site "Nostr"] + [White "${if (playerColor == Color.WHITE) playerPubkey else opponentPubkey}"] + [Black "${if (playerColor == Color.BLACK) playerPubkey else opponentPubkey}"] + [Result "$result"] + + $moveText $result + """.trimIndent() + } + + /** + * Reset game to initial position + */ + fun reset() { + engine.reset() + _currentPosition.value = engine.getPosition() + _moveHistory.value = emptyList() + _gameStatus.value = GameStatus.InProgress + _lastError.value = null + } +} + +/** + * Game status + */ +sealed class GameStatus { + data object InProgress : GameStatus() + + data class Finished( + val result: GameResult, + ) : GameStatus() +} + +/** + * Extension to get result for a winning color + */ +private fun GameResult.Companion.getResultForWinner(winner: Color): GameResult = + when (winner) { + Color.WHITE -> GameResult.WHITE_WINS + Color.BLACK -> GameResult.BLACK_WINS + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index bd1e2936b..9a732cee1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -113,6 +113,10 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -185,6 +189,10 @@ class EventFactory { CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessGameChallengeEvent.KIND -> LiveChessGameChallengeEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessMoveEvent.KIND -> LiveChessMoveEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessGameEndEvent.KIND -> LiveChessGameEndEvent(id, pubKey, createdAt, tags, content, sig) ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt new file mode 100644 index 000000000..b16c1c81d --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt @@ -0,0 +1,229 @@ +/** + * 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.quartz.nip64Chess + +import com.github.bhlangonijr.chesslib.Board +import com.github.bhlangonijr.chesslib.Piece +import com.github.bhlangonijr.chesslib.Side +import com.github.bhlangonijr.chesslib.Square +import com.github.bhlangonijr.chesslib.move.Move + +/** + * JVM/Android implementation of ChessEngine using kchesslib + */ +actual class ChessEngine { + private val board = Board() + + actual fun getFen(): String = board.fen + + actual fun loadFen(fen: String) { + board.loadFromFen(fen) + } + + actual fun reset() { + board.loadFromFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") + } + + actual fun makeMove(san: String): MoveResult = + try { + val move = board.doMove(san) + if (move != null) { + MoveResult( + success = true, + san = san, + position = boardToPosition(), + ) + } else { + board.undoMove() + MoveResult( + success = false, + error = "Invalid move: $san", + ) + } + } catch (e: Exception) { + MoveResult( + success = false, + error = e.message ?: "Unknown error making move $san", + ) + } + + actual fun makeMove( + from: String, + to: String, + promotion: PieceType?, + ): MoveResult = + try { + val fromSquare = Square.fromValue(from.uppercase()) + val toSquare = Square.fromValue(to.uppercase()) + + val promotionPiece = + when (promotion) { + PieceType.QUEEN -> if (board.sideToMove == Side.WHITE) Piece.WHITE_QUEEN else Piece.BLACK_QUEEN + PieceType.ROOK -> if (board.sideToMove == Side.WHITE) Piece.WHITE_ROOK else Piece.BLACK_ROOK + PieceType.BISHOP -> if (board.sideToMove == Side.WHITE) Piece.WHITE_BISHOP else Piece.BLACK_BISHOP + PieceType.KNIGHT -> if (board.sideToMove == Side.WHITE) Piece.WHITE_KNIGHT else Piece.BLACK_KNIGHT + else -> Piece.NONE + } + + val move = Move(fromSquare, toSquare, promotionPiece) + + if (board.legalMoves().contains(move)) { + board.doMove(move) + val san = move.toString() // kchesslib converts to SAN + MoveResult( + success = true, + san = san, + position = boardToPosition(), + ) + } else { + MoveResult( + success = false, + error = "Illegal move: $from to $to", + ) + } + } catch (e: Exception) { + MoveResult( + success = false, + error = e.message ?: "Unknown error making move $from to $to", + ) + } + + actual fun getLegalMoves(): List = board.legalMoves().map { it.toString() } + + actual fun getLegalMovesFrom(square: String): List { + val sq = Square.fromValue(square.uppercase()) + return board + .legalMoves() + .filter { it.from == sq } + .map { it.to.toString().lowercase() } + } + + actual fun isLegalMove(san: String): Boolean = + try { + val move = board.doMove(san) + if (move != null) { + board.undoMove() + true + } else { + false + } + } catch (e: Exception) { + false + } + + actual fun isLegalMove( + from: String, + to: String, + promotion: PieceType?, + ): Boolean = + try { + val fromSquare = Square.fromValue(from.uppercase()) + val toSquare = Square.fromValue(to.uppercase()) + + val promotionPiece = + when (promotion) { + PieceType.QUEEN -> if (board.sideToMove == Side.WHITE) Piece.WHITE_QUEEN else Piece.BLACK_QUEEN + PieceType.ROOK -> if (board.sideToMove == Side.WHITE) Piece.WHITE_ROOK else Piece.BLACK_ROOK + PieceType.BISHOP -> if (board.sideToMove == Side.WHITE) Piece.WHITE_BISHOP else Piece.BLACK_BISHOP + PieceType.KNIGHT -> if (board.sideToMove == Side.WHITE) Piece.WHITE_KNIGHT else Piece.BLACK_KNIGHT + else -> Piece.NONE + } + + val move = Move(fromSquare, toSquare, promotionPiece) + board.legalMoves().contains(move) + } catch (e: Exception) { + false + } + + actual fun isCheckmate(): Boolean = board.isMated + + actual fun isStalemate(): Boolean = board.isStaleMate + + actual fun isInCheck(): Boolean = board.isKingAttacked + + actual fun undoMove() { + board.undoMove() + } + + actual fun getPosition(): ChessPosition = boardToPosition() + + actual fun getSideToMove(): Color = + when (board.sideToMove) { + Side.WHITE -> Color.WHITE + Side.BLACK -> Color.BLACK + } + + actual fun getMoveHistory(): List { + // kchesslib stores move history + return board.backup.map { it.move.toString() } + } + + /** + * Convert kchesslib Board to our ChessPosition model + */ + private fun boardToPosition(): ChessPosition { + val positionArray = Array(8) { Array(8) { null } } + + for (rank in 0..7) { + for (file in 0..7) { + val square = Square.squareAt(rank * 8 + file) + val piece = board.getPiece(square) + + if (piece != Piece.NONE) { + val pieceType = + when (piece.pieceType) { + com.github.bhlangonijr.chesslib.PieceType.KING -> PieceType.KING + com.github.bhlangonijr.chesslib.PieceType.QUEEN -> PieceType.QUEEN + com.github.bhlangonijr.chesslib.PieceType.ROOK -> PieceType.ROOK + com.github.bhlangonijr.chesslib.PieceType.BISHOP -> PieceType.BISHOP + com.github.bhlangonijr.chesslib.PieceType.KNIGHT -> PieceType.KNIGHT + com.github.bhlangonijr.chesslib.PieceType.PAWN -> PieceType.PAWN + else -> PieceType.PAWN + } + + val color = + when (piece.pieceSide) { + Side.WHITE -> Color.WHITE + Side.BLACK -> Color.BLACK + else -> Color.WHITE + } + + positionArray[rank][file] = ChessPiece(pieceType, color) + } + } + } + + return ChessPosition( + board = positionArray, + activeColor = getSideToMove(), + moveNumber = board.moveCounter, + castlingRights = + CastlingRights( + whiteKingSide = board.castleRight.toString().contains("K"), + whiteQueenSide = board.castleRight.toString().contains("Q"), + blackKingSide = board.castleRight.toString().contains("k"), + blackQueenSide = board.castleRight.toString().contains("q"), + ), + enPassantSquare = board.enPassantTarget?.let { it.toString().lowercase() }, + halfMoveClock = board.halfMoveCounter, + ) + } +}