Merge pull request #1702 from nrobi144/feat/chess-pgn-display-878

chess: live chess game implementation with Jester protocol
This commit is contained in:
Vitor Pamplona
2026-02-17 16:12:26 -05:00
committed by GitHub
93 changed files with 18478 additions and 63 deletions
+5
View File
@@ -122,6 +122,11 @@ kotlin {
// Websockets API
implementation(libs.okhttp)
implementation(libs.okhttpCoroutines)
// Chess engine for move validation and legal move generation
// NOTE: 1.0.4+ uses Java 21's removeLast() which crashes on Android API < 34
// TODO: Test if 1.0.0 works, or fork library to fix
implementation("io.github.cvb941:kchesslib:1.0.0")
}
}
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
import androidx.compose.runtime.Immutable
/**
* Platform-agnostic chess engine interface for move validation and generation
* Implemented using expect/actual for JVM/Android platforms
*/
expect class ChessEngine() {
/**
* Get the current position as FEN string
*/
fun getFen(): String
/**
* Load a position from FEN notation
*/
fun loadFen(fen: String)
/**
* Reset to starting position
*/
fun reset()
/**
* Make a move using Standard Algebraic Notation (SAN)
* @param san Move in SAN format (e.g., "Nf3", "e4", "O-O")
* @return MoveResult with success status and resulting position
*/
fun makeMove(san: String): MoveResult
/**
* Make a move from source to destination square
* @param from Source square in algebraic notation (e.g., "e2")
* @param to Destination square in algebraic notation (e.g., "e4")
* @param promotion Optional promotion piece type for pawn promotion
* @return MoveResult with success status and resulting position
*/
fun makeMove(
from: String,
to: String,
promotion: PieceType? = null,
): MoveResult
/**
* Get all legal moves from the current position
* @return List of legal moves in SAN notation
*/
fun getLegalMoves(): List<String>
/**
* Get all legal moves for a specific square
* @param square Square in algebraic notation (e.g., "e2")
* @return List of destination squares that are legal
*/
fun getLegalMovesFrom(square: String): List<String>
/**
* Check if a move is legal in the current position
* @param san Move in SAN format
* @return true if the move is legal
*/
fun isLegalMove(san: String): Boolean
/**
* Check if a move from-to is legal
*/
fun isLegalMove(
from: String,
to: String,
promotion: PieceType? = null,
): Boolean
/**
* Check if current position is checkmate
*/
fun isCheckmate(): Boolean
/**
* Check if current position is stalemate
*/
fun isStalemate(): Boolean
/**
* Check if current position is in check
*/
fun isInCheck(): Boolean
/**
* Undo the last move
*/
fun undoMove()
/**
* Get the current position as ChessPosition
*/
fun getPosition(): ChessPosition
/**
* Get side to move
*/
fun getSideToMove(): Color
/**
* Get move history in SAN notation
*/
fun getMoveHistory(): List<String>
}
/**
* Result of attempting a chess move
*/
@Immutable
data class MoveResult(
val success: Boolean,
val san: String? = null, // Move in SAN notation if successful
val position: ChessPosition? = null, // Resulting position if successful
val error: String? = null, // Error message if unsuccessful
)
@@ -0,0 +1,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,234 @@
/*
* 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.random.Random
/**
* Generates human-readable chess game names.
*
* Produces memorable names like "Bold Knight", "Swift Rook", etc.
*/
object ChessGameNameGenerator {
// Chess-themed adjectives
private val adjectives =
listOf(
"Bold",
"Swift",
"Cunning",
"Royal",
"Noble",
"Shadow",
"Silent",
"Golden",
"Silver",
"Iron",
"Fierce",
"Wise",
"Dark",
"Bright",
"Ancient",
"Brave",
"Mystic",
"Grand",
"Crimson",
"Azure",
"Ivory",
"Obsidian",
"Emerald",
"Sapphire",
"Ruby",
"Phantom",
"Eternal",
"Secret",
"Hidden",
"Blazing",
)
// Chess pieces and related nouns
private val chessNouns =
listOf(
"King",
"Queen",
"Rook",
"Bishop",
"Knight",
"Pawn",
"Castle",
"Crown",
"Throne",
"Gambit",
"Defense",
"Attack",
"Checkmate",
"Fortress",
"Tower",
"Endgame",
"Opening",
"Sacrifice",
"Pin",
"Fork",
"Strategy",
"Victory",
"Battle",
"Duel",
"Match",
"Challenge",
)
// Famous chess-related animals/themes
private val animals =
listOf(
"Dragon",
"Phoenix",
"Griffin",
"Eagle",
"Lion",
"Wolf",
"Tiger",
"Falcon",
"Hawk",
"Raven",
"Bear",
"Stallion",
"Serpent",
"Panther",
"Cobra",
)
/**
* Generate a random chess game name.
*
* @param includeNumber Whether to append a random number (1-99)
* @return A name like "Bold Knight" or "Swift Dragon 42"
*/
fun generate(includeNumber: Boolean = false): String {
val adjective = adjectives.random()
val noun = if (Random.nextBoolean()) chessNouns.random() else animals.random()
val base = "$adjective $noun"
return if (includeNumber) {
val num = Random.nextInt(1, 100)
"$base $num"
} else {
base
}
}
/**
* Generate a name based on two player display names.
*
* Creates a name that incorporates elements from both players
* for a more personalized game name.
*
* @param playerName First player's display name (can be npub/pubkey)
* @param opponentName Second player's display name (can be npub/pubkey, or null for open challenge)
* @return A personalized game name
*/
fun generateFromPlayers(
playerName: String?,
opponentName: String?,
): String {
// Extract first letter or use adjective if name is a pubkey/npub
val p1Initial = extractInitial(playerName)
val p2Initial = opponentName?.let { extractInitial(it) }
// If we have good initials, use them in the name
val adjective =
if (p1Initial != null) {
// Try to find an adjective starting with that letter
adjectives.find { it.startsWith(p1Initial, ignoreCase = true) }
?: adjectives.random()
} else {
adjectives.random()
}
val noun =
if (p2Initial != null) {
// Try to find a noun starting with opponent's initial
(chessNouns + animals).find { it.startsWith(p2Initial, ignoreCase = true) }
?: if (Random.nextBoolean()) chessNouns.random() else animals.random()
} else {
// Open challenge - use "Challenge" or similar
listOf("Challenge", "Gambit", "Opening", "Battle", "Duel").random()
}
return "$adjective $noun"
}
/**
* Extract a usable initial from a name.
* Returns null if the name looks like a pubkey/npub.
*/
private fun extractInitial(name: String?): Char? {
if (name.isNullOrBlank()) return null
// Skip if it looks like a hex pubkey or npub
if (name.length > 20 && name.all { it.isLetterOrDigit() }) return null
if (name.startsWith("npub") || name.startsWith("nprofile")) return null
// Get first letter of the display name
val firstChar = name.firstOrNull { it.isLetter() }
return firstChar?.uppercaseChar()
}
/**
* Generate a game ID that includes a readable component.
*
* @param timestamp Unix timestamp
* @return Game ID like "chess-1234567890-bold-knight"
*/
fun generateGameId(timestamp: Long): String {
val name =
generate(includeNumber = false)
.lowercase()
.replace(" ", "-")
return "chess-$timestamp-$name"
}
/**
* Extract the human-readable name from a game ID.
*
* @param gameId Game ID like "chess-1234567890-bold-knight"
* @return Display name like "Bold Knight", or null if not a named game ID
*/
fun extractDisplayName(gameId: String): String? {
// Pattern: chess-timestamp-name-parts
if (!gameId.startsWith("chess-")) return null
val parts = gameId.removePrefix("chess-").split("-")
if (parts.size < 2) return null
// First part is timestamp, rest is the name
val nameParts = parts.drop(1)
if (nameParts.isEmpty()) return null
// Check if it looks like a UUID (8 hex chars) - old format
if (nameParts.size == 1 && nameParts[0].length == 8 && nameParts[0].all { it.isLetterOrDigit() }) {
return null
}
// Convert to title case
return nameParts.joinToString(" ") { part ->
part.replaceFirstChar { it.uppercaseChar() }
}
}
}
@@ -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,215 @@
/*
* 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 &&
castlingRights == other.castlingRights &&
enPassantSquare == other.enPassantSquare &&
halfMoveClock == other.halfMoveClock
}
override fun hashCode(): Int {
var result = board.contentDeepHashCode()
result = 31 * result + activeColor.hashCode()
result = 31 * result + moveNumber
result = 31 * result + castlingRights.hashCode()
result = 31 * result + (enPassantSquare?.hashCode() ?: 0)
result = 31 * result + halfMoveClock
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,304 @@
/*
* 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
/**
* Deterministic chess state reconstruction from Jester protocol events.
*
* This is the single source of truth for converting a collection of Jester events
* into a consistent game state. Both Android and Desktop platforms must use this
* algorithm to ensure identical state from the same events.
*
* Jester Protocol Reconstruction:
* 1. Find start event (content.kind=0) → establishes startEventId, challenger color, players
* 2. Find move with longest history → this is the current state
* 3. Replay moves from history to build engine state
* 4. Check for result/termination in latest move → mark finished if present
*
* Key Jester differences:
* - No separate accept event (acceptance is implicit)
* - Full move history in every move event
* - Can reconstruct from any single move (has complete history)
* - startEventId is the game identifier (event ID of start event)
*
* Guarantees:
* - Same events always produce same state (deterministic)
* - Uses longest history for authoritative state
* - Invalid moves are skipped without error
*/
object ChessStateReconstructor {
/**
* Reconstruct game state from a collection of Jester events.
*
* @param events The collected Jester events for a single game
* @param viewerPubkey The pubkey of the user viewing the game (determines perspective)
* @return ReconstructionResult or error if reconstruction fails
*/
fun reconstruct(
events: JesterGameEvents,
viewerPubkey: String,
): ReconstructionResult {
// Step 1: Get start event (required for player info)
val startEvent =
events.startEvent
?: return ReconstructionResult.Error("No start event found")
val startEventId = startEvent.id
val challengerPubkey = startEvent.pubKey
val challengerColor = startEvent.playerColor() ?: Color.WHITE
val challengedPubkey = startEvent.opponentPubkey()
// In Jester, we determine opponent from the p-tag or from move events
// For open challenges, we need to find who made the first move
val opponentFromMoves =
events.moves
.firstOrNull { it.pubKey != challengerPubkey }
?.pubKey
val actualOpponent = challengedPubkey ?: opponentFromMoves
// Determine players based on challenger's color choice
val (whitePubkey, blackPubkey) =
if (challengerColor == Color.WHITE) {
challengerPubkey to actualOpponent
} else {
actualOpponent to challengerPubkey
}
// Determine viewer's role
val viewerRole =
when (viewerPubkey) {
whitePubkey -> ViewerRole.WHITE_PLAYER
blackPubkey -> ViewerRole.BLACK_PLAYER
else -> ViewerRole.SPECTATOR
}
val playerColor =
when (viewerRole) {
ViewerRole.WHITE_PLAYER -> Color.WHITE
ViewerRole.BLACK_PLAYER -> Color.BLACK
ViewerRole.SPECTATOR -> Color.WHITE // Spectators see from white's perspective
}
val opponentPubkey =
when (viewerRole) {
ViewerRole.WHITE_PLAYER -> blackPubkey ?: ""
ViewerRole.BLACK_PLAYER -> whitePubkey ?: ""
ViewerRole.SPECTATOR -> blackPubkey ?: "" // For spectators, "opponent" is black
}
// Check if game is pending (no moves yet AND viewer is the challenger)
// If viewer accepted someone else's challenge, the game is NOT pending
val isChallenger = viewerPubkey == startEvent.pubKey
val isPendingChallenge = events.moves.isEmpty() && isChallenger
// Step 2: Create engine and apply moves from the longest history
val engine = ChessEngine()
val latestMove = events.latestMove()
val history = latestMove?.history() ?: emptyList()
// Track applied moves
val appliedMoveNumbers = mutableSetOf<Int>()
var isDesynced = false
// Apply moves from history
for ((index, san) in history.withIndex()) {
val moveNumber = index + 1
val result = engine.makeMove(san)
if (result.success) {
appliedMoveNumbers.add(moveNumber)
} else {
// Move failed - game might be desynced
isDesynced = true
// Try to recover by loading the FEN from latest move if available
latestMove?.fen()?.let { fen ->
engine.loadFen(fen)
}
break
}
}
// Verify final position matches if we have FEN
latestMove?.fen()?.let { expectedFen ->
val currentFen = engine.getFen()
if (!fenPositionsMatch(currentFen, expectedFen)) {
isDesynced = true
engine.loadFen(expectedFen)
}
}
// Step 3: Check game end status
val gameResult = latestMove?.result()
val gameStatus =
when {
gameResult != null -> {
val result = parseGameResult(gameResult)
GameStatus.Finished(result)
}
engine.isCheckmate() -> {
val winner = engine.getSideToMove().opposite()
GameStatus.Finished(
if (winner == Color.WHITE) GameResult.WHITE_WINS else GameResult.BLACK_WINS,
)
}
engine.isStalemate() -> {
GameStatus.Finished(GameResult.DRAW)
}
else -> {
GameStatus.InProgress
}
}
// Get the head event ID (for linking next move)
val headEventId = latestMove?.id ?: startEventId
// Build the reconstructed state
val state =
ReconstructedGameState(
startEventId = startEventId,
headEventId = headEventId,
whitePubkey = whitePubkey,
blackPubkey = blackPubkey,
viewerRole = viewerRole,
playerColor = playerColor,
opponentPubkey = opponentPubkey,
isPendingChallenge = isPendingChallenge,
currentPosition = engine.getPosition(),
moveHistory = engine.getMoveHistory(),
gameStatus = gameStatus,
isDesynced = isDesynced,
pendingDrawOffer = null, // Jester doesn't have explicit draw offers
appliedMoveNumbers = appliedMoveNumbers,
challengeCreatedAt = startEvent.createdAt,
timeControl = null, // Not supported in Jester
)
return ReconstructionResult.Success(state, engine)
}
/**
* Compare FEN positions, ignoring halfmove clock and fullmove number.
* This provides a more lenient comparison that focuses on the actual board state.
*/
private fun fenPositionsMatch(
fen1: String,
fen2: String,
): Boolean {
// FEN format: position activeColor castling enPassant halfmove fullmove
// Compare only first 4 parts (position, activeColor, castling, enPassant)
val parts1 = fen1.split(" ")
val parts2 = fen2.split(" ")
if (parts1.size < 4 || parts2.size < 4) {
return fen1 == fen2 // Fallback to exact match
}
return parts1[0] == parts2[0] && // Board position
parts1[1] == parts2[1] && // Active color
parts1[2] == parts2[2] && // Castling rights
parts1[3] == parts2[3] // En passant
}
private fun parseGameResult(result: String?): GameResult =
when (result) {
"1-0" -> GameResult.WHITE_WINS
"0-1" -> GameResult.BLACK_WINS
"1/2-1/2" -> GameResult.DRAW
else -> GameResult.IN_PROGRESS
}
}
/**
* The viewer's role in the game.
*/
enum class ViewerRole {
WHITE_PLAYER,
BLACK_PLAYER,
SPECTATOR,
}
/**
* Result of game state reconstruction.
*/
sealed class ReconstructionResult {
data class Success(
val state: ReconstructedGameState,
val engine: ChessEngine,
) : ReconstructionResult()
data class Error(
val message: String,
) : ReconstructionResult()
fun isSuccess(): Boolean = this is Success
fun getOrNull(): ReconstructedGameState? = (this as? Success)?.state
fun getEngineOrNull(): ChessEngine? = (this as? Success)?.engine
}
/**
* Reconstructed game state from Jester events.
* This is an immutable snapshot of the game at the time of reconstruction.
*/
@Immutable
data class ReconstructedGameState(
/** The start event ID - this is the game identifier in Jester protocol */
val startEventId: String,
/** The current head event ID - used for linking the next move */
val headEventId: String,
val whitePubkey: String?,
val blackPubkey: String?,
val viewerRole: ViewerRole,
val playerColor: Color,
val opponentPubkey: String,
val isPendingChallenge: Boolean,
val currentPosition: ChessPosition,
val moveHistory: List<String>,
val gameStatus: GameStatus,
val isDesynced: Boolean,
val pendingDrawOffer: String?,
val appliedMoveNumbers: Set<Int>,
val challengeCreatedAt: Long,
val timeControl: String?,
) {
/** Legacy alias for startEventId */
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val gameId: String get() = startEventId
fun isPlayerTurn(): Boolean =
when (viewerRole) {
ViewerRole.SPECTATOR -> false
ViewerRole.WHITE_PLAYER -> currentPosition.activeColor == Color.WHITE
ViewerRole.BLACK_PLAYER -> currentPosition.activeColor == Color.BLACK
}
fun isFinished(): Boolean = gameStatus is GameStatus.Finished
fun hasOpponentDrawOffer(playerPubkey: String): Boolean = pendingDrawOffer != null && pendingDrawOffer != playerPubkey
fun hasOurDrawOffer(playerPubkey: String): Boolean = pendingDrawOffer == playerPubkey
}
@@ -0,0 +1,382 @@
/*
* 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.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* Jester Protocol Implementation
*
* Compatible with jesterui (https://github.com/jesterui/jesterui)
*
* Key differences from previous implementation:
* - Single event kind (30) for all chess messages
* - Content is JSON with: version, kind, fen, move, history, nonce
* - Event linking via e-tags: [startId] or [startId, headId]
* - Full move history included in every move event
*
* Content kind values:
* - 0: Game start (challenge)
* - 1: Move
* - 2: Chat (not implemented)
*
* Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md
*/
object JesterProtocol {
/** Jester uses kind 30 for all chess events */
const val KIND = 30
/** SHA256 of starting FEN position - used as reference for game discovery */
const val START_POSITION_HASH = "b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879"
/** Standard starting FEN */
const val FEN_START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
/** Content kind for game start */
const val CONTENT_KIND_START = 0
/** Content kind for move */
const val CONTENT_KIND_MOVE = 1
/** Content kind for chat */
const val CONTENT_KIND_CHAT = 2
}
/**
* JSON content structure for Jester events
*/
@Serializable
data class JesterContent(
val version: String = "0",
val kind: Int,
val fen: String = JesterProtocol.FEN_START,
val move: String? = null,
val history: List<String> = emptyList(),
val nonce: String? = null,
// Extended fields for Amethyst (backward compatible - jesterui ignores unknown fields)
val playerColor: String? = null, // "white" or "black" - challenger's color choice
val result: String? = null, // "1-0", "0-1", "1/2-1/2" for game end
val termination: String? = null, // "checkmate", "resignation", "draw_agreement", etc.
)
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = false
}
/**
* Jester Chess Event (Kind 30)
*
* Unified event class for all Jester chess messages.
* The content.kind field determines the event type:
* - 0: Game start/challenge
* - 1: Move
* - 2: Chat
*/
@Immutable
class JesterEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, JesterProtocol.KIND, tags, content, sig) {
private val parsedContent: JesterContent? by lazy {
try {
json.decodeFromString<JesterContent>(content)
} catch (e: Exception) {
null
}
}
/** Get the content kind (0=start, 1=move, 2=chat) */
fun contentKind(): Int? = parsedContent?.kind
/** Check if this is a game start event */
fun isStartEvent(): Boolean = parsedContent?.kind == JesterProtocol.CONTENT_KIND_START
/** Check if this is a move event */
fun isMoveEvent(): Boolean = parsedContent?.kind == JesterProtocol.CONTENT_KIND_MOVE
/** Get the FEN position */
fun fen(): String? = parsedContent?.fen
/** Get the latest move (SAN notation) */
fun move(): String? = parsedContent?.move
/** Get the full move history */
fun history(): List<String> = parsedContent?.history ?: emptyList()
/** Get the nonce (for start events) */
fun nonce(): String? = parsedContent?.nonce
/** Get the player color choice (for start events) */
fun playerColor(): Color? =
parsedContent?.playerColor?.let {
if (it == "white") Color.WHITE else Color.BLACK
}
/** Get the game result (for end events) */
fun result(): String? = parsedContent?.result
/** Get the termination reason (for end events) */
fun termination(): String? = parsedContent?.termination
/** Get the start event ID (first e-tag) */
fun startEventId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
/** Get the head/parent move ID (second e-tag, for moves) */
fun headEventId(): String? = tags.filter { it.size >= 2 && it[0] == "e" }.getOrNull(1)?.get(1)
/** Get opponent pubkey (p-tag, for private games) */
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
/** Get all e-tags */
fun eTags(): List<String> = tags.filter { it.size >= 2 && it[0] == "e" }.map { it[1] }
companion object {
const val KIND = JesterProtocol.KIND
/**
* Build a game start event (open challenge)
*
* @param playerColor Color the challenger wants to play
* @param nonce Unique nonce for this game
* @param createdAt Event timestamp
*/
fun buildStart(
playerColor: Color,
nonce: String = generateNonce(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<JesterEvent>.() -> Unit = {},
): EventTemplate<JesterEvent> {
val content =
JesterContent(
kind = JesterProtocol.CONTENT_KIND_START,
fen = JesterProtocol.FEN_START,
history = emptyList(),
nonce = nonce,
playerColor = if (playerColor == Color.WHITE) "white" else "black",
)
return eventTemplate(KIND, json.encodeToString(content), createdAt) {
// Reference the standard start position for game discovery
add(arrayOf("e", JesterProtocol.START_POSITION_HASH))
initializer()
}
}
/**
* Build a private game start event (direct challenge)
*
* @param opponentPubkey Pubkey of the challenged player
* @param playerColor Color the challenger wants to play
* @param nonce Unique nonce for this game
* @param createdAt Event timestamp
*/
fun buildPrivateStart(
opponentPubkey: String,
playerColor: Color,
nonce: String = generateNonce(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<JesterEvent>.() -> Unit = {},
): EventTemplate<JesterEvent> {
val content =
JesterContent(
kind = JesterProtocol.CONTENT_KIND_START,
fen = JesterProtocol.FEN_START,
history = emptyList(),
nonce = nonce,
playerColor = if (playerColor == Color.WHITE) "white" else "black",
)
return eventTemplate(KIND, json.encodeToString(content), createdAt) {
// Reference the standard start position
add(arrayOf("e", JesterProtocol.START_POSITION_HASH))
// Tag the opponent for notifications (only if valid pubkey)
if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) {
add(arrayOf("p", opponentPubkey))
}
initializer()
}
}
/**
* Build a move event
*
* @param startEventId ID of the game start event
* @param headEventId ID of the previous move (or start event for first move)
* @param move The move in SAN notation (e.g., "e4", "Nf3")
* @param fen Resulting board position in FEN
* @param history Complete move history including this move
* @param opponentPubkey Opponent's pubkey for notifications
* @param createdAt Event timestamp
*/
fun buildMove(
startEventId: String,
headEventId: String,
move: String,
fen: String,
history: List<String>,
opponentPubkey: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<JesterEvent>.() -> Unit = {},
): EventTemplate<JesterEvent> {
val content =
JesterContent(
kind = JesterProtocol.CONTENT_KIND_MOVE,
fen = fen,
move = move,
history = history,
)
return eventTemplate(KIND, json.encodeToString(content), createdAt) {
// e-tags: [startId, headId]
add(arrayOf("e", startEventId))
add(arrayOf("e", headEventId))
// Tag opponent for notifications (only if valid pubkey)
if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) {
add(arrayOf("p", opponentPubkey))
}
initializer()
}
}
/**
* Build a game end move (includes result in content)
*
* This is a move event with additional result/termination fields.
* Used when the game ends (checkmate, resignation, draw).
*/
fun buildEndMove(
startEventId: String,
headEventId: String,
move: String?,
fen: String,
history: List<String>,
opponentPubkey: String,
result: GameResult,
termination: GameTermination,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<JesterEvent>.() -> Unit = {},
): EventTemplate<JesterEvent> {
val content =
JesterContent(
kind = JesterProtocol.CONTENT_KIND_MOVE,
fen = fen,
move = move,
history = history,
result = result.notation,
termination = termination.name.lowercase(),
)
return eventTemplate(KIND, json.encodeToString(content), createdAt) {
add(arrayOf("e", startEventId))
add(arrayOf("e", headEventId))
// Tag opponent for notifications (only if valid pubkey)
if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) {
add(arrayOf("p", opponentPubkey))
}
initializer()
}
}
private fun generateNonce(): String {
val chars = "abcdefghijklmnopqrstuvwxyz0123456789"
return (1..8).map { chars.random() }.joinToString("")
}
}
}
/**
* Helper to check if an event is a Jester chess event
*/
fun Event.isJesterEvent(): Boolean = kind == JesterProtocol.KIND
/**
* Helper to check if an event is a Jester start event
*/
fun Event.isJesterStartEvent(): Boolean {
if (kind != JesterProtocol.KIND) return false
return try {
val content = json.decodeFromString<JesterContent>(this.content)
content.kind == JesterProtocol.CONTENT_KIND_START && content.history.isEmpty()
} catch (e: Exception) {
false
}
}
/**
* Helper to check if an event is a Jester move event
*/
fun Event.isJesterMoveEvent(): Boolean {
if (kind != JesterProtocol.KIND) return false
return try {
val content = json.decodeFromString<JesterContent>(this.content)
content.kind == JesterProtocol.CONTENT_KIND_MOVE &&
content.history.isNotEmpty() &&
tags.count { it.size >= 2 && it[0] == "e" } == 2
} catch (e: Exception) {
false
}
}
/**
* Convert a raw Event to JesterEvent if valid
*/
fun Event.toJesterEvent(): JesterEvent? {
if (kind != JesterProtocol.KIND) return null
return JesterEvent(id, pubKey, createdAt, tags, content, sig)
}
/**
* Game events container for Jester protocol
*/
data class JesterGameEvents(
val startEvent: JesterEvent?,
val moves: List<JesterEvent>,
) {
companion object {
fun empty() = JesterGameEvents(null, emptyList())
}
/** Get the latest move (with longest history) */
fun latestMove(): JesterEvent? = moves.maxByOrNull { it.history().size }
/** Get the current FEN position */
fun currentFen(): String = latestMove()?.fen() ?: startEvent?.fen() ?: JesterProtocol.FEN_START
/** Get the complete move history */
fun fullHistory(): List<String> = latestMove()?.history() ?: emptyList()
/** Check if game has ended */
fun isEnded(): Boolean = latestMove()?.result() != null
/** Get the game result if ended */
fun result(): String? = latestMove()?.result()
}
@@ -0,0 +1,224 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
/*
* Live chess game state and move events
*
* Uses Jester protocol (kind 30) for compatibility with jesterui.
* See: https://github.com/jesterui/jesterui/blob/devel/FLOW.md
*
* Key features of Jester protocol:
* - Single event kind (30) for all chess messages
* - Content is JSON with: version, kind (0=start, 1=move), fen, move, history
* - Full move history included in every move event
* - Event linking via e-tags: [startId] or [startId, headId]
*
* This enables:
* - Easy game reconstruction from any single move event
* - Tolerance for missing intermediate moves
* - Compatibility with jesterui and other Jester clients
*/
/**
* Game start/challenge data for publishing
*
* Jester protocol (kind 30) start event:
* - e-tag: reference to standard start position hash
* - p-tag: opponent pubkey (for private/direct challenges)
* - Content JSON with: version, kind=0, fen, history=[], nonce, playerColor
*
* @param opponentPubkey Opponent's pubkey for direct challenge (null = open challenge)
* @param playerColor Color the challenger wants to play
* @param nonce Unique identifier for this game
*/
data class ChessGameChallenge(
val opponentPubkey: String? = null, // null = open challenge
val playerColor: Color,
val nonce: String = generateNonce(),
) {
companion object {
private fun generateNonce(): String {
val chars = "abcdefghijklmnopqrstuvwxyz0123456789"
return (1..8).map { chars.random() }.joinToString("")
}
}
// Legacy compatibility - gameId is now derived from start event ID after signing
@Deprecated("gameId is determined after event creation", ReplaceWith("startEventId"))
val gameId: String get() = nonce
}
/**
* Game acceptance data
*
* In Jester protocol, accepting a game is implicit - the opponent
* simply makes the first move (if challenger chose white) or waits
* for challenger's first move (if challenger chose black).
*
* This data class is kept for internal state tracking.
*
* @param startEventId ID of the game start event to accept
* @param challengerPubkey Pubkey of the challenger
*/
data class ChessGameAccept(
val startEventId: String,
val challengerPubkey: String,
) {
// Legacy compatibility
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val gameId: String get() = startEventId
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val challengeEventId: String get() = startEventId
}
/**
* Chess move data for publishing
*
* Jester protocol (kind 30) requires:
* - Full move history in every move event
* - e-tags linking: [startEventId, headEventId]
* - Content JSON with: version, kind=1, fen, move, history
*
* @param startEventId ID of the game start event
* @param headEventId ID of the previous move (or startEventId for first move)
* @param san Move in Standard Algebraic Notation (e.g., "e4", "Nf3")
* @param fen Resulting board position in FEN notation
* @param history Complete move history including this move
* @param opponentPubkey Opponent's pubkey for p-tag notification
*/
data class ChessMoveEvent(
val startEventId: String,
val headEventId: String,
val san: String,
val fen: String,
val history: List<String>,
val opponentPubkey: String,
) {
/** Move number (1-based, derived from history length) */
val moveNumber: Int get() = history.size
// Legacy compatibility - some code still uses gameId
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val gameId: String get() = startEventId
}
/**
* Game end data for publishing
*
* In Jester protocol, game end is a move event with result/termination
* fields in the content JSON.
*
* @param startEventId ID of the game start event
* @param headEventId ID of the previous move
* @param lastMove The final move (null for resignation without move)
* @param fen Final board position
* @param history Complete move history
* @param result Game result (1-0, 0-1, 1/2-1/2)
* @param termination How the game ended
* @param opponentPubkey Opponent's pubkey for notification
*/
data class ChessGameEnd(
val startEventId: String,
val headEventId: String,
val lastMove: String?,
val fen: String,
val history: List<String>,
val result: GameResult,
val termination: GameTermination,
val opponentPubkey: String,
) {
// Legacy compatibility
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val gameId: String get() = startEventId
val winnerPubkey: String? get() = null // Determined from result
}
/**
* Draw offer data
*
* In Jester protocol, draw offers can be communicated via:
* - Chat message (kind=2 in content)
* - Special field in move content
*
* Opponent can:
* - Accept by sending a game end with DRAW_AGREEMENT
* - Decline implicitly by making their next move
*
* @param startEventId ID of the game start event
* @param headEventId ID of the current head move
* @param opponentPubkey Opponent's pubkey for notification
* @param message Optional message with the draw offer
*/
data class ChessDrawOffer(
val startEventId: String,
val headEventId: String,
val opponentPubkey: String,
val message: String? = null,
) {
// Legacy compatibility
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val gameId: String get() = startEventId
}
/**
* Game termination reason
*/
enum class GameTermination {
CHECKMATE,
RESIGNATION,
DRAW_AGREEMENT,
STALEMATE,
TIMEOUT,
ABANDONMENT,
}
/**
* Event kind constants for live chess (Jester protocol)
*
* Jester uses a single kind (30) for all chess events.
* The event type is determined by the content.kind field:
* - 0: Game start/challenge
* - 1: Move
* - 2: Chat
*/
object LiveChessEventKinds {
/** Jester protocol uses kind 30 for all chess events */
const val JESTER = 30
// Legacy constants - deprecated, use JESTER instead
@Deprecated("Jester uses single kind 30", ReplaceWith("JESTER"))
const val GAME_CHALLENGE = 30
@Deprecated("Jester uses single kind 30", ReplaceWith("JESTER"))
const val GAME_ACCEPT = 30
@Deprecated("Jester uses single kind 30", ReplaceWith("JESTER"))
const val CHESS_MOVE = 30
@Deprecated("Jester uses single kind 30", ReplaceWith("JESTER"))
const val GAME_END = 30
@Deprecated("Jester uses single kind 30", ReplaceWith("JESTER"))
const val DRAW_OFFER = 30
}
@@ -0,0 +1,295 @@
/*
* 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.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Live Chess Game Challenge Event (Kind 30064)
*
* Challenge another player to a chess game or create an open challenge.
* This is a parameterized replaceable event.
*
* Tags:
* - d: game_id (unique identifier for this game)
* - p: opponent pubkey (optional, for direct challenges)
* - player_color: "white" or "black"
* - time_control: optional time control (e.g., "10+0", "5+3")
*/
@Immutable
class LiveChessGameChallengeEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30064
fun build(
gameId: String,
playerColor: Color,
opponentPubkey: String? = null,
timeControl: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveChessGameChallengeEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
add(arrayOf("d", gameId))
add(arrayOf("player_color", if (playerColor == Color.WHITE) "white" else "black"))
opponentPubkey?.let { add(arrayOf("p", it)) }
timeControl?.let { add(arrayOf("time_control", it)) }
alt("Chess game challenge")
initializer()
}
}
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
fun playerColor(): Color? =
tags
.firstOrNull { it.size >= 2 && it[0] == "player_color" }
?.get(1)
?.let { if (it == "white") Color.WHITE else Color.BLACK }
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
fun timeControl(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "time_control" }?.get(1)
}
/**
* Live Chess Game Accept Event (Kind 30065)
*
* Accept a chess game challenge
*
* Tags:
* - d: game_id (same as challenge)
* - e: challenge event ID
* - p: challenger pubkey
*/
@Immutable
class LiveChessGameAcceptEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30065
fun build(
gameId: String,
challengeEventId: String,
challengerPubkey: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveChessGameAcceptEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
add(arrayOf("d", gameId))
add(arrayOf("e", challengeEventId))
add(arrayOf("p", challengerPubkey))
alt("Chess game acceptance")
initializer()
}
}
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
fun challengeEventId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
fun challengerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
}
/**
* Live Chess Move Event (Kind 30066)
*
* Individual move in a live chess game
*
* Tags:
* - d: game_id
* - move_number: move number (1-based)
* - san: move in Standard Algebraic Notation
* - fen: resulting position in FEN notation
* - p: opponent pubkey
*
* Content: Optional move comment
*/
@Immutable
class LiveChessMoveEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30066
fun build(
gameId: String,
moveNumber: Int,
san: String,
fen: String,
opponentPubkey: String,
comment: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveChessMoveEvent>.() -> Unit = {},
) = eventTemplate(KIND, comment, createdAt) {
add(arrayOf("d", "$gameId-$moveNumber"))
add(arrayOf("game_id", gameId))
add(arrayOf("move_number", moveNumber.toString()))
add(arrayOf("san", san))
add(arrayOf("fen", fen))
add(arrayOf("p", opponentPubkey))
alt("Chess move: $san")
initializer()
}
}
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "game_id" }?.get(1)
fun moveNumber(): Int? =
tags
.firstOrNull { it.size >= 2 && it[0] == "move_number" }
?.get(1)
?.toIntOrNull()
fun san(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "san" }?.get(1)
fun fen(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "fen" }?.get(1)
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
fun comment(): String = content
}
/**
* Live Chess Game End Event (Kind 30067)
*
* Game result and termination
*
* Tags:
* - d: game_id
* - result: "1-0"|"0-1"|"1/2-1/2"
* - termination: reason for game end
* - winner: pubkey of winner (if applicable)
* - p: opponent pubkey
*
* Content: Optional PGN of complete game
*/
@Immutable
class LiveChessGameEndEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30067
fun build(
gameId: String,
result: GameResult,
termination: GameTermination,
winnerPubkey: String? = null,
opponentPubkey: String,
pgn: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveChessGameEndEvent>.() -> Unit = {},
) = eventTemplate(KIND, pgn, createdAt) {
add(arrayOf("d", gameId))
add(arrayOf("result", result.notation))
add(arrayOf("termination", termination.name.lowercase()))
winnerPubkey?.let { add(arrayOf("winner", it)) }
add(arrayOf("p", opponentPubkey))
alt("Chess game ended: ${result.notation}")
initializer()
}
}
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
fun result(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "result" }?.get(1)
fun termination(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "termination" }?.get(1)
fun winnerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "winner" }?.get(1)
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
fun pgn(): String = content
}
/**
* Live Chess Draw Offer Event (Kind 30068)
*
* Offer a draw to opponent. Opponent can accept by sending a game end event
* with DRAW_AGREEMENT termination, or decline/ignore by making their next move.
*
* Tags:
* - d: game_id
* - p: opponent pubkey
*
* Content: Optional message
*/
@Immutable
class LiveChessDrawOfferEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30068
fun build(
gameId: String,
opponentPubkey: String,
message: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveChessDrawOfferEvent>.() -> Unit = {},
) = eventTemplate(KIND, message, createdAt) {
add(arrayOf("d", gameId))
add(arrayOf("p", opponentPubkey))
alt("Chess draw offer")
initializer()
}
}
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
fun message(): String = content
}
@@ -0,0 +1,548 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Manages state for a live chess game
*
* This class coordinates between:
* - ChessEngine (move validation, board state)
* - Nostr events (publishing moves, receiving opponent moves)
* - UI (game state, turn management)
*
* Jester Protocol Notes:
* - startEventId: ID of the game start event (used as game identifier)
* - headEventId: ID of the current head move (updated after each move)
* - Full move history is included in every published move event
*/
class LiveChessGameState(
/** Start event ID - the game identifier in Jester protocol */
val startEventId: String,
val playerPubkey: String,
val opponentPubkey: String,
val playerColor: Color,
val engine: ChessEngine,
val createdAt: Long = TimeUtils.now(),
val isSpectator: Boolean = false,
val isPendingChallenge: Boolean = false,
/** Initial head event ID - same as startEventId for new games */
initialHeadEventId: String? = null,
) {
/** Legacy alias for startEventId */
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val gameId: String get() = startEventId
/** Current head event ID - tracks the most recent move for e-tag linking */
private val _headEventId = MutableStateFlow(initialHeadEventId ?: startEventId)
val headEventId: StateFlow<String> = _headEventId.asStateFlow()
companion object {
// Abandon timeout: 24 hours without a move
const val ABANDON_TIMEOUT_SECONDS = 24 * 60 * 60L
// Inactivity warning: 1 hour without a move
const val INACTIVITY_WARNING_SECONDS = 60 * 60L
}
private val _gameStatus = MutableStateFlow<GameStatus>(GameStatus.InProgress)
val gameStatus: StateFlow<GameStatus> = _gameStatus.asStateFlow()
private val _currentPosition = MutableStateFlow(engine.getPosition())
val currentPosition: StateFlow<ChessPosition> = _currentPosition.asStateFlow()
// Initialize from engine's history so freshly loaded games show all moves
private val _moveHistory = MutableStateFlow(engine.getMoveHistory())
val moveHistory: StateFlow<List<String>> = _moveHistory.asStateFlow()
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError.asStateFlow()
// Track when last activity occurred (move made or received)
private val _lastActivityAt = MutableStateFlow(createdAt)
val lastActivityAt: StateFlow<Long> = _lastActivityAt.asStateFlow()
// Track received move numbers to detect duplicates and out-of-order
private val receivedMoveNumbers = mutableSetOf<Int>()
// Pending moves waiting for earlier moves (move number -> move data)
private val pendingMoves = mutableMapOf<Int, Pair<String, String>>()
// Desync detection
private val _isDesynced = MutableStateFlow(false)
val isDesynced: StateFlow<Boolean> = _isDesynced.asStateFlow()
// Draw offer tracking - pubkey of who offered the draw (null = no pending offer)
private val _pendingDrawOffer = MutableStateFlow<String?>(null)
val pendingDrawOffer: StateFlow<String?> = _pendingDrawOffer.asStateFlow()
/**
* Check if there's a pending draw offer from opponent
*/
fun hasOpponentDrawOffer(): Boolean = _pendingDrawOffer.value == opponentPubkey
/**
* Check if we have an outgoing draw offer
*/
fun hasOurDrawOffer(): Boolean = _pendingDrawOffer.value == playerPubkey
/**
* Check if it's the player's turn
* Spectators never have a turn
*/
fun isPlayerTurn(): Boolean = !isSpectator && engine.getSideToMove() == playerColor
/**
* Make a move (called when player makes a move on the board)
* Returns the move event data to be published to Nostr
*
* Jester Protocol: Move events include full history and link to previous event
*/
fun makeMove(
from: String,
to: String,
promotion: PieceType? = null,
): ChessMoveEvent? {
if (!isPlayerTurn()) {
_lastError.value = "Not your turn"
return null
}
if (_gameStatus.value != GameStatus.InProgress) {
_lastError.value = "Game is not in progress"
return null
}
val result = engine.makeMove(from, to, promotion)
if (result.success && result.san != null && result.position != null) {
_currentPosition.value = result.position
_moveHistory.value = engine.getMoveHistory()
_lastActivityAt.value = TimeUtils.now()
_lastError.value = null
// Making a move implicitly declines any pending draw offer
_pendingDrawOffer.value = null
// Check for game end conditions
checkGameEnd()
// Return move event data with full history for Jester protocol
return ChessMoveEvent(
startEventId = startEventId,
headEventId = _headEventId.value,
san = result.san,
fen = engine.getFen(),
history = _moveHistory.value, // Full history for Jester
opponentPubkey = opponentPubkey,
)
} else {
_lastError.value = result.error ?: "Invalid move"
return null
}
}
/**
* Update the head event ID after a move is successfully published.
* Call this after the move event is signed and broadcast.
*
* @param newHeadEventId The ID of the newly published move event
*/
fun updateHeadEventId(newHeadEventId: String) {
_headEventId.value = newHeadEventId
}
/**
* Undo the last move made.
* Used to revert a move when publishing fails.
*
* @return true if the move was successfully undone, false if there was nothing to undo
*/
fun undoLastMove(): Boolean {
if (_moveHistory.value.isEmpty()) return false
engine.undoMove()
_currentPosition.value = engine.getPosition()
_moveHistory.value = engine.getMoveHistory()
_lastError.value = null
// Reset game status in case checkmate was detected
if (_gameStatus.value is GameStatus.Finished) {
_gameStatus.value = GameStatus.InProgress
}
return true
}
/**
* Apply opponent's move (called when receiving move event from Nostr)
*/
fun applyOpponentMove(
san: String,
fen: String,
moveNumber: Int? = null,
): Boolean {
// Check for duplicate move
if (moveNumber != null && receivedMoveNumbers.contains(moveNumber)) {
// Already processed this move, ignore
return true
}
// Check for out-of-order move
val expectedMoveNumber = _moveHistory.value.size + 1
if (moveNumber != null && moveNumber > expectedMoveNumber) {
// Store for later processing
pendingMoves[moveNumber] = san to fen
return true
}
if (isPlayerTurn()) {
_lastError.value = "Received move but it's not opponent's turn"
return false
}
// Verify the move is valid by trying to make it
val result = engine.makeMove(san)
if (result.success) {
// Mark as received
if (moveNumber != null) {
receivedMoveNumbers.add(moveNumber)
}
// Verify the resulting FEN matches what opponent sent
val currentFen = engine.getFen()
if (currentFen != fen) {
// Positions don't match - desync detected
_isDesynced.value = true
_lastError.value = "Position mismatch - syncing to opponent's position"
// Load the opponent's FEN to stay in sync
engine.loadFen(fen)
} else {
_isDesynced.value = false
}
_currentPosition.value = engine.getPosition()
_moveHistory.value = engine.getMoveHistory()
_lastActivityAt.value = TimeUtils.now()
_lastError.value = null
// Opponent making a move declines any pending draw offer
_pendingDrawOffer.value = null
// Check for game end conditions
checkGameEnd()
// Process any pending moves that are now valid
processPendingMoves()
return true
} else {
_lastError.value = "Invalid opponent move: $san"
return false
}
}
/**
* Process any pending out-of-order moves
*/
private fun processPendingMoves() {
val nextMoveNumber = _moveHistory.value.size + 1
val pendingMove = pendingMoves.remove(nextMoveNumber)
if (pendingMove != null) {
val (san, fen) = pendingMove
applyOpponentMove(san, fen, nextMoveNumber)
}
}
/**
* Force resync to a specific FEN (manual recovery)
*/
fun forceResync(fen: String) {
engine.loadFen(fen)
_currentPosition.value = engine.getPosition()
_moveHistory.value = engine.getMoveHistory()
_isDesynced.value = false
_lastError.value = null
}
/**
* Apply moves from another game state while preserving this state's participant info.
* This is used when relay reconstruction incorrectly marks us as spectator but has newer moves.
*
* @param other The other game state to take moves from
*/
fun applyMovesFrom(other: LiveChessGameState) {
val currentMoves = _moveHistory.value
val otherMoves = other.moveHistory.value
if (otherMoves.size <= currentMoves.size) return // Nothing new to apply
// Apply only the new moves
for (i in currentMoves.size until otherMoves.size) {
val san = otherMoves[i]
val result = engine.makeMove(san)
if (!result.success) {
// Try to resync from the other engine's FEN
val otherFen = other.engine.getFen()
engine.loadFen(otherFen)
break
}
}
// Update state flows
_currentPosition.value = engine.getPosition()
_moveHistory.value = engine.getMoveHistory()
_lastActivityAt.value = other.lastActivityAt.value
_isDesynced.value = false
_lastError.value = null
// Update head event ID from other state
_headEventId.value = other.headEventId.value
// Mark new moves as received
val newMoveNumbers = (currentMoves.size + 1..otherMoves.size).toSet()
receivedMoveNumbers.addAll(newMoveNumbers)
// Check for game end conditions
checkGameEnd()
}
/**
* Mark move numbers as already received.
* Used when loading a game from cache to prevent duplicate move application during refresh.
*
* @param moveNumbers Set of move numbers that have been loaded
*/
fun markMovesAsReceived(moveNumbers: Set<Int>) {
receivedMoveNumbers.addAll(moveNumbers)
}
/**
* Check if game appears abandoned (opponent hasn't moved in a long time)
*/
fun isAbandoned(): Boolean {
if (_gameStatus.value != GameStatus.InProgress) return false
if (isPlayerTurn()) return false // Only check when waiting for opponent
val elapsed = TimeUtils.now() - _lastActivityAt.value
return elapsed > ABANDON_TIMEOUT_SECONDS
}
/**
* Check if opponent is inactive (warning threshold)
*/
fun isOpponentInactive(): Boolean {
if (_gameStatus.value != GameStatus.InProgress) return false
if (isPlayerTurn()) return false
val elapsed = TimeUtils.now() - _lastActivityAt.value
return elapsed > INACTIVITY_WARNING_SECONDS
}
/**
* Claim victory due to abandonment
*/
fun claimAbandonmentVictory(): ChessGameEnd? {
if (!isAbandoned()) return null
_gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor))
return ChessGameEnd(
startEventId = startEventId,
headEventId = _headEventId.value,
lastMove = null,
fen = engine.getFen(),
history = _moveHistory.value,
result = GameResult.getResultForWinner(playerColor),
termination = GameTermination.ABANDONMENT,
opponentPubkey = opponentPubkey,
)
}
/**
* Offer resignation (player resigns)
*/
fun resign(): ChessGameEnd {
_gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor.opposite()))
return ChessGameEnd(
startEventId = startEventId,
headEventId = _headEventId.value,
lastMove = null,
fen = engine.getFen(),
history = _moveHistory.value,
result = GameResult.getResultForWinner(playerColor.opposite()),
termination = GameTermination.RESIGNATION,
opponentPubkey = opponentPubkey,
)
}
/**
* Offer a draw to opponent.
* Returns the draw offer data to be published to Nostr.
*/
fun offerDraw(): ChessDrawOffer {
_pendingDrawOffer.value = playerPubkey
return ChessDrawOffer(
startEventId = startEventId,
headEventId = _headEventId.value,
opponentPubkey = opponentPubkey,
)
}
/**
* Handle receiving a draw offer from opponent (via Nostr event)
*/
fun receiveDrawOffer(fromPubkey: String): Boolean {
if (fromPubkey != opponentPubkey) return false
_pendingDrawOffer.value = opponentPubkey
return true
}
/**
* Accept opponent's draw offer.
* Returns game end data to be published to Nostr.
*/
fun acceptDraw(): ChessGameEnd? {
if (_pendingDrawOffer.value != opponentPubkey) {
_lastError.value = "No draw offer to accept"
return null
}
_pendingDrawOffer.value = null
_gameStatus.value = GameStatus.Finished(GameResult.DRAW)
return ChessGameEnd(
startEventId = startEventId,
headEventId = _headEventId.value,
lastMove = null,
fen = engine.getFen(),
history = _moveHistory.value,
result = GameResult.DRAW,
termination = GameTermination.DRAW_AGREEMENT,
opponentPubkey = opponentPubkey,
)
}
/**
* Decline a draw offer (explicit decline, also happens implicitly on move)
*/
fun declineDraw() {
_pendingDrawOffer.value = null
}
/**
* Check if game has ended (checkmate, stalemate, etc.)
*/
private fun checkGameEnd() {
when {
engine.isCheckmate() -> {
val winner = engine.getSideToMove().opposite()
_gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(winner))
// In a real implementation, you'd publish GameEnd event here
}
engine.isStalemate() -> {
_gameStatus.value = GameStatus.Finished(GameResult.DRAW)
// In a real implementation, you'd publish GameEnd event here
}
}
}
/**
* Generate PGN for the current game
*/
private fun generatePGN(): String {
val moves = _moveHistory.value
val movePairs = moves.chunked(2)
val moveText =
movePairs
.mapIndexed { index, pair ->
val moveNum = index + 1
when (pair.size) {
2 -> "$moveNum. ${pair[0]} ${pair[1]}"
1 -> "$moveNum. ${pair[0]}"
else -> ""
}
}.joinToString(" ")
val result =
when (val status = _gameStatus.value) {
is GameStatus.Finished -> status.result.notation
else -> "*"
}
return """
[Event "Live Chess Game"]
[Site "Nostr"]
[White "${if (playerColor == Color.WHITE) playerPubkey else opponentPubkey}"]
[Black "${if (playerColor == Color.BLACK) playerPubkey else opponentPubkey}"]
[Result "$result"]
$moveText $result
""".trimIndent()
}
/**
* Mark game as finished with the given result.
* Used when loading a game from cache that already has an end event.
*/
fun markAsFinished(result: GameResult) {
_gameStatus.value = GameStatus.Finished(result)
}
/**
* Reset game to initial position
*/
fun reset() {
engine.reset()
_currentPosition.value = engine.getPosition()
_moveHistory.value = emptyList()
_gameStatus.value = GameStatus.InProgress
_lastError.value = null
}
}
/**
* Game status
*/
sealed class GameStatus {
data object InProgress : GameStatus()
data class Finished(
val result: GameResult,
) : GameStatus()
}
/**
* Extension to get result for a winning color
*/
private fun GameResult.Companion.getResultForWinner(winner: Color): GameResult =
when (winner) {
Color.WHITE -> GameResult.WHITE_WINS
Color.BLACK -> GameResult.BLACK_WINS
}
@@ -0,0 +1,325 @@
/*
* 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
}
}
}
@@ -112,6 +112,13 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent
import com.vitorpamplona.quartz.nip64Chess.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
@@ -185,6 +192,13 @@ 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)
JesterEvent.KIND -> JesterEvent(id, pubKey, createdAt, tags, content, sig)
LiveChessGameChallengeEvent.KIND -> LiveChessGameChallengeEvent(id, pubKey, createdAt, tags, content, sig)
LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig)
LiveChessMoveEvent.KIND -> LiveChessMoveEvent(id, pubKey, createdAt, tags, content, sig)
LiveChessGameEndEvent.KIND -> LiveChessGameEndEvent(id, pubKey, createdAt, tags, content, sig)
LiveChessDrawOfferEvent.KIND -> LiveChessDrawOfferEvent(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,573 @@
/*
* 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.assertNull
import kotlin.test.assertTrue
/**
* Tests for JesterEvent (Jester Protocol kind 30 events)
*
* Verifies:
* - Event kind is 30
* - JSON content parsing (version, kind, fen, move, history)
* - e-tag structure for event linking
* - p-tag for opponent tagging
* - Start vs Move event detection
* - Compatibility with jesterui protocol
*
* Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md
*/
class JesterEventTest {
private val testPubkey = "abc123def456"
private val opponentPubkey = "opponent789xyz"
private val startEventId = "start-event-id-001"
// ==========================================================================
// PROTOCOL CONSTANTS
// ==========================================================================
@Test
fun `verify event kind is 30`() {
assertEquals(30, JesterProtocol.KIND, "Jester protocol uses kind 30")
assertEquals(30, JesterEvent.KIND, "JesterEvent.KIND should be 30")
}
@Test
fun `verify start position hash constant`() {
assertEquals(
"b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879",
JesterProtocol.START_POSITION_HASH,
"Should match jesterui's START_POSITION_HASH",
)
}
@Test
fun `verify starting FEN constant`() {
assertEquals(
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
JesterProtocol.FEN_START,
"Standard chess starting position",
)
}
@Test
fun `verify content kind constants`() {
assertEquals(0, JesterProtocol.CONTENT_KIND_START, "Start event kind should be 0")
assertEquals(1, JesterProtocol.CONTENT_KIND_MOVE, "Move event kind should be 1")
assertEquals(2, JesterProtocol.CONTENT_KIND_CHAT, "Chat event kind should be 2")
}
// ==========================================================================
// START EVENT TESTS
// ==========================================================================
@Test
fun `parse start event - open challenge`() {
val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"abc12345","playerColor":"white"}"""
val event =
JesterEvent(
id = startEventId,
pubKey = testPubkey,
createdAt = 1000L,
tags =
arrayOf(
arrayOf("e", JesterProtocol.START_POSITION_HASH),
),
content = content,
sig = "test_sig",
)
assertEquals(30, event.kind)
assertTrue(event.isStartEvent(), "Should be detected as start event")
assertFalse(event.isMoveEvent(), "Should not be a move event")
assertEquals(0, event.contentKind())
assertEquals(JesterProtocol.FEN_START, event.fen())
assertEquals(Color.WHITE, event.playerColor())
assertEquals("abc12345", event.nonce())
assertTrue(event.history().isEmpty(), "Start event has no history")
assertNull(event.opponentPubkey(), "Open challenge has no opponent")
}
@Test
fun `parse start event - private challenge`() {
val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"xyz98765","playerColor":"black"}"""
val event =
JesterEvent(
id = startEventId,
pubKey = testPubkey,
createdAt = 1000L,
tags =
arrayOf(
arrayOf("e", JesterProtocol.START_POSITION_HASH),
arrayOf("p", opponentPubkey),
),
content = content,
sig = "test_sig",
)
assertTrue(event.isStartEvent())
assertEquals(Color.BLACK, event.playerColor())
assertEquals(opponentPubkey, event.opponentPubkey(), "Private challenge should have opponent")
}
@Test
fun `start event e-tag references START_POSITION_HASH`() {
val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[]}"""
val event =
JesterEvent(
id = startEventId,
pubKey = testPubkey,
createdAt = 1000L,
tags =
arrayOf(
arrayOf("e", JesterProtocol.START_POSITION_HASH),
),
content = content,
sig = "test_sig",
)
val eTags = event.eTags()
assertEquals(1, eTags.size)
assertEquals(JesterProtocol.START_POSITION_HASH, eTags[0], "Start event should reference START_POSITION_HASH")
}
// ==========================================================================
// MOVE EVENT TESTS
// ==========================================================================
@Test
fun `parse move event - first move e4`() {
val content = """{"version":"0","kind":1,"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1","move":"e4","history":["e4"]}"""
val event =
JesterEvent(
id = "move-001",
pubKey = testPubkey,
createdAt = 2000L,
tags =
arrayOf(
arrayOf("e", startEventId),
arrayOf("e", startEventId), // For first move, head is also start
arrayOf("p", opponentPubkey),
),
content = content,
sig = "test_sig",
)
assertFalse(event.isStartEvent(), "Should not be a start event")
assertTrue(event.isMoveEvent(), "Should be detected as move event")
assertEquals(1, event.contentKind())
assertEquals("e4", event.move())
assertEquals(listOf("e4"), event.history())
assertEquals("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", event.fen())
assertEquals(opponentPubkey, event.opponentPubkey())
}
@Test
fun `parse move event - multiple moves in history`() {
val content = """{"version":"0","kind":1,"fen":"rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2","move":"Nf3","history":["e4","e5","Nf3"]}"""
val event =
JesterEvent(
id = "move-003",
pubKey = testPubkey,
createdAt = 4000L,
tags =
arrayOf(
arrayOf("e", startEventId),
arrayOf("e", "move-002"), // Previous move
arrayOf("p", opponentPubkey),
),
content = content,
sig = "test_sig",
)
assertTrue(event.isMoveEvent())
assertEquals("Nf3", event.move())
assertEquals(listOf("e4", "e5", "Nf3"), event.history())
assertEquals(3, event.history().size)
}
@Test
fun `move event e-tags structure - startEventId and headEventId`() {
val content = """{"version":"0","kind":1,"fen":"test","move":"e4","history":["e4"]}"""
val headEventId = "move-002"
val event =
JesterEvent(
id = "move-003",
pubKey = testPubkey,
createdAt = 3000L,
tags =
arrayOf(
arrayOf("e", startEventId),
arrayOf("e", headEventId),
arrayOf("p", opponentPubkey),
),
content = content,
sig = "test_sig",
)
assertEquals(startEventId, event.startEventId(), "First e-tag should be startEventId")
assertEquals(headEventId, event.headEventId(), "Second e-tag should be headEventId")
val eTags = event.eTags()
assertEquals(2, eTags.size)
assertEquals(startEventId, eTags[0])
assertEquals(headEventId, eTags[1])
}
// ==========================================================================
// GAME END EVENT TESTS
// ==========================================================================
@Test
fun `parse game end event - checkmate`() {
val content = """{"version":"0","kind":1,"fen":"r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4","move":"Qxf7#","history":["e4","e5","Qh5","Nc6","Bc4","Nf6","Qxf7#"],"result":"1-0","termination":"checkmate"}"""
val event =
JesterEvent(
id = "move-007",
pubKey = testPubkey,
createdAt = 8000L,
tags =
arrayOf(
arrayOf("e", startEventId),
arrayOf("e", "move-006"),
arrayOf("p", opponentPubkey),
),
content = content,
sig = "test_sig",
)
assertTrue(event.isMoveEvent(), "End event is still a move event")
assertEquals("1-0", event.result(), "Should have result")
assertEquals("checkmate", event.termination(), "Should have termination reason")
assertEquals(7, event.history().size)
}
@Test
fun `parse game end event - resignation`() {
val content = """{"version":"0","kind":1,"fen":"test","move":"e5","history":["e4","e5"],"result":"0-1","termination":"resignation"}"""
val event =
JesterEvent(
id = "move-002",
pubKey = opponentPubkey,
createdAt = 3000L,
tags =
arrayOf(
arrayOf("e", startEventId),
arrayOf("e", "move-001"),
arrayOf("p", testPubkey),
),
content = content,
sig = "test_sig",
)
assertEquals("0-1", event.result(), "Black wins")
assertEquals("resignation", event.termination())
}
@Test
fun `parse game end event - draw`() {
val content = """{"version":"0","kind":1,"fen":"test","move":"Kf1","history":["e4","e5","Kf1"],"result":"1/2-1/2","termination":"draw_agreement"}"""
val event =
JesterEvent(
id = "move-003",
pubKey = testPubkey,
createdAt = 4000L,
tags =
arrayOf(
arrayOf("e", startEventId),
arrayOf("e", "move-002"),
arrayOf("p", opponentPubkey),
),
content = content,
sig = "test_sig",
)
assertEquals("1/2-1/2", event.result(), "Draw")
assertEquals("draw_agreement", event.termination())
}
// ==========================================================================
// EDGE CASES AND ERROR HANDLING
// ==========================================================================
@Test
fun `handle malformed JSON content gracefully`() {
val event =
JesterEvent(
id = "test",
pubKey = testPubkey,
createdAt = 1000L,
tags = emptyArray(),
content = "invalid json {{{}",
sig = "test_sig",
)
assertNull(event.contentKind(), "Should return null for invalid JSON")
assertFalse(event.isStartEvent(), "Should not crash on invalid content")
assertFalse(event.isMoveEvent(), "Should not crash on invalid content")
assertNull(event.fen())
assertNull(event.move())
assertTrue(event.history().isEmpty())
}
@Test
fun `handle empty content`() {
val event =
JesterEvent(
id = "test",
pubKey = testPubkey,
createdAt = 1000L,
tags = emptyArray(),
content = "",
sig = "test_sig",
)
assertNull(event.contentKind())
assertFalse(event.isStartEvent())
assertFalse(event.isMoveEvent())
}
@Test
fun `handle missing optional fields`() {
// Minimal valid content with only required fields
val content = """{"kind":1}"""
val event =
JesterEvent(
id = "test",
pubKey = testPubkey,
createdAt = 1000L,
tags = emptyArray(),
content = content,
sig = "test_sig",
)
assertEquals(1, event.contentKind())
assertNull(event.move())
assertTrue(event.history().isEmpty())
assertNull(event.result())
assertNull(event.termination())
assertNull(event.playerColor())
}
@Test
fun `handle event with no e-tags`() {
val content = """{"version":"0","kind":0}"""
val event =
JesterEvent(
id = "test",
pubKey = testPubkey,
createdAt = 1000L,
tags = emptyArray(),
content = content,
sig = "test_sig",
)
assertNull(event.startEventId(), "No e-tags means no startEventId")
assertNull(event.headEventId(), "No e-tags means no headEventId")
assertTrue(event.eTags().isEmpty())
}
@Test
fun `handle event with only one e-tag`() {
val content = """{"version":"0","kind":1}"""
val event =
JesterEvent(
id = "test",
pubKey = testPubkey,
createdAt = 1000L,
tags =
arrayOf(
arrayOf("e", startEventId),
),
content = content,
sig = "test_sig",
)
assertEquals(startEventId, event.startEventId())
assertNull(event.headEventId(), "Only one e-tag means no headEventId")
}
// ==========================================================================
// JESTER GAME EVENTS CONTAINER TESTS
// ==========================================================================
@Test
fun `JesterGameEvents - empty returns correct values`() {
val events = JesterGameEvents.empty()
assertNull(events.startEvent)
assertTrue(events.moves.isEmpty())
assertNull(events.latestMove())
assertEquals(JesterProtocol.FEN_START, events.currentFen())
assertTrue(events.fullHistory().isEmpty())
assertFalse(events.isEnded())
assertNull(events.result())
}
@Test
fun `JesterGameEvents - latestMove returns move with longest history`() {
val move1 = createTestMoveEvent("move-001", listOf("e4"))
val move2 = createTestMoveEvent("move-002", listOf("e4", "e5"))
val move3 = createTestMoveEvent("move-003", listOf("e4", "e5", "Nf3"))
// Provide moves in random order
val events =
JesterGameEvents(
startEvent = null,
moves = listOf(move2, move3, move1),
)
val latest = events.latestMove()
assertNotNull(latest)
assertEquals("move-003", latest.id, "Should return move with longest history")
assertEquals(3, latest.history().size)
}
@Test
fun `JesterGameEvents - currentFen returns FEN from latest move`() {
val move1 = createTestMoveEvent("move-001", listOf("e4"), fen = "fen-after-e4")
val move2 = createTestMoveEvent("move-002", listOf("e4", "e5"), fen = "fen-after-e5")
val events =
JesterGameEvents(
startEvent = null,
moves = listOf(move1, move2),
)
assertEquals("fen-after-e5", events.currentFen())
}
@Test
fun `JesterGameEvents - fullHistory returns history from latest move`() {
val move1 = createTestMoveEvent("move-001", listOf("e4"))
val move2 = createTestMoveEvent("move-002", listOf("e4", "e5"))
val events =
JesterGameEvents(
startEvent = null,
moves = listOf(move1, move2),
)
assertEquals(listOf("e4", "e5"), events.fullHistory())
}
@Test
fun `JesterGameEvents - isEnded detects result in latest move`() {
val normalMove = createTestMoveEvent("move-001", listOf("e4"))
val endMove = createTestMoveEventWithResult("move-002", listOf("e4", "Qxf7#"), result = "1-0")
val ongoingGame = JesterGameEvents(startEvent = null, moves = listOf(normalMove))
val finishedGame = JesterGameEvents(startEvent = null, moves = listOf(normalMove, endMove))
assertFalse(ongoingGame.isEnded())
assertTrue(finishedGame.isEnded())
assertEquals("1-0", finishedGame.result())
}
// ==========================================================================
// EXTENSION FUNCTION TESTS
// ==========================================================================
@Test
fun `isJesterEvent extension detects kind 30`() {
val jesterEvent =
JesterEvent(
id = "test",
pubKey = testPubkey,
createdAt = 1000L,
tags = emptyArray(),
content = "{}",
sig = "sig",
)
assertTrue(jesterEvent.isJesterEvent())
}
@Test
fun `toJesterEvent converts valid Event`() {
val event =
JesterEvent(
id = "test",
pubKey = testPubkey,
createdAt = 1000L,
tags = emptyArray(),
content = """{"kind":0}""",
sig = "sig",
)
val jesterEvent = event.toJesterEvent()
assertNotNull(jesterEvent)
assertEquals("test", jesterEvent.id)
}
// ==========================================================================
// HELPER FUNCTIONS
// ==========================================================================
private fun createTestMoveEvent(
id: String,
history: List<String>,
fen: String = "test-fen",
): JesterEvent {
val historyJson = history.joinToString(",") { "\"$it\"" }
val content = """{"version":"0","kind":1,"fen":"$fen","move":"${history.last()}","history":[$historyJson]}"""
return JesterEvent(
id = id,
pubKey = testPubkey,
createdAt = 1000L + history.size * 1000,
tags =
arrayOf(
arrayOf("e", startEventId),
arrayOf("e", "prev-move"),
arrayOf("p", opponentPubkey),
),
content = content,
sig = "sig",
)
}
private fun createTestMoveEventWithResult(
id: String,
history: List<String>,
result: String,
): JesterEvent {
val historyJson = history.joinToString(",") { "\"$it\"" }
val content = """{"version":"0","kind":1,"fen":"test-fen","move":"${history.last()}","history":[$historyJson],"result":"$result","termination":"checkmate"}"""
return JesterEvent(
id = id,
pubKey = testPubkey,
createdAt = 1000L + history.size * 1000,
tags =
arrayOf(
arrayOf("e", startEventId),
arrayOf("e", "prev-move"),
arrayOf("p", opponentPubkey),
),
content = content,
sig = "sig",
)
}
}
@@ -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)
}
}
@@ -0,0 +1,349 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
import com.github.bhlangonijr.chesslib.Board
import com.github.bhlangonijr.chesslib.Piece
import com.github.bhlangonijr.chesslib.Side
import com.github.bhlangonijr.chesslib.Square
import com.github.bhlangonijr.chesslib.move.Move
/**
* JVM/Android implementation of ChessEngine using kchesslib.
*
* Maintains its own SAN history because chesslib's Move.toString() returns
* UCI/coordinate notation (e.g. "e2e4") rather than proper SAN (e.g. "e4").
*/
actual class ChessEngine {
private val board = Board()
private val sanHistory = mutableListOf<String>()
actual fun getFen(): String = board.fen
actual fun loadFen(fen: String) {
board.loadFromFen(fen)
sanHistory.clear()
}
actual fun reset() {
board.loadFromFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
sanHistory.clear()
}
actual fun makeMove(san: String): MoveResult =
try {
if (board.doMove(san)) {
// Input is already SAN from Nostr events, store directly
sanHistory.add(san)
MoveResult(
success = true,
san = san,
position = boardToPosition(),
)
} else {
MoveResult(
success = false,
error = "Invalid move: $san",
)
}
} catch (e: Exception) {
MoveResult(
success = false,
error = e.message ?: "Unknown error making move $san",
)
}
actual fun makeMove(
from: String,
to: String,
promotion: PieceType?,
): MoveResult =
try {
val fromSquare = Square.fromValue(from.uppercase())
val toSquare = Square.fromValue(to.uppercase())
val promotionPiece =
when (promotion) {
PieceType.QUEEN -> if (board.sideToMove == Side.WHITE) Piece.WHITE_QUEEN else Piece.BLACK_QUEEN
PieceType.ROOK -> if (board.sideToMove == Side.WHITE) Piece.WHITE_ROOK else Piece.BLACK_ROOK
PieceType.BISHOP -> if (board.sideToMove == Side.WHITE) Piece.WHITE_BISHOP else Piece.BLACK_BISHOP
PieceType.KNIGHT -> if (board.sideToMove == Side.WHITE) Piece.WHITE_KNIGHT else Piece.BLACK_KNIGHT
else -> Piece.NONE
}
val move = Move(fromSquare, toSquare, promotionPiece)
if (board.legalMoves().contains(move)) {
val san = computeSan(move)
board.doMove(move)
sanHistory.add(san)
MoveResult(
success = true,
san = san,
position = boardToPosition(),
)
} else {
MoveResult(
success = false,
error = "Illegal move: $from to $to",
)
}
} catch (e: Exception) {
MoveResult(
success = false,
error = e.message ?: "Unknown error making move $from to $to",
)
}
actual fun getLegalMoves(): List<String> = board.legalMoves().map { it.toString() }
actual fun getLegalMovesFrom(square: String): List<String> {
val sq = Square.fromValue(square.uppercase())
return board
.legalMoves()
.filterNotNull()
.filter { it.from == sq }
.map { it.to.toString().lowercase() }
}
actual fun isLegalMove(san: String): Boolean =
try {
if (board.doMove(san)) {
board.undoMove()
true
} else {
false
}
} catch (e: Exception) {
false
}
actual fun isLegalMove(
from: String,
to: String,
promotion: PieceType?,
): Boolean =
try {
val fromSquare = Square.fromValue(from.uppercase())
val toSquare = Square.fromValue(to.uppercase())
val promotionPiece =
when (promotion) {
PieceType.QUEEN -> if (board.sideToMove == Side.WHITE) Piece.WHITE_QUEEN else Piece.BLACK_QUEEN
PieceType.ROOK -> if (board.sideToMove == Side.WHITE) Piece.WHITE_ROOK else Piece.BLACK_ROOK
PieceType.BISHOP -> if (board.sideToMove == Side.WHITE) Piece.WHITE_BISHOP else Piece.BLACK_BISHOP
PieceType.KNIGHT -> if (board.sideToMove == Side.WHITE) Piece.WHITE_KNIGHT else Piece.BLACK_KNIGHT
else -> Piece.NONE
}
val move = Move(fromSquare, toSquare, promotionPiece)
board.legalMoves().contains(move)
} catch (e: Exception) {
false
}
actual fun isCheckmate(): Boolean = board.isMated
actual fun isStalemate(): Boolean = board.isStaleMate
actual fun isInCheck(): Boolean = board.isKingAttacked
actual fun undoMove() {
board.undoMove()
if (sanHistory.isNotEmpty()) {
sanHistory.removeAt(sanHistory.lastIndex)
}
}
actual fun getPosition(): ChessPosition = boardToPosition()
actual fun getSideToMove(): Color =
when (board.sideToMove) {
Side.WHITE -> Color.WHITE
Side.BLACK -> Color.BLACK
}
actual fun getMoveHistory(): List<String> = sanHistory.toList()
/**
* Compute proper SAN notation for a move BEFORE it is applied to the board.
* Handles pieces, pawns, castling, captures, promotion, disambiguation, check/checkmate.
*/
private fun computeSan(move: Move): String {
val fromSquare = move.from
val toSquare = move.to
val piece = board.getPiece(fromSquare)
val pt = piece.pieceType ?: return move.toString()
val promotionPiece = move.promotion ?: Piece.NONE
// Castling
if (pt == com.github.bhlangonijr.chesslib.PieceType.KING) {
val fileDiff = toSquare.file.ordinal - fromSquare.file.ordinal
if (fileDiff == 2) {
val suffix = checkSuffixAfterMove(move)
return "O-O$suffix"
}
if (fileDiff == -2) {
val suffix = checkSuffixAfterMove(move)
return "O-O-O$suffix"
}
}
val sb = StringBuilder()
val epTarget = board.enPassantTarget
val isCapture =
board.getPiece(toSquare) != Piece.NONE ||
(
pt == com.github.bhlangonijr.chesslib.PieceType.PAWN &&
epTarget != null && epTarget != Square.NONE && toSquare == epTarget
)
if (pt != com.github.bhlangonijr.chesslib.PieceType.PAWN) {
sb.append(sanSymbol(pt))
// Disambiguation: check if other pieces of same type can reach the same square
val ambiguous =
board.legalMoves().filterNotNull().filter {
it.to == toSquare &&
board.getPiece(it.from).pieceType == pt &&
it.from != fromSquare
}
if (ambiguous.isNotEmpty()) {
val sameFile = ambiguous.any { it.from.file == fromSquare.file }
val sameRank = ambiguous.any { it.from.rank == fromSquare.rank }
when {
!sameFile -> {
sb.append(fileChar(fromSquare))
}
!sameRank -> {
sb.append(rankChar(fromSquare))
}
else -> {
sb.append(fileChar(fromSquare))
sb.append(rankChar(fromSquare))
}
}
}
} else if (isCapture) {
// Pawn captures include the source file
sb.append(fileChar(fromSquare))
}
if (isCapture) sb.append('x')
sb.append(toSquare.toString().lowercase())
// Promotion
if (promotionPiece != Piece.NONE) {
sb.append('=')
val promType = promotionPiece.pieceType
if (promType != null) sb.append(sanSymbol(promType))
}
// Check/checkmate suffix
sb.append(checkSuffixAfterMove(move))
return sb.toString()
}
/**
* Temporarily apply a move to check for check/checkmate, then undo.
*/
private fun checkSuffixAfterMove(move: Move): String {
board.doMove(move)
val suffix =
when {
board.isMated -> "#"
board.isKingAttacked -> "+"
else -> ""
}
board.undoMove()
return suffix
}
private fun sanSymbol(pt: com.github.bhlangonijr.chesslib.PieceType): String =
when (pt) {
com.github.bhlangonijr.chesslib.PieceType.KING -> "K"
com.github.bhlangonijr.chesslib.PieceType.QUEEN -> "Q"
com.github.bhlangonijr.chesslib.PieceType.ROOK -> "R"
com.github.bhlangonijr.chesslib.PieceType.BISHOP -> "B"
com.github.bhlangonijr.chesslib.PieceType.KNIGHT -> "N"
else -> ""
}
private fun fileChar(square: Square): Char = 'a' + square.file.ordinal
private fun rankChar(square: Square): Char = '1' + square.rank.ordinal
/**
* Convert kchesslib Board to our ChessPosition model
*/
private fun boardToPosition(): ChessPosition {
val positionArray = Array(8) { Array<ChessPiece?>(8) { null } }
for (rank in 0..7) {
for (file in 0..7) {
val square = Square.squareAt(rank * 8 + file)
val piece = board.getPiece(square)
if (piece != Piece.NONE) {
val pieceType =
when (piece.pieceType) {
com.github.bhlangonijr.chesslib.PieceType.KING -> PieceType.KING
com.github.bhlangonijr.chesslib.PieceType.QUEEN -> PieceType.QUEEN
com.github.bhlangonijr.chesslib.PieceType.ROOK -> PieceType.ROOK
com.github.bhlangonijr.chesslib.PieceType.BISHOP -> PieceType.BISHOP
com.github.bhlangonijr.chesslib.PieceType.KNIGHT -> PieceType.KNIGHT
com.github.bhlangonijr.chesslib.PieceType.PAWN -> PieceType.PAWN
else -> PieceType.PAWN
}
val color =
when (piece.pieceSide) {
Side.WHITE -> Color.WHITE
Side.BLACK -> Color.BLACK
else -> Color.WHITE
}
positionArray[rank][file] = ChessPiece(pieceType, color)
}
}
}
return ChessPosition(
board = positionArray,
activeColor = getSideToMove(),
moveNumber = board.moveCounter,
castlingRights =
CastlingRights(
whiteKingSide = board.castleRight.toString().contains("K"),
whiteQueenSide = board.castleRight.toString().contains("Q"),
blackKingSide = board.castleRight.toString().contains("k"),
blackQueenSide = board.castleRight.toString().contains("q"),
),
enPassantSquare = board.enPassantTarget?.let { it.toString().lowercase() },
halfMoveClock = board.halfMoveCounter,
)
}
}
@@ -0,0 +1,808 @@
/*
* 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.assertTrue
/**
* Integration tests for ChessStateReconstructor with Jester protocol.
*
* These tests verify that the reconstruction algorithm produces correct,
* deterministic game states from Jester events (kind 30).
*
* Jester Protocol:
* - Single event kind (30) for all chess messages
* - Content is JSON with: version, kind, fen, move, history
* - Start events have content.kind=0 and reference START_POSITION_HASH via e-tag
* - Move events have content.kind=1 and reference [startEventId, headEventId] via e-tags
* - Full move history is included in every move event
* - No separate accept event - acceptance is implicit when opponent makes first move
*
* Test Categories:
* 1. Basic game lifecycle (challenge, moves, end)
* 2. Move ordering and reconstruction from history
* 3. Game end conditions (checkmate, resignation)
* 4. Viewer perspective (white player, black player, spectator)
* 5. Determinism tests
*/
class ChessStateReconstructorTest {
// Test pubkeys
private val whitePubkey = "white_pubkey_abc123"
private val blackPubkey = "black_pubkey_def456"
private val spectatorPubkey = "spectator_pubkey_xyz789"
private val startEventId = "start-event-001"
// ==========================================================================
// 1. BASIC GAME LIFECYCLE TESTS
// ==========================================================================
@Test
fun `reconstruct pending challenge - no moves yet`() {
val events =
JesterGameEvents(
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
moves = emptyList(),
)
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
assertTrue(result.isSuccess(), "Should reconstruct pending challenge")
val state = result.getOrNull()!!
assertTrue(state.isPendingChallenge, "Game should be pending")
assertEquals(startEventId, state.startEventId)
assertEquals(whitePubkey, state.whitePubkey)
assertEquals(ViewerRole.WHITE_PLAYER, state.viewerRole)
assertEquals(Color.WHITE, state.playerColor)
assertTrue(state.moveHistory.isEmpty(), "No moves yet")
assertEquals(GameStatus.InProgress, state.gameStatus)
}
@Test
fun `reconstruct game with initial moves - e4 e5`() {
val move1 =
createMoveEvent(
id = "move-001",
pubKey = whitePubkey,
startEventId = startEventId,
headEventId = startEventId,
move = "e4",
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
history = listOf("e4"),
opponentPubkey = blackPubkey,
)
val move2 =
createMoveEvent(
id = "move-002",
pubKey = blackPubkey,
startEventId = startEventId,
headEventId = "move-001",
move = "e5",
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2",
history = listOf("e4", "e5"),
opponentPubkey = whitePubkey,
)
val events =
JesterGameEvents(
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
moves = listOf(move1, move2),
)
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
assertTrue(result.isSuccess())
val state = result.getOrNull()!!
assertEquals(2, state.moveHistory.size)
assertEquals("e4", state.moveHistory[0])
assertEquals("e5", state.moveHistory[1])
assertTrue(state.isPlayerTurn(), "White's turn after e4 e5")
assertEquals(Color.WHITE, state.currentPosition.activeColor)
}
@Test
fun `reconstruct game - acceptance is implicit via first opponent move`() {
// In Jester, there's no separate accept event
// Game is considered accepted when opponent makes their first move
val move1 =
createMoveEvent(
id = "move-001",
pubKey = whitePubkey,
startEventId = startEventId,
headEventId = startEventId,
move = "e4",
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
history = listOf("e4"),
opponentPubkey = blackPubkey,
)
val events =
JesterGameEvents(
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
moves = listOf(move1),
)
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
assertTrue(result.isSuccess())
val state = result.getOrNull()!!
assertFalse(state.isPendingChallenge, "Game is active with moves")
assertEquals(blackPubkey, state.blackPubkey)
assertEquals(Color.BLACK, state.currentPosition.activeColor, "Black's turn after e4")
}
// ==========================================================================
// 2. MOVE RECONSTRUCTION FROM HISTORY
// ==========================================================================
@Test
fun `reconstruct from latest move with full history`() {
// In Jester, each move contains the full history
// Reconstruction uses the move with the longest history
val move1 =
createMoveEvent(
id = "move-001",
pubKey = whitePubkey,
startEventId = startEventId,
headEventId = startEventId,
move = "e4",
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
history = listOf("e4"),
opponentPubkey = blackPubkey,
createdAt = 1000,
)
val move2 =
createMoveEvent(
id = "move-002",
pubKey = blackPubkey,
startEventId = startEventId,
headEventId = "move-001",
move = "e5",
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2",
history = listOf("e4", "e5"),
opponentPubkey = whitePubkey,
createdAt = 2000,
)
val move3 =
createMoveEvent(
id = "move-003",
pubKey = whitePubkey,
startEventId = startEventId,
headEventId = "move-002",
move = "Nf3",
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2",
history = listOf("e4", "e5", "Nf3"),
opponentPubkey = blackPubkey,
createdAt = 3000,
)
val events =
JesterGameEvents(
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
moves = listOf(move1, move2, move3),
)
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
assertTrue(result.isSuccess())
val state = result.getOrNull()!!
assertEquals(3, state.moveHistory.size)
assertEquals(listOf("e4", "e5", "Nf3"), state.moveHistory)
assertEquals("move-003", state.headEventId, "Head should be latest move")
}
@Test
fun `reconstruct with out-of-order moves - uses longest history`() {
// Events might arrive out of order, but we use the longest history
val move1 =
createMoveEvent(
id = "move-001",
pubKey = whitePubkey,
startEventId = startEventId,
headEventId = startEventId,
move = "e4",
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
history = listOf("e4"),
opponentPubkey = blackPubkey,
)
val move2 =
createMoveEvent(
id = "move-002",
pubKey = blackPubkey,
startEventId = startEventId,
headEventId = "move-001",
move = "e5",
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2",
history = listOf("e4", "e5"),
opponentPubkey = whitePubkey,
)
// Events arrive in reverse order
val events =
JesterGameEvents(
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
moves = listOf(move2, move1), // Reverse order
)
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
assertTrue(result.isSuccess())
val state = result.getOrNull()!!
assertEquals(2, state.moveHistory.size)
assertEquals("e4", state.moveHistory[0], "First move should be e4")
assertEquals("e5", state.moveHistory[1], "Second move should be e5")
}
@Test
fun `reconstruct with duplicate moves - uses longest history`() {
val move1 =
createMoveEvent(
id = "move-001",
pubKey = whitePubkey,
startEventId = startEventId,
headEventId = startEventId,
move = "e4",
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
history = listOf("e4"),
opponentPubkey = blackPubkey,
)
val move1Duplicate =
createMoveEvent(
id = "move-001-dup",
pubKey = whitePubkey,
startEventId = startEventId,
headEventId = startEventId,
move = "e4",
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
history = listOf("e4"),
opponentPubkey = blackPubkey,
)
val move2 =
createMoveEvent(
id = "move-002",
pubKey = blackPubkey,
startEventId = startEventId,
headEventId = "move-001",
move = "e5",
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2",
history = listOf("e4", "e5"),
opponentPubkey = whitePubkey,
)
val events =
JesterGameEvents(
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
moves = listOf(move1, move1Duplicate, move2),
)
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
assertTrue(result.isSuccess())
val state = result.getOrNull()!!
// Move2 has the longest history (2 moves), so that's what we reconstruct
assertEquals(2, state.moveHistory.size, "Should have 2 moves from longest history")
}
// ==========================================================================
// 3. GAME END CONDITIONS TESTS
// ==========================================================================
@Test
fun `reconstruct scholar's mate - checkmate by engine detection`() {
// Scholar's mate: 1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7#
val finalMove =
createMoveEvent(
id = "move-007",
pubKey = whitePubkey,
startEventId = startEventId,
headEventId = "move-006",
move = "Qxf7#",
fen = "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4",
history = listOf("e4", "e5", "Qh5", "Nc6", "Bc4", "Nf6", "Qxf7#"),
opponentPubkey = blackPubkey,
)
val events =
JesterGameEvents(
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
moves = listOf(finalMove), // Only need latest move with full history
)
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
assertTrue(result.isSuccess())
val state = result.getOrNull()!!
assertEquals(7, state.moveHistory.size)
val engine = result.getEngineOrNull()!!
assertTrue(engine.isCheckmate(), "Engine should detect checkmate")
assertTrue(state.gameStatus is GameStatus.Finished)
assertEquals(
GameResult.WHITE_WINS,
(state.gameStatus as GameStatus.Finished).result,
"White wins by checkmate",
)
}
@Test
fun `reconstruct game with resignation - result in move content`() {
// In Jester, resignation is indicated by result field in move content
val resignationMove =
createMoveEventWithResult(
id = "move-002",
pubKey = blackPubkey, // Black resigns
startEventId = startEventId,
headEventId = "move-001",
move = "e5",
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2",
history = listOf("e4", "e5"),
opponentPubkey = whitePubkey,
result = "1-0", // White wins by resignation
termination = "resignation",
)
val events =
JesterGameEvents(
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
moves = listOf(resignationMove),
)
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
assertTrue(result.isSuccess())
val state = result.getOrNull()!!
assertTrue(state.gameStatus is GameStatus.Finished)
assertEquals(
GameResult.WHITE_WINS,
(state.gameStatus as GameStatus.Finished).result,
"White wins by resignation",
)
}
@Test
fun `reconstruct fool's mate - 4 move checkmate for black`() {
// 1. f3 e5 2. g4 Qh4#
val finalMove =
createMoveEvent(
id = "move-004",
pubKey = blackPubkey,
startEventId = startEventId,
headEventId = "move-003",
move = "Qh4#",
fen = "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3",
history = listOf("f3", "e5", "g4", "Qh4#"),
opponentPubkey = whitePubkey,
)
val events =
JesterGameEvents(
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
moves = listOf(finalMove),
)
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
assertTrue(result.isSuccess())
val state = result.getOrNull()!!
assertTrue(state.gameStatus is GameStatus.Finished)
assertEquals(
GameResult.BLACK_WINS,
(state.gameStatus as GameStatus.Finished).result,
"Black wins by fool's mate",
)
}
// ==========================================================================
// 4. VIEWER PERSPECTIVE TESTS
// ==========================================================================
@Test
fun `reconstruct as white player - correct perspective`() {
val events = createBasicGameEvents()
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
assertTrue(result.isSuccess())
val state = result.getOrNull()!!
assertEquals(ViewerRole.WHITE_PLAYER, state.viewerRole)
assertEquals(Color.WHITE, state.playerColor)
assertEquals(blackPubkey, state.opponentPubkey)
}
@Test
fun `reconstruct as black player - correct perspective`() {
val events = createBasicGameEvents()
val result = ChessStateReconstructor.reconstruct(events, blackPubkey)
assertTrue(result.isSuccess())
val state = result.getOrNull()!!
assertEquals(ViewerRole.BLACK_PLAYER, state.viewerRole)
assertEquals(Color.BLACK, state.playerColor)
assertEquals(whitePubkey, state.opponentPubkey)
}
@Test
fun `reconstruct as spectator - white perspective, no turn`() {
val events = createBasicGameEvents()
val result = ChessStateReconstructor.reconstruct(events, spectatorPubkey)
assertTrue(result.isSuccess())
val state = result.getOrNull()!!
assertEquals(ViewerRole.SPECTATOR, state.viewerRole)
assertEquals(Color.WHITE, state.playerColor, "Spectators see from white's perspective")
assertFalse(state.isPlayerTurn(), "Spectators never have a turn")
}
@Test
fun `challenger is black - roles reversed correctly`() {
// Challenger chooses black
val move1 =
createMoveEvent(
id = "move-001",
pubKey = whitePubkey, // Opponent (white) makes first move
startEventId = startEventId,
headEventId = startEventId,
move = "e4",
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
history = listOf("e4"),
opponentPubkey = blackPubkey,
)
val events =
JesterGameEvents(
startEvent = createStartEvent(startEventId, blackPubkey, Color.BLACK, whitePubkey),
moves = listOf(move1),
)
val whiteResult = ChessStateReconstructor.reconstruct(events, whitePubkey)
val blackResult = ChessStateReconstructor.reconstruct(events, blackPubkey)
assertTrue(whiteResult.isSuccess())
assertTrue(blackResult.isSuccess())
val whiteState = whiteResult.getOrNull()!!
val blackState = blackResult.getOrNull()!!
assertEquals(whitePubkey, whiteState.whitePubkey)
assertEquals(blackPubkey, whiteState.blackPubkey)
assertEquals(ViewerRole.WHITE_PLAYER, whiteState.viewerRole)
assertEquals(ViewerRole.BLACK_PLAYER, blackState.viewerRole)
}
// ==========================================================================
// 5. ERROR HANDLING TESTS
// ==========================================================================
@Test
fun `reconstruct without start event - should fail`() {
val events = JesterGameEvents(startEvent = null, moves = emptyList())
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
assertTrue(result is ReconstructionResult.Error)
assertEquals("No start event found", (result as ReconstructionResult.Error).message)
}
@Test
fun `reconstruct with empty events - should fail`() {
val events = JesterGameEvents.empty()
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
assertTrue(result is ReconstructionResult.Error)
}
// ==========================================================================
// 6. CASTLING AND SPECIAL MOVES TESTS
// ==========================================================================
@Test
fun `reconstruct game with kingside castling`() {
// 1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O
val finalMove =
createMoveEvent(
id = "move-007",
pubKey = whitePubkey,
startEventId = startEventId,
headEventId = "move-006",
move = "O-O",
fen = "r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQ1RK1 b kq - 5 4",
history = listOf("e4", "e5", "Nf3", "Nc6", "Bc4", "Bc5", "O-O"),
opponentPubkey = blackPubkey,
)
val events =
JesterGameEvents(
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
moves = listOf(finalMove),
)
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
assertTrue(result.isSuccess())
val state = result.getOrNull()!!
assertEquals(7, state.moveHistory.size)
assertEquals("O-O", state.moveHistory[6])
assertEquals(Color.BLACK, state.currentPosition.activeColor, "Black's turn after white castled")
}
// ==========================================================================
// 7. DETERMINISM TESTS
// ==========================================================================
@Test
fun `same events always produce same state`() {
val events = createBasicGameEvents()
// Reconstruct multiple times
val results =
(1..5).map {
ChessStateReconstructor.reconstruct(events, whitePubkey)
}
// All should succeed
assertTrue(results.all { it.isSuccess() })
// All states should be identical
val states = results.map { it.getOrNull()!! }
val first = states.first()
states.forEach { state ->
assertEquals(first.startEventId, state.startEventId)
assertEquals(first.moveHistory, state.moveHistory)
assertEquals(first.gameStatus, state.gameStatus)
assertEquals(first.currentPosition.activeColor, state.currentPosition.activeColor)
assertEquals(first.currentPosition.moveNumber, state.currentPosition.moveNumber)
assertEquals(first.appliedMoveNumbers, state.appliedMoveNumbers)
}
}
@Test
fun `different move list order produces same state`() {
val move1 =
createMoveEvent(
id = "move-001",
pubKey = whitePubkey,
startEventId = startEventId,
headEventId = startEventId,
move = "e4",
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
history = listOf("e4"),
opponentPubkey = blackPubkey,
)
val move2 =
createMoveEvent(
id = "move-002",
pubKey = blackPubkey,
startEventId = startEventId,
headEventId = "move-001",
move = "e5",
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2",
history = listOf("e4", "e5"),
opponentPubkey = whitePubkey,
)
val move3 =
createMoveEvent(
id = "move-003",
pubKey = whitePubkey,
startEventId = startEventId,
headEventId = "move-002",
move = "Nf3",
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2",
history = listOf("e4", "e5", "Nf3"),
opponentPubkey = blackPubkey,
)
val startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey)
// Order 1: moves in order
val events1 = JesterGameEvents(startEvent, listOf(move1, move2, move3))
// Order 2: moves reversed (but move3 has longest history, so result is same)
val events2 = JesterGameEvents(startEvent, listOf(move3, move1, move2))
val result1 = ChessStateReconstructor.reconstruct(events1, whitePubkey)
val result2 = ChessStateReconstructor.reconstruct(events2, whitePubkey)
assertTrue(result1.isSuccess())
assertTrue(result2.isSuccess())
val state1 = result1.getOrNull()!!
val state2 = result2.getOrNull()!!
assertEquals(state1.moveHistory, state2.moveHistory, "Move history should be identical")
assertEquals(state1.appliedMoveNumbers, state2.appliedMoveNumbers)
}
// ==========================================================================
// 8. OPEN CHALLENGE TESTS
// ==========================================================================
@Test
fun `reconstruct open challenge - no specific opponent`() {
val events =
JesterGameEvents(
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, null), // Open challenge
moves = emptyList(),
)
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
assertTrue(result.isSuccess())
val state = result.getOrNull()!!
assertTrue(state.isPendingChallenge)
assertEquals(whitePubkey, state.whitePubkey)
assertEquals(null, state.blackPubkey, "No opponent assigned yet for open challenge")
}
@Test
fun `reconstruct open challenge with first opponent move`() {
// When an unknown player makes the first move, they become the opponent
val move1 =
createMoveEvent(
id = "move-001",
pubKey = whitePubkey,
startEventId = startEventId,
headEventId = startEventId,
move = "e4",
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
history = listOf("e4"),
opponentPubkey = blackPubkey, // Now we know the opponent
)
val events =
JesterGameEvents(
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, null), // Open challenge
moves = listOf(move1),
)
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
assertTrue(result.isSuccess())
val state = result.getOrNull()!!
assertFalse(state.isPendingChallenge)
assertEquals(whitePubkey, state.whitePubkey)
// Opponent is determined from move events when not specified in start
}
// ==========================================================================
// HELPER FUNCTIONS
// ==========================================================================
private fun createBasicGameEvents(): JesterGameEvents {
val move1 =
createMoveEvent(
id = "move-001",
pubKey = whitePubkey,
startEventId = startEventId,
headEventId = startEventId,
move = "e4",
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
history = listOf("e4"),
opponentPubkey = blackPubkey,
)
val move2 =
createMoveEvent(
id = "move-002",
pubKey = blackPubkey,
startEventId = startEventId,
headEventId = "move-001",
move = "e5",
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2",
history = listOf("e4", "e5"),
opponentPubkey = whitePubkey,
)
return JesterGameEvents(
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
moves = listOf(move1, move2),
)
}
private fun createStartEvent(
id: String,
challengerPubkey: String,
challengerColor: Color,
opponentPubkey: String?,
createdAt: Long = 1000,
): JesterEvent {
val tags =
mutableListOf(
arrayOf("e", JesterProtocol.START_POSITION_HASH),
)
opponentPubkey?.let { tags.add(arrayOf("p", it)) }
val colorString = if (challengerColor == Color.WHITE) "white" else "black"
val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"test123","playerColor":"$colorString"}"""
return JesterEvent(
id = id,
pubKey = challengerPubkey,
createdAt = createdAt,
tags = tags.toTypedArray(),
content = content,
sig = "sig-start",
)
}
private fun createMoveEvent(
id: String,
pubKey: String,
startEventId: String,
headEventId: String,
move: String,
fen: String,
history: List<String>,
opponentPubkey: String,
createdAt: Long = 2000,
): JesterEvent {
val historyJson = history.joinToString(",") { "\"$it\"" }
val content = """{"version":"0","kind":1,"fen":"$fen","move":"$move","history":[$historyJson]}"""
return JesterEvent(
id = id,
pubKey = pubKey,
createdAt = createdAt,
tags =
arrayOf(
arrayOf("e", startEventId),
arrayOf("e", headEventId),
arrayOf("p", opponentPubkey),
),
content = content,
sig = "sig-move",
)
}
private fun createMoveEventWithResult(
id: String,
pubKey: String,
startEventId: String,
headEventId: String,
move: String,
fen: String,
history: List<String>,
opponentPubkey: String,
result: String,
termination: String,
createdAt: Long = 2000,
): JesterEvent {
val historyJson = history.joinToString(",") { "\"$it\"" }
val content = """{"version":"0","kind":1,"fen":"$fen","move":"$move","history":[$historyJson],"result":"$result","termination":"$termination"}"""
return JesterEvent(
id = id,
pubKey = pubKey,
createdAt = createdAt,
tags =
arrayOf(
arrayOf("e", startEventId),
arrayOf("e", headEventId),
arrayOf("p", opponentPubkey),
),
content = content,
sig = "sig-move",
)
}
}