Merge branch 'vitorpamplona:main' into kmp-completeness
This commit is contained in:
+554
@@ -0,0 +1,554 @@
|
||||
/*
|
||||
* 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.nip05.namecoin
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.PrintWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.net.SocketFactory
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
* Result of an ElectrumX name_show query.
|
||||
*
|
||||
* Maps to the JSON fields returned by Namecoin Core / Electrum-NMC:
|
||||
* { "name": "d/example", "value": "{...}", "txid": "abc...", "height": 12345, ... }
|
||||
*/
|
||||
@Serializable
|
||||
data class NameShowResult(
|
||||
val name: String,
|
||||
val value: String,
|
||||
val txid: String? = null,
|
||||
val height: Int? = null,
|
||||
val expiresIn: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a single ElectrumX server endpoint.
|
||||
*/
|
||||
data class ElectrumxServer(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val useSsl: Boolean = true,
|
||||
/** If true, accept any certificate (self-signed, expired, etc.) */
|
||||
val trustAllCerts: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Lightweight, query-only ElectrumX client for Namecoin name resolution.
|
||||
*
|
||||
* Connects over TCP/TLS to a Namecoin ElectrumX server and resolves
|
||||
* Namecoin names to their current values using the standard Electrum
|
||||
* protocol (scripthash-based lookups). Works with both the Namecoin
|
||||
* ElectrumX fork and stock ElectrumX pointed at a Namecoin node, as
|
||||
* long as the server has a name index.
|
||||
*
|
||||
* Resolution strategy:
|
||||
* 1. Build a canonical name index script for the identifier
|
||||
* 2. Compute the Electrum-style scripthash (reversed SHA-256)
|
||||
* 3. Query `blockchain.scripthash.get_history` to find the latest tx
|
||||
* 4. Fetch the raw transaction and parse the name value from the script
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* val client = ElectrumxClient()
|
||||
* val result = client.nameShow("d/example", server)
|
||||
* ```
|
||||
*/
|
||||
class ElectrumxClient(
|
||||
private val connectTimeoutMs: Long = 10_000L,
|
||||
private val readTimeoutMs: Long = 15_000L,
|
||||
private val socketFactory: () -> SocketFactory = { SocketFactory.getDefault() },
|
||||
) {
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
private val requestId = AtomicInteger(0)
|
||||
private val mutex = Mutex()
|
||||
|
||||
companion object {
|
||||
/** Well-known public Namecoin ElectrumX servers (clearnet). */
|
||||
val DEFAULT_SERVERS =
|
||||
listOf(
|
||||
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
|
||||
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true),
|
||||
ElectrumxServer("46.229.238.187", 57002, useSsl = true, trustAllCerts = true),
|
||||
)
|
||||
|
||||
/** Tor-preferred server list: onion primary, clearnet fallback. */
|
||||
val TOR_SERVERS =
|
||||
listOf(
|
||||
ElectrumxServer(
|
||||
"i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion",
|
||||
50002,
|
||||
useSsl = true,
|
||||
trustAllCerts = true,
|
||||
),
|
||||
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
|
||||
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true),
|
||||
)
|
||||
|
||||
private const val PROTOCOL_VERSION = "1.4"
|
||||
|
||||
/**
|
||||
* Namecoin names expire this many blocks after their last update.
|
||||
* From chainparams.cpp: consensus.nNameExpirationDepth = 36000
|
||||
* (~250 days at ~10 min/block).
|
||||
*/
|
||||
const val NAME_EXPIRE_DEPTH = 36_000
|
||||
|
||||
// Namecoin script opcodes
|
||||
private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin
|
||||
private const val OP_2DROP: Byte = 0x6d
|
||||
private const val OP_DROP: Byte = 0x75
|
||||
private const val OP_RETURN: Byte = 0x6a
|
||||
private const val OP_PUSHDATA1: Byte = 0x4c
|
||||
private const val OP_PUSHDATA2: Byte = 0x4d
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a name_show lookup against the given ElectrumX server.
|
||||
*
|
||||
* Uses the scripthash-based approach: computes the name's canonical
|
||||
* index script hash, queries transaction history, and parses the
|
||||
* name value from the latest transaction's output script.
|
||||
*
|
||||
* @param identifier Full Namecoin name, e.g. "d/example" or "id/alice"
|
||||
* @param server ElectrumX server to query
|
||||
* @return [NameShowResult] on success, null if the name does not exist
|
||||
* or the server is unreachable
|
||||
*/
|
||||
suspend fun nameShow(
|
||||
identifier: String,
|
||||
server: ElectrumxServer = DEFAULT_SERVERS.first(),
|
||||
): NameShowResult? =
|
||||
withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
try {
|
||||
connectAndQuery(identifier, server)
|
||||
} catch (e: Exception) {
|
||||
// Log but don't crash — callers handle null gracefully.
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try each server in order until one succeeds.
|
||||
*
|
||||
* @param identifier Full Namecoin name, e.g. "d/example"
|
||||
* @param servers Ordered server list to try; defaults to [DEFAULT_SERVERS]
|
||||
*/
|
||||
suspend fun nameShowWithFallback(
|
||||
identifier: String,
|
||||
servers: List<ElectrumxServer> = DEFAULT_SERVERS,
|
||||
): NameShowResult? {
|
||||
for (server in servers) {
|
||||
val result = nameShow(identifier, server)
|
||||
if (result != null) return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── internals ──────────────────────────────────────────────────────
|
||||
|
||||
private fun connectAndQuery(
|
||||
identifier: String,
|
||||
server: ElectrumxServer,
|
||||
): NameShowResult? {
|
||||
val socket = createSocket(server)
|
||||
socket.soTimeout = readTimeoutMs.toInt()
|
||||
val writer = PrintWriter(socket.getOutputStream(), true)
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
|
||||
|
||||
try {
|
||||
// 1. Negotiate protocol version
|
||||
val versionReq = buildRpcRequest("server.version", listOf("AmethystNMC/0.1", PROTOCOL_VERSION))
|
||||
writer.println(versionReq)
|
||||
reader.readLine() // consume version response
|
||||
|
||||
// 2. Compute the canonical name index scripthash
|
||||
val nameScript = buildNameIndexScript(identifier.toByteArray(Charsets.US_ASCII))
|
||||
val scriptHash = electrumScriptHash(nameScript)
|
||||
|
||||
// 3. Get transaction history for this name
|
||||
val historyReq = buildRpcRequest("blockchain.scripthash.get_history", listOf(scriptHash))
|
||||
writer.println(historyReq)
|
||||
val historyResponse = reader.readLine() ?: return null
|
||||
val historyEntries = parseHistoryResponse(historyResponse) ?: return null
|
||||
if (historyEntries.isEmpty()) return null
|
||||
|
||||
// 4. Get the latest transaction (last entry = most recent update)
|
||||
val latestEntry = historyEntries.last()
|
||||
val txHash = latestEntry.first
|
||||
val height = latestEntry.second
|
||||
|
||||
val txReq = buildRpcRequest("blockchain.transaction.get", listOf(txHash, true))
|
||||
writer.println(txReq)
|
||||
val txResponse = reader.readLine() ?: return null
|
||||
|
||||
// 5. Get current block height to check name expiry
|
||||
val headersReq = buildRpcRequest("blockchain.headers.subscribe", emptyList<String>())
|
||||
writer.println(headersReq)
|
||||
val headersResponse = reader.readLine()
|
||||
val currentHeight = parseBlockHeight(headersResponse)
|
||||
|
||||
// 6. Check if the name has expired
|
||||
if (currentHeight != null && height > 0) {
|
||||
val blocksSinceUpdate = currentHeight - height
|
||||
if (blocksSinceUpdate >= NAME_EXPIRE_DEPTH) {
|
||||
return null // Name has expired
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Parse the name value from the transaction
|
||||
val result = parseNameFromTransaction(identifier, txHash, height, txResponse)
|
||||
// Populate expiresIn if we know the current height
|
||||
return if (result != null && currentHeight != null && height > 0) {
|
||||
result.copy(expiresIn = NAME_EXPIRE_DEPTH - (currentHeight - height))
|
||||
} else {
|
||||
result
|
||||
}
|
||||
} finally {
|
||||
runCatching { writer.close() }
|
||||
runCatching { reader.close() }
|
||||
runCatching { socket.close() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical script used by ElectrumX to index Namecoin names.
|
||||
*
|
||||
* Format: OP_NAME_UPDATE <push(name)> <push(empty)> OP_2DROP OP_DROP OP_RETURN
|
||||
*
|
||||
* This matches the `build_name_index_script` method in the Namecoin
|
||||
* ElectrumX fork (electrumx/lib/coins.py).
|
||||
*/
|
||||
private fun buildNameIndexScript(nameBytes: ByteArray): ByteArray {
|
||||
val result = mutableListOf<Byte>()
|
||||
result.add(OP_NAME_UPDATE)
|
||||
result.addAll(pushData(nameBytes).toList())
|
||||
result.addAll(pushData(byteArrayOf()).toList()) // empty value
|
||||
result.add(OP_2DROP)
|
||||
result.add(OP_DROP)
|
||||
result.add(OP_RETURN)
|
||||
return result.toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitcoin-style push data encoding.
|
||||
*/
|
||||
private fun pushData(data: ByteArray): ByteArray {
|
||||
val len = data.size
|
||||
return when {
|
||||
len < 0x4c -> {
|
||||
byteArrayOf(len.toByte()) + data
|
||||
}
|
||||
|
||||
len <= 0xff -> {
|
||||
byteArrayOf(OP_PUSHDATA1, len.toByte()) + data
|
||||
}
|
||||
|
||||
else -> {
|
||||
val lenBytes = byteArrayOf((len and 0xff).toByte(), ((len shr 8) and 0xff).toByte())
|
||||
byteArrayOf(OP_PUSHDATA2) + lenBytes + data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Electrum protocol scripthash: SHA-256 of the script, byte-reversed, hex-encoded.
|
||||
*/
|
||||
private fun electrumScriptHash(script: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(script)
|
||||
return digest.reversedArray().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the block height from a `blockchain.headers.subscribe` response.
|
||||
*
|
||||
* Response format: {"result": {"height": 814300, "hex": "..."}, ...}
|
||||
*/
|
||||
private fun parseBlockHeight(raw: String?): Int? {
|
||||
if (raw == null) return null
|
||||
return try {
|
||||
val envelope = json.parseToJsonElement(raw).jsonObject
|
||||
val result = envelope["result"]?.jsonObject ?: return null
|
||||
result["height"]?.jsonPrimitive?.int
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the history response into a list of (txHash, height) pairs.
|
||||
*/
|
||||
private fun parseHistoryResponse(raw: String): List<Pair<String, Int>>? {
|
||||
val envelope = json.parseToJsonElement(raw).jsonObject
|
||||
val error = envelope["error"]
|
||||
if (error != null && error !is kotlinx.serialization.json.JsonNull) return null
|
||||
|
||||
val result = envelope["result"]?.jsonArray ?: return null
|
||||
return result.mapNotNull { entry ->
|
||||
val obj = entry.jsonObject
|
||||
val txHash = obj["tx_hash"]?.jsonPrimitive?.content ?: return@mapNotNull null
|
||||
val height = obj["height"]?.jsonPrimitive?.int ?: return@mapNotNull null
|
||||
txHash to height
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Namecoin name and value from a verbose transaction response.
|
||||
*
|
||||
* Scans each output for a NAME_UPDATE script (starts with OP_3 = 0x53),
|
||||
* then extracts the name and value from the script's push data.
|
||||
*/
|
||||
private fun parseNameFromTransaction(
|
||||
identifier: String,
|
||||
txHash: String,
|
||||
height: Int,
|
||||
raw: String,
|
||||
): NameShowResult? {
|
||||
val envelope = json.parseToJsonElement(raw).jsonObject
|
||||
val error = envelope["error"]
|
||||
if (error != null && error !is kotlinx.serialization.json.JsonNull) return null
|
||||
|
||||
val result = envelope["result"]?.jsonObject ?: return null
|
||||
val vouts = result["vout"]?.jsonArray ?: return null
|
||||
|
||||
for (vout in vouts) {
|
||||
val scriptHex =
|
||||
vout.jsonObject["scriptPubKey"]
|
||||
?.jsonObject
|
||||
?.get("hex")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
?: continue
|
||||
|
||||
// NAME_UPDATE scripts start with OP_3 (0x53)
|
||||
if (!scriptHex.startsWith("53")) continue
|
||||
|
||||
val scriptBytes = hexToBytes(scriptHex)
|
||||
val parsed = parseNameScript(scriptBytes) ?: continue
|
||||
|
||||
// Verify this is the name we're looking for
|
||||
if (parsed.first == identifier) {
|
||||
return NameShowResult(
|
||||
name = parsed.first,
|
||||
value = parsed.second,
|
||||
txid = txHash,
|
||||
height = height,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a NAME_UPDATE script to extract the name and value.
|
||||
*
|
||||
* Script format: OP_NAME_UPDATE <push(name)> <push(value)> OP_2DROP OP_DROP <address_script>
|
||||
*
|
||||
* @return Pair of (name, value) as strings, or null if parsing fails
|
||||
*/
|
||||
private fun parseNameScript(script: ByteArray): Pair<String, String>? {
|
||||
if (script.isEmpty() || script[0] != OP_NAME_UPDATE) return null
|
||||
|
||||
var pos = 1
|
||||
|
||||
// Read name
|
||||
val (nameBytes, newPos1) = readPushData(script, pos) ?: return null
|
||||
pos = newPos1
|
||||
|
||||
// Read value
|
||||
val (valueBytes, _) = readPushData(script, pos) ?: return null
|
||||
|
||||
val name = String(nameBytes, Charsets.US_ASCII)
|
||||
val value = String(valueBytes, Charsets.UTF_8)
|
||||
return name to value
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a push-data encoded byte sequence from the script at the given position.
|
||||
*
|
||||
* @return Pair of (data, nextPosition), or null if the script is malformed
|
||||
*/
|
||||
private fun readPushData(
|
||||
script: ByteArray,
|
||||
pos: Int,
|
||||
): Pair<ByteArray, Int>? {
|
||||
if (pos >= script.size) return null
|
||||
|
||||
val opcode = script[pos].toInt() and 0xff
|
||||
return when {
|
||||
opcode == 0 -> {
|
||||
// OP_0 / push empty
|
||||
byteArrayOf() to (pos + 1)
|
||||
}
|
||||
|
||||
opcode < 0x4c -> {
|
||||
// Direct push: opcode is the length
|
||||
val end = pos + 1 + opcode
|
||||
if (end > script.size) return null
|
||||
script.copyOfRange(pos + 1, end) to end
|
||||
}
|
||||
|
||||
opcode == 0x4c -> {
|
||||
// OP_PUSHDATA1: next byte is length
|
||||
if (pos + 2 > script.size) return null
|
||||
val len = script[pos + 1].toInt() and 0xff
|
||||
val end = pos + 2 + len
|
||||
if (end > script.size) return null
|
||||
script.copyOfRange(pos + 2, end) to end
|
||||
}
|
||||
|
||||
opcode == 0x4d -> {
|
||||
// OP_PUSHDATA2: next 2 bytes are length (little-endian)
|
||||
if (pos + 3 > script.size) return null
|
||||
val len =
|
||||
(script[pos + 1].toInt() and 0xff) or
|
||||
((script[pos + 2].toInt() and 0xff) shl 8)
|
||||
val end = pos + 3 + len
|
||||
if (end > script.size) return null
|
||||
script.copyOfRange(pos + 3, end) to end
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hexToBytes(hex: String): ByteArray {
|
||||
val len = hex.length
|
||||
val data = ByteArray(len / 2)
|
||||
for (i in 0 until len step 2) {
|
||||
data[i / 2] =
|
||||
(
|
||||
(Character.digit(hex[i], 16) shl 4) +
|
||||
Character.digit(hex[i + 1], 16)
|
||||
).toByte()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private fun createSocket(server: ElectrumxServer): Socket {
|
||||
// Create the base socket through the injected factory, which
|
||||
// may route through a SOCKS proxy (e.g. Tor) if configured.
|
||||
val baseSocket =
|
||||
socketFactory().createSocket().apply {
|
||||
connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt())
|
||||
}
|
||||
|
||||
if (!server.useSsl) return baseSocket
|
||||
|
||||
// Upgrade to TLS over the already-connected (possibly proxied) socket.
|
||||
val sslFactory =
|
||||
if (server.trustAllCerts) {
|
||||
trustAllSslFactory()
|
||||
} else {
|
||||
SSLSocketFactory.getDefault() as SSLSocketFactory
|
||||
}
|
||||
return sslFactory.createSocket(baseSocket, server.host, server.port, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SSLSocketFactory that accepts any certificate.
|
||||
* Used for servers with self-signed certificates.
|
||||
*/
|
||||
private fun trustAllSslFactory(): SSLSocketFactory {
|
||||
val trustAllCerts =
|
||||
arrayOf<TrustManager>(
|
||||
object : X509TrustManager {
|
||||
override fun checkClientTrusted(
|
||||
chain: Array<java.security.cert.X509Certificate>,
|
||||
authType: String,
|
||||
) {}
|
||||
|
||||
override fun checkServerTrusted(
|
||||
chain: Array<java.security.cert.X509Certificate>,
|
||||
authType: String,
|
||||
) {}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||
},
|
||||
)
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(null, trustAllCerts, java.security.SecureRandom())
|
||||
return sslContext.socketFactory
|
||||
}
|
||||
|
||||
private fun buildRpcRequest(
|
||||
method: String,
|
||||
params: List<Any>,
|
||||
): String {
|
||||
val id = requestId.incrementAndGet()
|
||||
val obj =
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("id", id)
|
||||
put("method", method)
|
||||
put(
|
||||
"params",
|
||||
json.encodeToJsonElement(
|
||||
kotlinx.serialization.builtins.ListSerializer(
|
||||
kotlinx.serialization.json.JsonElement
|
||||
.serializer(),
|
||||
),
|
||||
params.map {
|
||||
when (it) {
|
||||
is Boolean -> JsonPrimitive(it)
|
||||
is Number -> JsonPrimitive(it)
|
||||
else -> JsonPrimitive(it.toString())
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
return json.encodeToString(JsonObject.serializer(), obj)
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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.nip05.namecoin
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
data class CachedResult(
|
||||
val result: NamecoinNostrResult?,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
class NamecoinLookupCache(
|
||||
private val maxEntries: Int = 500,
|
||||
private val ttlMs: Long = 3_600_000L, // 1 hour
|
||||
) {
|
||||
private val cache = LinkedHashMap<String, CachedResult>(maxEntries, 0.75f, true)
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* Normalised cache key from the user's raw input.
|
||||
*/
|
||||
private fun cacheKey(identifier: String): String = identifier.trim().lowercase()
|
||||
|
||||
suspend fun get(identifier: String): CachedResult? =
|
||||
mutex.withLock {
|
||||
val key = cacheKey(identifier)
|
||||
val entry = cache[key] ?: return null
|
||||
val age = System.currentTimeMillis() - entry.timestamp
|
||||
if (age > ttlMs) {
|
||||
cache.remove(key)
|
||||
return null
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
suspend fun put(
|
||||
identifier: String,
|
||||
result: NamecoinNostrResult?,
|
||||
) = mutex.withLock {
|
||||
val key = cacheKey(identifier)
|
||||
if (cache.size >= maxEntries) {
|
||||
// Remove eldest (LRU) entry
|
||||
val eldest = cache.entries.firstOrNull()
|
||||
if (eldest != null) cache.remove(eldest.key)
|
||||
}
|
||||
cache[key] = CachedResult(result)
|
||||
}
|
||||
|
||||
suspend fun invalidate(identifier: String) =
|
||||
mutex.withLock {
|
||||
cache.remove(cacheKey(identifier))
|
||||
}
|
||||
|
||||
suspend fun clear() =
|
||||
mutex.withLock {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
/*
|
||||
* 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.nip05.namecoin
|
||||
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
/**
|
||||
* Result of resolving a Namecoin name to Nostr identity data.
|
||||
*/
|
||||
data class NamecoinNostrResult(
|
||||
/** Hex-encoded 32-byte Schnorr public key */
|
||||
val pubkey: String,
|
||||
/** Optional relay URLs where this user can be found */
|
||||
val relays: List<String> = emptyList(),
|
||||
/** The Namecoin name that was resolved (e.g. "d/example") */
|
||||
val namecoinName: String,
|
||||
/** The local-part that was matched (e.g. "alice" or "_") */
|
||||
val localPart: String = "_",
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolves Namecoin names to Nostr public keys.
|
||||
*
|
||||
* This is the primary entry point for Namecoin→Nostr resolution.
|
||||
* It is designed to be used alongside Amethyst's existing NIP-05
|
||||
* verifier: if an identifier ends with `.bit`, it should be routed
|
||||
* here instead of to the HTTP-based NIP-05 path.
|
||||
*/
|
||||
class NamecoinNameResolver(
|
||||
private val electrumxClient: ElectrumxClient = ElectrumxClient(),
|
||||
private val lookupTimeoutMs: Long = 20_000L,
|
||||
private val serverListProvider: () -> List<ElectrumxServer> = { ElectrumxClient.DEFAULT_SERVERS },
|
||||
) {
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val HEX_PUBKEY_REGEX = Regex("^[0-9a-fA-F]{64}$")
|
||||
|
||||
/**
|
||||
* Check whether an identifier should be routed to Namecoin
|
||||
* resolution rather than standard NIP-05.
|
||||
*/
|
||||
fun isNamecoinIdentifier(identifier: String): Boolean {
|
||||
val normalized = identifier.trim().lowercase()
|
||||
return normalized.endsWith(".bit") ||
|
||||
normalized.startsWith("d/") ||
|
||||
normalized.startsWith("id/")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-supplied identifier to a Nostr pubkey via Namecoin.
|
||||
*
|
||||
* @param identifier User input, e.g. "alice@example.bit", "id/alice", "example.bit"
|
||||
* @return [NamecoinNostrResult] on success, null if resolution failed
|
||||
*/
|
||||
suspend fun resolve(identifier: String): NamecoinNostrResult? {
|
||||
val parsed = parseIdentifier(identifier) ?: return null
|
||||
return withTimeoutOrNull(lookupTimeoutMs) {
|
||||
performLookup(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identifier Parsing ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parsed representation of a Namecoin lookup request.
|
||||
*/
|
||||
private data class ParsedIdentifier(
|
||||
/** The Namecoin name to query, e.g. "d/example" or "id/alice" */
|
||||
val namecoinName: String,
|
||||
/** The local-part to look up within the name's value.
|
||||
* For d/ names: the user part (or "_" for root).
|
||||
* For id/ names: always "_". */
|
||||
val localPart: String,
|
||||
/** Which namespace: DOMAIN or IDENTITY */
|
||||
val namespace: Namespace,
|
||||
)
|
||||
|
||||
private enum class Namespace { DOMAIN, IDENTITY }
|
||||
|
||||
/**
|
||||
* Parse a user-supplied string into a structured lookup request.
|
||||
*
|
||||
* Accepted formats:
|
||||
* "alice@example.bit" → d/example, localPart=alice
|
||||
* "_@example.bit" → d/example, localPart=_
|
||||
* "example.bit" → d/example, localPart=_
|
||||
* "d/example" → d/example, localPart=_
|
||||
* "id/alice" → id/alice, localPart=_
|
||||
*/
|
||||
private fun parseIdentifier(raw: String): ParsedIdentifier? {
|
||||
val input = raw.trim()
|
||||
|
||||
// Direct namespace references
|
||||
if (input.startsWith("d/", ignoreCase = true)) {
|
||||
return ParsedIdentifier(
|
||||
namecoinName = input.lowercase(),
|
||||
localPart = "_",
|
||||
namespace = Namespace.DOMAIN,
|
||||
)
|
||||
}
|
||||
if (input.startsWith("id/", ignoreCase = true)) {
|
||||
return ParsedIdentifier(
|
||||
namecoinName = input.lowercase(),
|
||||
localPart = "_",
|
||||
namespace = Namespace.IDENTITY,
|
||||
)
|
||||
}
|
||||
|
||||
// NIP-05 style: user@domain.bit
|
||||
if (input.contains("@") && input.endsWith(".bit", ignoreCase = true)) {
|
||||
val parts = input.split("@", limit = 2)
|
||||
if (parts.size != 2) return null
|
||||
val localPart = parts[0].lowercase().ifEmpty { "_" }
|
||||
val domain = parts[1].removeSuffix(".bit").lowercase()
|
||||
if (domain.isEmpty()) return null
|
||||
return ParsedIdentifier(
|
||||
namecoinName = "d/$domain",
|
||||
localPart = localPart,
|
||||
namespace = Namespace.DOMAIN,
|
||||
)
|
||||
}
|
||||
|
||||
// Bare domain: example.bit
|
||||
if (input.endsWith(".bit", ignoreCase = true)) {
|
||||
val domain = input.removeSuffix(".bit").lowercase()
|
||||
if (domain.isEmpty()) return null
|
||||
return ParsedIdentifier(
|
||||
namecoinName = "d/$domain",
|
||||
localPart = "_",
|
||||
namespace = Namespace.DOMAIN,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Lookup & Value Parsing ─────────────────────────────────────────
|
||||
|
||||
private suspend fun performLookup(parsed: ParsedIdentifier): NamecoinNostrResult? {
|
||||
val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider()) ?: return null
|
||||
val valueJson = tryParseJson(nameResult.value) ?: return null
|
||||
|
||||
return when (parsed.namespace) {
|
||||
Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed)
|
||||
Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Nostr data from a `d/` domain value.
|
||||
*
|
||||
* Supports:
|
||||
* { "nostr": "hex-pubkey" } → simple form
|
||||
* { "nostr": { "names": { "alice": "hex" }, ... } } → extended NIP-05-like form
|
||||
*/
|
||||
private fun extractFromDomainValue(
|
||||
value: JsonObject,
|
||||
parsed: ParsedIdentifier,
|
||||
): NamecoinNostrResult? {
|
||||
val nostrField = value["nostr"] ?: return null
|
||||
|
||||
// Simple form: "nostr": "hex-pubkey"
|
||||
if (nostrField is JsonPrimitive && nostrField.isString) {
|
||||
val pubkey = nostrField.content
|
||||
if (parsed.localPart == "_" && isValidPubkey(pubkey)) {
|
||||
return NamecoinNostrResult(
|
||||
pubkey = pubkey.lowercase(),
|
||||
namecoinName = parsed.namecoinName,
|
||||
localPart = "_",
|
||||
)
|
||||
}
|
||||
// Simple form only supports root — if a non-root local-part
|
||||
// was requested, we can't resolve it.
|
||||
if (parsed.localPart != "_") return null
|
||||
}
|
||||
|
||||
// Extended form: "nostr": { "names": {...}, "relays": {...} }
|
||||
if (nostrField is JsonObject) {
|
||||
val names = nostrField["names"]?.jsonObject ?: return null
|
||||
val pubkeyElem = names[parsed.localPart] ?: names["_"] // fall back to root
|
||||
val pubkey = (pubkeyElem as? JsonPrimitive)?.content ?: return null
|
||||
if (!isValidPubkey(pubkey)) return null
|
||||
|
||||
val relays = extractRelays(nostrField, pubkey)
|
||||
return NamecoinNostrResult(
|
||||
pubkey = pubkey.lowercase(),
|
||||
relays = relays,
|
||||
namecoinName = parsed.namecoinName,
|
||||
localPart = parsed.localPart,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Nostr data from an `id/` identity value.
|
||||
*
|
||||
* The id/ namespace stores general identity data. We look for:
|
||||
* { "nostr": "hex-pubkey" }
|
||||
* { "nostr": { "pubkey": "hex", "relays": [...] } }
|
||||
*/
|
||||
private fun extractFromIdentityValue(
|
||||
value: JsonObject,
|
||||
parsed: ParsedIdentifier,
|
||||
): NamecoinNostrResult? {
|
||||
val nostrField = value["nostr"] ?: return null
|
||||
|
||||
// Simple: "nostr": "hex-pubkey"
|
||||
if (nostrField is JsonPrimitive && nostrField.isString) {
|
||||
val pubkey = nostrField.content
|
||||
if (isValidPubkey(pubkey)) {
|
||||
return NamecoinNostrResult(
|
||||
pubkey = pubkey.lowercase(),
|
||||
namecoinName = parsed.namecoinName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Object form: "nostr": { "pubkey": "hex", "relays": [...] }
|
||||
if (nostrField is JsonObject) {
|
||||
// Try "pubkey" field
|
||||
val pubkey = (nostrField["pubkey"] as? JsonPrimitive)?.content
|
||||
if (pubkey != null && isValidPubkey(pubkey)) {
|
||||
val relays =
|
||||
try {
|
||||
nostrField["relays"]?.jsonArray?.mapNotNull {
|
||||
(it as? JsonPrimitive)?.content
|
||||
} ?: emptyList()
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return NamecoinNostrResult(
|
||||
pubkey = pubkey.lowercase(),
|
||||
relays = relays,
|
||||
namecoinName = parsed.namecoinName,
|
||||
)
|
||||
}
|
||||
|
||||
// Also try NIP-05-like "names" structure for id/ names
|
||||
val names = nostrField["names"]?.jsonObject
|
||||
if (names != null) {
|
||||
val rootPubkey = (names["_"] as? JsonPrimitive)?.content
|
||||
if (rootPubkey != null && isValidPubkey(rootPubkey)) {
|
||||
val relays = extractRelays(nostrField, rootPubkey)
|
||||
return NamecoinNostrResult(
|
||||
pubkey = rootPubkey.lowercase(),
|
||||
relays = relays,
|
||||
namecoinName = parsed.namecoinName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun extractRelays(
|
||||
nostrObj: JsonObject,
|
||||
pubkey: String,
|
||||
): List<String> {
|
||||
return try {
|
||||
val relaysMap = nostrObj["relays"]?.jsonObject ?: return emptyList()
|
||||
val relayArray =
|
||||
relaysMap[pubkey.lowercase()]?.jsonArray
|
||||
?: relaysMap[pubkey]?.jsonArray
|
||||
?: return emptyList()
|
||||
relayArray.mapNotNull { (it as? JsonPrimitive)?.content }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryParseJson(raw: String): JsonObject? =
|
||||
try {
|
||||
json.parseToJsonElement(raw).jsonObject
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun isValidPubkey(s: String): Boolean = HEX_PUBKEY_REGEX.matches(s)
|
||||
}
|
||||
+17
-1
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.quartz.nip05DnsIdentifiers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
data class Nip05KeyInfo(
|
||||
@@ -30,6 +31,7 @@ data class Nip05KeyInfo(
|
||||
|
||||
class Nip05Client(
|
||||
val fetcher: Nip05Fetcher,
|
||||
val namecoinResolver: NamecoinNameResolver? = null,
|
||||
) {
|
||||
val parser = Nip05Parser()
|
||||
|
||||
@@ -37,6 +39,12 @@ class Nip05Client(
|
||||
nip05: Nip05Id,
|
||||
hexKey: HexKey,
|
||||
): Boolean {
|
||||
// Namecoin: route .bit domains to blockchain verification
|
||||
if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) {
|
||||
val result = namecoinResolver.resolve(nip05.toValue())
|
||||
return result?.pubkey == hexKey
|
||||
}
|
||||
|
||||
val json = fetchNip05Data(nip05)
|
||||
|
||||
val key =
|
||||
@@ -54,7 +62,15 @@ class Nip05Client(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun get(nip05: Nip05Id) = parser.parseHexKeyAndRelays(nip05, fetchNip05Data(nip05))
|
||||
suspend fun get(nip05: Nip05Id): Nip05KeyInfo? {
|
||||
// Namecoin: route .bit domains to blockchain resolution
|
||||
if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) {
|
||||
val result = namecoinResolver.resolve(nip05.toValue()) ?: return null
|
||||
return Nip05KeyInfo(result.pubkey, result.relays)
|
||||
}
|
||||
|
||||
return parser.parseHexKeyAndRelays(nip05, fetchNip05Data(nip05))
|
||||
}
|
||||
|
||||
suspend fun load(nip05: Nip05Id) = parser.parse(fetchNip05Data(nip05))
|
||||
|
||||
|
||||
+3
@@ -21,6 +21,9 @@
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
|
||||
|
||||
/**
|
||||
* Deterministic chess state reconstruction from Jester protocol events.
|
||||
|
||||
-295
@@ -1,295 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Live Chess Game Challenge Event (Kind 30064)
|
||||
*
|
||||
* Challenge another player to a chess game or create an open challenge.
|
||||
* This is a parameterized replaceable event.
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id (unique identifier for this game)
|
||||
* - p: opponent pubkey (optional, for direct challenges)
|
||||
* - player_color: "white" or "black"
|
||||
* - time_control: optional time control (e.g., "10+0", "5+3")
|
||||
*/
|
||||
@Immutable
|
||||
class LiveChessGameChallengeEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
companion object {
|
||||
const val KIND = 30064
|
||||
|
||||
fun build(
|
||||
gameId: String,
|
||||
playerColor: Color,
|
||||
opponentPubkey: String? = null,
|
||||
timeControl: String? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<LiveChessGameChallengeEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, "", createdAt) {
|
||||
add(arrayOf("d", gameId))
|
||||
add(arrayOf("player_color", if (playerColor == Color.WHITE) "white" else "black"))
|
||||
opponentPubkey?.let { add(arrayOf("p", it)) }
|
||||
timeControl?.let { add(arrayOf("time_control", it)) }
|
||||
alt("Chess game challenge")
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
|
||||
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
|
||||
|
||||
fun playerColor(): Color? =
|
||||
tags
|
||||
.firstOrNull { it.size >= 2 && it[0] == "player_color" }
|
||||
?.get(1)
|
||||
?.let { if (it == "white") Color.WHITE else Color.BLACK }
|
||||
|
||||
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
|
||||
|
||||
fun timeControl(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "time_control" }?.get(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Chess Game Accept Event (Kind 30065)
|
||||
*
|
||||
* Accept a chess game challenge
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id (same as challenge)
|
||||
* - e: challenge event ID
|
||||
* - p: challenger pubkey
|
||||
*/
|
||||
@Immutable
|
||||
class LiveChessGameAcceptEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
companion object {
|
||||
const val KIND = 30065
|
||||
|
||||
fun build(
|
||||
gameId: String,
|
||||
challengeEventId: String,
|
||||
challengerPubkey: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<LiveChessGameAcceptEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, "", createdAt) {
|
||||
add(arrayOf("d", gameId))
|
||||
add(arrayOf("e", challengeEventId))
|
||||
add(arrayOf("p", challengerPubkey))
|
||||
alt("Chess game acceptance")
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
|
||||
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
|
||||
|
||||
fun challengeEventId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun challengerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Chess Move Event (Kind 30066)
|
||||
*
|
||||
* Individual move in a live chess game
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id
|
||||
* - move_number: move number (1-based)
|
||||
* - san: move in Standard Algebraic Notation
|
||||
* - fen: resulting position in FEN notation
|
||||
* - p: opponent pubkey
|
||||
*
|
||||
* Content: Optional move comment
|
||||
*/
|
||||
@Immutable
|
||||
class LiveChessMoveEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
companion object {
|
||||
const val KIND = 30066
|
||||
|
||||
fun build(
|
||||
gameId: String,
|
||||
moveNumber: Int,
|
||||
san: String,
|
||||
fen: String,
|
||||
opponentPubkey: String,
|
||||
comment: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<LiveChessMoveEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, comment, createdAt) {
|
||||
add(arrayOf("d", "$gameId-$moveNumber"))
|
||||
add(arrayOf("game_id", gameId))
|
||||
add(arrayOf("move_number", moveNumber.toString()))
|
||||
add(arrayOf("san", san))
|
||||
add(arrayOf("fen", fen))
|
||||
add(arrayOf("p", opponentPubkey))
|
||||
alt("Chess move: $san")
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
|
||||
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "game_id" }?.get(1)
|
||||
|
||||
fun moveNumber(): Int? =
|
||||
tags
|
||||
.firstOrNull { it.size >= 2 && it[0] == "move_number" }
|
||||
?.get(1)
|
||||
?.toIntOrNull()
|
||||
|
||||
fun san(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "san" }?.get(1)
|
||||
|
||||
fun fen(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "fen" }?.get(1)
|
||||
|
||||
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
|
||||
|
||||
fun comment(): String = content
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Chess Game End Event (Kind 30067)
|
||||
*
|
||||
* Game result and termination
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id
|
||||
* - result: "1-0"|"0-1"|"1/2-1/2"
|
||||
* - termination: reason for game end
|
||||
* - winner: pubkey of winner (if applicable)
|
||||
* - p: opponent pubkey
|
||||
*
|
||||
* Content: Optional PGN of complete game
|
||||
*/
|
||||
@Immutable
|
||||
class LiveChessGameEndEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
companion object {
|
||||
const val KIND = 30067
|
||||
|
||||
fun build(
|
||||
gameId: String,
|
||||
result: GameResult,
|
||||
termination: GameTermination,
|
||||
winnerPubkey: String? = null,
|
||||
opponentPubkey: String,
|
||||
pgn: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<LiveChessGameEndEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, pgn, createdAt) {
|
||||
add(arrayOf("d", gameId))
|
||||
add(arrayOf("result", result.notation))
|
||||
add(arrayOf("termination", termination.name.lowercase()))
|
||||
winnerPubkey?.let { add(arrayOf("winner", it)) }
|
||||
add(arrayOf("p", opponentPubkey))
|
||||
alt("Chess game ended: ${result.notation}")
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
|
||||
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
|
||||
|
||||
fun result(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "result" }?.get(1)
|
||||
|
||||
fun termination(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "termination" }?.get(1)
|
||||
|
||||
fun winnerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "winner" }?.get(1)
|
||||
|
||||
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
|
||||
|
||||
fun pgn(): String = content
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Chess Draw Offer Event (Kind 30068)
|
||||
*
|
||||
* Offer a draw to opponent. Opponent can accept by sending a game end event
|
||||
* with DRAW_AGREEMENT termination, or decline/ignore by making their next move.
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id
|
||||
* - p: opponent pubkey
|
||||
*
|
||||
* Content: Optional message
|
||||
*/
|
||||
@Immutable
|
||||
class LiveChessDrawOfferEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
companion object {
|
||||
const val KIND = 30068
|
||||
|
||||
fun build(
|
||||
gameId: String,
|
||||
opponentPubkey: String,
|
||||
message: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<LiveChessDrawOfferEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, message, createdAt) {
|
||||
add(arrayOf("d", gameId))
|
||||
add(arrayOf("p", opponentPubkey))
|
||||
alt("Chess draw offer")
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
|
||||
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
|
||||
|
||||
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
|
||||
|
||||
fun message(): String = content
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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.accept
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Live Chess Game Accept Event (Kind 30065)
|
||||
*
|
||||
* Accept a chess game challenge
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id (same as challenge)
|
||||
* - e: challenge event ID
|
||||
* - p: challenger pubkey
|
||||
*/
|
||||
@Immutable
|
||||
class LiveChessGameAcceptEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
fun challengeEventId() = tags.challengeEventId()
|
||||
|
||||
fun challengerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val KIND = 30065
|
||||
const val ALT_DESCRIPTION = "Chess game acceptance"
|
||||
|
||||
fun build(
|
||||
gameId: String,
|
||||
challengeEventId: String,
|
||||
challengerPubkey: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<LiveChessGameAcceptEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, "", createdAt) {
|
||||
add(arrayOf("d", gameId))
|
||||
challengeEvent(challengeEventId)
|
||||
add(arrayOf("p", challengerPubkey))
|
||||
alt(ALT_DESCRIPTION)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.accept
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip64Chess.accept.tags.ChallengeEventTag
|
||||
|
||||
fun TagArrayBuilder<LiveChessGameAcceptEvent>.challengeEvent(challengeEventId: String) = add(ChallengeEventTag.assemble(challengeEventId))
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.accept
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip64Chess.accept.tags.ChallengeEventTag
|
||||
|
||||
fun TagArray.challengeEventId() = firstNotNullOfOrNull(ChallengeEventTag::parse)
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.accept.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class ChallengeEventTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "e"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1) && tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].length == 64) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(challengeEventId: String) = arrayOf(TAG_NAME, challengeEventId)
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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.challenge
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Live Chess Game Challenge Event (Kind 30064)
|
||||
*
|
||||
* Challenge another player to a chess game or create an open challenge.
|
||||
* This is a parameterized replaceable event.
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id (unique identifier for this game)
|
||||
* - p: opponent pubkey (optional, for direct challenges)
|
||||
* - player_color: "white" or "black"
|
||||
* - time_control: optional time control (e.g., "10+0", "5+3")
|
||||
*/
|
||||
@Immutable
|
||||
class LiveChessGameChallengeEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
fun playerColor() = tags.playerColor()
|
||||
|
||||
fun timeControl() = tags.timeControl()
|
||||
|
||||
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val KIND = 30064
|
||||
const val ALT_DESCRIPTION = "Chess game challenge"
|
||||
|
||||
fun build(
|
||||
gameId: String,
|
||||
playerColor: Color,
|
||||
opponentPubkey: String? = null,
|
||||
timeControl: String? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<LiveChessGameChallengeEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, "", createdAt) {
|
||||
add(arrayOf("d", gameId))
|
||||
playerColor(playerColor)
|
||||
opponentPubkey?.let { add(arrayOf("p", it)) }
|
||||
timeControl?.let { timeControl(it) }
|
||||
alt(ALT_DESCRIPTION)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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.challenge
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.tags.PlayerColorTag
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.tags.TimeControlTag
|
||||
|
||||
fun TagArrayBuilder<LiveChessGameChallengeEvent>.playerColor(color: Color) = addUnique(PlayerColorTag.assemble(color))
|
||||
|
||||
fun TagArrayBuilder<LiveChessGameChallengeEvent>.timeControl(timeControl: String) = addUnique(TimeControlTag.assemble(timeControl))
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.challenge
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.tags.PlayerColorTag
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.tags.TimeControlTag
|
||||
|
||||
fun TagArray.playerColor() = firstNotNullOfOrNull(PlayerColorTag::parse)
|
||||
|
||||
fun TagArray.timeControl() = firstNotNullOfOrNull(TimeControlTag::parse)
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.challenge.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class PlayerColorTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "player_color"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): Color? {
|
||||
ensure(tag.has(1) && tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return when (tag[1]) {
|
||||
"white" -> Color.WHITE
|
||||
"black" -> Color.BLACK
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun assemble(color: Color) = arrayOf(TAG_NAME, if (color == Color.WHITE) "white" else "black")
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.challenge.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class TimeControlTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "time_control"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1) && tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(timeControl: String) = arrayOf(TAG_NAME, timeControl)
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.draw
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Live Chess Draw Offer Event (Kind 30068)
|
||||
*
|
||||
* Offer a draw to opponent. Opponent can accept by sending a game end event
|
||||
* with DRAW_AGREEMENT termination, or decline/ignore by making their next move.
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id
|
||||
* - p: opponent pubkey
|
||||
*
|
||||
* Content: Optional message
|
||||
*/
|
||||
@Immutable
|
||||
class LiveChessDrawOfferEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
|
||||
|
||||
fun message(): String = content
|
||||
|
||||
companion object {
|
||||
const val KIND = 30068
|
||||
const val ALT_DESCRIPTION = "Chess draw offer"
|
||||
|
||||
fun build(
|
||||
gameId: String,
|
||||
opponentPubkey: String,
|
||||
message: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<LiveChessDrawOfferEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, message, createdAt) {
|
||||
add(arrayOf("d", gameId))
|
||||
add(arrayOf("p", opponentPubkey))
|
||||
alt(ALT_DESCRIPTION)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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.end
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip64Chess.GameResult
|
||||
import com.vitorpamplona.quartz.nip64Chess.GameTermination
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Live Chess Game End Event (Kind 30067)
|
||||
*
|
||||
* Game result and termination
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id
|
||||
* - result: "1-0"|"0-1"|"1/2-1/2"
|
||||
* - termination: reason for game end
|
||||
* - winner: pubkey of winner (if applicable)
|
||||
* - p: opponent pubkey
|
||||
*
|
||||
* Content: Optional PGN of complete game
|
||||
*/
|
||||
@Immutable
|
||||
class LiveChessGameEndEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
fun result() = tags.result()
|
||||
|
||||
fun termination() = tags.termination()
|
||||
|
||||
fun winnerPubkey() = tags.winnerPubkey()
|
||||
|
||||
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
|
||||
|
||||
fun pgn(): String = content
|
||||
|
||||
companion object {
|
||||
const val KIND = 30067
|
||||
const val ALT_DESCRIPTION = "Chess game ended"
|
||||
|
||||
fun build(
|
||||
gameId: String,
|
||||
result: GameResult,
|
||||
termination: GameTermination,
|
||||
winnerPubkey: String? = null,
|
||||
opponentPubkey: String,
|
||||
pgn: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<LiveChessGameEndEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, pgn, createdAt) {
|
||||
add(arrayOf("d", gameId))
|
||||
result(result)
|
||||
termination(termination)
|
||||
winnerPubkey?.let { winner(it) }
|
||||
add(arrayOf("p", opponentPubkey))
|
||||
alt("$ALT_DESCRIPTION: ${result.notation}")
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.end
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip64Chess.GameResult
|
||||
import com.vitorpamplona.quartz.nip64Chess.GameTermination
|
||||
import com.vitorpamplona.quartz.nip64Chess.end.tags.ResultTag
|
||||
import com.vitorpamplona.quartz.nip64Chess.end.tags.TerminationTag
|
||||
import com.vitorpamplona.quartz.nip64Chess.end.tags.WinnerTag
|
||||
|
||||
fun TagArrayBuilder<LiveChessGameEndEvent>.result(result: GameResult) = addUnique(ResultTag.assemble(result))
|
||||
|
||||
fun TagArrayBuilder<LiveChessGameEndEvent>.termination(termination: GameTermination) = addUnique(TerminationTag.assemble(termination))
|
||||
|
||||
fun TagArrayBuilder<LiveChessGameEndEvent>.winner(winnerPubkey: HexKey) = addUnique(WinnerTag.assemble(winnerPubkey))
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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.end
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip64Chess.end.tags.ResultTag
|
||||
import com.vitorpamplona.quartz.nip64Chess.end.tags.TerminationTag
|
||||
import com.vitorpamplona.quartz.nip64Chess.end.tags.WinnerTag
|
||||
|
||||
fun TagArray.result() = firstNotNullOfOrNull(ResultTag::parse)
|
||||
|
||||
fun TagArray.termination() = firstNotNullOfOrNull(TerminationTag::parse)
|
||||
|
||||
fun TagArray.winnerPubkey() = firstNotNullOfOrNull(WinnerTag::parse)
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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.end.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nip64Chess.GameResult
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class ResultTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "result"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1) && tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(result: GameResult) = arrayOf(TAG_NAME, result.notation)
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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.end.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nip64Chess.GameTermination
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class TerminationTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "termination"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1) && tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(termination: GameTermination) = arrayOf(TAG_NAME, termination.name.lowercase())
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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.end.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class WinnerTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "winner"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): HexKey? {
|
||||
ensure(tag.has(1) && tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(winnerPubkey: HexKey) = arrayOf(TAG_NAME, winnerPubkey)
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -18,7 +18,7 @@
|
||||
* 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
|
||||
package com.vitorpamplona.quartz.nip64Chess.game
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -50,6 +50,16 @@ class ChessGameEvent(
|
||||
content: String, // PGN database format
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/**
|
||||
* Get PGN content from event
|
||||
*/
|
||||
fun pgn(): String = content
|
||||
|
||||
/**
|
||||
* Get alt text for non-supporting clients (NIP-31)
|
||||
*/
|
||||
fun altText(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "alt" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val KIND = 64
|
||||
const val ALT_DESCRIPTION = "Chess Game"
|
||||
@@ -73,14 +83,4 @@ class ChessGameEvent(
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PGN content from event
|
||||
*/
|
||||
fun pgn(): String = content
|
||||
|
||||
/**
|
||||
* Get alt text for non-supporting clients (NIP-31)
|
||||
*/
|
||||
fun altText(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "alt" }?.get(1)
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.jester
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 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.
|
||||
)
|
||||
+4
-84
@@ -18,7 +18,7 @@
|
||||
* 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
|
||||
package com.vitorpamplona.quartz.nip64Chess.jester
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -26,65 +26,12 @@ 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.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.nip64Chess.GameResult
|
||||
import com.vitorpamplona.quartz.nip64Chess.GameTermination
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.serialization.Serializable
|
||||
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
|
||||
@@ -352,30 +299,3 @@ 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()
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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.jester
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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.jester
|
||||
|
||||
/**
|
||||
* Jester Protocol Constants
|
||||
*
|
||||
* 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
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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.move
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Live Chess Move Event (Kind 30066)
|
||||
*
|
||||
* Individual move in a live chess game
|
||||
*
|
||||
* Tags:
|
||||
* - d: game_id
|
||||
* - move_number: move number (1-based)
|
||||
* - san: move in Standard Algebraic Notation
|
||||
* - fen: resulting position in FEN notation
|
||||
* - p: opponent pubkey
|
||||
*
|
||||
* Content: Optional move comment
|
||||
*/
|
||||
@Immutable
|
||||
class LiveChessMoveEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
fun gameId() = tags.gameId()
|
||||
|
||||
fun moveNumber() = tags.moveNumber()
|
||||
|
||||
fun san() = tags.san()
|
||||
|
||||
fun fen() = tags.fen()
|
||||
|
||||
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
|
||||
|
||||
fun comment(): String = content
|
||||
|
||||
companion object {
|
||||
const val KIND = 30066
|
||||
const val ALT_DESCRIPTION = "Chess move"
|
||||
|
||||
fun build(
|
||||
gameId: String,
|
||||
moveNumber: Int,
|
||||
san: String,
|
||||
fen: String,
|
||||
opponentPubkey: String,
|
||||
comment: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<LiveChessMoveEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, comment, createdAt) {
|
||||
add(arrayOf("d", "$gameId-$moveNumber"))
|
||||
gameId(gameId)
|
||||
moveNumber(moveNumber)
|
||||
san(san)
|
||||
fen(fen)
|
||||
add(arrayOf("p", opponentPubkey))
|
||||
alt("$ALT_DESCRIPTION: $san")
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.move
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.tags.FenTag
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.tags.GameIdTag
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.tags.MoveNumberTag
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.tags.SanTag
|
||||
|
||||
fun TagArrayBuilder<LiveChessMoveEvent>.gameId(gameId: String) = addUnique(GameIdTag.assemble(gameId))
|
||||
|
||||
fun TagArrayBuilder<LiveChessMoveEvent>.moveNumber(moveNumber: Int) = addUnique(MoveNumberTag.assemble(moveNumber))
|
||||
|
||||
fun TagArrayBuilder<LiveChessMoveEvent>.san(san: String) = addUnique(SanTag.assemble(san))
|
||||
|
||||
fun TagArrayBuilder<LiveChessMoveEvent>.fen(fen: String) = addUnique(FenTag.assemble(fen))
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.move
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.tags.FenTag
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.tags.GameIdTag
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.tags.MoveNumberTag
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.tags.SanTag
|
||||
|
||||
fun TagArray.gameId() = firstNotNullOfOrNull(GameIdTag::parse)
|
||||
|
||||
fun TagArray.moveNumber() = firstNotNullOfOrNull(MoveNumberTag::parse)
|
||||
|
||||
fun TagArray.san() = firstNotNullOfOrNull(SanTag::parse)
|
||||
|
||||
fun TagArray.fen() = firstNotNullOfOrNull(FenTag::parse)
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.move.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class FenTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "fen"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1) && tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(fen: String) = arrayOf(TAG_NAME, fen)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.move.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class GameIdTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "game_id"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1) && tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(gameId: String) = arrayOf(TAG_NAME, gameId)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.move.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class MoveNumberTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "move_number"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): Int? {
|
||||
ensure(tag.has(1) && tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1].toIntOrNull()
|
||||
}
|
||||
|
||||
fun assemble(moveNumber: Int) = arrayOf(TAG_NAME, moveNumber.toString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.move.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class SanTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "san"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1) && tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(san: String) = arrayOf(TAG_NAME, san)
|
||||
}
|
||||
}
|
||||
@@ -114,13 +114,13 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.JesterEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.accept.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent
|
||||
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* 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.nip05.namecoin
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NamecoinNameResolverTest {
|
||||
// ── isNamecoinIdentifier ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `recognizes dot-bit domains`() {
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("example.bit"))
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("alice@example.bit"))
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("_@example.bit"))
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("EXAMPLE.BIT"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recognizes d-slash names`() {
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("d/example"))
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("D/Example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recognizes id-slash names`() {
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("id/alice"))
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("ID/Alice"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects non-namecoin identifiers`() {
|
||||
assertFalse(NamecoinNameResolver.isNamecoinIdentifier("[email protected]"))
|
||||
assertFalse(NamecoinNameResolver.isNamecoinIdentifier("npub1abc"))
|
||||
assertFalse(NamecoinNameResolver.isNamecoinIdentifier("some random text"))
|
||||
assertFalse(NamecoinNameResolver.isNamecoinIdentifier(""))
|
||||
}
|
||||
|
||||
// ── Value format: simple pubkey in d/ ──────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `parses simple nostr field from domain value`() {
|
||||
val value = """{"nostr":"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"}"""
|
||||
val result = extractNostrFromValue(value, "d/example", "_")
|
||||
assertNotNull(result)
|
||||
assertEquals("b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9", result!!.pubkey)
|
||||
}
|
||||
|
||||
// ── Value format: extended NIP-05-like in d/ ───────────────────────
|
||||
|
||||
@Test
|
||||
fun `parses extended nostr names from domain value`() {
|
||||
val value = """{
|
||||
"nostr": {
|
||||
"names": {
|
||||
"_": "aaaa000000000000000000000000000000000000000000000000000000000001",
|
||||
"alice": "bbbb000000000000000000000000000000000000000000000000000000000002"
|
||||
},
|
||||
"relays": {
|
||||
"bbbb000000000000000000000000000000000000000000000000000000000002": [
|
||||
"wss://relay.example.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
// Root lookup
|
||||
val rootResult = extractNostrFromValue(value, "d/example", "_")
|
||||
assertNotNull(rootResult)
|
||||
assertEquals("aaaa000000000000000000000000000000000000000000000000000000000001", rootResult!!.pubkey)
|
||||
|
||||
// Named lookup
|
||||
val aliceResult = extractNostrFromValue(value, "d/example", "alice")
|
||||
assertNotNull(aliceResult)
|
||||
assertEquals("bbbb000000000000000000000000000000000000000000000000000000000002", aliceResult!!.pubkey)
|
||||
assertEquals(listOf("wss://relay.example.com"), aliceResult.relays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to root when named user not found`() {
|
||||
val value = """{
|
||||
"nostr": {
|
||||
"names": {
|
||||
"_": "aaaa000000000000000000000000000000000000000000000000000000000001"
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
val result = extractNostrFromValue(value, "d/example", "nonexistent")
|
||||
assertNotNull(result)
|
||||
assertEquals("aaaa000000000000000000000000000000000000000000000000000000000001", result!!.pubkey)
|
||||
}
|
||||
|
||||
// ── Value format: id/ namespace ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `parses simple nostr field from identity value`() {
|
||||
val value = """{
|
||||
"nostr": "cccc000000000000000000000000000000000000000000000000000000000003",
|
||||
"email": "[email protected]"
|
||||
}"""
|
||||
val result = extractNostrFromIdentityValue(value, "id/alice")
|
||||
assertNotNull(result)
|
||||
assertEquals("cccc000000000000000000000000000000000000000000000000000000000003", result!!.pubkey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses object nostr field from identity value`() {
|
||||
val value = """{
|
||||
"nostr": {
|
||||
"pubkey": "dddd000000000000000000000000000000000000000000000000000000000004",
|
||||
"relays": ["wss://relay.example.com", "wss://relay2.example.com"]
|
||||
}
|
||||
}"""
|
||||
val result = extractNostrFromIdentityValue(value, "id/bob")
|
||||
assertNotNull(result)
|
||||
assertEquals("dddd000000000000000000000000000000000000000000000000000000000004", result!!.pubkey)
|
||||
assertEquals(2, result.relays.size)
|
||||
}
|
||||
|
||||
// ── Invalid data ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `rejects invalid pubkey lengths`() {
|
||||
val value = """{"nostr":"tooshort"}"""
|
||||
val result = extractNostrFromValue(value, "d/bad", "_")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects non-hex pubkeys`() {
|
||||
val value = """{"nostr":"zzzz000000000000000000000000000000000000000000000000000000000000"}"""
|
||||
val result = extractNostrFromValue(value, "d/bad", "_")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles missing nostr field`() {
|
||||
val value = """{"ip":"1.2.3.4","map":{"www":{"ip":"1.2.3.4"}}}"""
|
||||
val result = extractNostrFromValue(value, "d/example", "_")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles malformed JSON gracefully`() {
|
||||
val value = "not json at all"
|
||||
val result = extractNostrFromValue(value, "d/broken", "_")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
// ── Test helpers ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Directly test value parsing without network access.
|
||||
* Simulates what NamecoinNameResolver does after receiving a name_show result.
|
||||
*/
|
||||
private fun extractNostrFromValue(
|
||||
jsonValue: String,
|
||||
namecoinName: String,
|
||||
localPart: String,
|
||||
): NamecoinNostrResult? {
|
||||
val json =
|
||||
kotlinx.serialization.json.Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
val obj =
|
||||
try {
|
||||
json.parseToJsonElement(jsonValue).jsonObject
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
val nostrField = obj["nostr"] ?: return null
|
||||
|
||||
// Simple form
|
||||
if (nostrField is kotlinx.serialization.json.JsonPrimitive && nostrField.isString) {
|
||||
val pubkey = nostrField.content
|
||||
if (localPart == "_" && pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) {
|
||||
return NamecoinNostrResult(pubkey = pubkey.lowercase(), namecoinName = namecoinName)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Extended form
|
||||
if (nostrField is kotlinx.serialization.json.JsonObject) {
|
||||
val names = nostrField["names"]?.jsonObject ?: return null
|
||||
val pubkeyElem = names[localPart] ?: names["_"] ?: return null
|
||||
val pubkey = (pubkeyElem as? kotlinx.serialization.json.JsonPrimitive)?.content ?: return null
|
||||
if (!pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) return null
|
||||
|
||||
val relays =
|
||||
try {
|
||||
val relaysMap = nostrField["relays"]?.jsonObject
|
||||
relaysMap?.get(pubkey.lowercase())?.jsonArray?.mapNotNull {
|
||||
(it as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
} ?: emptyList()
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return NamecoinNostrResult(
|
||||
pubkey = pubkey.lowercase(),
|
||||
relays = relays,
|
||||
namecoinName = namecoinName,
|
||||
localPart = localPart,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun extractNostrFromIdentityValue(
|
||||
jsonValue: String,
|
||||
namecoinName: String,
|
||||
): NamecoinNostrResult? {
|
||||
val json =
|
||||
kotlinx.serialization.json.Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
val obj =
|
||||
try {
|
||||
json.parseToJsonElement(jsonValue).jsonObject
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
val nostrField = obj["nostr"] ?: return null
|
||||
|
||||
if (nostrField is kotlinx.serialization.json.JsonPrimitive && nostrField.isString) {
|
||||
val pubkey = nostrField.content
|
||||
if (pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) {
|
||||
return NamecoinNostrResult(pubkey = pubkey.lowercase(), namecoinName = namecoinName)
|
||||
}
|
||||
}
|
||||
|
||||
if (nostrField is kotlinx.serialization.json.JsonObject) {
|
||||
val pubkey = (nostrField["pubkey"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
if (pubkey != null && pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) {
|
||||
val relays =
|
||||
try {
|
||||
nostrField["relays"]?.jsonArray?.mapNotNull {
|
||||
(it as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
} ?: emptyList()
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
return NamecoinNostrResult(pubkey = pubkey.lowercase(), relays = relays, namecoinName = namecoinName)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Needed for jsonArray and jsonObject extensions
|
||||
private val kotlinx.serialization.json.JsonElement.jsonObject
|
||||
get() = this as kotlinx.serialization.json.JsonObject
|
||||
private val kotlinx.serialization.json.JsonElement.jsonArray
|
||||
get() = this as kotlinx.serialization.json.JsonArray
|
||||
}
|
||||
Reference in New Issue
Block a user