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:
+9
-8
@@ -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
|
||||
|
||||
|
||||
+16
-2
@@ -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)
|
||||
|
||||
+123
-10
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user