full chess implementation

This commit is contained in:
nrobi144
2025-12-29 13:29:10 +02:00
parent 18c8156ab2
commit 06accf5831
22 changed files with 2936 additions and 15 deletions
@@ -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,
),
)
}
}
@@ -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.nip28PublicChat.metadata.ChannelMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen 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.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.communities.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
@@ -174,6 +175,7 @@ fun AppNavigation(
composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) } composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) } composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) } composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.ChessGame> { ChessGameScreen(it.gameId, accountViewModel, nav) }
composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) } composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.FollowPack> { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs<Route.FollowPack> { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
@@ -137,6 +137,10 @@ sealed class Route {
val geohash: String, val geohash: String,
) : Route() ) : Route()
@Serializable data class ChessGame(
val gameId: String,
) : Route()
@Serializable data class Community( @Serializable data class Community(
val kind: Int, val kind: Int,
val pubKeyHex: HexKey, val pubKeyHex: HexKey,
@@ -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.RenderInteractiveStory
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent 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.RenderLongFormContent
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status 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.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent 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.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
@@ -921,6 +925,22 @@ private fun RenderNoteRow(
nav, nav,
) )
} }
is LiveChessGameChallengeEvent -> {
RenderLiveChessChallenge(
baseNote,
backgroundColor,
accountViewModel,
nav,
)
}
is LiveChessGameEndEvent -> {
RenderLiveChessGameEnd(
baseNote,
backgroundColor,
accountViewModel,
nav,
)
}
is ClassifiedsEvent -> { is ClassifiedsEvent -> {
RenderClassifieds( RenderClassifieds(
noteEvent, noteEvent,
@@ -20,15 +20,38 @@
*/ */
package com.vitorpamplona.amethyst.ui.note.types 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.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.commons.chess.ChessGameViewer
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.ChessGameEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
/** /**
* Render NIP-64 Chess Game event (Kind 64) * Render NIP-64 Chess Game event (Kind 64)
@@ -54,3 +77,180 @@ fun RenderChessGame(
ChessGameViewer(pgnContent = event.pgn()) 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<Color>,
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<Color>,
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)
}
}
}
}
@@ -54,6 +54,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent 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.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
@@ -128,7 +130,21 @@ class TopNavFilterState(
unpackList = listOf(MuteListEvent.blockListFor(account.userProfile().pubkeyHex)), 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( fun mergePeopleLists(
peopleLists: List<AddressableNote>, peopleLists: List<AddressableNote>,
@@ -242,7 +258,7 @@ class TopNavFilterState(
checkNotInMainThread() checkNotInMainThread()
emit( emit(
listOf( listOf(
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow),
myLivePeopleListsFlow, myLivePeopleListsFlow,
myLiveKind3FollowsFlow, myLiveKind3FollowsFlow,
listOf(muteListFollow), listOf(muteListFollow),
@@ -258,7 +274,7 @@ class TopNavFilterState(
checkNotInMainThread() checkNotInMainThread()
emit( emit(
listOf( listOf(
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow),
myLivePeopleListsFlow, myLivePeopleListsFlow,
listOf(muteListFollow), listOf(muteListFollow),
).flatten().toImmutableList(), ).flatten().toImmutableList(),
@@ -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) },
)
}
}
}
@@ -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<Map<String, LiveChessGameState>>(emptyMap())
val activeGames: StateFlow<Map<String, LiveChessGameState>> = _activeGames.asStateFlow()
private val _badgeCount = MutableStateFlow(0)
val badgeCount: StateFlow<Int> = _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()
}
}
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ChessViewModel::class.java)) {
return ChessViewModel(account) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
@@ -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.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.AROUND_ME import com.vitorpamplona.amethyst.model.AROUND_ME
import com.vitorpamplona.amethyst.model.CHESS
import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
@@ -234,21 +235,27 @@ fun HomeScreenFloatingButton(
accountViewModel.account.settings.defaultHomeFollowList accountViewModel.account.settings.defaultHomeFollowList
.collectAsStateWithLifecycle() .collectAsStateWithLifecycle()
if (list.value == AROUND_ME) { when (list.value) {
val location by Amethyst.instance.locationManager.geohashStateFlow AROUND_ME -> {
.collectAsStateWithLifecycle() val location by Amethyst.instance.locationManager.geohashStateFlow
.collectAsStateWithLifecycle()
when (val myLocation = location) { when (val myLocation = location) {
is LocationState.LocationResult.Success -> { is LocationState.LocationResult.Success -> {
NewGeoPostButton(myLocation.geoHash.toString(), accountViewModel, nav) NewGeoPostButton(myLocation.geoHash.toString(), accountViewModel, nav)
}
is LocationState.LocationResult.LackPermission -> { }
is LocationState.LocationResult.Loading -> { }
} }
is LocationState.LocationResult.LackPermission -> { }
is LocationState.LocationResult.Loading -> { }
} }
} else { CHESS -> {
NewNoteButton(nav) NewChessGameButton(accountViewModel, nav)
}
else -> {
NewNoteButton(nav)
}
} }
} }
@@ -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
},
)
}
}
+1
View File
@@ -570,6 +570,7 @@
<string name="follow_list_kind3follows_proxy">Follows via Proxy</string> <string name="follow_list_kind3follows_proxy">Follows via Proxy</string>
<string name="follow_list_aroundme">Around Me</string> <string name="follow_list_aroundme">Around Me</string>
<string name="follow_list_global">Global</string> <string name="follow_list_global">Global</string>
<string name="follow_list_chess">Chess</string>
<string name="follow_list_mute_list">Mute List</string> <string name="follow_list_mute_list">Mute List</string>
<string name="follow_sets">Follow Lists</string> <string name="follow_sets">Follow Lists</string>
@@ -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<Pair<Int, Int>?>(null) }
var legalMoves by remember { mutableStateOf<List<String>>(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 ""
}
}
@@ -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<String>) {
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")
}
}
}
+297
View File
@@ -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<List<LiveChessGameState>>
val challenges: StateFlow<List<LiveChessGameChallengeEvent>>
val badgeCount: StateFlow<Int> // 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<LiveChessGameChallengeEvent>,
activeGames: List<LiveChessGameState>,
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.
+3
View File
@@ -122,6 +122,9 @@ kotlin {
// Websockets API // Websockets API
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.okhttpCoroutines) implementation(libs.okhttpCoroutines)
// Chess engine for move validation and legal move generation
implementation("io.github.cvb941:kchesslib:1.0.4")
} }
} }
@@ -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<String>
/**
* 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<String>
/**
* 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<String>
}
/**
* 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
)
@@ -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
}
@@ -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<Array<String>>,
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<LiveChessGameChallengeEvent>.() -> 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<Array<String>>,
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<LiveChessGameAcceptEvent>.() -> 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<Array<String>>,
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<LiveChessMoveEvent>.() -> 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<Array<String>>,
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<LiveChessGameEndEvent>.() -> 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
}
@@ -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>(GameStatus.InProgress)
val gameStatus: StateFlow<GameStatus> = _gameStatus.asStateFlow()
private val _currentPosition = MutableStateFlow(engine.getPosition())
val currentPosition: StateFlow<ChessPosition> = _currentPosition.asStateFlow()
private val _moveHistory = MutableStateFlow<List<String>>(emptyList())
val moveHistory: StateFlow<List<String>> = _moveHistory.asStateFlow()
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _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
}
@@ -113,6 +113,10 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
@@ -185,6 +189,10 @@ class EventFactory {
CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig)
CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig)
ChessGameEvent.KIND -> ChessGameEvent(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) ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig) ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -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<String> = board.legalMoves().map { it.toString() }
actual fun getLegalMovesFrom(square: String): List<String> {
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<String> {
// 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<ChessPiece?>(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,
)
}
}