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:
nrobi144
2026-02-10 11:24:17 +02:00
parent 3c89c442bc
commit 8d098cf86d
42 changed files with 7397 additions and 4118 deletions
@@ -0,0 +1,234 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
import kotlin.random.Random
/**
* Generates human-readable chess game names.
*
* Produces memorable names like "Bold Knight", "Swift Rook", etc.
*/
object ChessGameNameGenerator {
// Chess-themed adjectives
private val adjectives =
listOf(
"Bold",
"Swift",
"Cunning",
"Royal",
"Noble",
"Shadow",
"Silent",
"Golden",
"Silver",
"Iron",
"Fierce",
"Wise",
"Dark",
"Bright",
"Ancient",
"Brave",
"Mystic",
"Grand",
"Crimson",
"Azure",
"Ivory",
"Obsidian",
"Emerald",
"Sapphire",
"Ruby",
"Phantom",
"Eternal",
"Secret",
"Hidden",
"Blazing",
)
// Chess pieces and related nouns
private val chessNouns =
listOf(
"King",
"Queen",
"Rook",
"Bishop",
"Knight",
"Pawn",
"Castle",
"Crown",
"Throne",
"Gambit",
"Defense",
"Attack",
"Checkmate",
"Fortress",
"Tower",
"Endgame",
"Opening",
"Sacrifice",
"Pin",
"Fork",
"Strategy",
"Victory",
"Battle",
"Duel",
"Match",
"Challenge",
)
// Famous chess-related animals/themes
private val animals =
listOf(
"Dragon",
"Phoenix",
"Griffin",
"Eagle",
"Lion",
"Wolf",
"Tiger",
"Falcon",
"Hawk",
"Raven",
"Bear",
"Stallion",
"Serpent",
"Panther",
"Cobra",
)
/**
* Generate a random chess game name.
*
* @param includeNumber Whether to append a random number (1-99)
* @return A name like "Bold Knight" or "Swift Dragon 42"
*/
fun generate(includeNumber: Boolean = false): String {
val adjective = adjectives.random()
val noun = if (Random.nextBoolean()) chessNouns.random() else animals.random()
val base = "$adjective $noun"
return if (includeNumber) {
val num = Random.nextInt(1, 100)
"$base $num"
} else {
base
}
}
/**
* Generate a name based on two player display names.
*
* Creates a name that incorporates elements from both players
* for a more personalized game name.
*
* @param playerName First player's display name (can be npub/pubkey)
* @param opponentName Second player's display name (can be npub/pubkey, or null for open challenge)
* @return A personalized game name
*/
fun generateFromPlayers(
playerName: String?,
opponentName: String?,
): String {
// Extract first letter or use adjective if name is a pubkey/npub
val p1Initial = extractInitial(playerName)
val p2Initial = opponentName?.let { extractInitial(it) }
// If we have good initials, use them in the name
val adjective =
if (p1Initial != null) {
// Try to find an adjective starting with that letter
adjectives.find { it.startsWith(p1Initial, ignoreCase = true) }
?: adjectives.random()
} else {
adjectives.random()
}
val noun =
if (p2Initial != null) {
// Try to find a noun starting with opponent's initial
(chessNouns + animals).find { it.startsWith(p2Initial, ignoreCase = true) }
?: if (Random.nextBoolean()) chessNouns.random() else animals.random()
} else {
// Open challenge - use "Challenge" or similar
listOf("Challenge", "Gambit", "Opening", "Battle", "Duel").random()
}
return "$adjective $noun"
}
/**
* Extract a usable initial from a name.
* Returns null if the name looks like a pubkey/npub.
*/
private fun extractInitial(name: String?): Char? {
if (name.isNullOrBlank()) return null
// Skip if it looks like a hex pubkey or npub
if (name.length > 20 && name.all { it.isLetterOrDigit() }) return null
if (name.startsWith("npub") || name.startsWith("nprofile")) return null
// Get first letter of the display name
val firstChar = name.firstOrNull { it.isLetter() }
return firstChar?.uppercaseChar()
}
/**
* Generate a game ID that includes a readable component.
*
* @param timestamp Unix timestamp
* @return Game ID like "chess-1234567890-bold-knight"
*/
fun generateGameId(timestamp: Long): String {
val name =
generate(includeNumber = false)
.lowercase()
.replace(" ", "-")
return "chess-$timestamp-$name"
}
/**
* Extract the human-readable name from a game ID.
*
* @param gameId Game ID like "chess-1234567890-bold-knight"
* @return Display name like "Bold Knight", or null if not a named game ID
*/
fun extractDisplayName(gameId: String): String? {
// Pattern: chess-timestamp-name-parts
if (!gameId.startsWith("chess-")) return null
val parts = gameId.removePrefix("chess-").split("-")
if (parts.size < 2) return null
// First part is timestamp, rest is the name
val nameParts = parts.drop(1)
if (nameParts.isEmpty()) return null
// Check if it looks like a UUID (8 hex chars) - old format
if (nameParts.size == 1 && nameParts[0].length == 8 && nameParts[0].all { it.isLetterOrDigit() }) {
return null
}
// Convert to title case
return nameParts.joinToString(" ") { part ->
part.replaceFirstChar { it.uppercaseChar() }
}
}
}
@@ -0,0 +1,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()
}
@@ -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
}
@@ -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",
)
}
}
@@ -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",
)
}
}