From 19c36c2979a43bb05ce8ba01e8d8ecbac6b7bd59 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 29 Dec 2025 12:17:40 +0200 Subject: [PATCH] initial nip64 implementation --- .../amethyst/ui/note/NoteCompose.kt | 10 + .../amethyst/ui/note/types/Chess.kt | 56 +++ .../loggedIn/chess/dal/ChessFeedFilter.kt | 88 ++++ .../amethyst/commons/chess/ChessBoard.kt | 145 ++++++ .../amethyst/commons/chess/ChessGameViewer.kt | 172 +++++++ .../amethyst/commons/chess/MoveNavigator.kt | 137 ++++++ .../amethyst/commons/chess/PGNMetadata.kt | 135 ++++++ .../quartz/nip64Chess/ChessGame.kt | 84 ++++ .../quartz/nip64Chess/ChessGameEvent.kt | 86 ++++ .../quartz/nip64Chess/ChessMove.kt | 78 +++ .../quartz/nip64Chess/ChessPosition.kt | 209 ++++++++ .../quartz/nip64Chess/PGNParser.kt | 323 +++++++++++++ .../quartz/utils/EventFactory.kt | 1 + .../quartz/nip64Chess/ChessGameEventTest.kt | 288 +++++++++++ .../quartz/nip64Chess/PGNParserTest.kt | 454 ++++++++++++++++++ 15 files changed, 2266 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameViewer.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/PGNMetadata.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGame.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessMove.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessPosition.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParser.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParserTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 4ff83b259..71e305a6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -103,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile +import com.vitorpamplona.amethyst.ui.note.types.RenderChessGame import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack @@ -209,6 +210,7 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -911,6 +913,14 @@ private fun RenderNoteRow( ) } + is ChessGameEvent -> { + RenderChessGame( + baseNote, + backgroundColor, + accountViewModel, + nav, + ) + } is ClassifiedsEvent -> { RenderClassifieds( noteEvent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt new file mode 100644 index 000000000..701658493 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -0,0 +1,56 @@ +/** + * 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.note.types + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.commons.chess.ChessGameViewer +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent + +/** + * Render NIP-64 Chess Game event (Kind 64) + * + * Displays interactive chess board with PGN game replay functionality. + * Wraps content in sensitivity warning for user-controlled content filtering. + * + * @param note The note containing the ChessGameEvent + * @param backgroundColor Mutable state for background color + * @param accountViewModel Account view model for sensitivity checking + * @param nav Navigation interface + */ +@Composable +fun RenderChessGame( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = (note.event as? ChessGameEvent) ?: return + + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + ChessGameViewer(pgnContent = event.pgn()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt new file mode 100644 index 000000000..9e37136c2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt @@ -0,0 +1,88 @@ +/** + * 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.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent + +/** + * Feed filter for NIP-64 Chess Game events (Kind 64) + * + * Filters the local cache to show only chess games, + * respecting user's follow lists and hidden users. + */ +class ChessFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-chess-feed" + + override fun limit() = 200 + + override fun showHiddenKey(): Boolean = + account.settings.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.settings.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + + override fun feed(): List { + val params = buildFilterParams(account) + + val notes = + LocalCache.notes.filterIntoSet { _, it -> + acceptableEvent(it, params) + } + + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + private fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + + return collection.filterTo(HashSet()) { acceptableEvent(it, params) } + } + + fun acceptableEvent( + note: Note, + params: FilterByListParams, + ): Boolean { + val noteEvent = note.event + + return (noteEvent is ChessGameEvent) && + params.match(noteEvent, note.relays) && + (params.isHiddenList || account.isAcceptable(note)) + } + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + followLists = account.liveStoriesFollowLists.value, + hiddenUsers = account.hiddenUsers.flow.value, + ) + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt new file mode 100644 index 000000000..a5b385932 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt @@ -0,0 +1,145 @@ +/** + * 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.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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.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.ChessPosition +import com.vitorpamplona.quartz.nip64Chess.PieceType +import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor + +/** + * Renders an 8x8 chess board with pieces + * Shared composable for Android and Desktop platforms + * + * @param position The chess position to display + * @param modifier Modifier for the board + * @param boardSize Total size of the board in dp + * @param showCoordinates Whether to show file labels (a-h) on bottom rank + */ +@Composable +fun ChessBoard( + position: ChessPosition, + modifier: Modifier = Modifier, + boardSize: Dp = 320.dp, + showCoordinates: Boolean = true, +) { + 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 + + ChessSquare( + piece = piece, + isLight = isLightSquare, + size = squareSize, + coordinate = + if (showCoordinates && rank == 0) { + ('a' + file).toString() + } else { + null + }, + ) + } + } + } + } +} + +/** + * Renders a single square on the chess board + */ +@Composable +private fun ChessSquare( + piece: com.vitorpamplona.quartz.nip64Chess.ChessPiece?, + isLight: Boolean, + size: Dp, + coordinate: String?, +) { + Box( + modifier = + Modifier + .size(size) + .background( + if (isLight) Color(0xFFF0D9B5) else Color(0xFFB58863), + ), + 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 file coordinate (a-h) on bottom rank + coordinate?.let { + Text( + text = it, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = if (isLight) Color(0xFFB58863) else Color(0xFFF0D9B5), + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(2.dp), + ) + } + } +} + +/** + * Extension function to convert ChessPiece to Unicode chess symbol + */ +private fun com.vitorpamplona.quartz.nip64Chess.ChessPiece.toUnicode(): String { + val white = color == ChessColor.WHITE + return when (type) { + PieceType.KING -> if (white) "♔" else "♚" + PieceType.QUEEN -> if (white) "♕" else "♛" + PieceType.ROOK -> if (white) "♖" else "♜" + PieceType.BISHOP -> if (white) "♗" else "♝" + PieceType.KNIGHT -> if (white) "♘" else "♞" + PieceType.PAWN -> if (white) "♙" else "♟" + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameViewer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameViewer.kt new file mode 100644 index 000000000..1eb4ad2a1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameViewer.kt @@ -0,0 +1,172 @@ +/** + * 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.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.vitorpamplona.quartz.nip64Chess.PGNParser + +/** + * Complete chess game viewer with board, metadata, and navigation + * Shared composable for Android and Desktop platforms + * + * Parses PGN content and displays: + * - Game metadata (event, players, result) + * - Interactive chess board + * - Move navigation controls + * + * @param pgnContent PGN format string + * @param modifier Modifier for the viewer + */ +@Composable +fun ChessGameViewer( + pgnContent: String, + modifier: Modifier = Modifier, +) { + val gameResult = + remember(pgnContent) { + PGNParser.parse(pgnContent) + } + + gameResult.fold( + onSuccess = { game -> + ChessGameDisplay(game, modifier) + }, + onFailure = { error -> + ChessGameError( + errorMessage = error.message ?: "Failed to parse PGN", + pgnContent = pgnContent, + modifier = modifier, + ) + }, + ) +} + +/** + * Displays a successfully parsed chess game + */ +@Composable +private fun ChessGameDisplay( + game: com.vitorpamplona.quartz.nip64Chess.ChessGame, + modifier: Modifier = Modifier, +) { + var currentMoveIndex by remember { mutableStateOf(0) } + + Card( + modifier = modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Game metadata header + PGNMetadata(game = game) + + // Chess board + ChessBoard( + position = game.positionAt(currentMoveIndex), + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f), + boardSize = 320.dp, + ) + + // Move navigation + MoveNavigator( + currentMove = currentMoveIndex, + totalMoves = game.moves.size, + onMoveChange = { currentMoveIndex = it }, + ) + } + } +} + +/** + * Displays error state when PGN parsing fails + */ +@Composable +private fun ChessGameError( + errorMessage: String, + pgnContent: String, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Error title + Text( + text = "Invalid Chess Game", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + + // Error message + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + + // PGN content label + Text( + text = "PGN Content:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(top = 4.dp), + ) + + // Raw PGN content + Text( + text = pgnContent, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt new file mode 100644 index 000000000..8fd4a6de5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt @@ -0,0 +1,137 @@ +/** + * 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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Move navigation controls for stepping through chess game positions + * Shared composable for Android and Desktop platforms + * + * @param currentMove Current move index (0 = starting position) + * @param totalMoves Total number of moves in the game + * @param onMoveChange Callback when user navigates to a different move + * @param modifier Modifier for the navigator + */ +@Composable +fun MoveNavigator( + currentMove: Int, + totalMoves: Int, + onMoveChange: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + // First move (starting position) + IconButton( + onClick = { onMoveChange(0) }, + enabled = currentMove > 0, + ) { + Icon( + Icons.Default.SkipPrevious, + contentDescription = "Start position", + tint = + if (currentMove > 0) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + + // Previous move + IconButton( + onClick = { onMoveChange((currentMove - 1).coerceAtLeast(0)) }, + enabled = currentMove > 0, + ) { + Icon( + Icons.Default.KeyboardArrowLeft, + contentDescription = "Previous move", + tint = + if (currentMove > 0) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + + // Current position indicator + Text( + text = "Move $currentMove / $totalMoves", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + // Next move + IconButton( + onClick = { onMoveChange((currentMove + 1).coerceAtMost(totalMoves)) }, + enabled = currentMove < totalMoves, + ) { + Icon( + Icons.Default.KeyboardArrowRight, + contentDescription = "Next move", + tint = + if (currentMove < totalMoves) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + + // Last move (final position) + IconButton( + onClick = { onMoveChange(totalMoves) }, + enabled = currentMove < totalMoves, + ) { + Icon( + Icons.Default.SkipNext, + contentDescription = "Final position", + tint = + if (currentMove < totalMoves) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/PGNMetadata.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/PGNMetadata.kt new file mode 100644 index 000000000..398f8657a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/PGNMetadata.kt @@ -0,0 +1,135 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.quartz.nip64Chess.ChessGame + +/** + * Display PGN game metadata (Event, players, result, etc.) + * Shared composable for Android and Desktop platforms + * + * @param game The chess game with metadata to display + * @param modifier Modifier for the metadata display + */ +@Composable +fun PGNMetadata( + game: ChessGame, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Event name + game.event?.let { event -> + Text( + text = event, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + // Site/location (if available) + game.site?.let { site -> + Text( + text = site, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Players + if (game.white != null || game.black != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + game.white?.let { white -> + Text( + text = white, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Text( + text = "vs", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + game.black?.let { black -> + Text( + text = black, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + + // Date and Result + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + game.date?.let { date -> + Text( + text = date, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Result + Text( + text = "Result: ${game.result.notation}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Round (if available) + game.round?.let { round -> + Text( + text = "Round $round", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGame.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGame.kt new file mode 100644 index 000000000..cea81c573 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGame.kt @@ -0,0 +1,84 @@ +/** + * 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 + +/** + * Represents a complete chess game parsed from PGN + * Per NIP-64, content must be valid PGN format + */ +@Immutable +data class ChessGame( + val metadata: Map, + val moves: List, + val positions: List, + val result: GameResult, +) { + init { + require(positions.isNotEmpty()) { "Game must have at least starting position" } + require(positions.size == moves.size + 1) { "Position count must be moves + 1" } + } + + /** + * Standard PGN tag accessors + */ + val event: String? get() = metadata["Event"] + val site: String? get() = metadata["Site"] + val date: String? get() = metadata["Date"] + val round: String? get() = metadata["Round"] + val white: String? get() = metadata["White"] + val black: String? get() = metadata["Black"] + + /** + * Get position after move N (0 = starting position) + */ + fun positionAt(moveIndex: Int): ChessPosition = positions.getOrElse(moveIndex) { positions.last() } + + /** + * Check if game has required metadata + */ + fun hasRequiredMetadata(): Boolean = + metadata.containsKey("Event") && + metadata.containsKey("Site") && + metadata.containsKey("Date") && + metadata.containsKey("Round") && + metadata.containsKey("White") && + metadata.containsKey("Black") && + metadata.containsKey("Result") +} + +/** + * Game result per PGN specification + */ +enum class GameResult( + val notation: String, +) { + WHITE_WINS("1-0"), + BLACK_WINS("0-1"), + DRAW("1/2-1/2"), + IN_PROGRESS("*"), + ; + + companion object { + fun parse(notation: String): GameResult = entries.find { it.notation == notation } ?: IN_PROGRESS + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt new file mode 100644 index 000000000..b0d89cea0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt @@ -0,0 +1,86 @@ +/** + * 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 + +/** + * NIP-64: Chess Game Event + * + * Kind 64 events contain chess games in PGN (Portable Game Notation) format. + * Per NIP-64 specification: + * - Content must be valid PGN format (PGN-database) + * - Clients should accept "import format" (human-created, flexible) + * - Clients should publish in "export format" (machine-generated, strict) + * - Moves must comply with chess rules + * - Clients should display content as rendered chessboard + * + * @see NIP-64 + */ +@Immutable +class ChessGameEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, // PGN database format + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 64 + const val ALT_DESCRIPTION = "Chess Game" + + /** + * Create a chess game event from PGN content + * Per NIP-64, clients should publish in strict "export format" + * + * @param pgnContent Valid PGN format string + * @param altDescription Alt text for non-supporting clients (NIP-31) + * @param createdAt Event timestamp + * @param initializer Additional tag builder operations + */ + fun build( + pgnContent: String, + altDescription: String = ALT_DESCRIPTION, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, pgnContent, createdAt) { + alt(altDescription) + initializer() + } + } + + /** + * Get PGN content from event + */ + fun pgn(): String = content + + /** + * Get alt text for non-supporting clients (NIP-31) + */ + fun altText(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "alt" }?.get(1) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessMove.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessMove.kt new file mode 100644 index 000000000..ed2e0f9c7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessMove.kt @@ -0,0 +1,78 @@ +/** + * 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 + +/** + * Represents a single chess move in Standard Algebraic Notation (SAN) + * Per NIP-64, moves must comply with chess rules + */ +@Immutable +data class ChessMove( + val san: String, // Standard Algebraic Notation, e.g., "Nf3", "e4", "O-O", "Qxe5+" + val moveNumber: Int, // Which move in the game (1-based) + val color: Color, // Who made the move + val piece: PieceType = PieceType.PAWN, + val fromSquare: String? = null, // Source square if known + val toSquare: String, // Destination square + val isCapture: Boolean = false, + val isCheck: Boolean = false, + val isCheckmate: Boolean = false, + val isCastling: Boolean = false, + val promotion: PieceType? = null, +) { + override fun toString(): String = san +} + +/** + * Chess piece types per standard notation + */ +enum class PieceType( + val symbol: Char, +) { + KING('K'), + QUEEN('Q'), + ROOK('R'), + BISHOP('B'), + KNIGHT('N'), + PAWN('P'), + ; + + companion object { + fun fromSymbol(c: Char): PieceType? = entries.find { it.symbol == c.uppercaseChar() } + } +} + +/** + * Player color + */ +enum class Color { + WHITE, + BLACK, + ; + + fun opposite(): Color = + when (this) { + WHITE -> BLACK + BLACK -> WHITE + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessPosition.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessPosition.kt new file mode 100644 index 000000000..e590a73e8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessPosition.kt @@ -0,0 +1,209 @@ +/** + * 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 + +/** + * Represents a chess position using 8x8 board array + * Coordinates: file (a-h) = columns 0-7, rank (1-8) = rows 0-7 + * a1 = [0,0], h1 = [7,0], a8 = [0,7], h8 = [7,7] + */ +@Immutable +data class ChessPosition( + private val board: Array>, + val activeColor: Color, + val moveNumber: Int, + val castlingRights: CastlingRights = CastlingRights(), + val enPassantSquare: String? = null, + val halfMoveClock: Int = 0, +) { + init { + require(board.size == 8) { "Board must be 8x8, got ${board.size} ranks" } + require(board.all { it.size == 8 }) { "Board must be 8x8, some ranks are not 8 files wide" } + } + + /** + * Get piece at square using algebraic notation (e.g., "e4") + */ + operator fun get(square: String): ChessPiece? { + val (file, rank) = parseSquare(square) + return board[rank][file] + } + + /** + * Get piece at coordinates (file: 0-7, rank: 0-7) + */ + fun pieceAt( + file: Int, + rank: Int, + ): ChessPiece? = board.getOrNull(rank)?.getOrNull(file) + + /** + * Create new position with a piece moved + */ + fun makeMove( + from: String, + to: String, + promotion: PieceType? = null, + ): ChessPosition { + val (fromFile, fromRank) = parseSquare(from) + val (toFile, toRank) = parseSquare(to) + + val newBoard = board.map { it.clone() }.toTypedArray() + val piece = newBoard[fromRank][fromFile] ?: throw IllegalArgumentException("No piece at $from") + + // Handle promotion + val finalPiece = + if (promotion != null && piece.type == PieceType.PAWN) { + piece.copy(type = promotion) + } else { + piece + } + + newBoard[toRank][toFile] = finalPiece + newBoard[fromRank][fromFile] = null + + return copy( + board = newBoard, + activeColor = activeColor.opposite(), + moveNumber = if (activeColor == Color.BLACK) moveNumber + 1 else moveNumber, + ) + } + + /** + * Create new position with castling + */ + fun makeCastlingMove(kingSide: Boolean): ChessPosition { + val rank = if (activeColor == Color.WHITE) 0 else 7 + val newBoard = board.map { it.clone() }.toTypedArray() + + if (kingSide) { + // King-side castling (O-O) + val king = newBoard[rank][4] + val rook = newBoard[rank][7] + newBoard[rank][6] = king + newBoard[rank][5] = rook + newBoard[rank][4] = null + newBoard[rank][7] = null + } else { + // Queen-side castling (O-O-O) + val king = newBoard[rank][4] + val rook = newBoard[rank][0] + newBoard[rank][2] = king + newBoard[rank][3] = rook + newBoard[rank][4] = null + newBoard[rank][0] = null + } + + return copy( + board = newBoard, + activeColor = activeColor.opposite(), + moveNumber = if (activeColor == Color.BLACK) moveNumber + 1 else moveNumber, + ) + } + + private fun parseSquare(square: String): Pair { + require(square.length == 2) { "Invalid square notation: $square" } + val file = square[0].lowercaseChar() - 'a' // 0-7 + val rank = square[1] - '1' // 0-7 + require(file in 0..7 && rank in 0..7) { "Square out of bounds: $square" } + return file to rank + } + + companion object { + /** + * Standard starting position per FIDE rules + */ + fun initial(): ChessPosition { + val board = Array(8) { Array(8) { null } } + + // White pieces (ranks 0-1) + board[0] = + arrayOf( + ChessPiece(PieceType.ROOK, Color.WHITE), + ChessPiece(PieceType.KNIGHT, Color.WHITE), + ChessPiece(PieceType.BISHOP, Color.WHITE), + ChessPiece(PieceType.QUEEN, Color.WHITE), + ChessPiece(PieceType.KING, Color.WHITE), + ChessPiece(PieceType.BISHOP, Color.WHITE), + ChessPiece(PieceType.KNIGHT, Color.WHITE), + ChessPiece(PieceType.ROOK, Color.WHITE), + ) + board[1] = Array(8) { ChessPiece(PieceType.PAWN, Color.WHITE) } + + // Black pieces (ranks 6-7) + board[6] = Array(8) { ChessPiece(PieceType.PAWN, Color.BLACK) } + board[7] = + arrayOf( + ChessPiece(PieceType.ROOK, Color.BLACK), + ChessPiece(PieceType.KNIGHT, Color.BLACK), + ChessPiece(PieceType.BISHOP, Color.BLACK), + ChessPiece(PieceType.QUEEN, Color.BLACK), + ChessPiece(PieceType.KING, Color.BLACK), + ChessPiece(PieceType.BISHOP, Color.BLACK), + ChessPiece(PieceType.KNIGHT, Color.BLACK), + ChessPiece(PieceType.ROOK, Color.BLACK), + ) + + return ChessPosition( + board = board, + activeColor = Color.WHITE, + moveNumber = 1, + ) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ChessPosition) return false + return board.contentDeepEquals(other.board) && + activeColor == other.activeColor && + moveNumber == other.moveNumber + } + + override fun hashCode(): Int { + var result = board.contentDeepHashCode() + result = 31 * result + activeColor.hashCode() + result = 31 * result + moveNumber + return result + } +} + +/** + * Represents a chess piece + */ +@Immutable +data class ChessPiece( + val type: PieceType, + val color: Color, +) + +/** + * Castling availability + */ +@Immutable +data class CastlingRights( + val whiteKingSide: Boolean = true, + val whiteQueenSide: Boolean = true, + val blackKingSide: Boolean = true, + val blackQueenSide: Boolean = true, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParser.kt new file mode 100644 index 000000000..9394f5768 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParser.kt @@ -0,0 +1,323 @@ +/** + * 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 + +/** + * Parse PGN (Portable Game Notation) format per NIP-64 specification + * + * Accepts "import format" (human-created, flexible) as per PGN spec + * Handles: + * - Tag pairs: [TagName "TagValue"] + * - Move text in Standard Algebraic Notation (SAN) + * - Comments: {...} + * - Variations: (...) + * - Result markers: 1-0, 0-1, 1/2-1/2, * + */ +object PGNParser { + /** + * Parse PGN content into ChessGame + * + * @param pgn PGN format string + * @return Result containing ChessGame or error + */ + fun parse(pgn: String): Result = + runCatching { + val lines = pgn.lines().map { it.trim() } + + // Extract metadata tags [Key "Value"] + val metadata = parseMetadata(lines) + + // Extract move text (everything after metadata) + val moveText = + lines + .dropWhile { it.startsWith("[") || it.isEmpty() } + .joinToString(" ") + + val (moves, result) = parseMoves(moveText) + + // Generate positions by replaying moves + val positions = generatePositions(moves) + + ChessGame( + metadata = metadata, + moves = moves, + positions = positions, + result = result, + ) + } + + /** + * Extract PGN tag pairs from lines + * Format: [TagName "TagValue"] + */ + private fun parseMetadata(lines: List): Map { + val tagRegex = """\[(\w+)\s+"([^"]*)"\]""".toRegex() + return lines + .mapNotNull { tagRegex.matchEntire(it) } + .associate { it.groupValues[1] to it.groupValues[2] } + } + + /** + * Parse move text into list of moves and game result + */ + private fun parseMoves(moveText: String): Pair, GameResult> { + // Remove comments {...} and variations (...) + val cleaned = + moveText + .replace(Regex("""\{[^}]*\}"""), "") // Remove comments + .replace(Regex("""\([^)]*\)"""), "") // Remove variations + .replace(Regex("""\$\d+"""), "") // Remove NAG annotations + .trim() + + // Extract result marker + val result = + when { + cleaned.contains("1-0") -> GameResult.WHITE_WINS + cleaned.contains("0-1") -> GameResult.BLACK_WINS + cleaned.contains("1/2-1/2") -> GameResult.DRAW + else -> GameResult.IN_PROGRESS + } + + // Remove result marker from move text + val movesOnly = + cleaned + .replace("1-0", "") + .replace("0-1", "") + .replace("1/2-1/2", "") + .replace("*", "") + .replace(Regex("""\s+"""), " ") // Normalize whitespace + .trim() + + // Parse move pairs: "1. e4 e5 2. Nf3 Nc6" + val moveRegex = """(\d+)\.\s*([^\s]+)(?:\s+([^\s]+))?""".toRegex() + val moves = mutableListOf() + + moveRegex.findAll(movesOnly).forEach { match -> + val moveNum = match.groupValues[1].toIntOrNull() ?: return@forEach + val whiteMove = match.groupValues[2] + val blackMove = match.groupValues.getOrNull(3)?.takeIf { it.isNotEmpty() } + + // Skip if this is just a result marker + if (!isResultMarker(whiteMove)) { + moves.add(parseMove(whiteMove, moveNum, Color.WHITE)) + } + + blackMove?.let { + if (!isResultMarker(it)) { + moves.add(parseMove(it, moveNum, Color.BLACK)) + } + } + } + + return moves to result + } + + /** + * Check if text is a game result marker + * Valid results: 1-0, 0-1, 1/2-1/2, * + */ + private fun isResultMarker(text: String): Boolean = text == "1-0" || text == "0-1" || text == "1/2-1/2" || text == "*" + + /** + * Parse a single move in Standard Algebraic Notation (SAN) + * + * Examples: + * - e4 (pawn move) + * - Nf3 (knight to f3) + * - Bxe5 (bishop captures on e5) + * - O-O (kingside castling) + * - O-O-O (queenside castling) + * - e8=Q (pawn promotion to queen) + * - Nbd2 (knight from b-file to d2) + * - R1a3 (rook from rank 1 to a3) + * - Qh4+ (queen to h4 with check) + * - Qh4# (queen to h4 with checkmate) + */ + private fun parseMove( + san: String, + moveNumber: Int, + color: Color, + ): ChessMove { + // Clean up annotations + var text = san.replace(Regex("[!?]+"), "").trim() + + val isCheck = text.contains("+") + val isCheckmate = text.contains("#") + val isCapture = text.contains("x") + + // Remove check/checkmate markers + text = text.replace("+", "").replace("#", "") + + // Castling + if (text == "O-O" || text == "0-0") { + return ChessMove( + san = san, + moveNumber = moveNumber, + color = color, + piece = PieceType.KING, + toSquare = if (color == Color.WHITE) "g1" else "g8", + isCheck = isCheck, + isCheckmate = isCheckmate, + isCastling = true, + ) + } + + if (text == "O-O-O" || text == "0-0-0") { + return ChessMove( + san = san, + moveNumber = moveNumber, + color = color, + piece = PieceType.KING, + toSquare = if (color == Color.WHITE) "c1" else "c8", + isCheck = isCheck, + isCheckmate = isCheckmate, + isCastling = true, + ) + } + + // Remove capture marker + text = text.replace("x", "") + + // Check for promotion (e.g., e8=Q) + val promotionRegex = """([a-h][18])=([QRBN])""".toRegex() + val promotionMatch = promotionRegex.find(text) + val promotion = promotionMatch?.groupValues?.get(2)?.let { PieceType.fromSymbol(it[0]) } + if (promotionMatch != null) { + text = text.replace(promotionRegex, promotionMatch.groupValues[1]) + } + + // Determine piece type (first char if uppercase, otherwise pawn) + val piece = + if (text.isNotEmpty() && text[0].isUpperCase()) { + PieceType.fromSymbol(text[0]) ?: PieceType.PAWN + } else { + PieceType.PAWN + } + + // Remove piece symbol if present + if (piece != PieceType.PAWN && text.isNotEmpty()) { + text = text.substring(1) + } + + // Extract destination square (last 2 chars should be file+rank) + val squareRegex = """([a-h][1-8])$""".toRegex() + val squareMatch = squareRegex.find(text) + val toSquare = squareMatch?.value ?: "" + + // Extract disambiguation (file or rank between piece and destination) + val fromSquare = text.replace(toSquare, "").takeIf { it.isNotEmpty() } + + return ChessMove( + san = san, + moveNumber = moveNumber, + color = color, + piece = piece, + fromSquare = fromSquare, + toSquare = toSquare, + isCapture = isCapture, + isCheck = isCheck, + isCheckmate = isCheckmate, + promotion = promotion, + ) + } + + /** + * Generate list of positions by replaying moves from starting position + * + * Note: This implementation uses simplified move application. + * Full legal move validation would require complete chess engine logic. + */ + private fun generatePositions(moves: List): List { + val positions = mutableListOf(ChessPosition.initial()) + var current = ChessPosition.initial() + + moves.forEach { move -> + try { + current = + when { + move.isCastling -> { + // Castling + val kingSide = move.toSquare.contains("g") + current.makeCastlingMove(kingSide) + } + move.fromSquare != null -> { + // Move with disambiguation (we can infer full source) + // For now, just use simplified move + makeSimplifiedMove(current, move) + } + else -> { + // Regular move + makeSimplifiedMove(current, move) + } + } + positions.add(current) + } catch (e: Exception) { + // If move application fails, keep previous position + positions.add(current) + } + } + + return positions + } + + /** + * Simplified move application without full legal move validation + * Finds piece that can move to destination and creates new position + */ + private fun makeSimplifiedMove( + position: ChessPosition, + move: ChessMove, + ): ChessPosition { + // For MVP: Try to find a piece of the right type that could move to destination + // This is simplified and doesn't validate full chess rules + val targetSquare = move.toSquare + + // Find all pieces of the moving color and type + val candidates = mutableListOf() + for (rank in 0..7) { + for (file in 0..7) { + val piece = position.pieceAt(file, rank) + if (piece != null && + piece.color == move.color && + piece.type == move.piece + ) { + val square = "${'a' + file}${rank + 1}" + candidates.add(square) + } + } + } + + // Use disambiguation if provided + val fromSquare = + if (move.fromSquare != null) { + candidates.firstOrNull { it.contains(move.fromSquare) } + } else { + candidates.firstOrNull() + } ?: "" + + return if (fromSquare.isNotEmpty()) { + position.makeMove(fromSquare, targetSquare, move.promotion) + } else { + // If we can't find source, return current position unchanged + position + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 2b79f5074..a8d633845 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -183,6 +183,7 @@ class EventFactory { CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) + ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig) ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEventTest.kt new file mode 100644 index 000000000..25e104f12 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEventTest.kt @@ -0,0 +1,288 @@ +/** + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Tests for ChessGameEvent (NIP-64 Kind 64 event) + * + * Verifies: + * - Event kind is 64 + * - PGN content storage + * - Alt text support (NIP-31) + * - Event structure compliance + */ +class ChessGameEventTest { + private val samplePGN = + """ + [Event "Test Game"] + [Site "Internet"] + [Date "2024.12.28"] + [Round "1"] + [White "Alice"] + [Black "Bob"] + [Result "1-0"] + + 1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0 + """.trimIndent() + + @Test + fun `verify event kind is 64`() { + assertEquals(64, ChessGameEvent.KIND, "Chess game event should be kind 64") + } + + @Test + fun `pgn content is accessible`() { + // Create a mock event manually for testing + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Chess Game")), + content = samplePGN, + sig = "test_sig", + ) + + assertEquals(samplePGN, testEvent.pgn(), "PGN content should be accessible via pgn()") + assertEquals(samplePGN, testEvent.content, "PGN should be in content field") + } + + @Test + fun `alt text is accessible when present`() { + val customAltText = "Scholar's Mate Example" + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", customAltText)), + content = samplePGN, + sig = "test_sig", + ) + + assertEquals(customAltText, testEvent.altText(), "Alt text should be extractable from tags") + } + + @Test + fun `alt text returns null when not present`() { + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = emptyArray(), + content = samplePGN, + sig = "test_sig", + ) + + assertEquals(null, testEvent.altText(), "Should return null when no alt tag present") + } + + @Test + fun `event can store complete game with metadata`() { + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Chess Game")), + content = samplePGN, + sig = "test_sig", + ) + + // Parse the PGN to verify it's valid + val gameResult = PGNParser.parse(testEvent.pgn()) + assertTrue(gameResult.isSuccess, "Event should contain valid PGN") + + val game = gameResult.getOrThrow() + assertEquals("Test Game", game.event) + assertEquals("Alice", game.white) + assertEquals("Bob", game.black) + assertEquals(GameResult.WHITE_WINS, game.result) + } + + @Test + fun `event can store minimal PGN`() { + val minimalPGN = "1. e4 *" + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Chess Game")), + content = minimalPGN, + sig = "test_sig", + ) + + val gameResult = PGNParser.parse(testEvent.pgn()) + assertTrue(gameResult.isSuccess, "Should handle minimal PGN") + + val game = gameResult.getOrThrow() + assertEquals(1, game.moves.size) + assertEquals(GameResult.IN_PROGRESS, game.result) + } + + @Test + fun `event can store game in progress`() { + val inProgressPGN = + """ + [Event "Live Game"] + [Result "*"] + + 1. e4 e5 2. Nf3 Nc6 3. Bb5 * + """.trimIndent() + + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Live Chess Game")), + content = inProgressPGN, + sig = "test_sig", + ) + + val gameResult = PGNParser.parse(testEvent.pgn()) + assertTrue(gameResult.isSuccess) + + val game = gameResult.getOrThrow() + assertEquals(GameResult.IN_PROGRESS, game.result) + assertEquals("*", game.metadata["Result"]) + } + + @Test + fun `event preserves PGN formatting`() { + val formattedPGN = + """ + [Event "Formatted Game"] + [White "Player 1"] + [Black "Player 2"] + + 1. e4 e5 + 2. Nf3 Nc6 + 3. Bb5 a6 + * + """.trimIndent() + + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Chess Game")), + content = formattedPGN, + sig = "test_sig", + ) + + // Content should be preserved exactly as provided + assertEquals(formattedPGN, testEvent.pgn()) + } + + @Test + fun `default alt text is Chess Game`() { + assertEquals("Chess Game", ChessGameEvent.ALT_DESCRIPTION) + } + + @Test + fun `event inherits from Event base class`() { + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = emptyArray(), + content = "1. e4 *", + sig = "test_sig", + ) + + // Verify base Event properties + assertEquals("test_id", testEvent.id) + assertEquals("test_pubkey", testEvent.pubKey) + assertEquals(1000L, testEvent.createdAt) + assertEquals(64, testEvent.kind) + assertEquals("test_sig", testEvent.sig) + } + + @Test + fun `event can contain long tournament game`() { + val longPGN = + """ + [Event "Tournament Game"] + [Site "Online"] + [Date "2024.12.28"] + [Round "5"] + [White "GM Player"] + [Black "IM Player"] + [Result "1/2-1/2"] + + 1. d4 Nf6 2. c4 g6 3. Nc3 Bg7 4. e4 d6 5. Nf3 O-O 6. Be2 e5 + 7. O-O Nc6 8. d5 Ne7 9. Ne1 Nd7 10. Nd3 f5 11. Bd2 Nf6 12. f3 f4 + 13. Rc1 g5 14. Nb5 Ng6 15. c5 Rf7 16. Qa4 h5 17. Rfe1 Bf8 + 18. cxd6 cxd6 19. Rc6 Bd7 20. Rec1 Bxc6 21. Rxc6 Qd7 22. Rc1 Rc8 + 23. Rxc8 Qxc8 24. Qa6 Qc2 25. Qxb7 Qxb2 26. Qxa7 Qxa2 27. Qb7 Qa1+ + 28. Kf2 Qb2 29. Qa7 Ra7 30. Qa4 Qa2 31. Qa8 Qb2 32. Qa4 Qa2 1/2-1/2 + """.trimIndent() + + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Long Tournament Game")), + content = longPGN, + sig = "test_sig", + ) + + val gameResult = PGNParser.parse(testEvent.pgn()) + assertTrue(gameResult.isSuccess) + + val game = gameResult.getOrThrow() + assertTrue(game.moves.size > 50, "Should handle long games") + assertEquals(GameResult.DRAW, game.result) + } + + @Test + fun `verify tags array structure`() { + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = + arrayOf( + arrayOf("alt", "Test Alt Text"), + arrayOf("t", "chess"), + arrayOf("t", "game"), + ), + content = "1. e4 *", + sig = "test_sig", + ) + + // Verify tags structure + assertTrue(testEvent.tags.size >= 1) + assertEquals("alt", testEvent.tags[0][0]) + assertEquals("Test Alt Text", testEvent.tags[0][1]) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParserTest.kt new file mode 100644 index 000000000..7e2fc0ad8 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParserTest.kt @@ -0,0 +1,454 @@ +/** + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Comprehensive tests for PGN parsing per NIP-64 specification + * + * Tests cover: + * - PGN metadata extraction + * - Move parsing in Standard Algebraic Notation + * - Game result parsing + * - Comments and variations handling + * - Edge cases and error handling + */ +class PGNParserTest { + // Test 1: Parse minimal PGN (NIP-64 requirement: accept import format) + @Test + fun `parse minimal PGN with single move`() { + val pgn = "1. e4 *" + val result = PGNParser.parse(pgn) + + assertTrue(result.isSuccess, "Should successfully parse minimal PGN") + val game = result.getOrThrow() + assertEquals(1, game.moves.size, "Should have 1 move") + assertEquals("e4", game.moves[0].san) + assertEquals(GameResult.IN_PROGRESS, game.result) + } + + // Test 2: Parse complete game with metadata (NIP-64 requirement) + @Test + fun `parse PGN with full metadata tags`() { + val pgn = + """ + [Event "F/S Return Match"] + [Site "Belgrade, Serbia JUG"] + [Date "1992.11.04"] + [Round "29"] + [White "Fischer, Robert J."] + [Black "Spassky, Boris V."] + [Result "1/2-1/2"] + + 1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 1/2-1/2 + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Verify metadata extraction + assertEquals("F/S Return Match", game.event) + assertEquals("Belgrade, Serbia JUG", game.site) + assertEquals("1992.11.04", game.date) + assertEquals("29", game.round) + assertEquals("Fischer, Robert J.", game.white) + assertEquals("Spassky, Boris V.", game.black) + assertEquals(GameResult.DRAW, game.result) + + // Verify moves + assertEquals(6, game.moves.size) + assertEquals("e4", game.moves[0].san) + assertEquals("Nf3", game.moves[2].san) + } + + // Test 3: Scholar's Mate (4 move checkmate) + @Test + fun `parse scholars mate with checkmate notation`() { + val pgn = + """ + [Event "Scholar's Mate"] + [White "Alice"] + [Black "Bob"] + [Result "1-0"] + + 1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6 4. Qxf7# 1-0 + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals(7, game.moves.size) + assertEquals(GameResult.WHITE_WINS, game.result) + + // Verify last move has checkmate marker + val lastMove = game.moves.last() + assertEquals("Qxf7#", lastMove.san) + assertTrue(lastMove.isCheckmate, "Last move should be checkmate") + assertTrue(lastMove.isCapture, "Last move should be capture") + } + + // Test 4: Fool's Mate (2 move checkmate) + @Test + fun `parse fools mate shortest checkmate`() { + val pgn = + """ + 1. f3 e5 2. g4 Qh4# 0-1 + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals(4, game.moves.size) + assertEquals(GameResult.BLACK_WINS, game.result) + + val lastMove = game.moves.last() + assertEquals("Qh4#", lastMove.san) + assertTrue(lastMove.isCheckmate) + } + + // Test 5: Castling notation + @Test + fun `parse castling moves kingside and queenside`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O O-O 5. d3 d6 6. c3 a6 7. a4 a5 8. O-O-O *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Find castling moves + val kingsideCastling = game.moves.filter { it.san == "O-O" } + val queensideCastling = game.moves.filter { it.san == "O-O-O" } + + assertEquals(2, kingsideCastling.size, "Should have 2 kingside castling moves") + assertEquals(1, queensideCastling.size, "Should have 1 queenside castling move") + + kingsideCastling.forEach { move -> + assertTrue(move.isCastling, "O-O should be marked as castling") + assertEquals(PieceType.KING, move.piece) + } + + queensideCastling.forEach { move -> + assertTrue(move.isCastling, "O-O-O should be marked as castling") + assertEquals(PieceType.KING, move.piece) + } + } + + // Test 6: Pawn promotion + @Test + fun `parse pawn promotion to queen`() { + val pgn = + """ + [Event "Promotion Example"] + + 1. e4 d5 2. exd5 Qxd5 3. Nc3 Qa5 4. d4 c6 5. Nf3 Bg4 6. Bf4 e6 + 7. h3 Bxf3 8. Qxf3 Bb4 9. Be2 Nd7 10. a3 O-O-O 11. axb4 Qxa1+ + 12. Kd2 Qxh1 13. Qxh1 a6 14. c4 f6 15. b5 axb5 16. cxb5 c5 + 17. b6 Ne7 18. Qh2 h6 19. dxc5 Nxc5 20. b3 Kc8 21. Qg3 Ncd7 + 22. Bd6 Nf5 23. Qf4 Nxd6 24. Qxd6 Nb8 25. Kc3 Rh7 26. Kb4 Rd7 + 27. Qc5+ Kd8 28. Bf3 Ke8 29. Bd5 exd5 30. Nxd5 Kf7 31. b7 Kg6 + 32. b8=Q * + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Find promotion move + val promotionMove = game.moves.firstOrNull { it.promotion != null } + assertNotNull(promotionMove, "Should have promotion move") + assertEquals(PieceType.QUEEN, promotionMove.promotion) + assertTrue(promotionMove.san.contains("=Q"), "Promotion move should contain =Q") + } + + // Test 7: Captures + @Test + fun `parse capture notation`() { + val pgn = "1. e4 d5 2. exd5 Qxd5 3. Nc3 Qxd4 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + val captures = game.moves.filter { it.isCapture } + assertEquals(3, captures.size, "Should have 3 capture moves") + + captures.forEach { move -> + assertTrue(move.san.contains("x"), "Capture moves should contain 'x'") + } + } + + // Test 8: Check notation + @Test + fun `parse check and checkmate markers`() { + val pgn = "1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + val checkMoves = game.moves.filter { it.isCheck } + val checkmateMoves = game.moves.filter { it.isCheckmate } + + assertTrue(checkmateMoves.isNotEmpty(), "Should have checkmate moves") + checkmateMoves.forEach { move -> + assertTrue(move.san.contains("#"), "Checkmate moves should contain #") + } + } + + // Test 9: Comments and variations (NIP-64: should handle PGN comments) + @Test + fun `parse PGN with comments and variations stripped`() { + val pgn = + """ + 1. e4 {Best by test} e5 (1...c5 2. Nf3) 2. Nf3 Nc6 {Developing} 3. Bb5 * + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Comments and variations should be stripped + assertEquals(5, game.moves.size) + assertEquals("e4", game.moves[0].san) + assertEquals("e5", game.moves[1].san) + assertEquals("Nf3", game.moves[2].san) + } + + // Test 10: NAG annotations (Numeric Annotation Glyphs) + @Test + fun `parse PGN with NAG annotations`() { + val pgn = "1. e4$1 e5$6 2. Nf3$10 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals(3, game.moves.size) + } + + // Test 11: Disambiguating moves + @Test + fun `parse moves with disambiguation`() { + val pgn = + """ + 1. Nf3 Nf6 2. Nc3 Nc6 3. d4 d5 4. Bf4 Bf5 5. e3 e6 + 6. Nbd2 Nbd7 7. Bd3 Bd6 * + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Check for disambiguated moves (Nbd2, Nbd7) + val disambiguatedMoves = game.moves.filter { it.fromSquare != null } + assertTrue(disambiguatedMoves.isNotEmpty(), "Should have disambiguated moves") + } + + // Test 12: All possible game results + @Test + fun `parse all game result notations`() { + val whiteWins = "1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0" + val blackWins = "1. f3 e5 2. g4 Qh4# 0-1" + val draw = "1. e4 e5 2. Nf3 Nc6 1/2-1/2" + val inProgress = "1. e4 e5 *" + + assertEquals(GameResult.WHITE_WINS, PGNParser.parse(whiteWins).getOrThrow().result) + assertEquals(GameResult.BLACK_WINS, PGNParser.parse(blackWins).getOrThrow().result) + assertEquals(GameResult.DRAW, PGNParser.parse(draw).getOrThrow().result) + assertEquals(GameResult.IN_PROGRESS, PGNParser.parse(inProgress).getOrThrow().result) + } + + // Test 13: Empty/invalid PGN handling + @Test + fun `handle empty PGN gracefully`() { + val emptyPgn = "" + val result = PGNParser.parse(emptyPgn) + + // Should not crash, might return empty game + assertTrue(result.isSuccess || result.isFailure) + } + + // Test 14: Position generation + @Test + fun `generate positions for each move`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Should have starting position + one per move + assertEquals(game.moves.size + 1, game.positions.size) + + // First position should be starting position + val startPos = game.positions[0] + assertEquals(Color.WHITE, startPos.activeColor) + assertEquals(1, startPos.moveNumber) + } + + // Test 15: Verify required metadata (NIP-64: PGN should have standard tags) + @Test + fun `detect presence of required PGN metadata tags`() { + val fullPgn = + """ + [Event "FIDE World Championship"] + [Site "London"] + [Date "2018.11.28"] + [Round "12"] + [White "Carlsen, Magnus"] + [Black "Caruana, Fabiano"] + [Result "1-0"] + + 1. e4 * + """.trimIndent() + + val minimalPgn = "1. e4 *" + + val fullGame = PGNParser.parse(fullPgn).getOrThrow() + val minimalGame = PGNParser.parse(minimalPgn).getOrThrow() + + assertTrue(fullGame.hasRequiredMetadata(), "Full PGN should have required metadata") + assertFalse(minimalGame.hasRequiredMetadata(), "Minimal PGN should not have required metadata") + } + + // Test 16: Long tournament game + @Test + fun `parse realistic tournament game`() { + val pgn = + """ + [Event "Wch"] + [Site "New York"] + [Date "1886.??.??"] + [Round "1"] + [White "Zukertort, Johannes"] + [Black "Steinitz, William"] + [Result "0-1"] + + 1. d4 d5 2. c4 e6 3. Nc3 Nf6 4. e3 c5 5. Nf3 Nc6 6. a3 dxc4 + 7. Bxc4 cxd4 8. exd4 Be7 9. O-O O-O 10. Qd3 Bd7 11. Qe2 Qb8 + 12. Rd1 Rd8 13. Be3 Be8 14. Ne5 Nxe5 15. dxe5 Rxd1+ 16. Rxd1 Nd7 + 17. f4 Nc5 18. Qf2 Rc8 19. b4 Na6 20. Bd3 Nb8 21. Ne4 Nc6 + 22. Nd6 Bxd6 23. exd6 Qxd6 24. Bxh7+ Kh8 25. Bf5 Qc7 26. Bxc8 Qxc8 + 27. Qd2 Bg6 28. Qd7 Qxd7 29. Rxd7 b6 30. Bc1 Nd8 31. Rxd8+ 0-1 + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals("Zukertort, Johannes", game.white) + assertEquals("Steinitz, William", game.black) + assertEquals(GameResult.BLACK_WINS, game.result) + assertTrue(game.moves.size > 50, "Tournament game should have many moves") + } + + // Test 17: Alternative castling notation (0-0 instead of O-O) + @Test + fun `parse alternative castling notation with zeros`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. 0-0 0-0 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + val castlingMoves = game.moves.filter { it.isCastling } + assertEquals(2, castlingMoves.size) + } + + // Test 18: Move count verification + @Test + fun `verify move numbers are correct`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals(6, game.moves.size) + + // Verify move numbers + assertEquals(1, game.moves[0].moveNumber) // e4 + assertEquals(1, game.moves[1].moveNumber) // e5 + assertEquals(2, game.moves[2].moveNumber) // Nf3 + assertEquals(2, game.moves[3].moveNumber) // Nc6 + assertEquals(3, game.moves[4].moveNumber) // Bb5 + assertEquals(3, game.moves[5].moveNumber) // a6 + } + + // Test 19: Move colors are correct + @Test + fun `verify move colors alternate correctly`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals(Color.WHITE, game.moves[0].color) + assertEquals(Color.BLACK, game.moves[1].color) + assertEquals(Color.WHITE, game.moves[2].color) + assertEquals(Color.BLACK, game.moves[3].color) + assertEquals(Color.WHITE, game.moves[4].color) + assertEquals(Color.BLACK, game.moves[5].color) + } + + // Test 20: Piece type detection + @Test + fun `detect piece types from SAN notation`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 Bc5 4. Qa4 Qf6 5. Ke2 Ke7 6. Ra3 Ra6 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // e4, e5 - pawns + assertEquals(PieceType.PAWN, game.moves[0].piece) + assertEquals(PieceType.PAWN, game.moves[1].piece) + + // Nf3, Nc6 - knights + assertEquals(PieceType.KNIGHT, game.moves[2].piece) + assertEquals(PieceType.KNIGHT, game.moves[3].piece) + + // Bb5, Bc5 - bishops + assertEquals(PieceType.BISHOP, game.moves[4].piece) + assertEquals(PieceType.BISHOP, game.moves[5].piece) + + // Qa4, Qf6 - queens + assertEquals(PieceType.QUEEN, game.moves[6].piece) + assertEquals(PieceType.QUEEN, game.moves[7].piece) + + // Ke2, Ke7 - kings + assertEquals(PieceType.KING, game.moves[8].piece) + assertEquals(PieceType.KING, game.moves[9].piece) + + // Ra3, Ra6 - rooks + assertEquals(PieceType.ROOK, game.moves[10].piece) + assertEquals(PieceType.ROOK, game.moves[11].piece) + } +}