full chess implementation
This commit is contained in:
@@ -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(),
|
||||||
|
|||||||
+112
@@ -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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+440
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-12
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+76
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
+233
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+249
@@ -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
|
||||||
|
}
|
||||||
+255
@@ -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)
|
||||||
|
|||||||
+229
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user