feat(chess): add platform adapters and enhance lobby state (steps 5-6)

- Add ChessLobbyState with completedGames, replaceGameState(), moveToCompleted()
- Add challengerAvatarUrl to ChessChallenge
- Add CompletedGame data class for game history

Platform Adapters:
- AndroidChessAdapter: AndroidChessPublisher, AndroidRelayFetcher, AndroidMetadataProvider
- DesktopChessAdapter: DesktopChessPublisher, DesktopRelayFetcher, DesktopMetadataProvider

Shared Infrastructure:
- ChessRelayFetchHelper for one-shot relay queries
- IUserMetadataProvider interface for platform-specific metadata
- ChessFilterBuilder with simple filter methods for fetchers
- ChessSubscriptionController interface for subscription management

Fix ChessSubscription.kt to remove broken dataSources().chess reference
(subscriptions now managed by ChessLobbyLogic)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-02-02 10:00:08 +02:00
parent e0d0e33408
commit 42b33ca04c
26 changed files with 4402 additions and 452 deletions
@@ -21,7 +21,7 @@
package com.vitorpamplona.quartz.nip64Chess
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
@@ -48,7 +48,7 @@ class LiveChessGameChallengeEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30064
@@ -100,7 +100,7 @@ class LiveChessGameAcceptEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30065
@@ -148,7 +148,7 @@ class LiveChessMoveEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30066
@@ -162,7 +162,8 @@ class LiveChessMoveEvent(
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveChessMoveEvent>.() -> Unit = {},
) = eventTemplate(KIND, comment, createdAt) {
add(arrayOf("d", gameId))
add(arrayOf("d", "$gameId-$moveNumber"))
add(arrayOf("game_id", gameId))
add(arrayOf("move_number", moveNumber.toString()))
add(arrayOf("san", san))
add(arrayOf("fen", fen))
@@ -172,7 +173,7 @@ class LiveChessMoveEvent(
}
}
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "game_id" }?.get(1)
fun moveNumber(): Int? =
tags
@@ -211,7 +212,7 @@ class LiveChessGameEndEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30067
@@ -268,7 +269,7 @@ class LiveChessDrawOfferEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30068
@@ -40,6 +40,8 @@ class LiveChessGameState(
val playerColor: Color,
val engine: ChessEngine,
val createdAt: Long = TimeUtils.now(),
val isSpectator: Boolean = false,
val isPendingChallenge: Boolean = false,
) {
companion object {
// Abandon timeout: 24 hours without a move
@@ -92,8 +94,9 @@ class LiveChessGameState(
/**
* Check if it's the player's turn
* Spectators never have a turn
*/
fun isPlayerTurn(): Boolean = engine.getSideToMove() == playerColor
fun isPlayerTurn(): Boolean = !isSpectator && engine.getSideToMove() == playerColor
/**
* Make a move (called when player makes a move on the board)
@@ -129,9 +132,12 @@ class LiveChessGameState(
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 ChessMoveEvent(
gameId = gameId,
moveNumber = result.position.moveNumber,
moveNumber = _moveHistory.value.size,
san = result.san,
fen = engine.getFen(),
opponentPubkey = opponentPubkey,
@@ -403,6 +409,14 @@ class LiveChessGameState(
""".trimIndent()
}
/**
* Mark game as finished with the given result.
* Used when loading a game from cache that already has an end event.
*/
fun markAsFinished(result: GameResult) {
_gameStatus.value = GameStatus.Finished(result)
}
/**
* Reset game to initial position
*/
@@ -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.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
@@ -193,6 +194,7 @@ class EventFactory {
LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig)
LiveChessMoveEvent.KIND -> LiveChessMoveEvent(id, pubKey, createdAt, tags, content, sig)
LiveChessGameEndEvent.KIND -> LiveChessGameEndEvent(id, pubKey, createdAt, tags, content, sig)
LiveChessDrawOfferEvent.KIND -> LiveChessDrawOfferEvent(id, pubKey, createdAt, tags, content, sig)
ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -27,32 +27,38 @@ import com.github.bhlangonijr.chesslib.Square
import com.github.bhlangonijr.chesslib.move.Move
/**
* JVM/Android implementation of ChessEngine using kchesslib
* JVM/Android implementation of ChessEngine using kchesslib.
*
* Maintains its own SAN history because chesslib's Move.toString() returns
* UCI/coordinate notation (e.g. "e2e4") rather than proper SAN (e.g. "e4").
*/
actual class ChessEngine {
private val board = Board()
private val sanHistory = mutableListOf<String>()
actual fun getFen(): String = board.fen
actual fun loadFen(fen: String) {
board.loadFromFen(fen)
sanHistory.clear()
}
actual fun reset() {
board.loadFromFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
sanHistory.clear()
}
actual fun makeMove(san: String): MoveResult =
try {
val move = board.doMove(san)
if (move != null) {
if (board.doMove(san)) {
// Input is already SAN from Nostr events, store directly
sanHistory.add(san)
MoveResult(
success = true,
san = san,
position = boardToPosition(),
)
} else {
board.undoMove()
MoveResult(
success = false,
error = "Invalid move: $san",
@@ -86,8 +92,9 @@ actual class ChessEngine {
val move = Move(fromSquare, toSquare, promotionPiece)
if (board.legalMoves().contains(move)) {
val san = computeSan(move)
board.doMove(move)
val san = move.toString() // kchesslib converts to SAN
sanHistory.add(san)
MoveResult(
success = true,
san = san,
@@ -119,8 +126,7 @@ actual class ChessEngine {
actual fun isLegalMove(san: String): Boolean =
try {
val move = board.doMove(san)
if (move != null) {
if (board.doMove(san)) {
board.undoMove()
true
} else {
@@ -162,6 +168,9 @@ actual class ChessEngine {
actual fun undoMove() {
board.undoMove()
if (sanHistory.isNotEmpty()) {
sanHistory.removeAt(sanHistory.lastIndex)
}
}
actual fun getPosition(): ChessPosition = boardToPosition()
@@ -172,11 +181,115 @@ actual class ChessEngine {
Side.BLACK -> Color.BLACK
}
actual fun getMoveHistory(): List<String> {
// kchesslib stores move history
return board.backup.mapNotNull { it?.move?.toString() }
actual fun getMoveHistory(): List<String> = sanHistory.toList()
/**
* Compute proper SAN notation for a move BEFORE it is applied to the board.
* Handles pieces, pawns, castling, captures, promotion, disambiguation, check/checkmate.
*/
private fun computeSan(move: Move): String {
val fromSquare = move.from
val toSquare = move.to
val piece = board.getPiece(fromSquare)
val pt = piece.pieceType ?: return move.toString()
val promotionPiece = move.promotion ?: Piece.NONE
// Castling
if (pt == com.github.bhlangonijr.chesslib.PieceType.KING) {
val fileDiff = toSquare.file.ordinal - fromSquare.file.ordinal
if (fileDiff == 2) {
val suffix = checkSuffixAfterMove(move)
return "O-O$suffix"
}
if (fileDiff == -2) {
val suffix = checkSuffixAfterMove(move)
return "O-O-O$suffix"
}
}
val sb = StringBuilder()
val epTarget = board.enPassantTarget
val isCapture =
board.getPiece(toSquare) != Piece.NONE ||
(
pt == com.github.bhlangonijr.chesslib.PieceType.PAWN &&
epTarget != null && epTarget != Square.NONE && toSquare == epTarget
)
if (pt != com.github.bhlangonijr.chesslib.PieceType.PAWN) {
sb.append(sanSymbol(pt))
// Disambiguation: check if other pieces of same type can reach the same square
val ambiguous =
board.legalMoves().filterNotNull().filter {
it.to == toSquare &&
board.getPiece(it.from).pieceType == pt &&
it.from != fromSquare
}
if (ambiguous.isNotEmpty()) {
val sameFile = ambiguous.any { it.from.file == fromSquare.file }
val sameRank = ambiguous.any { it.from.rank == fromSquare.rank }
when {
!sameFile -> sb.append(fileChar(fromSquare))
!sameRank -> sb.append(rankChar(fromSquare))
else -> {
sb.append(fileChar(fromSquare))
sb.append(rankChar(fromSquare))
}
}
}
} else if (isCapture) {
// Pawn captures include the source file
sb.append(fileChar(fromSquare))
}
if (isCapture) sb.append('x')
sb.append(toSquare.toString().lowercase())
// Promotion
if (promotionPiece != Piece.NONE) {
sb.append('=')
val promType = promotionPiece.pieceType
if (promType != null) sb.append(sanSymbol(promType))
}
// Check/checkmate suffix
sb.append(checkSuffixAfterMove(move))
return sb.toString()
}
/**
* Temporarily apply a move to check for check/checkmate, then undo.
*/
private fun checkSuffixAfterMove(move: Move): String {
board.doMove(move)
val suffix =
when {
board.isMated -> "#"
board.isKingAttacked -> "+"
else -> ""
}
board.undoMove()
return suffix
}
private fun sanSymbol(pt: com.github.bhlangonijr.chesslib.PieceType): String =
when (pt) {
com.github.bhlangonijr.chesslib.PieceType.KING -> "K"
com.github.bhlangonijr.chesslib.PieceType.QUEEN -> "Q"
com.github.bhlangonijr.chesslib.PieceType.ROOK -> "R"
com.github.bhlangonijr.chesslib.PieceType.BISHOP -> "B"
com.github.bhlangonijr.chesslib.PieceType.KNIGHT -> "N"
else -> ""
}
private fun fileChar(square: Square): Char = 'a' + square.file.ordinal
private fun rankChar(square: Square): Char = '1' + square.rank.ordinal
/**
* Convert kchesslib Board to our ChessPosition model
*/