initial nip64 implementation

This commit is contained in:
nrobi144
2025-12-29 12:17:40 +02:00
parent a70101f5e5
commit 19c36c2979
15 changed files with 2266 additions and 0 deletions
@@ -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)
CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig)
CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig)
ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig)
ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -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)
}
}