initial nip64 implementation
This commit is contained in:
@@ -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.RenderChannelMessage
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage
|
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile
|
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.RenderClassifieds
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
|
import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
|
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.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.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
|
||||||
@@ -911,6 +913,14 @@ private fun RenderNoteRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is ChessGameEvent -> {
|
||||||
|
RenderChessGame(
|
||||||
|
baseNote,
|
||||||
|
backgroundColor,
|
||||||
|
accountViewModel,
|
||||||
|
nav,
|
||||||
|
)
|
||||||
|
}
|
||||||
is ClassifiedsEvent -> {
|
is ClassifiedsEvent -> {
|
||||||
RenderClassifieds(
|
RenderClassifieds(
|
||||||
noteEvent,
|
noteEvent,
|
||||||
|
|||||||
@@ -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<Color>,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
val event = (note.event as? ChessGameEvent) ?: return
|
||||||
|
|
||||||
|
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
|
||||||
|
ChessGameViewer(pgnContent = event.pgn())
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -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<Note>() {
|
||||||
|
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<Note> {
|
||||||
|
val params = buildFilterParams(account)
|
||||||
|
|
||||||
|
val notes =
|
||||||
|
LocalCache.notes.filterIntoSet { _, it ->
|
||||||
|
acceptableEvent(it, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sort(notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
|
||||||
|
|
||||||
|
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||||
|
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<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||||
|
}
|
||||||
@@ -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 "♟"
|
||||||
|
}
|
||||||
|
}
|
||||||
+172
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
@@ -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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String>,
|
||||||
|
val moves: List<ChessMove>,
|
||||||
|
val positions: List<ChessPosition>,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <a href="https://github.com/nostr-protocol/nips/blob/master/64.md">NIP-64</a>
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
class ChessGameEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
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<ChessGameEvent>.() -> 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Array<ChessPiece?>>,
|
||||||
|
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<Int, Int> {
|
||||||
|
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<ChessPiece?>(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,
|
||||||
|
)
|
||||||
@@ -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<ChessGame> =
|
||||||
|
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<String>): Map<String, String> {
|
||||||
|
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<List<ChessMove>, 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<ChessMove>()
|
||||||
|
|
||||||
|
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<ChessMove>): List<ChessPosition> {
|
||||||
|
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<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,6 +183,7 @@ class EventFactory {
|
|||||||
CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig)
|
CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
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)
|
||||||
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)
|
||||||
|
|||||||
+288
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user