feat(chess): focused polling, promotion fix, and game end celebration
- Add focused game mode to ChessPollingDelegate: when viewing a game, only that game is polled instead of all active games - Fix pawn promotion by parsing promotion suffix in publishMove (e.g., "e8q" -> "e8" + QUEEN) - Add game end overlay with victory/defeat/draw celebration - Remove all debug println statements for production readiness Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
+138
-71
@@ -23,115 +23,163 @@ package com.vitorpamplona.quartz.nip64Chess
|
||||
/*
|
||||
* Live chess game state and move events
|
||||
*
|
||||
* For real-time chess games, we use a different event structure than NIP-64.
|
||||
* NIP-64 is for completed games in PGN format. Live games need individual
|
||||
* move events that can be published and subscribed to in real-time.
|
||||
* Uses Jester protocol (kind 30) for compatibility with jesterui.
|
||||
* See: https://github.com/jesterui/jesterui/blob/devel/FLOW.md
|
||||
*
|
||||
* Event Structure:
|
||||
* - Game Challenge (Kind 30064): Challenge another player or open challenge
|
||||
* - Game Accept (Kind 30065): Accept a challenge
|
||||
* - Chess Move (Kind 30066): Individual move in a live game
|
||||
* - Game End (Kind 30067): Game result (checkmate, resignation, draw, etc.)
|
||||
* 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]
|
||||
*
|
||||
* All events use 'd' tag with game_id to group moves from the same game
|
||||
* This enables:
|
||||
* - Easy game reconstruction from any single move event
|
||||
* - Tolerance for missing intermediate moves
|
||||
* - Compatibility with jesterui and other Jester clients
|
||||
*/
|
||||
|
||||
/**
|
||||
* Game challenge event - start a new game
|
||||
* Kind: 30064
|
||||
* Game start/challenge data for publishing
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id (unique identifier)
|
||||
* - p: opponent pubkey (optional, if challenging specific player)
|
||||
* - player_color: white|black (color challenger wants to play)
|
||||
* - time_control: time control format (e.g., "10+0", "5+3")
|
||||
* 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 gameId: String,
|
||||
val opponentPubkey: String? = null, // null = open challenge
|
||||
val playerColor: Color,
|
||||
val timeControl: String? = null,
|
||||
)
|
||||
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 event - accept a challenge
|
||||
* Kind: 30065
|
||||
* Game acceptance data
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id (same as challenge)
|
||||
* - e: challenge event ID
|
||||
* - p: challenger pubkey
|
||||
* 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 gameId: String,
|
||||
val challengeEventId: String,
|
||||
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 event - individual move in a live game
|
||||
* Kind: 30066
|
||||
* Chess move data for publishing
|
||||
*
|
||||
* 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 (for notifications)
|
||||
* 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
|
||||
*
|
||||
* Content: Optional move comment or time remaining
|
||||
* @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 gameId: String,
|
||||
val moveNumber: Int,
|
||||
val startEventId: String,
|
||||
val headEventId: String,
|
||||
val san: String,
|
||||
val fen: String,
|
||||
val history: List<String>,
|
||||
val opponentPubkey: String,
|
||||
val comment: String? = null,
|
||||
)
|
||||
) {
|
||||
/** 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 event - final game result
|
||||
* Kind: 30067
|
||||
* Game end data for publishing
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id
|
||||
* - result: 1-0|0-1|1/2-1/2 (white wins, black wins, draw)
|
||||
* - termination: checkmate|resignation|draw_agreement|stalemate|timeout|abandonment
|
||||
* - winner: pubkey of winner (if applicable)
|
||||
* - p: opponent pubkey
|
||||
* In Jester protocol, game end is a move event with result/termination
|
||||
* fields in the content JSON.
|
||||
*
|
||||
* Content: Optional game summary or final PGN
|
||||
* @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 gameId: String,
|
||||
val startEventId: String,
|
||||
val headEventId: String,
|
||||
val lastMove: String?,
|
||||
val fen: String,
|
||||
val history: List<String>,
|
||||
val result: GameResult,
|
||||
val termination: GameTermination,
|
||||
val winnerPubkey: String? = null,
|
||||
val opponentPubkey: String,
|
||||
val pgn: String? = null,
|
||||
)
|
||||
) {
|
||||
// Legacy compatibility
|
||||
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
|
||||
val gameId: String get() = startEventId
|
||||
|
||||
val winnerPubkey: String? get() = null // Determined from result
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw offer event - offer a draw to opponent
|
||||
* Kind: 30068
|
||||
* Draw offer data
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id
|
||||
* - p: opponent pubkey
|
||||
*
|
||||
* Content: Optional message
|
||||
* 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 GameEnd event with DRAW_AGREEMENT
|
||||
* - Accept by sending a game end with DRAW_AGREEMENT
|
||||
* - Decline implicitly by making their next move
|
||||
* - Decline explicitly (optional, no event needed)
|
||||
*
|
||||
* @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 gameId: String,
|
||||
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
|
||||
@@ -146,12 +194,31 @@ enum class GameTermination {
|
||||
}
|
||||
|
||||
/**
|
||||
* Event kind constants for live chess
|
||||
* 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 {
|
||||
const val GAME_CHALLENGE = 30064
|
||||
const val GAME_ACCEPT = 30065
|
||||
const val CHESS_MOVE = 30066
|
||||
const val GAME_END = 30067
|
||||
const val DRAW_OFFER = 30068
|
||||
/** 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
|
||||
}
|
||||
|
||||
+115
-18
@@ -32,9 +32,15 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
* - 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(
|
||||
val gameId: String,
|
||||
/** Start event ID - the game identifier in Jester protocol */
|
||||
val startEventId: String,
|
||||
val playerPubkey: String,
|
||||
val opponentPubkey: String,
|
||||
val playerColor: Color,
|
||||
@@ -42,7 +48,17 @@ class LiveChessGameState(
|
||||
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
|
||||
@@ -100,7 +116,9 @@ class LiveChessGameState(
|
||||
|
||||
/**
|
||||
* Make a move (called when player makes a move on the board)
|
||||
* Returns the move event to be published to Nostr
|
||||
* 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,
|
||||
@@ -131,15 +149,13 @@ class LiveChessGameState(
|
||||
// Check for game end conditions
|
||||
checkGameEnd()
|
||||
|
||||
// Return move event to publish
|
||||
// Use ply count (half-move count) for moveNumber so each move gets a unique
|
||||
// d-tag in the Nostr addressable event. The full-move counter from chesslib
|
||||
// repeats for White/Black in the same move pair, causing d-tag collisions.
|
||||
// Return move event data with full history for Jester protocol
|
||||
return ChessMoveEvent(
|
||||
gameId = gameId,
|
||||
moveNumber = _moveHistory.value.size,
|
||||
startEventId = startEventId,
|
||||
headEventId = _headEventId.value,
|
||||
san = result.san,
|
||||
fen = engine.getFen(),
|
||||
history = _moveHistory.value, // Full history for Jester
|
||||
opponentPubkey = opponentPubkey,
|
||||
)
|
||||
} else {
|
||||
@@ -148,6 +164,38 @@ class LiveChessGameState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
@@ -240,6 +288,48 @@ class LiveChessGameState(
|
||||
_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.
|
||||
@@ -281,12 +371,14 @@ class LiveChessGameState(
|
||||
_gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor))
|
||||
|
||||
return ChessGameEnd(
|
||||
gameId = gameId,
|
||||
startEventId = startEventId,
|
||||
headEventId = _headEventId.value,
|
||||
lastMove = null,
|
||||
fen = engine.getFen(),
|
||||
history = _moveHistory.value,
|
||||
result = GameResult.getResultForWinner(playerColor),
|
||||
termination = GameTermination.ABANDONMENT,
|
||||
winnerPubkey = playerPubkey,
|
||||
opponentPubkey = opponentPubkey,
|
||||
pgn = generatePGN(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -297,12 +389,14 @@ class LiveChessGameState(
|
||||
_gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor.opposite()))
|
||||
|
||||
return ChessGameEnd(
|
||||
gameId = gameId,
|
||||
startEventId = startEventId,
|
||||
headEventId = _headEventId.value,
|
||||
lastMove = null,
|
||||
fen = engine.getFen(),
|
||||
history = _moveHistory.value,
|
||||
result = GameResult.getResultForWinner(playerColor.opposite()),
|
||||
termination = GameTermination.RESIGNATION,
|
||||
winnerPubkey = opponentPubkey,
|
||||
opponentPubkey = opponentPubkey,
|
||||
pgn = generatePGN(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -313,7 +407,8 @@ class LiveChessGameState(
|
||||
fun offerDraw(): ChessDrawOffer {
|
||||
_pendingDrawOffer.value = playerPubkey
|
||||
return ChessDrawOffer(
|
||||
gameId = gameId,
|
||||
startEventId = startEventId,
|
||||
headEventId = _headEventId.value,
|
||||
opponentPubkey = opponentPubkey,
|
||||
)
|
||||
}
|
||||
@@ -341,12 +436,14 @@ class LiveChessGameState(
|
||||
_gameStatus.value = GameStatus.Finished(GameResult.DRAW)
|
||||
|
||||
return ChessGameEnd(
|
||||
gameId = gameId,
|
||||
startEventId = startEventId,
|
||||
headEventId = _headEventId.value,
|
||||
lastMove = null,
|
||||
fen = engine.getFen(),
|
||||
history = _moveHistory.value,
|
||||
result = GameResult.DRAW,
|
||||
termination = GameTermination.DRAW_AGREEMENT,
|
||||
winnerPubkey = null,
|
||||
opponentPubkey = opponentPubkey,
|
||||
pgn = generatePGN(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ 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
|
||||
@@ -190,6 +191,7 @@ class EventFactory {
|
||||
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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
+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