Merge pull request #1702 from nrobi144/feat/chess-pgn-display-878
chess: live chess game implementation with Jester protocol
This commit is contained in:
@@ -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)
|
||||
}
|
||||
+234
@@ -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,
|
||||
)
|
||||
+304
@@ -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
|
||||
}
|
||||
+295
@@ -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
|
||||
}
|
||||
+548
@@ -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)
|
||||
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for ChessGameEvent (NIP-64 Kind 64 event)
|
||||
*
|
||||
* Verifies:
|
||||
* - Event kind is 64
|
||||
* - PGN content storage
|
||||
* - Alt text support (NIP-31)
|
||||
* - Event structure compliance
|
||||
*/
|
||||
class ChessGameEventTest {
|
||||
private val samplePGN =
|
||||
"""
|
||||
[Event "Test Game"]
|
||||
[Site "Internet"]
|
||||
[Date "2024.12.28"]
|
||||
[Round "1"]
|
||||
[White "Alice"]
|
||||
[Black "Bob"]
|
||||
[Result "1-0"]
|
||||
|
||||
1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `verify event kind is 64`() {
|
||||
assertEquals(64, ChessGameEvent.KIND, "Chess game event should be kind 64")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pgn content is accessible`() {
|
||||
// Create a mock event manually for testing
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Chess Game")),
|
||||
content = samplePGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(samplePGN, testEvent.pgn(), "PGN content should be accessible via pgn()")
|
||||
assertEquals(samplePGN, testEvent.content, "PGN should be in content field")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alt text is accessible when present`() {
|
||||
val customAltText = "Scholar's Mate Example"
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", customAltText)),
|
||||
content = samplePGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(customAltText, testEvent.altText(), "Alt text should be extractable from tags")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alt text returns null when not present`() {
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = samplePGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(null, testEvent.altText(), "Should return null when no alt tag present")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event can store complete game with metadata`() {
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Chess Game")),
|
||||
content = samplePGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
// Parse the PGN to verify it's valid
|
||||
val gameResult = PGNParser.parse(testEvent.pgn())
|
||||
assertTrue(gameResult.isSuccess, "Event should contain valid PGN")
|
||||
|
||||
val game = gameResult.getOrThrow()
|
||||
assertEquals("Test Game", game.event)
|
||||
assertEquals("Alice", game.white)
|
||||
assertEquals("Bob", game.black)
|
||||
assertEquals(GameResult.WHITE_WINS, game.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event can store minimal PGN`() {
|
||||
val minimalPGN = "1. e4 *"
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Chess Game")),
|
||||
content = minimalPGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
val gameResult = PGNParser.parse(testEvent.pgn())
|
||||
assertTrue(gameResult.isSuccess, "Should handle minimal PGN")
|
||||
|
||||
val game = gameResult.getOrThrow()
|
||||
assertEquals(1, game.moves.size)
|
||||
assertEquals(GameResult.IN_PROGRESS, game.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event can store game in progress`() {
|
||||
val inProgressPGN =
|
||||
"""
|
||||
[Event "Live Game"]
|
||||
[Result "*"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6 3. Bb5 *
|
||||
""".trimIndent()
|
||||
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Live Chess Game")),
|
||||
content = inProgressPGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
val gameResult = PGNParser.parse(testEvent.pgn())
|
||||
assertTrue(gameResult.isSuccess)
|
||||
|
||||
val game = gameResult.getOrThrow()
|
||||
assertEquals(GameResult.IN_PROGRESS, game.result)
|
||||
assertEquals("*", game.metadata["Result"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event preserves PGN formatting`() {
|
||||
val formattedPGN =
|
||||
"""
|
||||
[Event "Formatted Game"]
|
||||
[White "Player 1"]
|
||||
[Black "Player 2"]
|
||||
|
||||
1. e4 e5
|
||||
2. Nf3 Nc6
|
||||
3. Bb5 a6
|
||||
*
|
||||
""".trimIndent()
|
||||
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Chess Game")),
|
||||
content = formattedPGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
// Content should be preserved exactly as provided
|
||||
assertEquals(formattedPGN, testEvent.pgn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default alt text is Chess Game`() {
|
||||
assertEquals("Chess Game", ChessGameEvent.ALT_DESCRIPTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event inherits from Event base class`() {
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = "1. e4 *",
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
// Verify base Event properties
|
||||
assertEquals("test_id", testEvent.id)
|
||||
assertEquals("test_pubkey", testEvent.pubKey)
|
||||
assertEquals(1000L, testEvent.createdAt)
|
||||
assertEquals(64, testEvent.kind)
|
||||
assertEquals("test_sig", testEvent.sig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event can contain long tournament game`() {
|
||||
val longPGN =
|
||||
"""
|
||||
[Event "Tournament Game"]
|
||||
[Site "Online"]
|
||||
[Date "2024.12.28"]
|
||||
[Round "5"]
|
||||
[White "GM Player"]
|
||||
[Black "IM Player"]
|
||||
[Result "1/2-1/2"]
|
||||
|
||||
1. d4 Nf6 2. c4 g6 3. Nc3 Bg7 4. e4 d6 5. Nf3 O-O 6. Be2 e5
|
||||
7. O-O Nc6 8. d5 Ne7 9. Ne1 Nd7 10. Nd3 f5 11. Bd2 Nf6 12. f3 f4
|
||||
13. Rc1 g5 14. Nb5 Ng6 15. c5 Rf7 16. Qa4 h5 17. Rfe1 Bf8
|
||||
18. cxd6 cxd6 19. Rc6 Bd7 20. Rec1 Bxc6 21. Rxc6 Qd7 22. Rc1 Rc8
|
||||
23. Rxc8 Qxc8 24. Qa6 Qc2 25. Qxb7 Qxb2 26. Qxa7 Qxa2 27. Qb7 Qa1+
|
||||
28. Kf2 Qb2 29. Qa7 Ra7 30. Qa4 Qa2 31. Qa8 Qb2 32. Qa4 Qa2 1/2-1/2
|
||||
""".trimIndent()
|
||||
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Long Tournament Game")),
|
||||
content = longPGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
val gameResult = PGNParser.parse(testEvent.pgn())
|
||||
assertTrue(gameResult.isSuccess)
|
||||
|
||||
val game = gameResult.getOrThrow()
|
||||
assertTrue(game.moves.size > 50, "Should handle long games")
|
||||
assertEquals(GameResult.DRAW, game.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verify tags array structure`() {
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("alt", "Test Alt Text"),
|
||||
arrayOf("t", "chess"),
|
||||
arrayOf("t", "game"),
|
||||
),
|
||||
content = "1. e4 *",
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
// Verify tags structure
|
||||
assertTrue(testEvent.tags.size >= 1)
|
||||
assertEquals("alt", testEvent.tags[0][0])
|
||||
assertEquals("Test Alt Text", testEvent.tags[0][1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
+349
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+808
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user