Merge branch 'main' into feat/desktop-private-dms
This commit is contained in:
@@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.bloom
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.sha256.Sha256Hasher
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -135,7 +135,7 @@ class BloomFilterTest {
|
||||
|
||||
var failureCounter = 0
|
||||
for (seed in 0..1000000) {
|
||||
if (bloomFilter.mightContains(Nip01.pubKeyCreate(Nip01.privKeyCreate()))) {
|
||||
if (bloomFilter.mightContains(Nip01Crypto.pubKeyCreate(Nip01Crypto.privKeyCreate()))) {
|
||||
failureCounter++
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -33,14 +33,14 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class Nip01Test {
|
||||
class Nip01CryptoTest {
|
||||
private val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray()
|
||||
|
||||
@Test
|
||||
fun testGetPublicFromPrivateKey() {
|
||||
assertEquals(
|
||||
"7d4b8806f1fd713c287235411bf95aa81b7242ead892733ec84b3f2719845be6",
|
||||
Nip01.pubKeyCreate(privateKey).toHexKey(),
|
||||
Nip01Crypto.pubKeyCreate(privateKey).toHexKey(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ class Nip01Test {
|
||||
fun testDeterministicSign() {
|
||||
assertEquals(
|
||||
"1484d0e0bd62165e822e31f1f4cc8e1ce8e20c30a060e24fb0ecd7baf7c624f661fb7a3e4f0ddb43018e5f0b4892c929af64d8b7a86021aa081ec8231e3dfa37",
|
||||
Nip01.sign(sha256("Test".toByteArray()), privateKey, null).toHexKey(),
|
||||
Nip01Crypto.sign(sha256("Test".toByteArray()), privateKey, null).toHexKey(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,10 +81,10 @@ class Nip01Test {
|
||||
@Test
|
||||
fun testDeterministicVerify() {
|
||||
assertTrue(
|
||||
Nip01.verify(
|
||||
Nip01Crypto.verify(
|
||||
"1484d0e0bd62165e822e31f1f4cc8e1ce8e20c30a060e24fb0ecd7baf7c624f661fb7a3e4f0ddb43018e5f0b4892c929af64d8b7a86021aa081ec8231e3dfa37".hexToByteArray(),
|
||||
sha256("Test".toByteArray()),
|
||||
Nip01.pubKeyCreate(privateKey),
|
||||
Nip01Crypto.pubKeyCreate(privateKey),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -93,18 +93,18 @@ class Nip01Test {
|
||||
fun testNonDeterministicSign() {
|
||||
assertNotEquals(
|
||||
"1484d0e0bd62165e822e31f1f4cc8e1ce8e20c30a060e24fb0ecd7baf7c624f661fb7a3e4f0ddb43018e5f0b4892c929af64d8b7a86021aa081ec8231e3dfa37",
|
||||
Nip01.sign(sha256("Test".toByteArray()), privateKey).toHexKey(),
|
||||
Nip01Crypto.sign(sha256("Test".toByteArray()), privateKey).toHexKey(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNonDeterministicSignVerify() {
|
||||
val signature = Nip01.sign(sha256("Test".toByteArray()), privateKey)
|
||||
val signature = Nip01Crypto.sign(sha256("Test".toByteArray()), privateKey)
|
||||
assertTrue(
|
||||
Nip01.verify(
|
||||
Nip01Crypto.verify(
|
||||
signature,
|
||||
sha256("Test".toByteArray()),
|
||||
Nip01.pubKeyCreate(privateKey),
|
||||
Nip01Crypto.pubKeyCreate(privateKey),
|
||||
),
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -33,7 +33,7 @@ class Nip01CryptoTest {
|
||||
fun testGetPublicFromPrivateKey() {
|
||||
val privateKey =
|
||||
"f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray()
|
||||
val publicKey = Nip01.pubKeyCreate(privateKey).toHexKey()
|
||||
val publicKey = Nip01Crypto.pubKeyCreate(privateKey).toHexKey()
|
||||
assertEquals("7d4b8806f1fd713c287235411bf95aa81b7242ead892733ec84b3f2719845be6", publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip04Dm
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
|
||||
import com.vitorpamplona.quartz.nip04Dm.crypto.Encryption
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -34,8 +34,8 @@ class EncryptionTest {
|
||||
|
||||
val sk1 = "91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe".hexToByteArray()
|
||||
val sk2 = "96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220".hexToByteArray()
|
||||
val pk1 = Nip01.pubKeyCreate(sk1)
|
||||
val pk2 = Nip01.pubKeyCreate(sk2)
|
||||
val pk1 = Nip01Crypto.pubKeyCreate(sk1)
|
||||
val pk2 = Nip01Crypto.pubKeyCreate(sk2)
|
||||
|
||||
val expectedShared = "7ce22696eb0e303ddaa491bdf2a56b79d249f2d861b8e012a933e01dc4beba81"
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.quartz.nip04Dm
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
@@ -33,8 +33,8 @@ class Nip04Test {
|
||||
fun encryptDecryptNIP4Test() {
|
||||
val msg = "Hi"
|
||||
|
||||
val privateKey = Nip01.privKeyCreate()
|
||||
val publicKey = Nip01.pubKeyCreate(privateKey)
|
||||
val privateKey = Nip01Crypto.privKeyCreate()
|
||||
val publicKey = Nip01Crypto.pubKeyCreate(privateKey)
|
||||
|
||||
val encrypted = Nip04.encrypt(msg, privateKey, publicKey)
|
||||
val decrypted = Nip04.decrypt(encrypted, privateKey, publicKey)
|
||||
@@ -46,8 +46,8 @@ class Nip04Test {
|
||||
fun encryptSharedSecretDecryptNIP4Test() {
|
||||
val msg = "Hi"
|
||||
|
||||
val privateKey = Nip01.privKeyCreate()
|
||||
val publicKey = Nip01.pubKeyCreate(privateKey)
|
||||
val privateKey = Nip01Crypto.privKeyCreate()
|
||||
val publicKey = Nip01Crypto.pubKeyCreate(privateKey)
|
||||
|
||||
val encrypted = Nip04.encrypt(msg, privateKey, publicKey)
|
||||
val decrypted = Nip04.decrypt(encrypted, privateKey, publicKey)
|
||||
|
||||
+5
-5
@@ -24,7 +24,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -68,8 +68,8 @@ class Nip44v1Test {
|
||||
fun encryptDecrypt() {
|
||||
val msg = "Hi"
|
||||
|
||||
val privateKey = Nip01.privKeyCreate()
|
||||
val publicKey = Nip01.pubKeyCreate(privateKey)
|
||||
val privateKey = Nip01Crypto.privKeyCreate()
|
||||
val publicKey = Nip01Crypto.pubKeyCreate(privateKey)
|
||||
|
||||
val encrypted = nip44v1.encrypt(msg, privateKey, publicKey)
|
||||
val decrypted = nip44v1.decrypt(encrypted, privateKey, publicKey)
|
||||
@@ -81,8 +81,8 @@ class Nip44v1Test {
|
||||
fun encryptDecryptSharedSecret() {
|
||||
val msg = "Hi"
|
||||
|
||||
val privateKey = Nip01.privKeyCreate()
|
||||
val publicKey = Nip01.pubKeyCreate(privateKey)
|
||||
val privateKey = Nip01Crypto.privKeyCreate()
|
||||
val publicKey = Nip01Crypto.pubKeyCreate(privateKey)
|
||||
|
||||
val sharedSecret = nip44v1.getSharedSecret(privateKey, publicKey)
|
||||
|
||||
|
||||
+5
-5
@@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
@@ -68,8 +68,8 @@ class Nip44v2Test {
|
||||
val privateKeyA = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray()
|
||||
val privateKeyB = "65f039136f8da8d3e87b4818746b53318d5481e24b2673f162815144223a0b5a".hexToByteArray()
|
||||
|
||||
val publicKeyA = Nip01.pubKeyCreate(privateKeyA)
|
||||
val publicKeyB = Nip01.pubKeyCreate(privateKeyB)
|
||||
val publicKeyA = Nip01Crypto.pubKeyCreate(privateKeyA)
|
||||
val publicKeyB = Nip01Crypto.pubKeyCreate(privateKeyB)
|
||||
|
||||
assertEquals(
|
||||
nip44v2.getConversationKey(privateKeyA, publicKeyB).toHexKey(),
|
||||
@@ -82,8 +82,8 @@ class Nip44v2Test {
|
||||
val privateKeyA = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray()
|
||||
val privateKeyB = "e6159851715b4aa6190c22b899b0c792847de0a4435ac5b678f35738351c43b0".hexToByteArray()
|
||||
|
||||
val publicKeyA = Nip01.pubKeyCreate(privateKeyA)
|
||||
val publicKeyB = Nip01.pubKeyCreate(privateKeyB)
|
||||
val publicKeyA = Nip01Crypto.pubKeyCreate(privateKeyA)
|
||||
val publicKeyB = Nip01Crypto.pubKeyCreate(privateKeyB)
|
||||
|
||||
assertEquals(
|
||||
nip44v2.getConversationKey(privateKeyA, publicKeyB).toHexKey(),
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -37,7 +37,7 @@ class SignStringTest {
|
||||
val message = "8e58c8251bb406b6ded69e9eb14f55282a9a53bdab16fc49a3218c2ad3abc887".hexToByteArray()
|
||||
val keyPair = KeyPair("a5ab474552c8f9c46c2eda5a0b68f27430ad81f96cb405e0cb4e34bf0c6494a2".hexToByteArray())
|
||||
|
||||
val signedMessage = Nip01.sign(message, keyPair.privKey!!, random).toHexKey()
|
||||
val signedMessage = Nip01Crypto.sign(message, keyPair.privKey!!, random).toHexKey()
|
||||
val expectedValue = "0f9be7e01ba53d5ee6874b9180c7956269fda7a5be424634c3d17b5cfcea6da001be89183876415ba08b7dafa6cff4555e393dc228fb8769b384344e9a27b77c"
|
||||
assertEquals(expectedValue, signedMessage)
|
||||
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
actual class TestResourceLoader actual constructor() {
|
||||
actual fun loadString(file: String): String {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip55AndroidSigner
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
|
||||
@@ -28,4 +28,4 @@ fun signString(
|
||||
message: String,
|
||||
privKey: ByteArray,
|
||||
nonce: ByteArray = RandomInstance.bytes(32),
|
||||
): ByteArray = Nip01.sign(sha256(message.toByteArray()), privKey, nonce)
|
||||
): ByteArray = Nip01Crypto.sign(sha256(message.toByteArray()), privKey, nonce)
|
||||
|
||||
+13
-16
@@ -28,7 +28,7 @@ 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.core.builder
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
@@ -83,17 +83,16 @@ class InteractiveStoryReadingStateEvent(
|
||||
|
||||
fun update(
|
||||
base: InteractiveStoryReadingStateEvent,
|
||||
currentScene: InteractiveStoryBaseEvent,
|
||||
currentSceneRelay: NormalizedRelayUrl?,
|
||||
currentScene: EventHintBundle<InteractiveStoryBaseEvent>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): EventTemplate<InteractiveStoryReadingStateEvent> {
|
||||
val rootTag = base.dTag()
|
||||
val sceneTag = currentScene.aTag(currentSceneRelay)
|
||||
val sceneTag = ATag(currentScene.event.address(), currentScene.relay)
|
||||
|
||||
val status =
|
||||
if (rootTag == sceneTag.toTag()) {
|
||||
ReadStatusTag.STATUS.NEW
|
||||
} else if (currentScene.options().isEmpty()) {
|
||||
} else if (currentScene.event.options().isEmpty()) {
|
||||
ReadStatusTag.STATUS.DONE
|
||||
} else {
|
||||
ReadStatusTag.STATUS.READING
|
||||
@@ -109,34 +108,32 @@ class InteractiveStoryReadingStateEvent(
|
||||
}
|
||||
|
||||
fun build(
|
||||
root: InteractiveStoryBaseEvent,
|
||||
rootRelay: NormalizedRelayUrl?,
|
||||
currentScene: InteractiveStoryBaseEvent,
|
||||
currentSceneRelay: NormalizedRelayUrl?,
|
||||
root: EventHintBundle<InteractiveStoryBaseEvent>,
|
||||
currentScene: EventHintBundle<InteractiveStoryBaseEvent>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<InteractiveStoryReadingStateEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, "", createdAt) {
|
||||
val rootTag = root.aTag(rootRelay)
|
||||
val sceneTag = currentScene.aTag(currentSceneRelay)
|
||||
val rootTag = ATag(root.event.address(), root.relay)
|
||||
val sceneTag = ATag(currentScene.event.address(), currentScene.relay)
|
||||
val status =
|
||||
if (rootTag == sceneTag) {
|
||||
ReadStatusTag.STATUS.NEW
|
||||
} else if (currentScene.options().isEmpty()) {
|
||||
} else if (currentScene.event.options().isEmpty()) {
|
||||
ReadStatusTag.STATUS.DONE
|
||||
} else {
|
||||
ReadStatusTag.STATUS.READING
|
||||
}
|
||||
|
||||
dTag(rootTag.toTag())
|
||||
alt(root.title()?.let { ALT2 + it } ?: ALT1)
|
||||
alt(root.event.title()?.let { ALT2 + it } ?: ALT1)
|
||||
|
||||
rootScene(rootTag)
|
||||
currentScene(sceneTag)
|
||||
status(status)
|
||||
|
||||
root.title()?.let { storyTitle(it) }
|
||||
root.summary()?.let { storyImage(it) }
|
||||
root.image()?.let { storySummary(it) }
|
||||
root.event.title()?.let { storyTitle(it) }
|
||||
root.event.summary()?.let { storyImage(it) }
|
||||
root.event.image()?.let { storySummary(it) }
|
||||
|
||||
initializer()
|
||||
}
|
||||
|
||||
-3
@@ -32,7 +32,6 @@ import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
@@ -71,8 +70,6 @@ class NipTextEvent(
|
||||
|
||||
override fun dTag() = tags.dTag()
|
||||
|
||||
override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint)
|
||||
|
||||
override fun address() = Address(kind, pubKey, dTag())
|
||||
|
||||
override fun addressTag() = Address.assemble(kind, pubKey, dTag())
|
||||
|
||||
-4
@@ -21,15 +21,11 @@
|
||||
package com.vitorpamplona.quartz.nip01Core.core
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
|
||||
@Immutable
|
||||
interface AddressableEvent : IEvent {
|
||||
fun dTag(): String
|
||||
|
||||
fun aTag(relayHint: NormalizedRelayUrl? = null): ATag
|
||||
|
||||
fun address(): Address
|
||||
|
||||
fun addressTag(): String
|
||||
|
||||
-4
@@ -21,8 +21,6 @@
|
||||
package com.vitorpamplona.quartz.nip01Core.core
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
|
||||
@Immutable
|
||||
@@ -38,8 +36,6 @@ open class BaseAddressableEvent(
|
||||
AddressableEvent {
|
||||
override fun dTag() = tags.dTag()
|
||||
|
||||
override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint)
|
||||
|
||||
override fun address() = Address(kind, pubKey, dTag())
|
||||
|
||||
/**
|
||||
|
||||
+2
-5
@@ -21,8 +21,6 @@
|
||||
package com.vitorpamplona.quartz.nip01Core.core
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
|
||||
@Immutable
|
||||
open class BaseReplaceableEvent(
|
||||
@@ -33,11 +31,10 @@ open class BaseReplaceableEvent(
|
||||
tags: TagArray,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig),
|
||||
AddressableEvent {
|
||||
override fun dTag() = FIXED_D_TAG
|
||||
|
||||
override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, FIXED_D_TAG, relayHint)
|
||||
|
||||
override fun address(): Address = Address(kind, pubKey, dTag())
|
||||
|
||||
/**
|
||||
|
||||
+21
-23
@@ -26,29 +26,27 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
|
||||
class EventAssembler {
|
||||
companion object {
|
||||
fun <T : Event> hashAndSign(
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
privKey: ByteArray,
|
||||
nonce: ByteArray? = RandomInstance.bytes(32),
|
||||
): T {
|
||||
val id = EventHasher.hashIdBytes(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Nip01.sign(id, privKey, nonce).toHexKey()
|
||||
object EventAssembler {
|
||||
fun <T : Event> hashAndSign(
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
privKey: ByteArray,
|
||||
nonce: ByteArray? = RandomInstance.bytes(32),
|
||||
): T {
|
||||
val id = EventHasher.hashIdBytes(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Nip01Crypto.sign(id, privKey, nonce).toHexKey()
|
||||
|
||||
return EventFactory.create(
|
||||
id.toHexKey(),
|
||||
pubKey,
|
||||
createdAt,
|
||||
kind,
|
||||
tags,
|
||||
content,
|
||||
sig,
|
||||
)
|
||||
}
|
||||
return EventFactory.create(
|
||||
id.toHexKey(),
|
||||
pubKey,
|
||||
createdAt,
|
||||
kind,
|
||||
tags,
|
||||
content,
|
||||
sig,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ fun Event.verifyId(): Boolean {
|
||||
|
||||
fun Event.verifySignature(): Boolean {
|
||||
if (id.isEmpty() || sig.isEmpty()) return false
|
||||
return Nip01.verify(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
|
||||
return Nip01Crypto.verify(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
|
||||
}
|
||||
|
||||
/** Checks if the ID is correct and then if the pubKey's secret key signed the event. */
|
||||
|
||||
+23
-27
@@ -24,33 +24,29 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
|
||||
class EventHasher {
|
||||
companion object {
|
||||
fun hashIdBytes(
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
): ByteArray = sha256(EventHasherSerializer.fastMakeJsonForId(pubKey, createdAt, kind, tags, content))
|
||||
object EventHasher {
|
||||
fun hashIdBytes(
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
): ByteArray = sha256(EventHasherSerializer.fastMakeJsonForId(pubKey, createdAt, kind, tags, content))
|
||||
|
||||
fun hashId(serializedJsonAsBytes: ByteArray): String = sha256(serializedJsonAsBytes).toHexKey()
|
||||
fun hashId(
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
): String = hashIdBytes(pubKey, createdAt, kind, tags, content).toHexKey()
|
||||
|
||||
fun hashId(
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
): String = hashIdBytes(pubKey, createdAt, kind, tags, content).toHexKey()
|
||||
|
||||
fun hashIdCheck(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
): Boolean = EventHasherSerializer.makeJsonForIdHashAndCheck(id, pubKey, createdAt, kind, tags, content)
|
||||
}
|
||||
fun hashIdCheck(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
): Boolean = EventHasherSerializer.makeJsonForIdHashAndCheck(id, pubKey, createdAt, kind, tags, content)
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ class KeyPair(
|
||||
if (privKey == null) {
|
||||
if (pubKey == null) {
|
||||
// create new, random keys
|
||||
this.privKey = Nip01.privKeyCreate()
|
||||
this.pubKey = Nip01.pubKeyCreate(this.privKey)
|
||||
this.privKey = Nip01Crypto.privKeyCreate()
|
||||
this.pubKey = Nip01Crypto.pubKeyCreate(this.privKey)
|
||||
} else {
|
||||
// this is a read-only account
|
||||
check(pubKey.size == 32)
|
||||
@@ -46,7 +46,7 @@ class KeyPair(
|
||||
// as private key is provided, ignore the public key and set keys according to private key
|
||||
this.privKey = privKey
|
||||
if (pubKey == null || forceReplacePubkey) {
|
||||
this.pubKey = Nip01.pubKeyCreate(privKey)
|
||||
this.pubKey = Nip01Crypto.pubKeyCreate(privKey)
|
||||
} else {
|
||||
this.pubKey = pubKey
|
||||
}
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.crypto
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||
|
||||
object Nip01 {
|
||||
object Nip01Crypto {
|
||||
fun privKeyCreate() = RandomInstance.bytes(32)
|
||||
|
||||
fun pubKeyCreate(privKey: ByteArray) = Secp256k1Instance.compressedPubKeyFor(privKey).copyOfRange(1, 33)
|
||||
+9
@@ -20,11 +20,20 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.tags.aTag
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
fun <T : Event> TagArrayBuilder<T>.aTag(tag: ATag) = add(tag.toATagArray())
|
||||
|
||||
fun <T : Event> TagArrayBuilder<T>.aTag(
|
||||
address: Address,
|
||||
relayHint: NormalizedRelayUrl? = null,
|
||||
) = add(ATag.assemble(address, relayHint))
|
||||
|
||||
fun <T : Event> TagArrayBuilder<T>.aTags(tag: List<ATag>) = addAll(tag.map { it.toATagArray() })
|
||||
|
||||
fun <T : Event> TagArrayBuilder<T>.removeATag(tag: ATag) = this.removeIf(ATag::isSameAddress, tag.toATagArray())
|
||||
|
||||
fun <T : Event> TagArrayBuilder<T>.removeAddress(address: Address) = this.removeIf(ATag::isSameAddress, ATag.assemble(address, null))
|
||||
|
||||
+2
-2
@@ -89,7 +89,7 @@ class DeletionEvent(
|
||||
deleteEvents.forEach {
|
||||
eTag(ETag(it.id))
|
||||
if (it is AddressableEvent) {
|
||||
aTag(it.aTag())
|
||||
aTag(it.address())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ class DeletionEvent(
|
||||
|
||||
deleteEvents.forEach {
|
||||
if (it is AddressableEvent) {
|
||||
aTag(it.aTag())
|
||||
aTag(it.address())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-28
@@ -26,9 +26,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
@@ -38,7 +38,6 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.eTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull
|
||||
@@ -93,45 +92,28 @@ class GenericRepostEvent(
|
||||
const val ALT = "Generic repost"
|
||||
|
||||
fun build(
|
||||
boostedPost: Event,
|
||||
eventSourceRelay: NormalizedRelayUrl?,
|
||||
authorHomeRelay: NormalizedRelayUrl?,
|
||||
eventHint: EventHintBundle<Event>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<GenericRepostEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, boostedPost.toJson(), createdAt) {
|
||||
) = eventTemplate(KIND, eventHint.event.toJson(), createdAt) {
|
||||
val boostedPost = eventHint.event
|
||||
|
||||
alt(ALT)
|
||||
|
||||
kind(boostedPost.kind)
|
||||
pTag(PTag(boostedPost.pubKey, authorHomeRelay))
|
||||
eTag(ETag(boostedPost.id, eventSourceRelay, boostedPost.pubKey))
|
||||
pTag(PTag(boostedPost.pubKey, eventHint.authorHomeRelay))
|
||||
eTag(ETag(boostedPost.id, eventHint.relay, boostedPost.pubKey))
|
||||
if (boostedPost is AddressableEvent) {
|
||||
aTag(boostedPost.aTag(eventSourceRelay))
|
||||
aTag(boostedPost.address(), eventHint.relay)
|
||||
}
|
||||
|
||||
initializer()
|
||||
}
|
||||
|
||||
suspend fun create(
|
||||
boostedPost: Event,
|
||||
eventHint: EventHintBundle<Event>,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): GenericRepostEvent {
|
||||
val content = boostedPost.toJson()
|
||||
|
||||
val tags =
|
||||
mutableListOf(
|
||||
arrayOf("e", boostedPost.id),
|
||||
arrayOf("p", boostedPost.pubKey),
|
||||
)
|
||||
|
||||
if (boostedPost is AddressableEvent) {
|
||||
tags.add(arrayOf("a", boostedPost.aTag().toTag()))
|
||||
}
|
||||
|
||||
tags.add(arrayOf("k", "${boostedPost.kind}"))
|
||||
tags.add(AltTag.assemble(ALT))
|
||||
|
||||
return signer.sign(createdAt, KIND, tags.toTypedArray(), content)
|
||||
}
|
||||
): GenericRepostEvent = signer.sign(build(eventHint, createdAt))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag
|
||||
@@ -82,7 +83,7 @@ class RepostEvent(
|
||||
fun containedPost() =
|
||||
try {
|
||||
fromJson(content)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -91,22 +92,28 @@ class RepostEvent(
|
||||
const val ALT = "Repost event"
|
||||
|
||||
fun build(
|
||||
boostedPost: Event,
|
||||
eventSourceRelay: NormalizedRelayUrl?,
|
||||
authorHomeRelay: NormalizedRelayUrl?,
|
||||
eventHint: EventHintBundle<Event>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<RepostEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, boostedPost.toJson(), createdAt) {
|
||||
) = eventTemplate(KIND, eventHint.event.toJson(), createdAt) {
|
||||
val boostedPost = eventHint.event
|
||||
|
||||
alt(ALT)
|
||||
|
||||
pTag(PTag(boostedPost.pubKey, authorHomeRelay))
|
||||
eTag(ETag(boostedPost.id, eventSourceRelay, boostedPost.pubKey))
|
||||
pTag(PTag(boostedPost.pubKey, eventHint.authorHomeRelay))
|
||||
eTag(ETag(boostedPost.id, eventHint.relay, boostedPost.pubKey))
|
||||
if (boostedPost is AddressableEvent) {
|
||||
aTag(boostedPost.aTag(eventSourceRelay))
|
||||
aTag(boostedPost.address(), eventHint.relay)
|
||||
}
|
||||
kind(boostedPost.kind)
|
||||
|
||||
initializer()
|
||||
}
|
||||
|
||||
suspend fun create(
|
||||
eventHint: EventHintBundle<Event>,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): RepostEvent = signer.sign(build(eventHint, createdAt))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
@@ -175,7 +174,7 @@ object Nip19Parser {
|
||||
|
||||
fun decodePublicKey(key: String): ByteArray =
|
||||
when (val parsed = Nip19Parser.uriToRoute(key)?.entity) {
|
||||
is NSec -> Nip01.pubKeyCreate(parsed.hex.hexToByteArray())
|
||||
is NSec -> parsed.toPubKey()
|
||||
is NPub -> parsed.hex.hexToByteArray()
|
||||
is NProfile -> parsed.hex.hexToByteArray()
|
||||
else -> Hex.decode(key) // crashes on purpose
|
||||
@@ -202,7 +201,7 @@ fun decodePrivateKeyAsHexOrNull(key: String): HexKey? =
|
||||
fun decodePublicKeyAsHexOrNull(key: String): HexKey? =
|
||||
try {
|
||||
when (val parsed = Nip19Parser.uriToRoute(key)?.entity) {
|
||||
is NSec -> Nip01.pubKeyCreate(parsed.hex.hexToByteArray()).toHexKey()
|
||||
is NSec -> parsed.toPubKeyHex()
|
||||
is NPub -> parsed.hex
|
||||
is NProfile -> parsed.hex
|
||||
is NNote -> null
|
||||
|
||||
@@ -23,13 +23,15 @@ package com.vitorpamplona.quartz.nip19Bech32.entities
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
|
||||
@Immutable
|
||||
data class NSec(
|
||||
val hex: String,
|
||||
) : Entity {
|
||||
fun toPubKeyHex() = Nip01.pubKeyCreate(hex.hexToByteArray()).toHexKey()
|
||||
fun toPubKey() = Nip01Crypto.pubKeyCreate(hex.hexToByteArray())
|
||||
|
||||
fun toPubKeyHex() = toPubKey().toHexKey()
|
||||
|
||||
companion object {
|
||||
fun parse(bytes: ByteArray): NSec? {
|
||||
|
||||
-4
@@ -31,9 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
@@ -119,8 +117,6 @@ class LongTextNoteEvent(
|
||||
|
||||
override fun dTag() = tags.dTag()
|
||||
|
||||
override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint)
|
||||
|
||||
override fun address() = Address(kind, pubKey, dTag())
|
||||
|
||||
override fun addressTag() = Address.assemble(kind, pubKey, dTag())
|
||||
|
||||
+2
-2
@@ -91,7 +91,7 @@ class ReactionEvent(
|
||||
) = eventTemplate<ReactionEvent>(KIND, reaction, createdAt) {
|
||||
eTag(reactedTo.toETag())
|
||||
if (reactedTo.event is AddressableEvent) {
|
||||
aTag(reactedTo.event.aTag(reactedTo.relay))
|
||||
aTag(reactedTo.event.address(), reactedTo.relay)
|
||||
}
|
||||
pTag(reactedTo.event.pubKey, reactedTo.relay)
|
||||
kind(reactedTo.event.kind)
|
||||
@@ -104,7 +104,7 @@ class ReactionEvent(
|
||||
) = eventTemplate<ReactionEvent>(KIND, reaction.toContentEncode(), createdAt) {
|
||||
eTag(reactedTo.toETag())
|
||||
if (reactedTo.event is AddressableEvent) {
|
||||
aTag(reactedTo.event.aTag(reactedTo.relay))
|
||||
aTag(reactedTo.event.address(), reactedTo.relay)
|
||||
}
|
||||
pTag(reactedTo.event.pubKey, reactedTo.relay)
|
||||
kind(reactedTo.event.kind)
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ class EmojiPackSelectionEvent(
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
updater: TagArrayBuilder<EmojiPackSelectionEvent>.() -> Unit = {},
|
||||
) = eventUpdate(currentSelection, createdAt) {
|
||||
removePack(packToRemove.aTag(null))
|
||||
removePack(packToRemove.address())
|
||||
updater()
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -20,14 +20,15 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip30CustomEmoji.selection
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.removeATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.removeAddress
|
||||
|
||||
fun TagArrayBuilder<EmojiPackSelectionEvent>.pack(tag: ATag) = aTag(tag)
|
||||
|
||||
fun TagArrayBuilder<EmojiPackSelectionEvent>.packs(tags: List<ATag>) = aTags(tags)
|
||||
|
||||
fun TagArrayBuilder<EmojiPackSelectionEvent>.removePack(tag: ATag) = removeATag(tag)
|
||||
fun TagArrayBuilder<EmojiPackSelectionEvent>.removePack(address: Address) = removeAddress(address)
|
||||
|
||||
@@ -34,7 +34,6 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
@@ -129,8 +128,6 @@ class WikiNoteEvent(
|
||||
|
||||
override fun dTag() = tags.dTag()
|
||||
|
||||
override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint)
|
||||
|
||||
override fun address() = Address(kind, pubKey, dTag())
|
||||
|
||||
override fun addressTag() = Address.assemble(kind, pubKey, dTag())
|
||||
|
||||
+1
-1
@@ -104,7 +104,7 @@ class LnZapRequestEvent(
|
||||
AltTag.assemble(ALT),
|
||||
)
|
||||
if (zappedEvent is AddressableEvent) {
|
||||
tags = tags + listOf(arrayOf("a", zappedEvent.aTag().toTag()))
|
||||
tags = tags + listOf(ATag.assemble(zappedEvent.address(), null))
|
||||
}
|
||||
if (pollOption != null && pollOption >= 0) {
|
||||
tags = tags + listOf(arrayOf(PollOptionTag.TAG_NAME, pollOption.toString()))
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
|
||||
@@ -106,7 +106,7 @@ class PrivateZapRequestBuilder {
|
||||
|
||||
try {
|
||||
if (altPubkeyToUse != null) {
|
||||
val altPubKeyFromPrivate = Nip01.pubKeyCreate(myPrivateKeyForThisEvent).toHexKey()
|
||||
val altPubKeyFromPrivate = Nip01Crypto.pubKeyCreate(myPrivateKeyForThisEvent).toHexKey()
|
||||
|
||||
if (altPubKeyFromPrivate == event.pubKey) {
|
||||
// the sender is logged in.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Platform-agnostic chess engine interface for move validation and generation
|
||||
* Implemented using expect/actual for JVM/Android platforms
|
||||
*/
|
||||
expect class ChessEngine() {
|
||||
/**
|
||||
* Get the current position as FEN string
|
||||
*/
|
||||
fun getFen(): String
|
||||
|
||||
/**
|
||||
* Load a position from FEN notation
|
||||
*/
|
||||
fun loadFen(fen: String)
|
||||
|
||||
/**
|
||||
* Reset to starting position
|
||||
*/
|
||||
fun reset()
|
||||
|
||||
/**
|
||||
* Make a move using Standard Algebraic Notation (SAN)
|
||||
* @param san Move in SAN format (e.g., "Nf3", "e4", "O-O")
|
||||
* @return MoveResult with success status and resulting position
|
||||
*/
|
||||
fun makeMove(san: String): MoveResult
|
||||
|
||||
/**
|
||||
* Make a move from source to destination square
|
||||
* @param from Source square in algebraic notation (e.g., "e2")
|
||||
* @param to Destination square in algebraic notation (e.g., "e4")
|
||||
* @param promotion Optional promotion piece type for pawn promotion
|
||||
* @return MoveResult with success status and resulting position
|
||||
*/
|
||||
fun makeMove(
|
||||
from: String,
|
||||
to: String,
|
||||
promotion: PieceType? = null,
|
||||
): MoveResult
|
||||
|
||||
/**
|
||||
* Get all legal moves from the current position
|
||||
* @return List of legal moves in SAN notation
|
||||
*/
|
||||
fun getLegalMoves(): List<String>
|
||||
|
||||
/**
|
||||
* Get all legal moves for a specific square
|
||||
* @param square Square in algebraic notation (e.g., "e2")
|
||||
* @return List of destination squares that are legal
|
||||
*/
|
||||
fun getLegalMovesFrom(square: String): List<String>
|
||||
|
||||
/**
|
||||
* Check if a move is legal in the current position
|
||||
* @param san Move in SAN format
|
||||
* @return true if the move is legal
|
||||
*/
|
||||
fun isLegalMove(san: String): Boolean
|
||||
|
||||
/**
|
||||
* Check if a move from-to is legal
|
||||
*/
|
||||
fun isLegalMove(
|
||||
from: String,
|
||||
to: String,
|
||||
promotion: PieceType? = null,
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
* Check if current position is checkmate
|
||||
*/
|
||||
fun isCheckmate(): Boolean
|
||||
|
||||
/**
|
||||
* Check if current position is stalemate
|
||||
*/
|
||||
fun isStalemate(): Boolean
|
||||
|
||||
/**
|
||||
* Check if current position is in check
|
||||
*/
|
||||
fun isInCheck(): Boolean
|
||||
|
||||
/**
|
||||
* Undo the last move
|
||||
*/
|
||||
fun undoMove()
|
||||
|
||||
/**
|
||||
* Get the current position as ChessPosition
|
||||
*/
|
||||
fun getPosition(): ChessPosition
|
||||
|
||||
/**
|
||||
* Get side to move
|
||||
*/
|
||||
fun getSideToMove(): Color
|
||||
|
||||
/**
|
||||
* Get move history in SAN notation
|
||||
*/
|
||||
fun getMoveHistory(): List<String>
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of attempting a chess move
|
||||
*/
|
||||
@Immutable
|
||||
data class MoveResult(
|
||||
val success: Boolean,
|
||||
val san: String? = null, // Move in SAN notation if successful
|
||||
val position: ChessPosition? = null, // Resulting position if successful
|
||||
val error: String? = null, // Error message if unsuccessful
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Represents a complete chess game parsed from PGN
|
||||
* Per NIP-64, content must be valid PGN format
|
||||
*/
|
||||
@Immutable
|
||||
data class ChessGame(
|
||||
val metadata: Map<String, String>,
|
||||
val moves: List<ChessMove>,
|
||||
val positions: List<ChessPosition>,
|
||||
val result: GameResult,
|
||||
) {
|
||||
init {
|
||||
require(positions.isNotEmpty()) { "Game must have at least starting position" }
|
||||
require(positions.size == moves.size + 1) { "Position count must be moves + 1" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard PGN tag accessors
|
||||
*/
|
||||
val event: String? get() = metadata["Event"]
|
||||
val site: String? get() = metadata["Site"]
|
||||
val date: String? get() = metadata["Date"]
|
||||
val round: String? get() = metadata["Round"]
|
||||
val white: String? get() = metadata["White"]
|
||||
val black: String? get() = metadata["Black"]
|
||||
|
||||
/**
|
||||
* Get position after move N (0 = starting position)
|
||||
*/
|
||||
fun positionAt(moveIndex: Int): ChessPosition = positions.getOrElse(moveIndex) { positions.last() }
|
||||
|
||||
/**
|
||||
* Check if game has required metadata
|
||||
*/
|
||||
fun hasRequiredMetadata(): Boolean =
|
||||
metadata.containsKey("Event") &&
|
||||
metadata.containsKey("Site") &&
|
||||
metadata.containsKey("Date") &&
|
||||
metadata.containsKey("Round") &&
|
||||
metadata.containsKey("White") &&
|
||||
metadata.containsKey("Black") &&
|
||||
metadata.containsKey("Result")
|
||||
}
|
||||
|
||||
/**
|
||||
* Game result per PGN specification
|
||||
*/
|
||||
enum class GameResult(
|
||||
val notation: String,
|
||||
) {
|
||||
WHITE_WINS("1-0"),
|
||||
BLACK_WINS("0-1"),
|
||||
DRAW("1/2-1/2"),
|
||||
IN_PROGRESS("*"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun parse(notation: String): GameResult = entries.find { it.notation == notation } ?: IN_PROGRESS
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* NIP-64: Chess Game Event
|
||||
*
|
||||
* Kind 64 events contain chess games in PGN (Portable Game Notation) format.
|
||||
* Per NIP-64 specification:
|
||||
* - Content must be valid PGN format (PGN-database)
|
||||
* - Clients should accept "import format" (human-created, flexible)
|
||||
* - Clients should publish in "export format" (machine-generated, strict)
|
||||
* - Moves must comply with chess rules
|
||||
* - Clients should display content as rendered chessboard
|
||||
*
|
||||
* @see <a href="https://github.com/nostr-protocol/nips/blob/master/64.md">NIP-64</a>
|
||||
*/
|
||||
@Immutable
|
||||
class ChessGameEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String, // PGN database format
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
companion object {
|
||||
const val KIND = 64
|
||||
const val ALT_DESCRIPTION = "Chess Game"
|
||||
|
||||
/**
|
||||
* Create a chess game event from PGN content
|
||||
* Per NIP-64, clients should publish in strict "export format"
|
||||
*
|
||||
* @param pgnContent Valid PGN format string
|
||||
* @param altDescription Alt text for non-supporting clients (NIP-31)
|
||||
* @param createdAt Event timestamp
|
||||
* @param initializer Additional tag builder operations
|
||||
*/
|
||||
fun build(
|
||||
pgnContent: String,
|
||||
altDescription: String = ALT_DESCRIPTION,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<ChessGameEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, pgnContent, createdAt) {
|
||||
alt(altDescription)
|
||||
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)
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Generates human-readable chess game names.
|
||||
*
|
||||
* Produces memorable names like "Bold Knight", "Swift Rook", etc.
|
||||
*/
|
||||
object ChessGameNameGenerator {
|
||||
// Chess-themed adjectives
|
||||
private val adjectives =
|
||||
listOf(
|
||||
"Bold",
|
||||
"Swift",
|
||||
"Cunning",
|
||||
"Royal",
|
||||
"Noble",
|
||||
"Shadow",
|
||||
"Silent",
|
||||
"Golden",
|
||||
"Silver",
|
||||
"Iron",
|
||||
"Fierce",
|
||||
"Wise",
|
||||
"Dark",
|
||||
"Bright",
|
||||
"Ancient",
|
||||
"Brave",
|
||||
"Mystic",
|
||||
"Grand",
|
||||
"Crimson",
|
||||
"Azure",
|
||||
"Ivory",
|
||||
"Obsidian",
|
||||
"Emerald",
|
||||
"Sapphire",
|
||||
"Ruby",
|
||||
"Phantom",
|
||||
"Eternal",
|
||||
"Secret",
|
||||
"Hidden",
|
||||
"Blazing",
|
||||
)
|
||||
|
||||
// Chess pieces and related nouns
|
||||
private val chessNouns =
|
||||
listOf(
|
||||
"King",
|
||||
"Queen",
|
||||
"Rook",
|
||||
"Bishop",
|
||||
"Knight",
|
||||
"Pawn",
|
||||
"Castle",
|
||||
"Crown",
|
||||
"Throne",
|
||||
"Gambit",
|
||||
"Defense",
|
||||
"Attack",
|
||||
"Checkmate",
|
||||
"Fortress",
|
||||
"Tower",
|
||||
"Endgame",
|
||||
"Opening",
|
||||
"Sacrifice",
|
||||
"Pin",
|
||||
"Fork",
|
||||
"Strategy",
|
||||
"Victory",
|
||||
"Battle",
|
||||
"Duel",
|
||||
"Match",
|
||||
"Challenge",
|
||||
)
|
||||
|
||||
// Famous chess-related animals/themes
|
||||
private val animals =
|
||||
listOf(
|
||||
"Dragon",
|
||||
"Phoenix",
|
||||
"Griffin",
|
||||
"Eagle",
|
||||
"Lion",
|
||||
"Wolf",
|
||||
"Tiger",
|
||||
"Falcon",
|
||||
"Hawk",
|
||||
"Raven",
|
||||
"Bear",
|
||||
"Stallion",
|
||||
"Serpent",
|
||||
"Panther",
|
||||
"Cobra",
|
||||
)
|
||||
|
||||
/**
|
||||
* Generate a random chess game name.
|
||||
*
|
||||
* @param includeNumber Whether to append a random number (1-99)
|
||||
* @return A name like "Bold Knight" or "Swift Dragon 42"
|
||||
*/
|
||||
fun generate(includeNumber: Boolean = false): String {
|
||||
val adjective = adjectives.random()
|
||||
val noun = if (Random.nextBoolean()) chessNouns.random() else animals.random()
|
||||
val base = "$adjective $noun"
|
||||
|
||||
return if (includeNumber) {
|
||||
val num = Random.nextInt(1, 100)
|
||||
"$base $num"
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a name based on two player display names.
|
||||
*
|
||||
* Creates a name that incorporates elements from both players
|
||||
* for a more personalized game name.
|
||||
*
|
||||
* @param playerName First player's display name (can be npub/pubkey)
|
||||
* @param opponentName Second player's display name (can be npub/pubkey, or null for open challenge)
|
||||
* @return A personalized game name
|
||||
*/
|
||||
fun generateFromPlayers(
|
||||
playerName: String?,
|
||||
opponentName: String?,
|
||||
): String {
|
||||
// Extract first letter or use adjective if name is a pubkey/npub
|
||||
val p1Initial = extractInitial(playerName)
|
||||
val p2Initial = opponentName?.let { extractInitial(it) }
|
||||
|
||||
// If we have good initials, use them in the name
|
||||
val adjective =
|
||||
if (p1Initial != null) {
|
||||
// Try to find an adjective starting with that letter
|
||||
adjectives.find { it.startsWith(p1Initial, ignoreCase = true) }
|
||||
?: adjectives.random()
|
||||
} else {
|
||||
adjectives.random()
|
||||
}
|
||||
|
||||
val noun =
|
||||
if (p2Initial != null) {
|
||||
// Try to find a noun starting with opponent's initial
|
||||
(chessNouns + animals).find { it.startsWith(p2Initial, ignoreCase = true) }
|
||||
?: if (Random.nextBoolean()) chessNouns.random() else animals.random()
|
||||
} else {
|
||||
// Open challenge - use "Challenge" or similar
|
||||
listOf("Challenge", "Gambit", "Opening", "Battle", "Duel").random()
|
||||
}
|
||||
|
||||
return "$adjective $noun"
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a usable initial from a name.
|
||||
* Returns null if the name looks like a pubkey/npub.
|
||||
*/
|
||||
private fun extractInitial(name: String?): Char? {
|
||||
if (name.isNullOrBlank()) return null
|
||||
|
||||
// Skip if it looks like a hex pubkey or npub
|
||||
if (name.length > 20 && name.all { it.isLetterOrDigit() }) return null
|
||||
if (name.startsWith("npub") || name.startsWith("nprofile")) return null
|
||||
|
||||
// Get first letter of the display name
|
||||
val firstChar = name.firstOrNull { it.isLetter() }
|
||||
return firstChar?.uppercaseChar()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a game ID that includes a readable component.
|
||||
*
|
||||
* @param timestamp Unix timestamp
|
||||
* @return Game ID like "chess-1234567890-bold-knight"
|
||||
*/
|
||||
fun generateGameId(timestamp: Long): String {
|
||||
val name =
|
||||
generate(includeNumber = false)
|
||||
.lowercase()
|
||||
.replace(" ", "-")
|
||||
return "chess-$timestamp-$name"
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the human-readable name from a game ID.
|
||||
*
|
||||
* @param gameId Game ID like "chess-1234567890-bold-knight"
|
||||
* @return Display name like "Bold Knight", or null if not a named game ID
|
||||
*/
|
||||
fun extractDisplayName(gameId: String): String? {
|
||||
// Pattern: chess-timestamp-name-parts
|
||||
if (!gameId.startsWith("chess-")) return null
|
||||
|
||||
val parts = gameId.removePrefix("chess-").split("-")
|
||||
if (parts.size < 2) return null
|
||||
|
||||
// First part is timestamp, rest is the name
|
||||
val nameParts = parts.drop(1)
|
||||
if (nameParts.isEmpty()) return null
|
||||
|
||||
// Check if it looks like a UUID (8 hex chars) - old format
|
||||
if (nameParts.size == 1 && nameParts[0].length == 8 && nameParts[0].all { it.isLetterOrDigit() }) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Convert to title case
|
||||
return nameParts.joinToString(" ") { part ->
|
||||
part.replaceFirstChar { it.uppercaseChar() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Represents a single chess move in Standard Algebraic Notation (SAN)
|
||||
* Per NIP-64, moves must comply with chess rules
|
||||
*/
|
||||
@Immutable
|
||||
data class ChessMove(
|
||||
val san: String, // Standard Algebraic Notation, e.g., "Nf3", "e4", "O-O", "Qxe5+"
|
||||
val moveNumber: Int, // Which move in the game (1-based)
|
||||
val color: Color, // Who made the move
|
||||
val piece: PieceType = PieceType.PAWN,
|
||||
val fromSquare: String? = null, // Source square if known
|
||||
val toSquare: String, // Destination square
|
||||
val isCapture: Boolean = false,
|
||||
val isCheck: Boolean = false,
|
||||
val isCheckmate: Boolean = false,
|
||||
val isCastling: Boolean = false,
|
||||
val promotion: PieceType? = null,
|
||||
) {
|
||||
override fun toString(): String = san
|
||||
}
|
||||
|
||||
/**
|
||||
* Chess piece types per standard notation
|
||||
*/
|
||||
enum class PieceType(
|
||||
val symbol: Char,
|
||||
) {
|
||||
KING('K'),
|
||||
QUEEN('Q'),
|
||||
ROOK('R'),
|
||||
BISHOP('B'),
|
||||
KNIGHT('N'),
|
||||
PAWN('P'),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromSymbol(c: Char): PieceType? = entries.find { it.symbol == c.uppercaseChar() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Player color
|
||||
*/
|
||||
enum class Color {
|
||||
WHITE,
|
||||
BLACK,
|
||||
;
|
||||
|
||||
fun opposite(): Color =
|
||||
when (this) {
|
||||
WHITE -> BLACK
|
||||
BLACK -> WHITE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Represents a chess position using 8x8 board array
|
||||
* Coordinates: file (a-h) = columns 0-7, rank (1-8) = rows 0-7
|
||||
* a1 = [0,0], h1 = [7,0], a8 = [0,7], h8 = [7,7]
|
||||
*/
|
||||
@Immutable
|
||||
data class ChessPosition(
|
||||
private val board: Array<Array<ChessPiece?>>,
|
||||
val activeColor: Color,
|
||||
val moveNumber: Int,
|
||||
val castlingRights: CastlingRights = CastlingRights(),
|
||||
val enPassantSquare: String? = null,
|
||||
val halfMoveClock: Int = 0,
|
||||
) {
|
||||
init {
|
||||
require(board.size == 8) { "Board must be 8x8, got ${board.size} ranks" }
|
||||
require(board.all { it.size == 8 }) { "Board must be 8x8, some ranks are not 8 files wide" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get piece at square using algebraic notation (e.g., "e4")
|
||||
*/
|
||||
operator fun get(square: String): ChessPiece? {
|
||||
val (file, rank) = parseSquare(square)
|
||||
return board[rank][file]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get piece at coordinates (file: 0-7, rank: 0-7)
|
||||
*/
|
||||
fun pieceAt(
|
||||
file: Int,
|
||||
rank: Int,
|
||||
): ChessPiece? = board.getOrNull(rank)?.getOrNull(file)
|
||||
|
||||
/**
|
||||
* Create new position with a piece moved
|
||||
*/
|
||||
fun makeMove(
|
||||
from: String,
|
||||
to: String,
|
||||
promotion: PieceType? = null,
|
||||
): ChessPosition {
|
||||
val (fromFile, fromRank) = parseSquare(from)
|
||||
val (toFile, toRank) = parseSquare(to)
|
||||
|
||||
val newBoard = board.map { it.copyOf() }.toTypedArray()
|
||||
val piece = newBoard[fromRank][fromFile] ?: throw IllegalArgumentException("No piece at $from")
|
||||
|
||||
// Handle promotion
|
||||
val finalPiece =
|
||||
if (promotion != null && piece.type == PieceType.PAWN) {
|
||||
piece.copy(type = promotion)
|
||||
} else {
|
||||
piece
|
||||
}
|
||||
|
||||
newBoard[toRank][toFile] = finalPiece
|
||||
newBoard[fromRank][fromFile] = null
|
||||
|
||||
return copy(
|
||||
board = newBoard,
|
||||
activeColor = activeColor.opposite(),
|
||||
moveNumber = if (activeColor == Color.BLACK) moveNumber + 1 else moveNumber,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new position with castling
|
||||
*/
|
||||
fun makeCastlingMove(kingSide: Boolean): ChessPosition {
|
||||
val rank = if (activeColor == Color.WHITE) 0 else 7
|
||||
val newBoard = board.map { it.copyOf() }.toTypedArray()
|
||||
|
||||
if (kingSide) {
|
||||
// King-side castling (O-O)
|
||||
val king = newBoard[rank][4]
|
||||
val rook = newBoard[rank][7]
|
||||
newBoard[rank][6] = king
|
||||
newBoard[rank][5] = rook
|
||||
newBoard[rank][4] = null
|
||||
newBoard[rank][7] = null
|
||||
} else {
|
||||
// Queen-side castling (O-O-O)
|
||||
val king = newBoard[rank][4]
|
||||
val rook = newBoard[rank][0]
|
||||
newBoard[rank][2] = king
|
||||
newBoard[rank][3] = rook
|
||||
newBoard[rank][4] = null
|
||||
newBoard[rank][0] = null
|
||||
}
|
||||
|
||||
return copy(
|
||||
board = newBoard,
|
||||
activeColor = activeColor.opposite(),
|
||||
moveNumber = if (activeColor == Color.BLACK) moveNumber + 1 else moveNumber,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSquare(square: String): Pair<Int, Int> {
|
||||
require(square.length == 2) { "Invalid square notation: $square" }
|
||||
val file = square[0].lowercaseChar() - 'a' // 0-7
|
||||
val rank = square[1] - '1' // 0-7
|
||||
require(file in 0..7 && rank in 0..7) { "Square out of bounds: $square" }
|
||||
return file to rank
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Standard starting position per FIDE rules
|
||||
*/
|
||||
fun initial(): ChessPosition {
|
||||
val board = Array(8) { Array<ChessPiece?>(8) { null } }
|
||||
|
||||
// White pieces (ranks 0-1)
|
||||
board[0] =
|
||||
arrayOf(
|
||||
ChessPiece(PieceType.ROOK, Color.WHITE),
|
||||
ChessPiece(PieceType.KNIGHT, Color.WHITE),
|
||||
ChessPiece(PieceType.BISHOP, Color.WHITE),
|
||||
ChessPiece(PieceType.QUEEN, Color.WHITE),
|
||||
ChessPiece(PieceType.KING, Color.WHITE),
|
||||
ChessPiece(PieceType.BISHOP, Color.WHITE),
|
||||
ChessPiece(PieceType.KNIGHT, Color.WHITE),
|
||||
ChessPiece(PieceType.ROOK, Color.WHITE),
|
||||
)
|
||||
board[1] = Array(8) { ChessPiece(PieceType.PAWN, Color.WHITE) }
|
||||
|
||||
// Black pieces (ranks 6-7)
|
||||
board[6] = Array(8) { ChessPiece(PieceType.PAWN, Color.BLACK) }
|
||||
board[7] =
|
||||
arrayOf(
|
||||
ChessPiece(PieceType.ROOK, Color.BLACK),
|
||||
ChessPiece(PieceType.KNIGHT, Color.BLACK),
|
||||
ChessPiece(PieceType.BISHOP, Color.BLACK),
|
||||
ChessPiece(PieceType.QUEEN, Color.BLACK),
|
||||
ChessPiece(PieceType.KING, Color.BLACK),
|
||||
ChessPiece(PieceType.BISHOP, Color.BLACK),
|
||||
ChessPiece(PieceType.KNIGHT, Color.BLACK),
|
||||
ChessPiece(PieceType.ROOK, Color.BLACK),
|
||||
)
|
||||
|
||||
return ChessPosition(
|
||||
board = board,
|
||||
activeColor = Color.WHITE,
|
||||
moveNumber = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is ChessPosition) return false
|
||||
return board.contentDeepEquals(other.board) &&
|
||||
activeColor == other.activeColor &&
|
||||
moveNumber == other.moveNumber &&
|
||||
castlingRights == other.castlingRights &&
|
||||
enPassantSquare == other.enPassantSquare &&
|
||||
halfMoveClock == other.halfMoveClock
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = board.contentDeepHashCode()
|
||||
result = 31 * result + activeColor.hashCode()
|
||||
result = 31 * result + moveNumber
|
||||
result = 31 * result + castlingRights.hashCode()
|
||||
result = 31 * result + (enPassantSquare?.hashCode() ?: 0)
|
||||
result = 31 * result + halfMoveClock
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a chess piece
|
||||
*/
|
||||
@Immutable
|
||||
data class ChessPiece(
|
||||
val type: PieceType,
|
||||
val color: Color,
|
||||
)
|
||||
|
||||
/**
|
||||
* Castling availability
|
||||
*/
|
||||
@Immutable
|
||||
data class CastlingRights(
|
||||
val whiteKingSide: Boolean = true,
|
||||
val whiteQueenSide: Boolean = true,
|
||||
val blackKingSide: Boolean = true,
|
||||
val blackQueenSide: Boolean = true,
|
||||
)
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/**
|
||||
* Deterministic chess state reconstruction from Jester protocol events.
|
||||
*
|
||||
* This is the single source of truth for converting a collection of Jester events
|
||||
* into a consistent game state. Both Android and Desktop platforms must use this
|
||||
* algorithm to ensure identical state from the same events.
|
||||
*
|
||||
* Jester Protocol Reconstruction:
|
||||
* 1. Find start event (content.kind=0) → establishes startEventId, challenger color, players
|
||||
* 2. Find move with longest history → this is the current state
|
||||
* 3. Replay moves from history to build engine state
|
||||
* 4. Check for result/termination in latest move → mark finished if present
|
||||
*
|
||||
* Key Jester differences:
|
||||
* - No separate accept event (acceptance is implicit)
|
||||
* - Full move history in every move event
|
||||
* - Can reconstruct from any single move (has complete history)
|
||||
* - startEventId is the game identifier (event ID of start event)
|
||||
*
|
||||
* Guarantees:
|
||||
* - Same events always produce same state (deterministic)
|
||||
* - Uses longest history for authoritative state
|
||||
* - Invalid moves are skipped without error
|
||||
*/
|
||||
object ChessStateReconstructor {
|
||||
/**
|
||||
* Reconstruct game state from a collection of Jester events.
|
||||
*
|
||||
* @param events The collected Jester events for a single game
|
||||
* @param viewerPubkey The pubkey of the user viewing the game (determines perspective)
|
||||
* @return ReconstructionResult or error if reconstruction fails
|
||||
*/
|
||||
fun reconstruct(
|
||||
events: JesterGameEvents,
|
||||
viewerPubkey: String,
|
||||
): ReconstructionResult {
|
||||
// Step 1: Get start event (required for player info)
|
||||
val startEvent =
|
||||
events.startEvent
|
||||
?: return ReconstructionResult.Error("No start event found")
|
||||
|
||||
val startEventId = startEvent.id
|
||||
val challengerPubkey = startEvent.pubKey
|
||||
val challengerColor = startEvent.playerColor() ?: Color.WHITE
|
||||
val challengedPubkey = startEvent.opponentPubkey()
|
||||
|
||||
// In Jester, we determine opponent from the p-tag or from move events
|
||||
// For open challenges, we need to find who made the first move
|
||||
val opponentFromMoves =
|
||||
events.moves
|
||||
.firstOrNull { it.pubKey != challengerPubkey }
|
||||
?.pubKey
|
||||
|
||||
val actualOpponent = challengedPubkey ?: opponentFromMoves
|
||||
|
||||
// Determine players based on challenger's color choice
|
||||
val (whitePubkey, blackPubkey) =
|
||||
if (challengerColor == Color.WHITE) {
|
||||
challengerPubkey to actualOpponent
|
||||
} else {
|
||||
actualOpponent to challengerPubkey
|
||||
}
|
||||
|
||||
// Determine viewer's role
|
||||
val viewerRole =
|
||||
when (viewerPubkey) {
|
||||
whitePubkey -> ViewerRole.WHITE_PLAYER
|
||||
blackPubkey -> ViewerRole.BLACK_PLAYER
|
||||
else -> ViewerRole.SPECTATOR
|
||||
}
|
||||
|
||||
val playerColor =
|
||||
when (viewerRole) {
|
||||
ViewerRole.WHITE_PLAYER -> Color.WHITE
|
||||
ViewerRole.BLACK_PLAYER -> Color.BLACK
|
||||
ViewerRole.SPECTATOR -> Color.WHITE // Spectators see from white's perspective
|
||||
}
|
||||
|
||||
val opponentPubkey =
|
||||
when (viewerRole) {
|
||||
ViewerRole.WHITE_PLAYER -> blackPubkey ?: ""
|
||||
ViewerRole.BLACK_PLAYER -> whitePubkey ?: ""
|
||||
ViewerRole.SPECTATOR -> blackPubkey ?: "" // For spectators, "opponent" is black
|
||||
}
|
||||
|
||||
// Check if game is pending (no moves yet AND viewer is the challenger)
|
||||
// If viewer accepted someone else's challenge, the game is NOT pending
|
||||
val isChallenger = viewerPubkey == startEvent.pubKey
|
||||
val isPendingChallenge = events.moves.isEmpty() && isChallenger
|
||||
|
||||
// Step 2: Create engine and apply moves from the longest history
|
||||
val engine = ChessEngine()
|
||||
val latestMove = events.latestMove()
|
||||
val history = latestMove?.history() ?: emptyList()
|
||||
|
||||
// Track applied moves
|
||||
val appliedMoveNumbers = mutableSetOf<Int>()
|
||||
var isDesynced = false
|
||||
|
||||
// Apply moves from history
|
||||
for ((index, san) in history.withIndex()) {
|
||||
val moveNumber = index + 1
|
||||
val result = engine.makeMove(san)
|
||||
if (result.success) {
|
||||
appliedMoveNumbers.add(moveNumber)
|
||||
} else {
|
||||
// Move failed - game might be desynced
|
||||
isDesynced = true
|
||||
// Try to recover by loading the FEN from latest move if available
|
||||
latestMove?.fen()?.let { fen ->
|
||||
engine.loadFen(fen)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Verify final position matches if we have FEN
|
||||
latestMove?.fen()?.let { expectedFen ->
|
||||
val currentFen = engine.getFen()
|
||||
if (!fenPositionsMatch(currentFen, expectedFen)) {
|
||||
isDesynced = true
|
||||
engine.loadFen(expectedFen)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Check game end status
|
||||
val gameResult = latestMove?.result()
|
||||
val gameStatus =
|
||||
when {
|
||||
gameResult != null -> {
|
||||
val result = parseGameResult(gameResult)
|
||||
GameStatus.Finished(result)
|
||||
}
|
||||
|
||||
engine.isCheckmate() -> {
|
||||
val winner = engine.getSideToMove().opposite()
|
||||
GameStatus.Finished(
|
||||
if (winner == Color.WHITE) GameResult.WHITE_WINS else GameResult.BLACK_WINS,
|
||||
)
|
||||
}
|
||||
|
||||
engine.isStalemate() -> {
|
||||
GameStatus.Finished(GameResult.DRAW)
|
||||
}
|
||||
|
||||
else -> {
|
||||
GameStatus.InProgress
|
||||
}
|
||||
}
|
||||
|
||||
// Get the head event ID (for linking next move)
|
||||
val headEventId = latestMove?.id ?: startEventId
|
||||
|
||||
// Build the reconstructed state
|
||||
val state =
|
||||
ReconstructedGameState(
|
||||
startEventId = startEventId,
|
||||
headEventId = headEventId,
|
||||
whitePubkey = whitePubkey,
|
||||
blackPubkey = blackPubkey,
|
||||
viewerRole = viewerRole,
|
||||
playerColor = playerColor,
|
||||
opponentPubkey = opponentPubkey,
|
||||
isPendingChallenge = isPendingChallenge,
|
||||
currentPosition = engine.getPosition(),
|
||||
moveHistory = engine.getMoveHistory(),
|
||||
gameStatus = gameStatus,
|
||||
isDesynced = isDesynced,
|
||||
pendingDrawOffer = null, // Jester doesn't have explicit draw offers
|
||||
appliedMoveNumbers = appliedMoveNumbers,
|
||||
challengeCreatedAt = startEvent.createdAt,
|
||||
timeControl = null, // Not supported in Jester
|
||||
)
|
||||
|
||||
return ReconstructionResult.Success(state, engine)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare FEN positions, ignoring halfmove clock and fullmove number.
|
||||
* This provides a more lenient comparison that focuses on the actual board state.
|
||||
*/
|
||||
private fun fenPositionsMatch(
|
||||
fen1: String,
|
||||
fen2: String,
|
||||
): Boolean {
|
||||
// FEN format: position activeColor castling enPassant halfmove fullmove
|
||||
// Compare only first 4 parts (position, activeColor, castling, enPassant)
|
||||
val parts1 = fen1.split(" ")
|
||||
val parts2 = fen2.split(" ")
|
||||
|
||||
if (parts1.size < 4 || parts2.size < 4) {
|
||||
return fen1 == fen2 // Fallback to exact match
|
||||
}
|
||||
|
||||
return parts1[0] == parts2[0] && // Board position
|
||||
parts1[1] == parts2[1] && // Active color
|
||||
parts1[2] == parts2[2] && // Castling rights
|
||||
parts1[3] == parts2[3] // En passant
|
||||
}
|
||||
|
||||
private fun parseGameResult(result: String?): GameResult =
|
||||
when (result) {
|
||||
"1-0" -> GameResult.WHITE_WINS
|
||||
"0-1" -> GameResult.BLACK_WINS
|
||||
"1/2-1/2" -> GameResult.DRAW
|
||||
else -> GameResult.IN_PROGRESS
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewer's role in the game.
|
||||
*/
|
||||
enum class ViewerRole {
|
||||
WHITE_PLAYER,
|
||||
BLACK_PLAYER,
|
||||
SPECTATOR,
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of game state reconstruction.
|
||||
*/
|
||||
sealed class ReconstructionResult {
|
||||
data class Success(
|
||||
val state: ReconstructedGameState,
|
||||
val engine: ChessEngine,
|
||||
) : ReconstructionResult()
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
) : ReconstructionResult()
|
||||
|
||||
fun isSuccess(): Boolean = this is Success
|
||||
|
||||
fun getOrNull(): ReconstructedGameState? = (this as? Success)?.state
|
||||
|
||||
fun getEngineOrNull(): ChessEngine? = (this as? Success)?.engine
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructed game state from Jester events.
|
||||
* This is an immutable snapshot of the game at the time of reconstruction.
|
||||
*/
|
||||
@Immutable
|
||||
data class ReconstructedGameState(
|
||||
/** The start event ID - this is the game identifier in Jester protocol */
|
||||
val startEventId: String,
|
||||
/** The current head event ID - used for linking the next move */
|
||||
val headEventId: String,
|
||||
val whitePubkey: String?,
|
||||
val blackPubkey: String?,
|
||||
val viewerRole: ViewerRole,
|
||||
val playerColor: Color,
|
||||
val opponentPubkey: String,
|
||||
val isPendingChallenge: Boolean,
|
||||
val currentPosition: ChessPosition,
|
||||
val moveHistory: List<String>,
|
||||
val gameStatus: GameStatus,
|
||||
val isDesynced: Boolean,
|
||||
val pendingDrawOffer: String?,
|
||||
val appliedMoveNumbers: Set<Int>,
|
||||
val challengeCreatedAt: Long,
|
||||
val timeControl: String?,
|
||||
) {
|
||||
/** Legacy alias for startEventId */
|
||||
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
|
||||
val gameId: String get() = startEventId
|
||||
|
||||
fun isPlayerTurn(): Boolean =
|
||||
when (viewerRole) {
|
||||
ViewerRole.SPECTATOR -> false
|
||||
ViewerRole.WHITE_PLAYER -> currentPosition.activeColor == Color.WHITE
|
||||
ViewerRole.BLACK_PLAYER -> currentPosition.activeColor == Color.BLACK
|
||||
}
|
||||
|
||||
fun isFinished(): Boolean = gameStatus is GameStatus.Finished
|
||||
|
||||
fun hasOpponentDrawOffer(playerPubkey: String): Boolean = pendingDrawOffer != null && pendingDrawOffer != playerPubkey
|
||||
|
||||
fun hasOurDrawOffer(playerPubkey: String): Boolean = pendingDrawOffer == playerPubkey
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Jester Protocol Implementation
|
||||
*
|
||||
* Compatible with jesterui (https://github.com/jesterui/jesterui)
|
||||
*
|
||||
* Key differences from previous implementation:
|
||||
* - Single event kind (30) for all chess messages
|
||||
* - Content is JSON with: version, kind, fen, move, history, nonce
|
||||
* - Event linking via e-tags: [startId] or [startId, headId]
|
||||
* - Full move history included in every move event
|
||||
*
|
||||
* Content kind values:
|
||||
* - 0: Game start (challenge)
|
||||
* - 1: Move
|
||||
* - 2: Chat (not implemented)
|
||||
*
|
||||
* Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md
|
||||
*/
|
||||
object JesterProtocol {
|
||||
/** Jester uses kind 30 for all chess events */
|
||||
const val KIND = 30
|
||||
|
||||
/** SHA256 of starting FEN position - used as reference for game discovery */
|
||||
const val START_POSITION_HASH = "b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879"
|
||||
|
||||
/** Standard starting FEN */
|
||||
const val FEN_START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
|
||||
/** Content kind for game start */
|
||||
const val CONTENT_KIND_START = 0
|
||||
|
||||
/** Content kind for move */
|
||||
const val CONTENT_KIND_MOVE = 1
|
||||
|
||||
/** Content kind for chat */
|
||||
const val CONTENT_KIND_CHAT = 2
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON content structure for Jester events
|
||||
*/
|
||||
@Serializable
|
||||
data class JesterContent(
|
||||
val version: String = "0",
|
||||
val kind: Int,
|
||||
val fen: String = JesterProtocol.FEN_START,
|
||||
val move: String? = null,
|
||||
val history: List<String> = emptyList(),
|
||||
val nonce: String? = null,
|
||||
// Extended fields for Amethyst (backward compatible - jesterui ignores unknown fields)
|
||||
val playerColor: String? = null, // "white" or "black" - challenger's color choice
|
||||
val result: String? = null, // "1-0", "0-1", "1/2-1/2" for game end
|
||||
val termination: String? = null, // "checkmate", "resignation", "draw_agreement", etc.
|
||||
)
|
||||
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Jester Chess Event (Kind 30)
|
||||
*
|
||||
* Unified event class for all Jester chess messages.
|
||||
* The content.kind field determines the event type:
|
||||
* - 0: Game start/challenge
|
||||
* - 1: Move
|
||||
* - 2: Chat
|
||||
*/
|
||||
@Immutable
|
||||
class JesterEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, JesterProtocol.KIND, tags, content, sig) {
|
||||
private val parsedContent: JesterContent? by lazy {
|
||||
try {
|
||||
json.decodeFromString<JesterContent>(content)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the content kind (0=start, 1=move, 2=chat) */
|
||||
fun contentKind(): Int? = parsedContent?.kind
|
||||
|
||||
/** Check if this is a game start event */
|
||||
fun isStartEvent(): Boolean = parsedContent?.kind == JesterProtocol.CONTENT_KIND_START
|
||||
|
||||
/** Check if this is a move event */
|
||||
fun isMoveEvent(): Boolean = parsedContent?.kind == JesterProtocol.CONTENT_KIND_MOVE
|
||||
|
||||
/** Get the FEN position */
|
||||
fun fen(): String? = parsedContent?.fen
|
||||
|
||||
/** Get the latest move (SAN notation) */
|
||||
fun move(): String? = parsedContent?.move
|
||||
|
||||
/** Get the full move history */
|
||||
fun history(): List<String> = parsedContent?.history ?: emptyList()
|
||||
|
||||
/** Get the nonce (for start events) */
|
||||
fun nonce(): String? = parsedContent?.nonce
|
||||
|
||||
/** Get the player color choice (for start events) */
|
||||
fun playerColor(): Color? =
|
||||
parsedContent?.playerColor?.let {
|
||||
if (it == "white") Color.WHITE else Color.BLACK
|
||||
}
|
||||
|
||||
/** Get the game result (for end events) */
|
||||
fun result(): String? = parsedContent?.result
|
||||
|
||||
/** Get the termination reason (for end events) */
|
||||
fun termination(): String? = parsedContent?.termination
|
||||
|
||||
/** Get the start event ID (first e-tag) */
|
||||
fun startEventId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
|
||||
|
||||
/** Get the head/parent move ID (second e-tag, for moves) */
|
||||
fun headEventId(): String? = tags.filter { it.size >= 2 && it[0] == "e" }.getOrNull(1)?.get(1)
|
||||
|
||||
/** Get opponent pubkey (p-tag, for private games) */
|
||||
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
|
||||
|
||||
/** Get all e-tags */
|
||||
fun eTags(): List<String> = tags.filter { it.size >= 2 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
companion object {
|
||||
const val KIND = JesterProtocol.KIND
|
||||
|
||||
/**
|
||||
* Build a game start event (open challenge)
|
||||
*
|
||||
* @param playerColor Color the challenger wants to play
|
||||
* @param nonce Unique nonce for this game
|
||||
* @param createdAt Event timestamp
|
||||
*/
|
||||
fun buildStart(
|
||||
playerColor: Color,
|
||||
nonce: String = generateNonce(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<JesterEvent>.() -> Unit = {},
|
||||
): EventTemplate<JesterEvent> {
|
||||
val content =
|
||||
JesterContent(
|
||||
kind = JesterProtocol.CONTENT_KIND_START,
|
||||
fen = JesterProtocol.FEN_START,
|
||||
history = emptyList(),
|
||||
nonce = nonce,
|
||||
playerColor = if (playerColor == Color.WHITE) "white" else "black",
|
||||
)
|
||||
return eventTemplate(KIND, json.encodeToString(content), createdAt) {
|
||||
// Reference the standard start position for game discovery
|
||||
add(arrayOf("e", JesterProtocol.START_POSITION_HASH))
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a private game start event (direct challenge)
|
||||
*
|
||||
* @param opponentPubkey Pubkey of the challenged player
|
||||
* @param playerColor Color the challenger wants to play
|
||||
* @param nonce Unique nonce for this game
|
||||
* @param createdAt Event timestamp
|
||||
*/
|
||||
fun buildPrivateStart(
|
||||
opponentPubkey: String,
|
||||
playerColor: Color,
|
||||
nonce: String = generateNonce(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<JesterEvent>.() -> Unit = {},
|
||||
): EventTemplate<JesterEvent> {
|
||||
val content =
|
||||
JesterContent(
|
||||
kind = JesterProtocol.CONTENT_KIND_START,
|
||||
fen = JesterProtocol.FEN_START,
|
||||
history = emptyList(),
|
||||
nonce = nonce,
|
||||
playerColor = if (playerColor == Color.WHITE) "white" else "black",
|
||||
)
|
||||
return eventTemplate(KIND, json.encodeToString(content), createdAt) {
|
||||
// Reference the standard start position
|
||||
add(arrayOf("e", JesterProtocol.START_POSITION_HASH))
|
||||
// Tag the opponent for notifications (only if valid pubkey)
|
||||
if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) {
|
||||
add(arrayOf("p", opponentPubkey))
|
||||
}
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a move event
|
||||
*
|
||||
* @param startEventId ID of the game start event
|
||||
* @param headEventId ID of the previous move (or start event for first move)
|
||||
* @param move The move in SAN notation (e.g., "e4", "Nf3")
|
||||
* @param fen Resulting board position in FEN
|
||||
* @param history Complete move history including this move
|
||||
* @param opponentPubkey Opponent's pubkey for notifications
|
||||
* @param createdAt Event timestamp
|
||||
*/
|
||||
fun buildMove(
|
||||
startEventId: String,
|
||||
headEventId: String,
|
||||
move: String,
|
||||
fen: String,
|
||||
history: List<String>,
|
||||
opponentPubkey: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<JesterEvent>.() -> Unit = {},
|
||||
): EventTemplate<JesterEvent> {
|
||||
val content =
|
||||
JesterContent(
|
||||
kind = JesterProtocol.CONTENT_KIND_MOVE,
|
||||
fen = fen,
|
||||
move = move,
|
||||
history = history,
|
||||
)
|
||||
return eventTemplate(KIND, json.encodeToString(content), createdAt) {
|
||||
// e-tags: [startId, headId]
|
||||
add(arrayOf("e", startEventId))
|
||||
add(arrayOf("e", headEventId))
|
||||
// Tag opponent for notifications (only if valid pubkey)
|
||||
if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) {
|
||||
add(arrayOf("p", opponentPubkey))
|
||||
}
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a game end move (includes result in content)
|
||||
*
|
||||
* This is a move event with additional result/termination fields.
|
||||
* Used when the game ends (checkmate, resignation, draw).
|
||||
*/
|
||||
fun buildEndMove(
|
||||
startEventId: String,
|
||||
headEventId: String,
|
||||
move: String?,
|
||||
fen: String,
|
||||
history: List<String>,
|
||||
opponentPubkey: String,
|
||||
result: GameResult,
|
||||
termination: GameTermination,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<JesterEvent>.() -> Unit = {},
|
||||
): EventTemplate<JesterEvent> {
|
||||
val content =
|
||||
JesterContent(
|
||||
kind = JesterProtocol.CONTENT_KIND_MOVE,
|
||||
fen = fen,
|
||||
move = move,
|
||||
history = history,
|
||||
result = result.notation,
|
||||
termination = termination.name.lowercase(),
|
||||
)
|
||||
return eventTemplate(KIND, json.encodeToString(content), createdAt) {
|
||||
add(arrayOf("e", startEventId))
|
||||
add(arrayOf("e", headEventId))
|
||||
// Tag opponent for notifications (only if valid pubkey)
|
||||
if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) {
|
||||
add(arrayOf("p", opponentPubkey))
|
||||
}
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateNonce(): String {
|
||||
val chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
return (1..8).map { chars.random() }.joinToString("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if an event is a Jester chess event
|
||||
*/
|
||||
fun Event.isJesterEvent(): Boolean = kind == JesterProtocol.KIND
|
||||
|
||||
/**
|
||||
* Helper to check if an event is a Jester start event
|
||||
*/
|
||||
fun Event.isJesterStartEvent(): Boolean {
|
||||
if (kind != JesterProtocol.KIND) return false
|
||||
return try {
|
||||
val content = json.decodeFromString<JesterContent>(this.content)
|
||||
content.kind == JesterProtocol.CONTENT_KIND_START && content.history.isEmpty()
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if an event is a Jester move event
|
||||
*/
|
||||
fun Event.isJesterMoveEvent(): Boolean {
|
||||
if (kind != JesterProtocol.KIND) return false
|
||||
return try {
|
||||
val content = json.decodeFromString<JesterContent>(this.content)
|
||||
content.kind == JesterProtocol.CONTENT_KIND_MOVE &&
|
||||
content.history.isNotEmpty() &&
|
||||
tags.count { it.size >= 2 && it[0] == "e" } == 2
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a raw Event to JesterEvent if valid
|
||||
*/
|
||||
fun Event.toJesterEvent(): JesterEvent? {
|
||||
if (kind != JesterProtocol.KIND) return null
|
||||
return JesterEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Game events container for Jester protocol
|
||||
*/
|
||||
data class JesterGameEvents(
|
||||
val startEvent: JesterEvent?,
|
||||
val moves: List<JesterEvent>,
|
||||
) {
|
||||
companion object {
|
||||
fun empty() = JesterGameEvents(null, emptyList())
|
||||
}
|
||||
|
||||
/** Get the latest move (with longest history) */
|
||||
fun latestMove(): JesterEvent? = moves.maxByOrNull { it.history().size }
|
||||
|
||||
/** Get the current FEN position */
|
||||
fun currentFen(): String = latestMove()?.fen() ?: startEvent?.fen() ?: JesterProtocol.FEN_START
|
||||
|
||||
/** Get the complete move history */
|
||||
fun fullHistory(): List<String> = latestMove()?.history() ?: emptyList()
|
||||
|
||||
/** Check if game has ended */
|
||||
fun isEnded(): Boolean = latestMove()?.result() != null
|
||||
|
||||
/** Get the game result if ended */
|
||||
fun result(): String? = latestMove()?.result()
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/*
|
||||
* Live chess game state and move events
|
||||
*
|
||||
* Uses Jester protocol (kind 30) for compatibility with jesterui.
|
||||
* See: https://github.com/jesterui/jesterui/blob/devel/FLOW.md
|
||||
*
|
||||
* Key features of Jester protocol:
|
||||
* - Single event kind (30) for all chess messages
|
||||
* - Content is JSON with: version, kind (0=start, 1=move), fen, move, history
|
||||
* - Full move history included in every move event
|
||||
* - Event linking via e-tags: [startId] or [startId, headId]
|
||||
*
|
||||
* This enables:
|
||||
* - Easy game reconstruction from any single move event
|
||||
* - Tolerance for missing intermediate moves
|
||||
* - Compatibility with jesterui and other Jester clients
|
||||
*/
|
||||
|
||||
/**
|
||||
* Game start/challenge data for publishing
|
||||
*
|
||||
* Jester protocol (kind 30) start event:
|
||||
* - e-tag: reference to standard start position hash
|
||||
* - p-tag: opponent pubkey (for private/direct challenges)
|
||||
* - Content JSON with: version, kind=0, fen, history=[], nonce, playerColor
|
||||
*
|
||||
* @param opponentPubkey Opponent's pubkey for direct challenge (null = open challenge)
|
||||
* @param playerColor Color the challenger wants to play
|
||||
* @param nonce Unique identifier for this game
|
||||
*/
|
||||
data class ChessGameChallenge(
|
||||
val opponentPubkey: String? = null, // null = open challenge
|
||||
val playerColor: Color,
|
||||
val nonce: String = generateNonce(),
|
||||
) {
|
||||
companion object {
|
||||
private fun generateNonce(): String {
|
||||
val chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
return (1..8).map { chars.random() }.joinToString("")
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy compatibility - gameId is now derived from start event ID after signing
|
||||
@Deprecated("gameId is determined after event creation", ReplaceWith("startEventId"))
|
||||
val gameId: String get() = nonce
|
||||
}
|
||||
|
||||
/**
|
||||
* Game acceptance data
|
||||
*
|
||||
* In Jester protocol, accepting a game is implicit - the opponent
|
||||
* simply makes the first move (if challenger chose white) or waits
|
||||
* for challenger's first move (if challenger chose black).
|
||||
*
|
||||
* This data class is kept for internal state tracking.
|
||||
*
|
||||
* @param startEventId ID of the game start event to accept
|
||||
* @param challengerPubkey Pubkey of the challenger
|
||||
*/
|
||||
data class ChessGameAccept(
|
||||
val startEventId: String,
|
||||
val challengerPubkey: String,
|
||||
) {
|
||||
// Legacy compatibility
|
||||
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
|
||||
val gameId: String get() = startEventId
|
||||
|
||||
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
|
||||
val challengeEventId: String get() = startEventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Chess move data for publishing
|
||||
*
|
||||
* Jester protocol (kind 30) requires:
|
||||
* - Full move history in every move event
|
||||
* - e-tags linking: [startEventId, headEventId]
|
||||
* - Content JSON with: version, kind=1, fen, move, history
|
||||
*
|
||||
* @param startEventId ID of the game start event
|
||||
* @param headEventId ID of the previous move (or startEventId for first move)
|
||||
* @param san Move in Standard Algebraic Notation (e.g., "e4", "Nf3")
|
||||
* @param fen Resulting board position in FEN notation
|
||||
* @param history Complete move history including this move
|
||||
* @param opponentPubkey Opponent's pubkey for p-tag notification
|
||||
*/
|
||||
data class ChessMoveEvent(
|
||||
val startEventId: String,
|
||||
val headEventId: String,
|
||||
val san: String,
|
||||
val fen: String,
|
||||
val history: List<String>,
|
||||
val opponentPubkey: String,
|
||||
) {
|
||||
/** Move number (1-based, derived from history length) */
|
||||
val moveNumber: Int get() = history.size
|
||||
|
||||
// Legacy compatibility - some code still uses gameId
|
||||
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
|
||||
val gameId: String get() = startEventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Game end data for publishing
|
||||
*
|
||||
* In Jester protocol, game end is a move event with result/termination
|
||||
* fields in the content JSON.
|
||||
*
|
||||
* @param startEventId ID of the game start event
|
||||
* @param headEventId ID of the previous move
|
||||
* @param lastMove The final move (null for resignation without move)
|
||||
* @param fen Final board position
|
||||
* @param history Complete move history
|
||||
* @param result Game result (1-0, 0-1, 1/2-1/2)
|
||||
* @param termination How the game ended
|
||||
* @param opponentPubkey Opponent's pubkey for notification
|
||||
*/
|
||||
data class ChessGameEnd(
|
||||
val startEventId: String,
|
||||
val headEventId: String,
|
||||
val lastMove: String?,
|
||||
val fen: String,
|
||||
val history: List<String>,
|
||||
val result: GameResult,
|
||||
val termination: GameTermination,
|
||||
val opponentPubkey: String,
|
||||
) {
|
||||
// Legacy compatibility
|
||||
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
|
||||
val gameId: String get() = startEventId
|
||||
|
||||
val winnerPubkey: String? get() = null // Determined from result
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw offer data
|
||||
*
|
||||
* In Jester protocol, draw offers can be communicated via:
|
||||
* - Chat message (kind=2 in content)
|
||||
* - Special field in move content
|
||||
*
|
||||
* Opponent can:
|
||||
* - Accept by sending a game end with DRAW_AGREEMENT
|
||||
* - Decline implicitly by making their next move
|
||||
*
|
||||
* @param startEventId ID of the game start event
|
||||
* @param headEventId ID of the current head move
|
||||
* @param opponentPubkey Opponent's pubkey for notification
|
||||
* @param message Optional message with the draw offer
|
||||
*/
|
||||
data class ChessDrawOffer(
|
||||
val startEventId: String,
|
||||
val headEventId: String,
|
||||
val opponentPubkey: String,
|
||||
val message: String? = null,
|
||||
) {
|
||||
// Legacy compatibility
|
||||
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
|
||||
val gameId: String get() = startEventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Game termination reason
|
||||
*/
|
||||
enum class GameTermination {
|
||||
CHECKMATE,
|
||||
RESIGNATION,
|
||||
DRAW_AGREEMENT,
|
||||
STALEMATE,
|
||||
TIMEOUT,
|
||||
ABANDONMENT,
|
||||
}
|
||||
|
||||
/**
|
||||
* Event kind constants for live chess (Jester protocol)
|
||||
*
|
||||
* Jester uses a single kind (30) for all chess events.
|
||||
* The event type is determined by the content.kind field:
|
||||
* - 0: Game start/challenge
|
||||
* - 1: Move
|
||||
* - 2: Chat
|
||||
*/
|
||||
object LiveChessEventKinds {
|
||||
/** Jester protocol uses kind 30 for all chess events */
|
||||
const val JESTER = 30
|
||||
|
||||
// Legacy constants - deprecated, use JESTER instead
|
||||
@Deprecated("Jester uses single kind 30", ReplaceWith("JESTER"))
|
||||
const val GAME_CHALLENGE = 30
|
||||
|
||||
@Deprecated("Jester uses single kind 30", ReplaceWith("JESTER"))
|
||||
const val GAME_ACCEPT = 30
|
||||
|
||||
@Deprecated("Jester uses single kind 30", ReplaceWith("JESTER"))
|
||||
const val CHESS_MOVE = 30
|
||||
|
||||
@Deprecated("Jester uses single kind 30", ReplaceWith("JESTER"))
|
||||
const val GAME_END = 30
|
||||
|
||||
@Deprecated("Jester uses single kind 30", ReplaceWith("JESTER"))
|
||||
const val DRAW_OFFER = 30
|
||||
}
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
+548
@@ -0,0 +1,548 @@
|
||||
/*
|
||||
* 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 com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Manages state for a live chess game
|
||||
*
|
||||
* This class coordinates between:
|
||||
* - ChessEngine (move validation, board state)
|
||||
* - Nostr events (publishing moves, receiving opponent moves)
|
||||
* - UI (game state, turn management)
|
||||
*
|
||||
* Jester Protocol Notes:
|
||||
* - startEventId: ID of the game start event (used as game identifier)
|
||||
* - headEventId: ID of the current head move (updated after each move)
|
||||
* - Full move history is included in every published move event
|
||||
*/
|
||||
class LiveChessGameState(
|
||||
/** Start event ID - the game identifier in Jester protocol */
|
||||
val startEventId: String,
|
||||
val playerPubkey: String,
|
||||
val opponentPubkey: String,
|
||||
val playerColor: Color,
|
||||
val engine: ChessEngine,
|
||||
val createdAt: Long = TimeUtils.now(),
|
||||
val isSpectator: Boolean = false,
|
||||
val isPendingChallenge: Boolean = false,
|
||||
/** Initial head event ID - same as startEventId for new games */
|
||||
initialHeadEventId: String? = null,
|
||||
) {
|
||||
/** Legacy alias for startEventId */
|
||||
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
|
||||
val gameId: String get() = startEventId
|
||||
|
||||
/** Current head event ID - tracks the most recent move for e-tag linking */
|
||||
private val _headEventId = MutableStateFlow(initialHeadEventId ?: startEventId)
|
||||
val headEventId: StateFlow<String> = _headEventId.asStateFlow()
|
||||
|
||||
companion object {
|
||||
// Abandon timeout: 24 hours without a move
|
||||
const val ABANDON_TIMEOUT_SECONDS = 24 * 60 * 60L
|
||||
|
||||
// Inactivity warning: 1 hour without a move
|
||||
const val INACTIVITY_WARNING_SECONDS = 60 * 60L
|
||||
}
|
||||
|
||||
private val _gameStatus = MutableStateFlow<GameStatus>(GameStatus.InProgress)
|
||||
val gameStatus: StateFlow<GameStatus> = _gameStatus.asStateFlow()
|
||||
|
||||
private val _currentPosition = MutableStateFlow(engine.getPosition())
|
||||
val currentPosition: StateFlow<ChessPosition> = _currentPosition.asStateFlow()
|
||||
|
||||
// Initialize from engine's history so freshly loaded games show all moves
|
||||
private val _moveHistory = MutableStateFlow(engine.getMoveHistory())
|
||||
val moveHistory: StateFlow<List<String>> = _moveHistory.asStateFlow()
|
||||
|
||||
private val _lastError = MutableStateFlow<String?>(null)
|
||||
val lastError: StateFlow<String?> = _lastError.asStateFlow()
|
||||
|
||||
// Track when last activity occurred (move made or received)
|
||||
private val _lastActivityAt = MutableStateFlow(createdAt)
|
||||
val lastActivityAt: StateFlow<Long> = _lastActivityAt.asStateFlow()
|
||||
|
||||
// Track received move numbers to detect duplicates and out-of-order
|
||||
private val receivedMoveNumbers = mutableSetOf<Int>()
|
||||
|
||||
// Pending moves waiting for earlier moves (move number -> move data)
|
||||
private val pendingMoves = mutableMapOf<Int, Pair<String, String>>()
|
||||
|
||||
// Desync detection
|
||||
private val _isDesynced = MutableStateFlow(false)
|
||||
val isDesynced: StateFlow<Boolean> = _isDesynced.asStateFlow()
|
||||
|
||||
// Draw offer tracking - pubkey of who offered the draw (null = no pending offer)
|
||||
private val _pendingDrawOffer = MutableStateFlow<String?>(null)
|
||||
val pendingDrawOffer: StateFlow<String?> = _pendingDrawOffer.asStateFlow()
|
||||
|
||||
/**
|
||||
* Check if there's a pending draw offer from opponent
|
||||
*/
|
||||
fun hasOpponentDrawOffer(): Boolean = _pendingDrawOffer.value == opponentPubkey
|
||||
|
||||
/**
|
||||
* Check if we have an outgoing draw offer
|
||||
*/
|
||||
fun hasOurDrawOffer(): Boolean = _pendingDrawOffer.value == playerPubkey
|
||||
|
||||
/**
|
||||
* Check if it's the player's turn
|
||||
* Spectators never have a turn
|
||||
*/
|
||||
fun isPlayerTurn(): Boolean = !isSpectator && engine.getSideToMove() == playerColor
|
||||
|
||||
/**
|
||||
* Make a move (called when player makes a move on the board)
|
||||
* Returns the move event data to be published to Nostr
|
||||
*
|
||||
* Jester Protocol: Move events include full history and link to previous event
|
||||
*/
|
||||
fun makeMove(
|
||||
from: String,
|
||||
to: String,
|
||||
promotion: PieceType? = null,
|
||||
): ChessMoveEvent? {
|
||||
if (!isPlayerTurn()) {
|
||||
_lastError.value = "Not your turn"
|
||||
return null
|
||||
}
|
||||
|
||||
if (_gameStatus.value != GameStatus.InProgress) {
|
||||
_lastError.value = "Game is not in progress"
|
||||
return null
|
||||
}
|
||||
|
||||
val result = engine.makeMove(from, to, promotion)
|
||||
|
||||
if (result.success && result.san != null && result.position != null) {
|
||||
_currentPosition.value = result.position
|
||||
_moveHistory.value = engine.getMoveHistory()
|
||||
_lastActivityAt.value = TimeUtils.now()
|
||||
_lastError.value = null
|
||||
|
||||
// Making a move implicitly declines any pending draw offer
|
||||
_pendingDrawOffer.value = null
|
||||
|
||||
// Check for game end conditions
|
||||
checkGameEnd()
|
||||
|
||||
// Return move event data with full history for Jester protocol
|
||||
return ChessMoveEvent(
|
||||
startEventId = startEventId,
|
||||
headEventId = _headEventId.value,
|
||||
san = result.san,
|
||||
fen = engine.getFen(),
|
||||
history = _moveHistory.value, // Full history for Jester
|
||||
opponentPubkey = opponentPubkey,
|
||||
)
|
||||
} else {
|
||||
_lastError.value = result.error ?: "Invalid move"
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the head event ID after a move is successfully published.
|
||||
* Call this after the move event is signed and broadcast.
|
||||
*
|
||||
* @param newHeadEventId The ID of the newly published move event
|
||||
*/
|
||||
fun updateHeadEventId(newHeadEventId: String) {
|
||||
_headEventId.value = newHeadEventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the last move made.
|
||||
* Used to revert a move when publishing fails.
|
||||
*
|
||||
* @return true if the move was successfully undone, false if there was nothing to undo
|
||||
*/
|
||||
fun undoLastMove(): Boolean {
|
||||
if (_moveHistory.value.isEmpty()) return false
|
||||
|
||||
engine.undoMove()
|
||||
_currentPosition.value = engine.getPosition()
|
||||
_moveHistory.value = engine.getMoveHistory()
|
||||
_lastError.value = null
|
||||
|
||||
// Reset game status in case checkmate was detected
|
||||
if (_gameStatus.value is GameStatus.Finished) {
|
||||
_gameStatus.value = GameStatus.InProgress
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply opponent's move (called when receiving move event from Nostr)
|
||||
*/
|
||||
fun applyOpponentMove(
|
||||
san: String,
|
||||
fen: String,
|
||||
moveNumber: Int? = null,
|
||||
): Boolean {
|
||||
// Check for duplicate move
|
||||
if (moveNumber != null && receivedMoveNumbers.contains(moveNumber)) {
|
||||
// Already processed this move, ignore
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for out-of-order move
|
||||
val expectedMoveNumber = _moveHistory.value.size + 1
|
||||
if (moveNumber != null && moveNumber > expectedMoveNumber) {
|
||||
// Store for later processing
|
||||
pendingMoves[moveNumber] = san to fen
|
||||
return true
|
||||
}
|
||||
|
||||
if (isPlayerTurn()) {
|
||||
_lastError.value = "Received move but it's not opponent's turn"
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify the move is valid by trying to make it
|
||||
val result = engine.makeMove(san)
|
||||
|
||||
if (result.success) {
|
||||
// Mark as received
|
||||
if (moveNumber != null) {
|
||||
receivedMoveNumbers.add(moveNumber)
|
||||
}
|
||||
|
||||
// Verify the resulting FEN matches what opponent sent
|
||||
val currentFen = engine.getFen()
|
||||
if (currentFen != fen) {
|
||||
// Positions don't match - desync detected
|
||||
_isDesynced.value = true
|
||||
_lastError.value = "Position mismatch - syncing to opponent's position"
|
||||
// Load the opponent's FEN to stay in sync
|
||||
engine.loadFen(fen)
|
||||
} else {
|
||||
_isDesynced.value = false
|
||||
}
|
||||
|
||||
_currentPosition.value = engine.getPosition()
|
||||
_moveHistory.value = engine.getMoveHistory()
|
||||
_lastActivityAt.value = TimeUtils.now()
|
||||
_lastError.value = null
|
||||
|
||||
// Opponent making a move declines any pending draw offer
|
||||
_pendingDrawOffer.value = null
|
||||
|
||||
// Check for game end conditions
|
||||
checkGameEnd()
|
||||
|
||||
// Process any pending moves that are now valid
|
||||
processPendingMoves()
|
||||
|
||||
return true
|
||||
} else {
|
||||
_lastError.value = "Invalid opponent move: $san"
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process any pending out-of-order moves
|
||||
*/
|
||||
private fun processPendingMoves() {
|
||||
val nextMoveNumber = _moveHistory.value.size + 1
|
||||
val pendingMove = pendingMoves.remove(nextMoveNumber)
|
||||
if (pendingMove != null) {
|
||||
val (san, fen) = pendingMove
|
||||
applyOpponentMove(san, fen, nextMoveNumber)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force resync to a specific FEN (manual recovery)
|
||||
*/
|
||||
fun forceResync(fen: String) {
|
||||
engine.loadFen(fen)
|
||||
_currentPosition.value = engine.getPosition()
|
||||
_moveHistory.value = engine.getMoveHistory()
|
||||
_isDesynced.value = false
|
||||
_lastError.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply moves from another game state while preserving this state's participant info.
|
||||
* This is used when relay reconstruction incorrectly marks us as spectator but has newer moves.
|
||||
*
|
||||
* @param other The other game state to take moves from
|
||||
*/
|
||||
fun applyMovesFrom(other: LiveChessGameState) {
|
||||
val currentMoves = _moveHistory.value
|
||||
val otherMoves = other.moveHistory.value
|
||||
|
||||
if (otherMoves.size <= currentMoves.size) return // Nothing new to apply
|
||||
|
||||
// Apply only the new moves
|
||||
for (i in currentMoves.size until otherMoves.size) {
|
||||
val san = otherMoves[i]
|
||||
val result = engine.makeMove(san)
|
||||
if (!result.success) {
|
||||
// Try to resync from the other engine's FEN
|
||||
val otherFen = other.engine.getFen()
|
||||
engine.loadFen(otherFen)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Update state flows
|
||||
_currentPosition.value = engine.getPosition()
|
||||
_moveHistory.value = engine.getMoveHistory()
|
||||
_lastActivityAt.value = other.lastActivityAt.value
|
||||
_isDesynced.value = false
|
||||
_lastError.value = null
|
||||
|
||||
// Update head event ID from other state
|
||||
_headEventId.value = other.headEventId.value
|
||||
|
||||
// Mark new moves as received
|
||||
val newMoveNumbers = (currentMoves.size + 1..otherMoves.size).toSet()
|
||||
receivedMoveNumbers.addAll(newMoveNumbers)
|
||||
|
||||
// Check for game end conditions
|
||||
checkGameEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark move numbers as already received.
|
||||
* Used when loading a game from cache to prevent duplicate move application during refresh.
|
||||
*
|
||||
* @param moveNumbers Set of move numbers that have been loaded
|
||||
*/
|
||||
fun markMovesAsReceived(moveNumbers: Set<Int>) {
|
||||
receivedMoveNumbers.addAll(moveNumbers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if game appears abandoned (opponent hasn't moved in a long time)
|
||||
*/
|
||||
fun isAbandoned(): Boolean {
|
||||
if (_gameStatus.value != GameStatus.InProgress) return false
|
||||
if (isPlayerTurn()) return false // Only check when waiting for opponent
|
||||
|
||||
val elapsed = TimeUtils.now() - _lastActivityAt.value
|
||||
return elapsed > ABANDON_TIMEOUT_SECONDS
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if opponent is inactive (warning threshold)
|
||||
*/
|
||||
fun isOpponentInactive(): Boolean {
|
||||
if (_gameStatus.value != GameStatus.InProgress) return false
|
||||
if (isPlayerTurn()) return false
|
||||
|
||||
val elapsed = TimeUtils.now() - _lastActivityAt.value
|
||||
return elapsed > INACTIVITY_WARNING_SECONDS
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim victory due to abandonment
|
||||
*/
|
||||
fun claimAbandonmentVictory(): ChessGameEnd? {
|
||||
if (!isAbandoned()) return null
|
||||
|
||||
_gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor))
|
||||
|
||||
return ChessGameEnd(
|
||||
startEventId = startEventId,
|
||||
headEventId = _headEventId.value,
|
||||
lastMove = null,
|
||||
fen = engine.getFen(),
|
||||
history = _moveHistory.value,
|
||||
result = GameResult.getResultForWinner(playerColor),
|
||||
termination = GameTermination.ABANDONMENT,
|
||||
opponentPubkey = opponentPubkey,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer resignation (player resigns)
|
||||
*/
|
||||
fun resign(): ChessGameEnd {
|
||||
_gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor.opposite()))
|
||||
|
||||
return ChessGameEnd(
|
||||
startEventId = startEventId,
|
||||
headEventId = _headEventId.value,
|
||||
lastMove = null,
|
||||
fen = engine.getFen(),
|
||||
history = _moveHistory.value,
|
||||
result = GameResult.getResultForWinner(playerColor.opposite()),
|
||||
termination = GameTermination.RESIGNATION,
|
||||
opponentPubkey = opponentPubkey,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer a draw to opponent.
|
||||
* Returns the draw offer data to be published to Nostr.
|
||||
*/
|
||||
fun offerDraw(): ChessDrawOffer {
|
||||
_pendingDrawOffer.value = playerPubkey
|
||||
return ChessDrawOffer(
|
||||
startEventId = startEventId,
|
||||
headEventId = _headEventId.value,
|
||||
opponentPubkey = opponentPubkey,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle receiving a draw offer from opponent (via Nostr event)
|
||||
*/
|
||||
fun receiveDrawOffer(fromPubkey: String): Boolean {
|
||||
if (fromPubkey != opponentPubkey) return false
|
||||
_pendingDrawOffer.value = opponentPubkey
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept opponent's draw offer.
|
||||
* Returns game end data to be published to Nostr.
|
||||
*/
|
||||
fun acceptDraw(): ChessGameEnd? {
|
||||
if (_pendingDrawOffer.value != opponentPubkey) {
|
||||
_lastError.value = "No draw offer to accept"
|
||||
return null
|
||||
}
|
||||
|
||||
_pendingDrawOffer.value = null
|
||||
_gameStatus.value = GameStatus.Finished(GameResult.DRAW)
|
||||
|
||||
return ChessGameEnd(
|
||||
startEventId = startEventId,
|
||||
headEventId = _headEventId.value,
|
||||
lastMove = null,
|
||||
fen = engine.getFen(),
|
||||
history = _moveHistory.value,
|
||||
result = GameResult.DRAW,
|
||||
termination = GameTermination.DRAW_AGREEMENT,
|
||||
opponentPubkey = opponentPubkey,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline a draw offer (explicit decline, also happens implicitly on move)
|
||||
*/
|
||||
fun declineDraw() {
|
||||
_pendingDrawOffer.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if game has ended (checkmate, stalemate, etc.)
|
||||
*/
|
||||
private fun checkGameEnd() {
|
||||
when {
|
||||
engine.isCheckmate() -> {
|
||||
val winner = engine.getSideToMove().opposite()
|
||||
_gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(winner))
|
||||
// In a real implementation, you'd publish GameEnd event here
|
||||
}
|
||||
|
||||
engine.isStalemate() -> {
|
||||
_gameStatus.value = GameStatus.Finished(GameResult.DRAW)
|
||||
// In a real implementation, you'd publish GameEnd event here
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PGN for the current game
|
||||
*/
|
||||
private fun generatePGN(): String {
|
||||
val moves = _moveHistory.value
|
||||
val movePairs = moves.chunked(2)
|
||||
|
||||
val moveText =
|
||||
movePairs
|
||||
.mapIndexed { index, pair ->
|
||||
val moveNum = index + 1
|
||||
when (pair.size) {
|
||||
2 -> "$moveNum. ${pair[0]} ${pair[1]}"
|
||||
1 -> "$moveNum. ${pair[0]}"
|
||||
else -> ""
|
||||
}
|
||||
}.joinToString(" ")
|
||||
|
||||
val result =
|
||||
when (val status = _gameStatus.value) {
|
||||
is GameStatus.Finished -> status.result.notation
|
||||
else -> "*"
|
||||
}
|
||||
|
||||
return """
|
||||
[Event "Live Chess Game"]
|
||||
[Site "Nostr"]
|
||||
[White "${if (playerColor == Color.WHITE) playerPubkey else opponentPubkey}"]
|
||||
[Black "${if (playerColor == Color.BLACK) playerPubkey else opponentPubkey}"]
|
||||
[Result "$result"]
|
||||
|
||||
$moveText $result
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark game as finished with the given result.
|
||||
* Used when loading a game from cache that already has an end event.
|
||||
*/
|
||||
fun markAsFinished(result: GameResult) {
|
||||
_gameStatus.value = GameStatus.Finished(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset game to initial position
|
||||
*/
|
||||
fun reset() {
|
||||
engine.reset()
|
||||
_currentPosition.value = engine.getPosition()
|
||||
_moveHistory.value = emptyList()
|
||||
_gameStatus.value = GameStatus.InProgress
|
||||
_lastError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Game status
|
||||
*/
|
||||
sealed class GameStatus {
|
||||
data object InProgress : GameStatus()
|
||||
|
||||
data class Finished(
|
||||
val result: GameResult,
|
||||
) : GameStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension to get result for a winning color
|
||||
*/
|
||||
private fun GameResult.Companion.getResultForWinner(winner: Color): GameResult =
|
||||
when (winner) {
|
||||
Color.WHITE -> GameResult.WHITE_WINS
|
||||
Color.BLACK -> GameResult.BLACK_WINS
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Parse PGN (Portable Game Notation) format per NIP-64 specification
|
||||
*
|
||||
* Accepts "import format" (human-created, flexible) as per PGN spec
|
||||
* Handles:
|
||||
* - Tag pairs: [TagName "TagValue"]
|
||||
* - Move text in Standard Algebraic Notation (SAN)
|
||||
* - Comments: {...}
|
||||
* - Variations: (...)
|
||||
* - Result markers: 1-0, 0-1, 1/2-1/2, *
|
||||
*/
|
||||
object PGNParser {
|
||||
/**
|
||||
* Parse PGN content into ChessGame
|
||||
*
|
||||
* @param pgn PGN format string
|
||||
* @return Result containing ChessGame or error
|
||||
*/
|
||||
fun parse(pgn: String): Result<ChessGame> =
|
||||
runCatching {
|
||||
val lines = pgn.lines().map { it.trim() }
|
||||
|
||||
// Extract metadata tags [Key "Value"]
|
||||
val metadata = parseMetadata(lines)
|
||||
|
||||
// Extract move text (everything after metadata)
|
||||
val moveText =
|
||||
lines
|
||||
.dropWhile { it.startsWith("[") || it.isEmpty() }
|
||||
.joinToString(" ")
|
||||
|
||||
val (moves, result) = parseMoves(moveText)
|
||||
|
||||
// Generate positions by replaying moves
|
||||
val positions = generatePositions(moves)
|
||||
|
||||
ChessGame(
|
||||
metadata = metadata,
|
||||
moves = moves,
|
||||
positions = positions,
|
||||
result = result,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract PGN tag pairs from lines
|
||||
* Format: [TagName "TagValue"]
|
||||
*/
|
||||
private fun parseMetadata(lines: List<String>): Map<String, String> {
|
||||
val tagRegex = """\[(\w+)\s+"([^"]*)"\]""".toRegex()
|
||||
return lines
|
||||
.mapNotNull { tagRegex.matchEntire(it) }
|
||||
.associate { it.groupValues[1] to it.groupValues[2] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse move text into list of moves and game result
|
||||
*/
|
||||
private fun parseMoves(moveText: String): Pair<List<ChessMove>, GameResult> {
|
||||
// Remove comments {...} and variations (...)
|
||||
val cleaned =
|
||||
moveText
|
||||
.replace(Regex("""\{[^}]*\}"""), "") // Remove comments
|
||||
.replace(Regex("""\([^)]*\)"""), "") // Remove variations
|
||||
.replace(Regex("""\$\d+"""), "") // Remove NAG annotations
|
||||
.trim()
|
||||
|
||||
// Extract result marker
|
||||
val result =
|
||||
when {
|
||||
cleaned.contains("1-0") -> GameResult.WHITE_WINS
|
||||
cleaned.contains("0-1") -> GameResult.BLACK_WINS
|
||||
cleaned.contains("1/2-1/2") -> GameResult.DRAW
|
||||
else -> GameResult.IN_PROGRESS
|
||||
}
|
||||
|
||||
// Remove result marker from move text
|
||||
val movesOnly =
|
||||
cleaned
|
||||
.replace("1-0", "")
|
||||
.replace("0-1", "")
|
||||
.replace("1/2-1/2", "")
|
||||
.replace("*", "")
|
||||
.replace(Regex("""\s+"""), " ") // Normalize whitespace
|
||||
.trim()
|
||||
|
||||
// Parse move pairs: "1. e4 e5 2. Nf3 Nc6"
|
||||
val moveRegex = """(\d+)\.\s*([^\s]+)(?:\s+([^\s]+))?""".toRegex()
|
||||
val moves = mutableListOf<ChessMove>()
|
||||
|
||||
moveRegex.findAll(movesOnly).forEach { match ->
|
||||
val moveNum = match.groupValues[1].toIntOrNull() ?: return@forEach
|
||||
val whiteMove = match.groupValues[2]
|
||||
val blackMove = match.groupValues.getOrNull(3)?.takeIf { it.isNotEmpty() }
|
||||
|
||||
// Skip if this is just a result marker
|
||||
if (!isResultMarker(whiteMove)) {
|
||||
moves.add(parseMove(whiteMove, moveNum, Color.WHITE))
|
||||
}
|
||||
|
||||
blackMove?.let {
|
||||
if (!isResultMarker(it)) {
|
||||
moves.add(parseMove(it, moveNum, Color.BLACK))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return moves to result
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text is a game result marker
|
||||
* Valid results: 1-0, 0-1, 1/2-1/2, *
|
||||
*/
|
||||
private fun isResultMarker(text: String): Boolean = text == "1-0" || text == "0-1" || text == "1/2-1/2" || text == "*"
|
||||
|
||||
/**
|
||||
* Parse a single move in Standard Algebraic Notation (SAN)
|
||||
*
|
||||
* Examples:
|
||||
* - e4 (pawn move)
|
||||
* - Nf3 (knight to f3)
|
||||
* - Bxe5 (bishop captures on e5)
|
||||
* - O-O (kingside castling)
|
||||
* - O-O-O (queenside castling)
|
||||
* - e8=Q (pawn promotion to queen)
|
||||
* - Nbd2 (knight from b-file to d2)
|
||||
* - R1a3 (rook from rank 1 to a3)
|
||||
* - Qh4+ (queen to h4 with check)
|
||||
* - Qh4# (queen to h4 with checkmate)
|
||||
*/
|
||||
private fun parseMove(
|
||||
san: String,
|
||||
moveNumber: Int,
|
||||
color: Color,
|
||||
): ChessMove {
|
||||
// Clean up annotations
|
||||
var text = san.replace(Regex("[!?]+"), "").trim()
|
||||
|
||||
val isCheck = text.contains("+")
|
||||
val isCheckmate = text.contains("#")
|
||||
val isCapture = text.contains("x")
|
||||
|
||||
// Remove check/checkmate markers
|
||||
text = text.replace("+", "").replace("#", "")
|
||||
|
||||
// Castling
|
||||
if (text == "O-O" || text == "0-0") {
|
||||
return ChessMove(
|
||||
san = san,
|
||||
moveNumber = moveNumber,
|
||||
color = color,
|
||||
piece = PieceType.KING,
|
||||
toSquare = if (color == Color.WHITE) "g1" else "g8",
|
||||
isCheck = isCheck,
|
||||
isCheckmate = isCheckmate,
|
||||
isCastling = true,
|
||||
)
|
||||
}
|
||||
|
||||
if (text == "O-O-O" || text == "0-0-0") {
|
||||
return ChessMove(
|
||||
san = san,
|
||||
moveNumber = moveNumber,
|
||||
color = color,
|
||||
piece = PieceType.KING,
|
||||
toSquare = if (color == Color.WHITE) "c1" else "c8",
|
||||
isCheck = isCheck,
|
||||
isCheckmate = isCheckmate,
|
||||
isCastling = true,
|
||||
)
|
||||
}
|
||||
|
||||
// Remove capture marker
|
||||
text = text.replace("x", "")
|
||||
|
||||
// Check for promotion (e.g., e8=Q)
|
||||
val promotionRegex = """([a-h][18])=([QRBN])""".toRegex()
|
||||
val promotionMatch = promotionRegex.find(text)
|
||||
val promotion = promotionMatch?.groupValues?.get(2)?.let { PieceType.fromSymbol(it[0]) }
|
||||
if (promotionMatch != null) {
|
||||
text = text.replace(promotionRegex, promotionMatch.groupValues[1])
|
||||
}
|
||||
|
||||
// Determine piece type (first char if uppercase, otherwise pawn)
|
||||
val piece =
|
||||
if (text.isNotEmpty() && text[0].isUpperCase()) {
|
||||
PieceType.fromSymbol(text[0]) ?: PieceType.PAWN
|
||||
} else {
|
||||
PieceType.PAWN
|
||||
}
|
||||
|
||||
// Remove piece symbol if present
|
||||
if (piece != PieceType.PAWN && text.isNotEmpty()) {
|
||||
text = text.substring(1)
|
||||
}
|
||||
|
||||
// Extract destination square (last 2 chars should be file+rank)
|
||||
val squareRegex = """([a-h][1-8])$""".toRegex()
|
||||
val squareMatch = squareRegex.find(text)
|
||||
val toSquare = squareMatch?.value ?: ""
|
||||
|
||||
// Extract disambiguation (file or rank between piece and destination)
|
||||
val fromSquare = text.replace(toSquare, "").takeIf { it.isNotEmpty() }
|
||||
|
||||
return ChessMove(
|
||||
san = san,
|
||||
moveNumber = moveNumber,
|
||||
color = color,
|
||||
piece = piece,
|
||||
fromSquare = fromSquare,
|
||||
toSquare = toSquare,
|
||||
isCapture = isCapture,
|
||||
isCheck = isCheck,
|
||||
isCheckmate = isCheckmate,
|
||||
promotion = promotion,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate list of positions by replaying moves from starting position
|
||||
*
|
||||
* Note: This implementation uses simplified move application.
|
||||
* Full legal move validation would require complete chess engine logic.
|
||||
*/
|
||||
private fun generatePositions(moves: List<ChessMove>): List<ChessPosition> {
|
||||
val positions = mutableListOf(ChessPosition.initial())
|
||||
var current = ChessPosition.initial()
|
||||
|
||||
moves.forEach { move ->
|
||||
try {
|
||||
current =
|
||||
when {
|
||||
move.isCastling -> {
|
||||
// Castling
|
||||
val kingSide = move.toSquare.contains("g")
|
||||
current.makeCastlingMove(kingSide)
|
||||
}
|
||||
|
||||
move.fromSquare != null -> {
|
||||
// Move with disambiguation (we can infer full source)
|
||||
// For now, just use simplified move
|
||||
makeSimplifiedMove(current, move)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Regular move
|
||||
makeSimplifiedMove(current, move)
|
||||
}
|
||||
}
|
||||
positions.add(current)
|
||||
} catch (e: Exception) {
|
||||
// If move application fails, keep previous position
|
||||
positions.add(current)
|
||||
}
|
||||
}
|
||||
|
||||
return positions
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified move application without full legal move validation
|
||||
* Finds piece that can move to destination and creates new position
|
||||
*/
|
||||
private fun makeSimplifiedMove(
|
||||
position: ChessPosition,
|
||||
move: ChessMove,
|
||||
): ChessPosition {
|
||||
// For MVP: Try to find a piece of the right type that could move to destination
|
||||
// This is simplified and doesn't validate full chess rules
|
||||
val targetSquare = move.toSquare
|
||||
|
||||
// Find all pieces of the moving color and type
|
||||
val candidates = mutableListOf<String>()
|
||||
for (rank in 0..7) {
|
||||
for (file in 0..7) {
|
||||
val piece = position.pieceAt(file, rank)
|
||||
if (piece != null &&
|
||||
piece.color == move.color &&
|
||||
piece.type == move.piece
|
||||
) {
|
||||
val square = "${'a' + file}${rank + 1}"
|
||||
candidates.add(square)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use disambiguation if provided
|
||||
val fromSquare =
|
||||
if (move.fromSquare != null) {
|
||||
candidates.firstOrNull { it.contains(move.fromSquare) }
|
||||
} else {
|
||||
candidates.firstOrNull()
|
||||
} ?: ""
|
||||
|
||||
return if (fromSquare.isNotEmpty()) {
|
||||
position.makeMove(fromSquare, targetSquare, move.promotion)
|
||||
} else {
|
||||
// If we can't find source, return current position unchanged
|
||||
position
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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.nip88Polls.poll
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip22Comments.RootScope
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Immutable
|
||||
class PollEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
RootScope {
|
||||
fun options() = tags.options()
|
||||
|
||||
fun relays() = tags.relays()
|
||||
|
||||
fun pollType() = tags.pollType()
|
||||
|
||||
fun endsAt() = tags.endsAt()
|
||||
|
||||
fun hasEnded() = tags.hasEnded()
|
||||
|
||||
companion object {
|
||||
const val KIND = 1068
|
||||
const val ALT_DESCRIPTION = "Poll"
|
||||
|
||||
fun build(
|
||||
description: String,
|
||||
options: List<OptionTag>,
|
||||
endsAt: Long?,
|
||||
relays: List<NormalizedRelayUrl>,
|
||||
pollType: PollType = PollType.SINGLE_CHOICE,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<PollEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, description, createdAt) {
|
||||
alt(ALT_DESCRIPTION)
|
||||
poolType(pollType)
|
||||
options(options)
|
||||
relays(relays)
|
||||
endsAt?.let {
|
||||
endsAt(it)
|
||||
}
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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.nip88Polls.poll
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.EndsAtTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollTypeTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.RelayTag
|
||||
|
||||
fun TagArrayBuilder<PollEvent>.poolType(type: PollType) = addUnique(PollTypeTag.assemble(type))
|
||||
|
||||
fun TagArrayBuilder<PollEvent>.endsAt(timestamp: Long) = addUnique(EndsAtTag.assemble(timestamp))
|
||||
|
||||
fun TagArrayBuilder<PollEvent>.relays(relays: List<NormalizedRelayUrl>) = addAll(RelayTag.assemble(relays))
|
||||
|
||||
fun TagArrayBuilder<PollEvent>.options(options: List<OptionTag>) = addAll(OptionTag.assemble(options))
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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.nip88Polls.poll
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.EndsAtTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.EndsAtTag.Companion.parse
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag.Companion.parse
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollTypeTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollTypeTag.Companion.parse
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.RelayTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.RelayTag.Companion.parse
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
fun TagArray.options() = mapNotNull(OptionTag::parse)
|
||||
|
||||
fun TagArray.relays() = mapNotNull(RelayTag::parse)
|
||||
|
||||
fun TagArray.pollType() = firstNotNullOfOrNull(PollTypeTag::parse) ?: PollType.SINGLE_CHOICE
|
||||
|
||||
fun TagArray.endsAt() = firstNotNullOfOrNull(EndsAtTag::parse)
|
||||
|
||||
fun TagArray.hasEnded() = endsAt()?.let { it < TimeUtils.now() } ?: false
|
||||
+17
-16
@@ -18,23 +18,24 @@
|
||||
* 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.nip01Core.crypto
|
||||
package com.vitorpamplona.quartz.nip88Polls.poll.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.arrayOfNotNull
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class DeterministicSigner(
|
||||
val key: KeyPair,
|
||||
val pubKey: HexKey = key.pubKey.toHexKey(),
|
||||
) {
|
||||
fun <T : Event> sign(
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
): T = EventAssembler.hashAndSign(pubKey, createdAt, kind, tags, content, key.privKey!!, nonce = null)
|
||||
class EndsAtTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "endsAt"
|
||||
|
||||
fun <T : Event> sign(ev: EventTemplate<T>): T = sign(ev.createdAt, ev.kind, ev.tags, ev.content)
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): Long? {
|
||||
ensure(tag.has(1) && tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1].toLongOrNull()
|
||||
}
|
||||
|
||||
fun assemble(time: Long) = arrayOfNotNull(TAG_NAME, time.toString())
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.nip88Polls.poll.tags
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
@Stable
|
||||
class OptionTag(
|
||||
val code: String,
|
||||
val label: String,
|
||||
) {
|
||||
companion object {
|
||||
const val TAG_NAME = "option"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[2].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): OptionTag? {
|
||||
ensure(tag.has(2)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
ensure(tag[2].isNotEmpty()) { return null }
|
||||
return OptionTag(tag[1], tag[2])
|
||||
}
|
||||
|
||||
fun assemble(
|
||||
code: String,
|
||||
label: String,
|
||||
) = arrayOf(TAG_NAME, code, label)
|
||||
|
||||
fun assemble(option: OptionTag) = assemble(option.code, option.label)
|
||||
|
||||
fun assemble(labels: List<OptionTag>) = labels.map { opt -> assemble(opt) }
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.nip88Polls.poll.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
enum class PollType(
|
||||
val code: String,
|
||||
) {
|
||||
SINGLE_CHOICE("singlechoice"),
|
||||
MULTI_CHOICE("multiplechoice"),
|
||||
}
|
||||
|
||||
class PollTypeTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "polltype"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): PollType? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
|
||||
return when (tag[1]) {
|
||||
PollType.SINGLE_CHOICE.code -> PollType.SINGLE_CHOICE
|
||||
PollType.MULTI_CHOICE.code -> PollType.MULTI_CHOICE
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun assemble(type: PollType) = arrayOf(TAG_NAME, type.code)
|
||||
}
|
||||
}
|
||||
+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.nip88Polls.poll.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class RelayTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "relay"
|
||||
|
||||
fun match(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun notMatch(tag: Array<String>) = !(tag.has(0) && tag[0] == TAG_NAME)
|
||||
|
||||
fun parse(tag: Array<String>): NormalizedRelayUrl? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
|
||||
return RelayUrlNormalizer.normalizeOrNull(tag[1])
|
||||
}
|
||||
|
||||
fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url)
|
||||
|
||||
fun assemble(relays: List<NormalizedRelayUrl>) = relays.map { assemble(it) }
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.nip88Polls.response
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseAsHint
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseId
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseAsHint
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseKey
|
||||
import com.vitorpamplona.quartz.nip22Comments.RootScope
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.responses
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.tags.PollTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Immutable
|
||||
class PollResponseEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
EventHintProvider,
|
||||
PubKeyHintProvider,
|
||||
RootScope {
|
||||
override fun eventHints() = tags.mapNotNull(PollTag::parseAsHint)
|
||||
|
||||
override fun linkedEventIds() = tags.mapNotNull(PollTag::parseId)
|
||||
|
||||
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
|
||||
|
||||
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
|
||||
|
||||
fun responses() = tags.responses()
|
||||
|
||||
fun poll() = tags.poll()
|
||||
|
||||
companion object {
|
||||
const val KIND = 1018
|
||||
const val ALT_DESCRIPTION = "Poll Response"
|
||||
|
||||
fun build(
|
||||
poll: EventHintBundle<PollEvent>,
|
||||
responses: Set<String>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<PollResponseEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, "", createdAt) {
|
||||
alt(ALT_DESCRIPTION)
|
||||
poll(poll)
|
||||
notifyAuthor(poll)
|
||||
responses(responses)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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.nip88Polls.response
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.tags.PollTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.tags.ResponseTag
|
||||
|
||||
fun TagArrayBuilder<PollResponseEvent>.poll(pollHint: EventHintBundle<PollEvent>) = addUnique(PollTag.assemble(pollHint))
|
||||
|
||||
fun TagArrayBuilder<PollResponseEvent>.responses(responses: Set<String>) = addAll(ResponseTag.assemble(responses))
|
||||
|
||||
fun TagArrayBuilder<PollResponseEvent>.notifyAuthor(pollHint: EventHintBundle<PollEvent>) = add(pollHint.toPTag().toTagArray())
|
||||
+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.nip88Polls.response
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parse
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.EndsAtTag.Companion.parse
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag.Companion.parse
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollTypeTag.Companion.parse
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.RelayTag.Companion.parse
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.tags.PollTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.tags.ResponseTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.tags.ResponseTag.Companion.parse
|
||||
|
||||
fun TagArray.responses() = mapNotNull(ResponseTag::parse)
|
||||
|
||||
fun TagArray.poll() = firstNotNullOfOrNull(PollTag::parse)
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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.nip88Polls.response.tags
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.utils.arrayOfNotNull
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
@Immutable
|
||||
data class PollTag(
|
||||
override val eventId: HexKey,
|
||||
) : GenericETag {
|
||||
override var relay: NormalizedRelayUrl? = null
|
||||
override var author: HexKey? = null
|
||||
|
||||
constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, authorPubKeyHex: HexKey? = null) : this(eventId) {
|
||||
this.relay = relayHint
|
||||
this.author = authorPubKeyHex
|
||||
}
|
||||
|
||||
fun toNEvent(): String = NEvent.create(eventId, author, null, relay)
|
||||
|
||||
override fun toTagArray() = assemble(eventId, relay, author)
|
||||
|
||||
companion object {
|
||||
const val TAG_NAME = "e"
|
||||
|
||||
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
|
||||
|
||||
fun isTagged(
|
||||
tag: Array<String>,
|
||||
eventId: HexKey,
|
||||
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId
|
||||
|
||||
fun isTagged(
|
||||
tag: Array<String>,
|
||||
eventIds: Set<HexKey>,
|
||||
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in eventIds
|
||||
|
||||
fun parse(tag: Array<String>): PollTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].length == 64) { return null }
|
||||
|
||||
val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
|
||||
return PollTag(tag[1], hint, tag.getOrNull(3))
|
||||
}
|
||||
|
||||
fun parseId(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].length == 64) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun parseAsHint(tag: Array<String>): EventIdHint? {
|
||||
ensure(tag.has(2)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].length == 64) { return null }
|
||||
ensure(tag[2].isNotEmpty()) { return null }
|
||||
|
||||
val hint = RelayUrlNormalizer.normalizeOrNull(tag[2])
|
||||
|
||||
ensure(hint != null) { return null }
|
||||
|
||||
return EventIdHint(tag[1], hint)
|
||||
}
|
||||
|
||||
fun assemble(
|
||||
eventId: HexKey,
|
||||
relay: NormalizedRelayUrl?,
|
||||
author: HexKey?,
|
||||
) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author)
|
||||
|
||||
fun assemble(eventHint: EventHintBundle<PollEvent>) = assemble(eventHint.event.id, eventHint.relay, eventHint.event.pubKey)
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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.nip88Polls.response.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class ResponseTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "response"
|
||||
|
||||
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)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(code: String) = arrayOf(TAG_NAME, code)
|
||||
|
||||
fun assemble(responses: Set<String>) = responses.map { code -> assemble(code) }
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,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.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
@@ -124,6 +131,8 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent
|
||||
@@ -183,6 +192,13 @@ class EventFactory {
|
||||
CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
JesterEvent.KIND -> JesterEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveChessGameChallengeEvent.KIND -> LiveChessGameChallengeEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveChessMoveEvent.KIND -> LiveChessMoveEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveChessGameEndEvent.KIND -> LiveChessGameEndEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveChessDrawOfferEvent.KIND -> LiveChessDrawOfferEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
@@ -254,6 +270,8 @@ class EventFactory {
|
||||
PictureEvent.KIND -> PictureEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PollEvent.KIND -> PollEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PollResponseEvent.KIND -> PollResponseEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ProxyRelayListEvent.KIND -> ProxyRelayListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
@@ -55,6 +55,8 @@ object TimeUtils {
|
||||
|
||||
fun oneDayAgo() = now() - ONE_DAY
|
||||
|
||||
fun oneDayAhead() = now() + ONE_DAY
|
||||
|
||||
fun eightHoursAgo() = now() - EIGHT_HOURS
|
||||
|
||||
fun twoDays() = ONE_DAY * 2
|
||||
|
||||
@@ -20,26 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
expect class TestResourceLoader() {
|
||||
fun loadString(file: String): String
|
||||
}
|
||||
|
||||
+13
-11
@@ -56,18 +56,20 @@ class HintIndexerTest {
|
||||
)
|
||||
}
|
||||
|
||||
val relays =
|
||||
TestResourceLoader()
|
||||
.loadString("relayDB.txt")
|
||||
.split('\n')
|
||||
.mapNotNull {
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(it)
|
||||
if (relay == null || relay.isLocalHost()) {
|
||||
null
|
||||
} else {
|
||||
relay
|
||||
val relays by
|
||||
lazy {
|
||||
TestResourceLoader()
|
||||
.loadString("relayDB.txt")
|
||||
.split('\n')
|
||||
.mapNotNull {
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(it)
|
||||
if (relay == null || relay.isLocalHost()) {
|
||||
null
|
||||
} else {
|
||||
relay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val indexer by lazy {
|
||||
val result = HintIndexer()
|
||||
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for ChessGameEvent (NIP-64 Kind 64 event)
|
||||
*
|
||||
* Verifies:
|
||||
* - Event kind is 64
|
||||
* - PGN content storage
|
||||
* - Alt text support (NIP-31)
|
||||
* - Event structure compliance
|
||||
*/
|
||||
class ChessGameEventTest {
|
||||
private val samplePGN =
|
||||
"""
|
||||
[Event "Test Game"]
|
||||
[Site "Internet"]
|
||||
[Date "2024.12.28"]
|
||||
[Round "1"]
|
||||
[White "Alice"]
|
||||
[Black "Bob"]
|
||||
[Result "1-0"]
|
||||
|
||||
1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `verify event kind is 64`() {
|
||||
assertEquals(64, ChessGameEvent.KIND, "Chess game event should be kind 64")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pgn content is accessible`() {
|
||||
// Create a mock event manually for testing
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Chess Game")),
|
||||
content = samplePGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(samplePGN, testEvent.pgn(), "PGN content should be accessible via pgn()")
|
||||
assertEquals(samplePGN, testEvent.content, "PGN should be in content field")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alt text is accessible when present`() {
|
||||
val customAltText = "Scholar's Mate Example"
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", customAltText)),
|
||||
content = samplePGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(customAltText, testEvent.altText(), "Alt text should be extractable from tags")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alt text returns null when not present`() {
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = samplePGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(null, testEvent.altText(), "Should return null when no alt tag present")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event can store complete game with metadata`() {
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Chess Game")),
|
||||
content = samplePGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
// Parse the PGN to verify it's valid
|
||||
val gameResult = PGNParser.parse(testEvent.pgn())
|
||||
assertTrue(gameResult.isSuccess, "Event should contain valid PGN")
|
||||
|
||||
val game = gameResult.getOrThrow()
|
||||
assertEquals("Test Game", game.event)
|
||||
assertEquals("Alice", game.white)
|
||||
assertEquals("Bob", game.black)
|
||||
assertEquals(GameResult.WHITE_WINS, game.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event can store minimal PGN`() {
|
||||
val minimalPGN = "1. e4 *"
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Chess Game")),
|
||||
content = minimalPGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
val gameResult = PGNParser.parse(testEvent.pgn())
|
||||
assertTrue(gameResult.isSuccess, "Should handle minimal PGN")
|
||||
|
||||
val game = gameResult.getOrThrow()
|
||||
assertEquals(1, game.moves.size)
|
||||
assertEquals(GameResult.IN_PROGRESS, game.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event can store game in progress`() {
|
||||
val inProgressPGN =
|
||||
"""
|
||||
[Event "Live Game"]
|
||||
[Result "*"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6 3. Bb5 *
|
||||
""".trimIndent()
|
||||
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Live Chess Game")),
|
||||
content = inProgressPGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
val gameResult = PGNParser.parse(testEvent.pgn())
|
||||
assertTrue(gameResult.isSuccess)
|
||||
|
||||
val game = gameResult.getOrThrow()
|
||||
assertEquals(GameResult.IN_PROGRESS, game.result)
|
||||
assertEquals("*", game.metadata["Result"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event preserves PGN formatting`() {
|
||||
val formattedPGN =
|
||||
"""
|
||||
[Event "Formatted Game"]
|
||||
[White "Player 1"]
|
||||
[Black "Player 2"]
|
||||
|
||||
1. e4 e5
|
||||
2. Nf3 Nc6
|
||||
3. Bb5 a6
|
||||
*
|
||||
""".trimIndent()
|
||||
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Chess Game")),
|
||||
content = formattedPGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
// Content should be preserved exactly as provided
|
||||
assertEquals(formattedPGN, testEvent.pgn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default alt text is Chess Game`() {
|
||||
assertEquals("Chess Game", ChessGameEvent.ALT_DESCRIPTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event inherits from Event base class`() {
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = "1. e4 *",
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
// Verify base Event properties
|
||||
assertEquals("test_id", testEvent.id)
|
||||
assertEquals("test_pubkey", testEvent.pubKey)
|
||||
assertEquals(1000L, testEvent.createdAt)
|
||||
assertEquals(64, testEvent.kind)
|
||||
assertEquals("test_sig", testEvent.sig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event can contain long tournament game`() {
|
||||
val longPGN =
|
||||
"""
|
||||
[Event "Tournament Game"]
|
||||
[Site "Online"]
|
||||
[Date "2024.12.28"]
|
||||
[Round "5"]
|
||||
[White "GM Player"]
|
||||
[Black "IM Player"]
|
||||
[Result "1/2-1/2"]
|
||||
|
||||
1. d4 Nf6 2. c4 g6 3. Nc3 Bg7 4. e4 d6 5. Nf3 O-O 6. Be2 e5
|
||||
7. O-O Nc6 8. d5 Ne7 9. Ne1 Nd7 10. Nd3 f5 11. Bd2 Nf6 12. f3 f4
|
||||
13. Rc1 g5 14. Nb5 Ng6 15. c5 Rf7 16. Qa4 h5 17. Rfe1 Bf8
|
||||
18. cxd6 cxd6 19. Rc6 Bd7 20. Rec1 Bxc6 21. Rxc6 Qd7 22. Rc1 Rc8
|
||||
23. Rxc8 Qxc8 24. Qa6 Qc2 25. Qxb7 Qxb2 26. Qxa7 Qxa2 27. Qb7 Qa1+
|
||||
28. Kf2 Qb2 29. Qa7 Ra7 30. Qa4 Qa2 31. Qa8 Qb2 32. Qa4 Qa2 1/2-1/2
|
||||
""".trimIndent()
|
||||
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Long Tournament Game")),
|
||||
content = longPGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
val gameResult = PGNParser.parse(testEvent.pgn())
|
||||
assertTrue(gameResult.isSuccess)
|
||||
|
||||
val game = gameResult.getOrThrow()
|
||||
assertTrue(game.moves.size > 50, "Should handle long games")
|
||||
assertEquals(GameResult.DRAW, game.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verify tags array structure`() {
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("alt", "Test Alt Text"),
|
||||
arrayOf("t", "chess"),
|
||||
arrayOf("t", "game"),
|
||||
),
|
||||
content = "1. e4 *",
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
// Verify tags structure
|
||||
assertTrue(testEvent.tags.size >= 1)
|
||||
assertEquals("alt", testEvent.tags[0][0])
|
||||
assertEquals("Test Alt Text", testEvent.tags[0][1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for JesterEvent (Jester Protocol kind 30 events)
|
||||
*
|
||||
* Verifies:
|
||||
* - Event kind is 30
|
||||
* - JSON content parsing (version, kind, fen, move, history)
|
||||
* - e-tag structure for event linking
|
||||
* - p-tag for opponent tagging
|
||||
* - Start vs Move event detection
|
||||
* - Compatibility with jesterui protocol
|
||||
*
|
||||
* Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md
|
||||
*/
|
||||
class JesterEventTest {
|
||||
private val testPubkey = "abc123def456"
|
||||
private val opponentPubkey = "opponent789xyz"
|
||||
private val startEventId = "start-event-id-001"
|
||||
|
||||
// ==========================================================================
|
||||
// PROTOCOL CONSTANTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `verify event kind is 30`() {
|
||||
assertEquals(30, JesterProtocol.KIND, "Jester protocol uses kind 30")
|
||||
assertEquals(30, JesterEvent.KIND, "JesterEvent.KIND should be 30")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verify start position hash constant`() {
|
||||
assertEquals(
|
||||
"b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879",
|
||||
JesterProtocol.START_POSITION_HASH,
|
||||
"Should match jesterui's START_POSITION_HASH",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verify starting FEN constant`() {
|
||||
assertEquals(
|
||||
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
||||
JesterProtocol.FEN_START,
|
||||
"Standard chess starting position",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verify content kind constants`() {
|
||||
assertEquals(0, JesterProtocol.CONTENT_KIND_START, "Start event kind should be 0")
|
||||
assertEquals(1, JesterProtocol.CONTENT_KIND_MOVE, "Move event kind should be 1")
|
||||
assertEquals(2, JesterProtocol.CONTENT_KIND_CHAT, "Chat event kind should be 2")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// START EVENT TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `parse start event - open challenge`() {
|
||||
val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"abc12345","playerColor":"white"}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = startEventId,
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", JesterProtocol.START_POSITION_HASH),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(30, event.kind)
|
||||
assertTrue(event.isStartEvent(), "Should be detected as start event")
|
||||
assertFalse(event.isMoveEvent(), "Should not be a move event")
|
||||
assertEquals(0, event.contentKind())
|
||||
assertEquals(JesterProtocol.FEN_START, event.fen())
|
||||
assertEquals(Color.WHITE, event.playerColor())
|
||||
assertEquals("abc12345", event.nonce())
|
||||
assertTrue(event.history().isEmpty(), "Start event has no history")
|
||||
assertNull(event.opponentPubkey(), "Open challenge has no opponent")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse start event - private challenge`() {
|
||||
val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"xyz98765","playerColor":"black"}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = startEventId,
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", JesterProtocol.START_POSITION_HASH),
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertTrue(event.isStartEvent())
|
||||
assertEquals(Color.BLACK, event.playerColor())
|
||||
assertEquals(opponentPubkey, event.opponentPubkey(), "Private challenge should have opponent")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start event e-tag references START_POSITION_HASH`() {
|
||||
val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[]}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = startEventId,
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", JesterProtocol.START_POSITION_HASH),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
val eTags = event.eTags()
|
||||
assertEquals(1, eTags.size)
|
||||
assertEquals(JesterProtocol.START_POSITION_HASH, eTags[0], "Start event should reference START_POSITION_HASH")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// MOVE EVENT TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `parse move event - first move e4`() {
|
||||
val content = """{"version":"0","kind":1,"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1","move":"e4","history":["e4"]}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "move-001",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 2000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", startEventId), // For first move, head is also start
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertFalse(event.isStartEvent(), "Should not be a start event")
|
||||
assertTrue(event.isMoveEvent(), "Should be detected as move event")
|
||||
assertEquals(1, event.contentKind())
|
||||
assertEquals("e4", event.move())
|
||||
assertEquals(listOf("e4"), event.history())
|
||||
assertEquals("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", event.fen())
|
||||
assertEquals(opponentPubkey, event.opponentPubkey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse move event - multiple moves in history`() {
|
||||
val content = """{"version":"0","kind":1,"fen":"rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2","move":"Nf3","history":["e4","e5","Nf3"]}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "move-003",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 4000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", "move-002"), // Previous move
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertTrue(event.isMoveEvent())
|
||||
assertEquals("Nf3", event.move())
|
||||
assertEquals(listOf("e4", "e5", "Nf3"), event.history())
|
||||
assertEquals(3, event.history().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `move event e-tags structure - startEventId and headEventId`() {
|
||||
val content = """{"version":"0","kind":1,"fen":"test","move":"e4","history":["e4"]}"""
|
||||
val headEventId = "move-002"
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "move-003",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 3000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", headEventId),
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(startEventId, event.startEventId(), "First e-tag should be startEventId")
|
||||
assertEquals(headEventId, event.headEventId(), "Second e-tag should be headEventId")
|
||||
|
||||
val eTags = event.eTags()
|
||||
assertEquals(2, eTags.size)
|
||||
assertEquals(startEventId, eTags[0])
|
||||
assertEquals(headEventId, eTags[1])
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// GAME END EVENT TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `parse game end event - checkmate`() {
|
||||
val content = """{"version":"0","kind":1,"fen":"r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4","move":"Qxf7#","history":["e4","e5","Qh5","Nc6","Bc4","Nf6","Qxf7#"],"result":"1-0","termination":"checkmate"}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "move-007",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 8000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", "move-006"),
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertTrue(event.isMoveEvent(), "End event is still a move event")
|
||||
assertEquals("1-0", event.result(), "Should have result")
|
||||
assertEquals("checkmate", event.termination(), "Should have termination reason")
|
||||
assertEquals(7, event.history().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse game end event - resignation`() {
|
||||
val content = """{"version":"0","kind":1,"fen":"test","move":"e5","history":["e4","e5"],"result":"0-1","termination":"resignation"}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "move-002",
|
||||
pubKey = opponentPubkey,
|
||||
createdAt = 3000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", "move-001"),
|
||||
arrayOf("p", testPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals("0-1", event.result(), "Black wins")
|
||||
assertEquals("resignation", event.termination())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse game end event - draw`() {
|
||||
val content = """{"version":"0","kind":1,"fen":"test","move":"Kf1","history":["e4","e5","Kf1"],"result":"1/2-1/2","termination":"draw_agreement"}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "move-003",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 4000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", "move-002"),
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals("1/2-1/2", event.result(), "Draw")
|
||||
assertEquals("draw_agreement", event.termination())
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// EDGE CASES AND ERROR HANDLING
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `handle malformed JSON content gracefully`() {
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "test",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = "invalid json {{{}",
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertNull(event.contentKind(), "Should return null for invalid JSON")
|
||||
assertFalse(event.isStartEvent(), "Should not crash on invalid content")
|
||||
assertFalse(event.isMoveEvent(), "Should not crash on invalid content")
|
||||
assertNull(event.fen())
|
||||
assertNull(event.move())
|
||||
assertTrue(event.history().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle empty content`() {
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "test",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertNull(event.contentKind())
|
||||
assertFalse(event.isStartEvent())
|
||||
assertFalse(event.isMoveEvent())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle missing optional fields`() {
|
||||
// Minimal valid content with only required fields
|
||||
val content = """{"kind":1}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "test",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(1, event.contentKind())
|
||||
assertNull(event.move())
|
||||
assertTrue(event.history().isEmpty())
|
||||
assertNull(event.result())
|
||||
assertNull(event.termination())
|
||||
assertNull(event.playerColor())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle event with no e-tags`() {
|
||||
val content = """{"version":"0","kind":0}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "test",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertNull(event.startEventId(), "No e-tags means no startEventId")
|
||||
assertNull(event.headEventId(), "No e-tags means no headEventId")
|
||||
assertTrue(event.eTags().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle event with only one e-tag`() {
|
||||
val content = """{"version":"0","kind":1}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "test",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(startEventId, event.startEventId())
|
||||
assertNull(event.headEventId(), "Only one e-tag means no headEventId")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// JESTER GAME EVENTS CONTAINER TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `JesterGameEvents - empty returns correct values`() {
|
||||
val events = JesterGameEvents.empty()
|
||||
|
||||
assertNull(events.startEvent)
|
||||
assertTrue(events.moves.isEmpty())
|
||||
assertNull(events.latestMove())
|
||||
assertEquals(JesterProtocol.FEN_START, events.currentFen())
|
||||
assertTrue(events.fullHistory().isEmpty())
|
||||
assertFalse(events.isEnded())
|
||||
assertNull(events.result())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `JesterGameEvents - latestMove returns move with longest history`() {
|
||||
val move1 = createTestMoveEvent("move-001", listOf("e4"))
|
||||
val move2 = createTestMoveEvent("move-002", listOf("e4", "e5"))
|
||||
val move3 = createTestMoveEvent("move-003", listOf("e4", "e5", "Nf3"))
|
||||
|
||||
// Provide moves in random order
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = null,
|
||||
moves = listOf(move2, move3, move1),
|
||||
)
|
||||
|
||||
val latest = events.latestMove()
|
||||
assertNotNull(latest)
|
||||
assertEquals("move-003", latest.id, "Should return move with longest history")
|
||||
assertEquals(3, latest.history().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `JesterGameEvents - currentFen returns FEN from latest move`() {
|
||||
val move1 = createTestMoveEvent("move-001", listOf("e4"), fen = "fen-after-e4")
|
||||
val move2 = createTestMoveEvent("move-002", listOf("e4", "e5"), fen = "fen-after-e5")
|
||||
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = null,
|
||||
moves = listOf(move1, move2),
|
||||
)
|
||||
|
||||
assertEquals("fen-after-e5", events.currentFen())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `JesterGameEvents - fullHistory returns history from latest move`() {
|
||||
val move1 = createTestMoveEvent("move-001", listOf("e4"))
|
||||
val move2 = createTestMoveEvent("move-002", listOf("e4", "e5"))
|
||||
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = null,
|
||||
moves = listOf(move1, move2),
|
||||
)
|
||||
|
||||
assertEquals(listOf("e4", "e5"), events.fullHistory())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `JesterGameEvents - isEnded detects result in latest move`() {
|
||||
val normalMove = createTestMoveEvent("move-001", listOf("e4"))
|
||||
val endMove = createTestMoveEventWithResult("move-002", listOf("e4", "Qxf7#"), result = "1-0")
|
||||
|
||||
val ongoingGame = JesterGameEvents(startEvent = null, moves = listOf(normalMove))
|
||||
val finishedGame = JesterGameEvents(startEvent = null, moves = listOf(normalMove, endMove))
|
||||
|
||||
assertFalse(ongoingGame.isEnded())
|
||||
assertTrue(finishedGame.isEnded())
|
||||
assertEquals("1-0", finishedGame.result())
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// EXTENSION FUNCTION TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `isJesterEvent extension detects kind 30`() {
|
||||
val jesterEvent =
|
||||
JesterEvent(
|
||||
id = "test",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "sig",
|
||||
)
|
||||
|
||||
assertTrue(jesterEvent.isJesterEvent())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toJesterEvent converts valid Event`() {
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "test",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = """{"kind":0}""",
|
||||
sig = "sig",
|
||||
)
|
||||
|
||||
val jesterEvent = event.toJesterEvent()
|
||||
assertNotNull(jesterEvent)
|
||||
assertEquals("test", jesterEvent.id)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ==========================================================================
|
||||
|
||||
private fun createTestMoveEvent(
|
||||
id: String,
|
||||
history: List<String>,
|
||||
fen: String = "test-fen",
|
||||
): JesterEvent {
|
||||
val historyJson = history.joinToString(",") { "\"$it\"" }
|
||||
val content = """{"version":"0","kind":1,"fen":"$fen","move":"${history.last()}","history":[$historyJson]}"""
|
||||
return JesterEvent(
|
||||
id = id,
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L + history.size * 1000,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", "prev-move"),
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "sig",
|
||||
)
|
||||
}
|
||||
|
||||
private fun createTestMoveEventWithResult(
|
||||
id: String,
|
||||
history: List<String>,
|
||||
result: String,
|
||||
): JesterEvent {
|
||||
val historyJson = history.joinToString(",") { "\"$it\"" }
|
||||
val content = """{"version":"0","kind":1,"fen":"test-fen","move":"${history.last()}","history":[$historyJson],"result":"$result","termination":"checkmate"}"""
|
||||
return JesterEvent(
|
||||
id = id,
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L + history.size * 1000,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", "prev-move"),
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "sig",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Comprehensive tests for PGN parsing per NIP-64 specification
|
||||
*
|
||||
* Tests cover:
|
||||
* - PGN metadata extraction
|
||||
* - Move parsing in Standard Algebraic Notation
|
||||
* - Game result parsing
|
||||
* - Comments and variations handling
|
||||
* - Edge cases and error handling
|
||||
*/
|
||||
class PGNParserTest {
|
||||
// Test 1: Parse minimal PGN (NIP-64 requirement: accept import format)
|
||||
@Test
|
||||
fun `parse minimal PGN with single move`() {
|
||||
val pgn = "1. e4 *"
|
||||
val result = PGNParser.parse(pgn)
|
||||
|
||||
assertTrue(result.isSuccess, "Should successfully parse minimal PGN")
|
||||
val game = result.getOrThrow()
|
||||
assertEquals(1, game.moves.size, "Should have 1 move")
|
||||
assertEquals("e4", game.moves[0].san)
|
||||
assertEquals(GameResult.IN_PROGRESS, game.result)
|
||||
}
|
||||
|
||||
// Test 2: Parse complete game with metadata (NIP-64 requirement)
|
||||
@Test
|
||||
fun `parse PGN with full metadata tags`() {
|
||||
val pgn =
|
||||
"""
|
||||
[Event "F/S Return Match"]
|
||||
[Site "Belgrade, Serbia JUG"]
|
||||
[Date "1992.11.04"]
|
||||
[Round "29"]
|
||||
[White "Fischer, Robert J."]
|
||||
[Black "Spassky, Boris V."]
|
||||
[Result "1/2-1/2"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 1/2-1/2
|
||||
""".trimIndent()
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
// Verify metadata extraction
|
||||
assertEquals("F/S Return Match", game.event)
|
||||
assertEquals("Belgrade, Serbia JUG", game.site)
|
||||
assertEquals("1992.11.04", game.date)
|
||||
assertEquals("29", game.round)
|
||||
assertEquals("Fischer, Robert J.", game.white)
|
||||
assertEquals("Spassky, Boris V.", game.black)
|
||||
assertEquals(GameResult.DRAW, game.result)
|
||||
|
||||
// Verify moves
|
||||
assertEquals(6, game.moves.size)
|
||||
assertEquals("e4", game.moves[0].san)
|
||||
assertEquals("Nf3", game.moves[2].san)
|
||||
}
|
||||
|
||||
// Test 3: Scholar's Mate (4 move checkmate)
|
||||
@Test
|
||||
fun `parse scholars mate with checkmate notation`() {
|
||||
val pgn =
|
||||
"""
|
||||
[Event "Scholar's Mate"]
|
||||
[White "Alice"]
|
||||
[Black "Bob"]
|
||||
[Result "1-0"]
|
||||
|
||||
1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6 4. Qxf7# 1-0
|
||||
""".trimIndent()
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
assertEquals(7, game.moves.size)
|
||||
assertEquals(GameResult.WHITE_WINS, game.result)
|
||||
|
||||
// Verify last move has checkmate marker
|
||||
val lastMove = game.moves.last()
|
||||
assertEquals("Qxf7#", lastMove.san)
|
||||
assertTrue(lastMove.isCheckmate, "Last move should be checkmate")
|
||||
assertTrue(lastMove.isCapture, "Last move should be capture")
|
||||
}
|
||||
|
||||
// Test 4: Fool's Mate (2 move checkmate)
|
||||
@Test
|
||||
fun `parse fools mate shortest checkmate`() {
|
||||
val pgn =
|
||||
"""
|
||||
1. f3 e5 2. g4 Qh4# 0-1
|
||||
""".trimIndent()
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
assertEquals(4, game.moves.size)
|
||||
assertEquals(GameResult.BLACK_WINS, game.result)
|
||||
|
||||
val lastMove = game.moves.last()
|
||||
assertEquals("Qh4#", lastMove.san)
|
||||
assertTrue(lastMove.isCheckmate)
|
||||
}
|
||||
|
||||
// Test 5: Castling notation
|
||||
@Test
|
||||
fun `parse castling moves kingside and queenside`() {
|
||||
val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O O-O 5. d3 d6 6. c3 a6 7. a4 a5 8. O-O-O *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
// Find castling moves
|
||||
val kingsideCastling = game.moves.filter { it.san == "O-O" }
|
||||
val queensideCastling = game.moves.filter { it.san == "O-O-O" }
|
||||
|
||||
assertEquals(2, kingsideCastling.size, "Should have 2 kingside castling moves")
|
||||
assertEquals(1, queensideCastling.size, "Should have 1 queenside castling move")
|
||||
|
||||
kingsideCastling.forEach { move ->
|
||||
assertTrue(move.isCastling, "O-O should be marked as castling")
|
||||
assertEquals(PieceType.KING, move.piece)
|
||||
}
|
||||
|
||||
queensideCastling.forEach { move ->
|
||||
assertTrue(move.isCastling, "O-O-O should be marked as castling")
|
||||
assertEquals(PieceType.KING, move.piece)
|
||||
}
|
||||
}
|
||||
|
||||
// Test 6: Pawn promotion
|
||||
@Test
|
||||
fun `parse pawn promotion to queen`() {
|
||||
val pgn =
|
||||
"""
|
||||
[Event "Promotion Example"]
|
||||
|
||||
1. e4 d5 2. exd5 Qxd5 3. Nc3 Qa5 4. d4 c6 5. Nf3 Bg4 6. Bf4 e6
|
||||
7. h3 Bxf3 8. Qxf3 Bb4 9. Be2 Nd7 10. a3 O-O-O 11. axb4 Qxa1+
|
||||
12. Kd2 Qxh1 13. Qxh1 a6 14. c4 f6 15. b5 axb5 16. cxb5 c5
|
||||
17. b6 Ne7 18. Qh2 h6 19. dxc5 Nxc5 20. b3 Kc8 21. Qg3 Ncd7
|
||||
22. Bd6 Nf5 23. Qf4 Nxd6 24. Qxd6 Nb8 25. Kc3 Rh7 26. Kb4 Rd7
|
||||
27. Qc5+ Kd8 28. Bf3 Ke8 29. Bd5 exd5 30. Nxd5 Kf7 31. b7 Kg6
|
||||
32. b8=Q *
|
||||
""".trimIndent()
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
// Find promotion move
|
||||
val promotionMove = game.moves.firstOrNull { it.promotion != null }
|
||||
assertNotNull(promotionMove, "Should have promotion move")
|
||||
assertEquals(PieceType.QUEEN, promotionMove.promotion)
|
||||
assertTrue(promotionMove.san.contains("=Q"), "Promotion move should contain =Q")
|
||||
}
|
||||
|
||||
// Test 7: Captures
|
||||
@Test
|
||||
fun `parse capture notation`() {
|
||||
val pgn = "1. e4 d5 2. exd5 Qxd5 3. Nc3 Qxd4 *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
val captures = game.moves.filter { it.isCapture }
|
||||
assertEquals(3, captures.size, "Should have 3 capture moves")
|
||||
|
||||
captures.forEach { move ->
|
||||
assertTrue(move.san.contains("x"), "Capture moves should contain 'x'")
|
||||
}
|
||||
}
|
||||
|
||||
// Test 8: Check notation
|
||||
@Test
|
||||
fun `parse check and checkmate markers`() {
|
||||
val pgn = "1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
val checkMoves = game.moves.filter { it.isCheck }
|
||||
val checkmateMoves = game.moves.filter { it.isCheckmate }
|
||||
|
||||
assertTrue(checkmateMoves.isNotEmpty(), "Should have checkmate moves")
|
||||
checkmateMoves.forEach { move ->
|
||||
assertTrue(move.san.contains("#"), "Checkmate moves should contain #")
|
||||
}
|
||||
}
|
||||
|
||||
// Test 9: Comments and variations (NIP-64: should handle PGN comments)
|
||||
@Test
|
||||
fun `parse PGN with comments and variations stripped`() {
|
||||
val pgn =
|
||||
"""
|
||||
1. e4 {Best by test} e5 (1...c5 2. Nf3) 2. Nf3 Nc6 {Developing} 3. Bb5 *
|
||||
""".trimIndent()
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
// Comments and variations should be stripped
|
||||
assertEquals(5, game.moves.size)
|
||||
assertEquals("e4", game.moves[0].san)
|
||||
assertEquals("e5", game.moves[1].san)
|
||||
assertEquals("Nf3", game.moves[2].san)
|
||||
}
|
||||
|
||||
// Test 10: NAG annotations (Numeric Annotation Glyphs)
|
||||
@Test
|
||||
fun `parse PGN with NAG annotations`() {
|
||||
val pgn = "1. e4$1 e5$6 2. Nf3$10 *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
assertEquals(3, game.moves.size)
|
||||
}
|
||||
|
||||
// Test 11: Disambiguating moves
|
||||
@Test
|
||||
fun `parse moves with disambiguation`() {
|
||||
val pgn =
|
||||
"""
|
||||
1. Nf3 Nf6 2. Nc3 Nc6 3. d4 d5 4. Bf4 Bf5 5. e3 e6
|
||||
6. Nbd2 Nbd7 7. Bd3 Bd6 *
|
||||
""".trimIndent()
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
// Check for disambiguated moves (Nbd2, Nbd7)
|
||||
val disambiguatedMoves = game.moves.filter { it.fromSquare != null }
|
||||
assertTrue(disambiguatedMoves.isNotEmpty(), "Should have disambiguated moves")
|
||||
}
|
||||
|
||||
// Test 12: All possible game results
|
||||
@Test
|
||||
fun `parse all game result notations`() {
|
||||
val whiteWins = "1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0"
|
||||
val blackWins = "1. f3 e5 2. g4 Qh4# 0-1"
|
||||
val draw = "1. e4 e5 2. Nf3 Nc6 1/2-1/2"
|
||||
val inProgress = "1. e4 e5 *"
|
||||
|
||||
assertEquals(GameResult.WHITE_WINS, PGNParser.parse(whiteWins).getOrThrow().result)
|
||||
assertEquals(GameResult.BLACK_WINS, PGNParser.parse(blackWins).getOrThrow().result)
|
||||
assertEquals(GameResult.DRAW, PGNParser.parse(draw).getOrThrow().result)
|
||||
assertEquals(GameResult.IN_PROGRESS, PGNParser.parse(inProgress).getOrThrow().result)
|
||||
}
|
||||
|
||||
// Test 13: Empty/invalid PGN handling
|
||||
@Test
|
||||
fun `handle empty PGN gracefully`() {
|
||||
val emptyPgn = ""
|
||||
val result = PGNParser.parse(emptyPgn)
|
||||
|
||||
// Should not crash, might return empty game
|
||||
assertTrue(result.isSuccess || result.isFailure)
|
||||
}
|
||||
|
||||
// Test 14: Position generation
|
||||
@Test
|
||||
fun `generate positions for each move`() {
|
||||
val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
// Should have starting position + one per move
|
||||
assertEquals(game.moves.size + 1, game.positions.size)
|
||||
|
||||
// First position should be starting position
|
||||
val startPos = game.positions[0]
|
||||
assertEquals(Color.WHITE, startPos.activeColor)
|
||||
assertEquals(1, startPos.moveNumber)
|
||||
}
|
||||
|
||||
// Test 15: Verify required metadata (NIP-64: PGN should have standard tags)
|
||||
@Test
|
||||
fun `detect presence of required PGN metadata tags`() {
|
||||
val fullPgn =
|
||||
"""
|
||||
[Event "FIDE World Championship"]
|
||||
[Site "London"]
|
||||
[Date "2018.11.28"]
|
||||
[Round "12"]
|
||||
[White "Carlsen, Magnus"]
|
||||
[Black "Caruana, Fabiano"]
|
||||
[Result "1-0"]
|
||||
|
||||
1. e4 *
|
||||
""".trimIndent()
|
||||
|
||||
val minimalPgn = "1. e4 *"
|
||||
|
||||
val fullGame = PGNParser.parse(fullPgn).getOrThrow()
|
||||
val minimalGame = PGNParser.parse(minimalPgn).getOrThrow()
|
||||
|
||||
assertTrue(fullGame.hasRequiredMetadata(), "Full PGN should have required metadata")
|
||||
assertFalse(minimalGame.hasRequiredMetadata(), "Minimal PGN should not have required metadata")
|
||||
}
|
||||
|
||||
// Test 16: Long tournament game
|
||||
@Test
|
||||
fun `parse realistic tournament game`() {
|
||||
val pgn =
|
||||
"""
|
||||
[Event "Wch"]
|
||||
[Site "New York"]
|
||||
[Date "1886.??.??"]
|
||||
[Round "1"]
|
||||
[White "Zukertort, Johannes"]
|
||||
[Black "Steinitz, William"]
|
||||
[Result "0-1"]
|
||||
|
||||
1. d4 d5 2. c4 e6 3. Nc3 Nf6 4. e3 c5 5. Nf3 Nc6 6. a3 dxc4
|
||||
7. Bxc4 cxd4 8. exd4 Be7 9. O-O O-O 10. Qd3 Bd7 11. Qe2 Qb8
|
||||
12. Rd1 Rd8 13. Be3 Be8 14. Ne5 Nxe5 15. dxe5 Rxd1+ 16. Rxd1 Nd7
|
||||
17. f4 Nc5 18. Qf2 Rc8 19. b4 Na6 20. Bd3 Nb8 21. Ne4 Nc6
|
||||
22. Nd6 Bxd6 23. exd6 Qxd6 24. Bxh7+ Kh8 25. Bf5 Qc7 26. Bxc8 Qxc8
|
||||
27. Qd2 Bg6 28. Qd7 Qxd7 29. Rxd7 b6 30. Bc1 Nd8 31. Rxd8+ 0-1
|
||||
""".trimIndent()
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
assertEquals("Zukertort, Johannes", game.white)
|
||||
assertEquals("Steinitz, William", game.black)
|
||||
assertEquals(GameResult.BLACK_WINS, game.result)
|
||||
assertTrue(game.moves.size > 50, "Tournament game should have many moves")
|
||||
}
|
||||
|
||||
// Test 17: Alternative castling notation (0-0 instead of O-O)
|
||||
@Test
|
||||
fun `parse alternative castling notation with zeros`() {
|
||||
val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. 0-0 0-0 *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
val castlingMoves = game.moves.filter { it.isCastling }
|
||||
assertEquals(2, castlingMoves.size)
|
||||
}
|
||||
|
||||
// Test 18: Move count verification
|
||||
@Test
|
||||
fun `verify move numbers are correct`() {
|
||||
val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
assertEquals(6, game.moves.size)
|
||||
|
||||
// Verify move numbers
|
||||
assertEquals(1, game.moves[0].moveNumber) // e4
|
||||
assertEquals(1, game.moves[1].moveNumber) // e5
|
||||
assertEquals(2, game.moves[2].moveNumber) // Nf3
|
||||
assertEquals(2, game.moves[3].moveNumber) // Nc6
|
||||
assertEquals(3, game.moves[4].moveNumber) // Bb5
|
||||
assertEquals(3, game.moves[5].moveNumber) // a6
|
||||
}
|
||||
|
||||
// Test 19: Move colors are correct
|
||||
@Test
|
||||
fun `verify move colors alternate correctly`() {
|
||||
val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
assertEquals(Color.WHITE, game.moves[0].color)
|
||||
assertEquals(Color.BLACK, game.moves[1].color)
|
||||
assertEquals(Color.WHITE, game.moves[2].color)
|
||||
assertEquals(Color.BLACK, game.moves[3].color)
|
||||
assertEquals(Color.WHITE, game.moves[4].color)
|
||||
assertEquals(Color.BLACK, game.moves[5].color)
|
||||
}
|
||||
|
||||
// Test 20: Piece type detection
|
||||
@Test
|
||||
fun `detect piece types from SAN notation`() {
|
||||
val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 Bc5 4. Qa4 Qf6 5. Ke2 Ke7 6. Ra3 Ra6 *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
// e4, e5 - pawns
|
||||
assertEquals(PieceType.PAWN, game.moves[0].piece)
|
||||
assertEquals(PieceType.PAWN, game.moves[1].piece)
|
||||
|
||||
// Nf3, Nc6 - knights
|
||||
assertEquals(PieceType.KNIGHT, game.moves[2].piece)
|
||||
assertEquals(PieceType.KNIGHT, game.moves[3].piece)
|
||||
|
||||
// Bb5, Bc5 - bishops
|
||||
assertEquals(PieceType.BISHOP, game.moves[4].piece)
|
||||
assertEquals(PieceType.BISHOP, game.moves[5].piece)
|
||||
|
||||
// Qa4, Qf6 - queens
|
||||
assertEquals(PieceType.QUEEN, game.moves[6].piece)
|
||||
assertEquals(PieceType.QUEEN, game.moves[7].piece)
|
||||
|
||||
// Ke2, Ke7 - kings
|
||||
assertEquals(PieceType.KING, game.moves[8].piece)
|
||||
assertEquals(PieceType.KING, game.moves[9].piece)
|
||||
|
||||
// Ra3, Ra6 - rooks
|
||||
assertEquals(PieceType.ROOK, game.moves[10].piece)
|
||||
assertEquals(PieceType.ROOK, game.moves[11].piece)
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,28 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.utils
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.DeterministicSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventAssembler
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
||||
|
||||
fun String.nsecToKeyPair() = KeyPair(this.bechToBytes())
|
||||
|
||||
fun String.nsecToSigner() = this.nsecToKeyPair().let { DeterministicSigner(it) }
|
||||
|
||||
class DeterministicSigner(
|
||||
val key: KeyPair,
|
||||
val pubKey: HexKey = key.pubKey.toHexKey(),
|
||||
) {
|
||||
fun <T : Event> sign(
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
): T = EventAssembler.hashAndSign(pubKey, createdAt, kind, tags, content, key.privKey!!, nonce = null)
|
||||
|
||||
fun <T : Event> sign(ev: EventTemplate<T>): T = sign(ev.createdAt, ev.kind, ev.tags, ev.content)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
actual class ChessEngine actual constructor() {
|
||||
actual fun getFen(): String {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
actual fun loadFen(fen: String) {
|
||||
}
|
||||
|
||||
actual fun reset() {
|
||||
}
|
||||
|
||||
actual fun makeMove(san: String): MoveResult {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
actual fun makeMove(
|
||||
from: String,
|
||||
to: String,
|
||||
promotion: PieceType?,
|
||||
): MoveResult {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
actual fun getLegalMoves(): List<String> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
actual fun getLegalMovesFrom(square: String): List<String> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
actual fun isLegalMove(san: String): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
actual fun isLegalMove(
|
||||
from: String,
|
||||
to: String,
|
||||
promotion: PieceType?,
|
||||
): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
actual fun isCheckmate(): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
actual fun isStalemate(): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
actual fun isInCheck(): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
actual fun undoMove() {
|
||||
}
|
||||
|
||||
actual fun getPosition(): ChessPosition {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
actual fun getSideToMove(): Color {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
actual fun getMoveHistory(): List<String> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
@@ -32,11 +32,22 @@ import platform.darwin.NSObjectMeta
|
||||
actual class TestResourceLoader {
|
||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||
actual fun loadString(file: String): String {
|
||||
val bundle = NSBundle.Companion.bundleForClass(BundleMarker)
|
||||
// Split the filename and extension (e.g., "data.json" -> "data", "json")
|
||||
val basename = file.substringBeforeLast(".")
|
||||
val extension = file.substringAfterLast(".", "")
|
||||
|
||||
// Locate the file in the main application bundle
|
||||
val path =
|
||||
bundle.pathForResource(file, ofType = null)
|
||||
NSBundle.mainBundle.pathForResource(basename, ofType = extension)
|
||||
?: throw IllegalArgumentException("Resource not found: $file")
|
||||
return NSString.Companion.stringWithContentsOfFile(path, encoding = NSUTF8StringEncoding, error = null)!!
|
||||
|
||||
// Read the file content as a UTF-8 string
|
||||
return NSString
|
||||
.stringWithContentsOfFile(
|
||||
path = path,
|
||||
encoding = NSUTF8StringEncoding,
|
||||
error = null,
|
||||
).toString()
|
||||
}
|
||||
|
||||
private class BundleMarker : NSObject() {
|
||||
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
/*
|
||||
* 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 com.github.bhlangonijr.chesslib.Board
|
||||
import com.github.bhlangonijr.chesslib.Piece
|
||||
import com.github.bhlangonijr.chesslib.Side
|
||||
import com.github.bhlangonijr.chesslib.Square
|
||||
import com.github.bhlangonijr.chesslib.move.Move
|
||||
|
||||
/**
|
||||
* JVM/Android implementation of ChessEngine using kchesslib.
|
||||
*
|
||||
* Maintains its own SAN history because chesslib's Move.toString() returns
|
||||
* UCI/coordinate notation (e.g. "e2e4") rather than proper SAN (e.g. "e4").
|
||||
*/
|
||||
actual class ChessEngine {
|
||||
private val board = Board()
|
||||
private val sanHistory = mutableListOf<String>()
|
||||
|
||||
actual fun getFen(): String = board.fen
|
||||
|
||||
actual fun loadFen(fen: String) {
|
||||
board.loadFromFen(fen)
|
||||
sanHistory.clear()
|
||||
}
|
||||
|
||||
actual fun reset() {
|
||||
board.loadFromFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
||||
sanHistory.clear()
|
||||
}
|
||||
|
||||
actual fun makeMove(san: String): MoveResult =
|
||||
try {
|
||||
if (board.doMove(san)) {
|
||||
// Input is already SAN from Nostr events, store directly
|
||||
sanHistory.add(san)
|
||||
MoveResult(
|
||||
success = true,
|
||||
san = san,
|
||||
position = boardToPosition(),
|
||||
)
|
||||
} else {
|
||||
MoveResult(
|
||||
success = false,
|
||||
error = "Invalid move: $san",
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
MoveResult(
|
||||
success = false,
|
||||
error = e.message ?: "Unknown error making move $san",
|
||||
)
|
||||
}
|
||||
|
||||
actual fun makeMove(
|
||||
from: String,
|
||||
to: String,
|
||||
promotion: PieceType?,
|
||||
): MoveResult =
|
||||
try {
|
||||
val fromSquare = Square.fromValue(from.uppercase())
|
||||
val toSquare = Square.fromValue(to.uppercase())
|
||||
|
||||
val promotionPiece =
|
||||
when (promotion) {
|
||||
PieceType.QUEEN -> if (board.sideToMove == Side.WHITE) Piece.WHITE_QUEEN else Piece.BLACK_QUEEN
|
||||
PieceType.ROOK -> if (board.sideToMove == Side.WHITE) Piece.WHITE_ROOK else Piece.BLACK_ROOK
|
||||
PieceType.BISHOP -> if (board.sideToMove == Side.WHITE) Piece.WHITE_BISHOP else Piece.BLACK_BISHOP
|
||||
PieceType.KNIGHT -> if (board.sideToMove == Side.WHITE) Piece.WHITE_KNIGHT else Piece.BLACK_KNIGHT
|
||||
else -> Piece.NONE
|
||||
}
|
||||
|
||||
val move = Move(fromSquare, toSquare, promotionPiece)
|
||||
|
||||
if (board.legalMoves().contains(move)) {
|
||||
val san = computeSan(move)
|
||||
board.doMove(move)
|
||||
sanHistory.add(san)
|
||||
MoveResult(
|
||||
success = true,
|
||||
san = san,
|
||||
position = boardToPosition(),
|
||||
)
|
||||
} else {
|
||||
MoveResult(
|
||||
success = false,
|
||||
error = "Illegal move: $from to $to",
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
MoveResult(
|
||||
success = false,
|
||||
error = e.message ?: "Unknown error making move $from to $to",
|
||||
)
|
||||
}
|
||||
|
||||
actual fun getLegalMoves(): List<String> = board.legalMoves().map { it.toString() }
|
||||
|
||||
actual fun getLegalMovesFrom(square: String): List<String> {
|
||||
val sq = Square.fromValue(square.uppercase())
|
||||
return board
|
||||
.legalMoves()
|
||||
.filterNotNull()
|
||||
.filter { it.from == sq }
|
||||
.map { it.to.toString().lowercase() }
|
||||
}
|
||||
|
||||
actual fun isLegalMove(san: String): Boolean =
|
||||
try {
|
||||
if (board.doMove(san)) {
|
||||
board.undoMove()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
actual fun isLegalMove(
|
||||
from: String,
|
||||
to: String,
|
||||
promotion: PieceType?,
|
||||
): Boolean =
|
||||
try {
|
||||
val fromSquare = Square.fromValue(from.uppercase())
|
||||
val toSquare = Square.fromValue(to.uppercase())
|
||||
|
||||
val promotionPiece =
|
||||
when (promotion) {
|
||||
PieceType.QUEEN -> if (board.sideToMove == Side.WHITE) Piece.WHITE_QUEEN else Piece.BLACK_QUEEN
|
||||
PieceType.ROOK -> if (board.sideToMove == Side.WHITE) Piece.WHITE_ROOK else Piece.BLACK_ROOK
|
||||
PieceType.BISHOP -> if (board.sideToMove == Side.WHITE) Piece.WHITE_BISHOP else Piece.BLACK_BISHOP
|
||||
PieceType.KNIGHT -> if (board.sideToMove == Side.WHITE) Piece.WHITE_KNIGHT else Piece.BLACK_KNIGHT
|
||||
else -> Piece.NONE
|
||||
}
|
||||
|
||||
val move = Move(fromSquare, toSquare, promotionPiece)
|
||||
board.legalMoves().contains(move)
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
actual fun isCheckmate(): Boolean = board.isMated
|
||||
|
||||
actual fun isStalemate(): Boolean = board.isStaleMate
|
||||
|
||||
actual fun isInCheck(): Boolean = board.isKingAttacked
|
||||
|
||||
actual fun undoMove() {
|
||||
board.undoMove()
|
||||
if (sanHistory.isNotEmpty()) {
|
||||
sanHistory.removeAt(sanHistory.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun getPosition(): ChessPosition = boardToPosition()
|
||||
|
||||
actual fun getSideToMove(): Color =
|
||||
when (board.sideToMove) {
|
||||
Side.WHITE -> Color.WHITE
|
||||
Side.BLACK -> Color.BLACK
|
||||
}
|
||||
|
||||
actual fun getMoveHistory(): List<String> = sanHistory.toList()
|
||||
|
||||
/**
|
||||
* Compute proper SAN notation for a move BEFORE it is applied to the board.
|
||||
* Handles pieces, pawns, castling, captures, promotion, disambiguation, check/checkmate.
|
||||
*/
|
||||
private fun computeSan(move: Move): String {
|
||||
val fromSquare = move.from
|
||||
val toSquare = move.to
|
||||
val piece = board.getPiece(fromSquare)
|
||||
val pt = piece.pieceType ?: return move.toString()
|
||||
val promotionPiece = move.promotion ?: Piece.NONE
|
||||
|
||||
// Castling
|
||||
if (pt == com.github.bhlangonijr.chesslib.PieceType.KING) {
|
||||
val fileDiff = toSquare.file.ordinal - fromSquare.file.ordinal
|
||||
if (fileDiff == 2) {
|
||||
val suffix = checkSuffixAfterMove(move)
|
||||
return "O-O$suffix"
|
||||
}
|
||||
if (fileDiff == -2) {
|
||||
val suffix = checkSuffixAfterMove(move)
|
||||
return "O-O-O$suffix"
|
||||
}
|
||||
}
|
||||
|
||||
val sb = StringBuilder()
|
||||
val epTarget = board.enPassantTarget
|
||||
val isCapture =
|
||||
board.getPiece(toSquare) != Piece.NONE ||
|
||||
(
|
||||
pt == com.github.bhlangonijr.chesslib.PieceType.PAWN &&
|
||||
epTarget != null && epTarget != Square.NONE && toSquare == epTarget
|
||||
)
|
||||
|
||||
if (pt != com.github.bhlangonijr.chesslib.PieceType.PAWN) {
|
||||
sb.append(sanSymbol(pt))
|
||||
|
||||
// Disambiguation: check if other pieces of same type can reach the same square
|
||||
val ambiguous =
|
||||
board.legalMoves().filterNotNull().filter {
|
||||
it.to == toSquare &&
|
||||
board.getPiece(it.from).pieceType == pt &&
|
||||
it.from != fromSquare
|
||||
}
|
||||
|
||||
if (ambiguous.isNotEmpty()) {
|
||||
val sameFile = ambiguous.any { it.from.file == fromSquare.file }
|
||||
val sameRank = ambiguous.any { it.from.rank == fromSquare.rank }
|
||||
when {
|
||||
!sameFile -> {
|
||||
sb.append(fileChar(fromSquare))
|
||||
}
|
||||
|
||||
!sameRank -> {
|
||||
sb.append(rankChar(fromSquare))
|
||||
}
|
||||
|
||||
else -> {
|
||||
sb.append(fileChar(fromSquare))
|
||||
sb.append(rankChar(fromSquare))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isCapture) {
|
||||
// Pawn captures include the source file
|
||||
sb.append(fileChar(fromSquare))
|
||||
}
|
||||
|
||||
if (isCapture) sb.append('x')
|
||||
|
||||
sb.append(toSquare.toString().lowercase())
|
||||
|
||||
// Promotion
|
||||
if (promotionPiece != Piece.NONE) {
|
||||
sb.append('=')
|
||||
val promType = promotionPiece.pieceType
|
||||
if (promType != null) sb.append(sanSymbol(promType))
|
||||
}
|
||||
|
||||
// Check/checkmate suffix
|
||||
sb.append(checkSuffixAfterMove(move))
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily apply a move to check for check/checkmate, then undo.
|
||||
*/
|
||||
private fun checkSuffixAfterMove(move: Move): String {
|
||||
board.doMove(move)
|
||||
val suffix =
|
||||
when {
|
||||
board.isMated -> "#"
|
||||
board.isKingAttacked -> "+"
|
||||
else -> ""
|
||||
}
|
||||
board.undoMove()
|
||||
return suffix
|
||||
}
|
||||
|
||||
private fun sanSymbol(pt: com.github.bhlangonijr.chesslib.PieceType): String =
|
||||
when (pt) {
|
||||
com.github.bhlangonijr.chesslib.PieceType.KING -> "K"
|
||||
com.github.bhlangonijr.chesslib.PieceType.QUEEN -> "Q"
|
||||
com.github.bhlangonijr.chesslib.PieceType.ROOK -> "R"
|
||||
com.github.bhlangonijr.chesslib.PieceType.BISHOP -> "B"
|
||||
com.github.bhlangonijr.chesslib.PieceType.KNIGHT -> "N"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun fileChar(square: Square): Char = 'a' + square.file.ordinal
|
||||
|
||||
private fun rankChar(square: Square): Char = '1' + square.rank.ordinal
|
||||
|
||||
/**
|
||||
* Convert kchesslib Board to our ChessPosition model
|
||||
*/
|
||||
private fun boardToPosition(): ChessPosition {
|
||||
val positionArray = Array(8) { Array<ChessPiece?>(8) { null } }
|
||||
|
||||
for (rank in 0..7) {
|
||||
for (file in 0..7) {
|
||||
val square = Square.squareAt(rank * 8 + file)
|
||||
val piece = board.getPiece(square)
|
||||
|
||||
if (piece != Piece.NONE) {
|
||||
val pieceType =
|
||||
when (piece.pieceType) {
|
||||
com.github.bhlangonijr.chesslib.PieceType.KING -> PieceType.KING
|
||||
com.github.bhlangonijr.chesslib.PieceType.QUEEN -> PieceType.QUEEN
|
||||
com.github.bhlangonijr.chesslib.PieceType.ROOK -> PieceType.ROOK
|
||||
com.github.bhlangonijr.chesslib.PieceType.BISHOP -> PieceType.BISHOP
|
||||
com.github.bhlangonijr.chesslib.PieceType.KNIGHT -> PieceType.KNIGHT
|
||||
com.github.bhlangonijr.chesslib.PieceType.PAWN -> PieceType.PAWN
|
||||
else -> PieceType.PAWN
|
||||
}
|
||||
|
||||
val color =
|
||||
when (piece.pieceSide) {
|
||||
Side.WHITE -> Color.WHITE
|
||||
Side.BLACK -> Color.BLACK
|
||||
else -> Color.WHITE
|
||||
}
|
||||
|
||||
positionArray[rank][file] = ChessPiece(pieceType, color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ChessPosition(
|
||||
board = positionArray,
|
||||
activeColor = getSideToMove(),
|
||||
moveNumber = board.moveCounter,
|
||||
castlingRights =
|
||||
CastlingRights(
|
||||
whiteKingSide = board.castleRight.toString().contains("K"),
|
||||
whiteQueenSide = board.castleRight.toString().contains("Q"),
|
||||
blackKingSide = board.castleRight.toString().contains("k"),
|
||||
blackQueenSide = board.castleRight.toString().contains("q"),
|
||||
),
|
||||
enPassantSquare = board.enPassantTarget?.let { it.toString().lowercase() },
|
||||
halfMoveClock = board.halfMoveCounter,
|
||||
)
|
||||
}
|
||||
}
|
||||
+808
@@ -0,0 +1,808 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Integration tests for ChessStateReconstructor with Jester protocol.
|
||||
*
|
||||
* These tests verify that the reconstruction algorithm produces correct,
|
||||
* deterministic game states from Jester events (kind 30).
|
||||
*
|
||||
* Jester Protocol:
|
||||
* - Single event kind (30) for all chess messages
|
||||
* - Content is JSON with: version, kind, fen, move, history
|
||||
* - Start events have content.kind=0 and reference START_POSITION_HASH via e-tag
|
||||
* - Move events have content.kind=1 and reference [startEventId, headEventId] via e-tags
|
||||
* - Full move history is included in every move event
|
||||
* - No separate accept event - acceptance is implicit when opponent makes first move
|
||||
*
|
||||
* Test Categories:
|
||||
* 1. Basic game lifecycle (challenge, moves, end)
|
||||
* 2. Move ordering and reconstruction from history
|
||||
* 3. Game end conditions (checkmate, resignation)
|
||||
* 4. Viewer perspective (white player, black player, spectator)
|
||||
* 5. Determinism tests
|
||||
*/
|
||||
class ChessStateReconstructorTest {
|
||||
// Test pubkeys
|
||||
private val whitePubkey = "white_pubkey_abc123"
|
||||
private val blackPubkey = "black_pubkey_def456"
|
||||
private val spectatorPubkey = "spectator_pubkey_xyz789"
|
||||
private val startEventId = "start-event-001"
|
||||
|
||||
// ==========================================================================
|
||||
// 1. BASIC GAME LIFECYCLE TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `reconstruct pending challenge - no moves yet`() {
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
|
||||
moves = emptyList(),
|
||||
)
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
assertTrue(result.isSuccess(), "Should reconstruct pending challenge")
|
||||
|
||||
val state = result.getOrNull()!!
|
||||
assertTrue(state.isPendingChallenge, "Game should be pending")
|
||||
assertEquals(startEventId, state.startEventId)
|
||||
assertEquals(whitePubkey, state.whitePubkey)
|
||||
assertEquals(ViewerRole.WHITE_PLAYER, state.viewerRole)
|
||||
assertEquals(Color.WHITE, state.playerColor)
|
||||
assertTrue(state.moveHistory.isEmpty(), "No moves yet")
|
||||
assertEquals(GameStatus.InProgress, state.gameStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconstruct game with initial moves - e4 e5`() {
|
||||
val move1 =
|
||||
createMoveEvent(
|
||||
id = "move-001",
|
||||
pubKey = whitePubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = startEventId,
|
||||
move = "e4",
|
||||
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
|
||||
history = listOf("e4"),
|
||||
opponentPubkey = blackPubkey,
|
||||
)
|
||||
val move2 =
|
||||
createMoveEvent(
|
||||
id = "move-002",
|
||||
pubKey = blackPubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = "move-001",
|
||||
move = "e5",
|
||||
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2",
|
||||
history = listOf("e4", "e5"),
|
||||
opponentPubkey = whitePubkey,
|
||||
)
|
||||
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
|
||||
moves = listOf(move1, move2),
|
||||
)
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
assertTrue(result.isSuccess())
|
||||
|
||||
val state = result.getOrNull()!!
|
||||
assertEquals(2, state.moveHistory.size)
|
||||
assertEquals("e4", state.moveHistory[0])
|
||||
assertEquals("e5", state.moveHistory[1])
|
||||
assertTrue(state.isPlayerTurn(), "White's turn after e4 e5")
|
||||
assertEquals(Color.WHITE, state.currentPosition.activeColor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconstruct game - acceptance is implicit via first opponent move`() {
|
||||
// In Jester, there's no separate accept event
|
||||
// Game is considered accepted when opponent makes their first move
|
||||
val move1 =
|
||||
createMoveEvent(
|
||||
id = "move-001",
|
||||
pubKey = whitePubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = startEventId,
|
||||
move = "e4",
|
||||
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
|
||||
history = listOf("e4"),
|
||||
opponentPubkey = blackPubkey,
|
||||
)
|
||||
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
|
||||
moves = listOf(move1),
|
||||
)
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
assertTrue(result.isSuccess())
|
||||
|
||||
val state = result.getOrNull()!!
|
||||
assertFalse(state.isPendingChallenge, "Game is active with moves")
|
||||
assertEquals(blackPubkey, state.blackPubkey)
|
||||
assertEquals(Color.BLACK, state.currentPosition.activeColor, "Black's turn after e4")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 2. MOVE RECONSTRUCTION FROM HISTORY
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `reconstruct from latest move with full history`() {
|
||||
// In Jester, each move contains the full history
|
||||
// Reconstruction uses the move with the longest history
|
||||
val move1 =
|
||||
createMoveEvent(
|
||||
id = "move-001",
|
||||
pubKey = whitePubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = startEventId,
|
||||
move = "e4",
|
||||
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
|
||||
history = listOf("e4"),
|
||||
opponentPubkey = blackPubkey,
|
||||
createdAt = 1000,
|
||||
)
|
||||
val move2 =
|
||||
createMoveEvent(
|
||||
id = "move-002",
|
||||
pubKey = blackPubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = "move-001",
|
||||
move = "e5",
|
||||
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2",
|
||||
history = listOf("e4", "e5"),
|
||||
opponentPubkey = whitePubkey,
|
||||
createdAt = 2000,
|
||||
)
|
||||
val move3 =
|
||||
createMoveEvent(
|
||||
id = "move-003",
|
||||
pubKey = whitePubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = "move-002",
|
||||
move = "Nf3",
|
||||
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2",
|
||||
history = listOf("e4", "e5", "Nf3"),
|
||||
opponentPubkey = blackPubkey,
|
||||
createdAt = 3000,
|
||||
)
|
||||
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
|
||||
moves = listOf(move1, move2, move3),
|
||||
)
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
assertTrue(result.isSuccess())
|
||||
|
||||
val state = result.getOrNull()!!
|
||||
assertEquals(3, state.moveHistory.size)
|
||||
assertEquals(listOf("e4", "e5", "Nf3"), state.moveHistory)
|
||||
assertEquals("move-003", state.headEventId, "Head should be latest move")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconstruct with out-of-order moves - uses longest history`() {
|
||||
// Events might arrive out of order, but we use the longest history
|
||||
val move1 =
|
||||
createMoveEvent(
|
||||
id = "move-001",
|
||||
pubKey = whitePubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = startEventId,
|
||||
move = "e4",
|
||||
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
|
||||
history = listOf("e4"),
|
||||
opponentPubkey = blackPubkey,
|
||||
)
|
||||
val move2 =
|
||||
createMoveEvent(
|
||||
id = "move-002",
|
||||
pubKey = blackPubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = "move-001",
|
||||
move = "e5",
|
||||
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2",
|
||||
history = listOf("e4", "e5"),
|
||||
opponentPubkey = whitePubkey,
|
||||
)
|
||||
|
||||
// Events arrive in reverse order
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
|
||||
moves = listOf(move2, move1), // Reverse order
|
||||
)
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
assertTrue(result.isSuccess())
|
||||
|
||||
val state = result.getOrNull()!!
|
||||
assertEquals(2, state.moveHistory.size)
|
||||
assertEquals("e4", state.moveHistory[0], "First move should be e4")
|
||||
assertEquals("e5", state.moveHistory[1], "Second move should be e5")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconstruct with duplicate moves - uses longest history`() {
|
||||
val move1 =
|
||||
createMoveEvent(
|
||||
id = "move-001",
|
||||
pubKey = whitePubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = startEventId,
|
||||
move = "e4",
|
||||
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
|
||||
history = listOf("e4"),
|
||||
opponentPubkey = blackPubkey,
|
||||
)
|
||||
val move1Duplicate =
|
||||
createMoveEvent(
|
||||
id = "move-001-dup",
|
||||
pubKey = whitePubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = startEventId,
|
||||
move = "e4",
|
||||
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
|
||||
history = listOf("e4"),
|
||||
opponentPubkey = blackPubkey,
|
||||
)
|
||||
val move2 =
|
||||
createMoveEvent(
|
||||
id = "move-002",
|
||||
pubKey = blackPubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = "move-001",
|
||||
move = "e5",
|
||||
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2",
|
||||
history = listOf("e4", "e5"),
|
||||
opponentPubkey = whitePubkey,
|
||||
)
|
||||
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
|
||||
moves = listOf(move1, move1Duplicate, move2),
|
||||
)
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
assertTrue(result.isSuccess())
|
||||
|
||||
val state = result.getOrNull()!!
|
||||
// Move2 has the longest history (2 moves), so that's what we reconstruct
|
||||
assertEquals(2, state.moveHistory.size, "Should have 2 moves from longest history")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 3. GAME END CONDITIONS TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `reconstruct scholar's mate - checkmate by engine detection`() {
|
||||
// Scholar's mate: 1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7#
|
||||
val finalMove =
|
||||
createMoveEvent(
|
||||
id = "move-007",
|
||||
pubKey = whitePubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = "move-006",
|
||||
move = "Qxf7#",
|
||||
fen = "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4",
|
||||
history = listOf("e4", "e5", "Qh5", "Nc6", "Bc4", "Nf6", "Qxf7#"),
|
||||
opponentPubkey = blackPubkey,
|
||||
)
|
||||
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
|
||||
moves = listOf(finalMove), // Only need latest move with full history
|
||||
)
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
assertTrue(result.isSuccess())
|
||||
|
||||
val state = result.getOrNull()!!
|
||||
assertEquals(7, state.moveHistory.size)
|
||||
|
||||
val engine = result.getEngineOrNull()!!
|
||||
assertTrue(engine.isCheckmate(), "Engine should detect checkmate")
|
||||
|
||||
assertTrue(state.gameStatus is GameStatus.Finished)
|
||||
assertEquals(
|
||||
GameResult.WHITE_WINS,
|
||||
state.gameStatus.result,
|
||||
"White wins by checkmate",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconstruct game with resignation - result in move content`() {
|
||||
// In Jester, resignation is indicated by result field in move content
|
||||
val resignationMove =
|
||||
createMoveEventWithResult(
|
||||
id = "move-002",
|
||||
pubKey = blackPubkey, // Black resigns
|
||||
startEventId = startEventId,
|
||||
headEventId = "move-001",
|
||||
move = "e5",
|
||||
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2",
|
||||
history = listOf("e4", "e5"),
|
||||
opponentPubkey = whitePubkey,
|
||||
result = "1-0", // White wins by resignation
|
||||
termination = "resignation",
|
||||
)
|
||||
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
|
||||
moves = listOf(resignationMove),
|
||||
)
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
assertTrue(result.isSuccess())
|
||||
|
||||
val state = result.getOrNull()!!
|
||||
assertTrue(state.gameStatus is GameStatus.Finished)
|
||||
assertEquals(
|
||||
GameResult.WHITE_WINS,
|
||||
state.gameStatus.result,
|
||||
"White wins by resignation",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconstruct fool's mate - 4 move checkmate for black`() {
|
||||
// 1. f3 e5 2. g4 Qh4#
|
||||
val finalMove =
|
||||
createMoveEvent(
|
||||
id = "move-004",
|
||||
pubKey = blackPubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = "move-003",
|
||||
move = "Qh4#",
|
||||
fen = "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3",
|
||||
history = listOf("f3", "e5", "g4", "Qh4#"),
|
||||
opponentPubkey = whitePubkey,
|
||||
)
|
||||
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
|
||||
moves = listOf(finalMove),
|
||||
)
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
assertTrue(result.isSuccess())
|
||||
|
||||
val state = result.getOrNull()!!
|
||||
assertTrue(state.gameStatus is GameStatus.Finished)
|
||||
assertEquals(
|
||||
GameResult.BLACK_WINS,
|
||||
state.gameStatus.result,
|
||||
"Black wins by fool's mate",
|
||||
)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 4. VIEWER PERSPECTIVE TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `reconstruct as white player - correct perspective`() {
|
||||
val events = createBasicGameEvents()
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
assertTrue(result.isSuccess())
|
||||
|
||||
val state = result.getOrNull()!!
|
||||
assertEquals(ViewerRole.WHITE_PLAYER, state.viewerRole)
|
||||
assertEquals(Color.WHITE, state.playerColor)
|
||||
assertEquals(blackPubkey, state.opponentPubkey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconstruct as black player - correct perspective`() {
|
||||
val events = createBasicGameEvents()
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, blackPubkey)
|
||||
assertTrue(result.isSuccess())
|
||||
|
||||
val state = result.getOrNull()!!
|
||||
assertEquals(ViewerRole.BLACK_PLAYER, state.viewerRole)
|
||||
assertEquals(Color.BLACK, state.playerColor)
|
||||
assertEquals(whitePubkey, state.opponentPubkey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconstruct as spectator - white perspective, no turn`() {
|
||||
val events = createBasicGameEvents()
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, spectatorPubkey)
|
||||
assertTrue(result.isSuccess())
|
||||
|
||||
val state = result.getOrNull()!!
|
||||
assertEquals(ViewerRole.SPECTATOR, state.viewerRole)
|
||||
assertEquals(Color.WHITE, state.playerColor, "Spectators see from white's perspective")
|
||||
assertFalse(state.isPlayerTurn(), "Spectators never have a turn")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `challenger is black - roles reversed correctly`() {
|
||||
// Challenger chooses black
|
||||
val move1 =
|
||||
createMoveEvent(
|
||||
id = "move-001",
|
||||
pubKey = whitePubkey, // Opponent (white) makes first move
|
||||
startEventId = startEventId,
|
||||
headEventId = startEventId,
|
||||
move = "e4",
|
||||
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
|
||||
history = listOf("e4"),
|
||||
opponentPubkey = blackPubkey,
|
||||
)
|
||||
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = createStartEvent(startEventId, blackPubkey, Color.BLACK, whitePubkey),
|
||||
moves = listOf(move1),
|
||||
)
|
||||
|
||||
val whiteResult = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
val blackResult = ChessStateReconstructor.reconstruct(events, blackPubkey)
|
||||
|
||||
assertTrue(whiteResult.isSuccess())
|
||||
assertTrue(blackResult.isSuccess())
|
||||
|
||||
val whiteState = whiteResult.getOrNull()!!
|
||||
val blackState = blackResult.getOrNull()!!
|
||||
|
||||
assertEquals(whitePubkey, whiteState.whitePubkey)
|
||||
assertEquals(blackPubkey, whiteState.blackPubkey)
|
||||
assertEquals(ViewerRole.WHITE_PLAYER, whiteState.viewerRole)
|
||||
assertEquals(ViewerRole.BLACK_PLAYER, blackState.viewerRole)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 5. ERROR HANDLING TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `reconstruct without start event - should fail`() {
|
||||
val events = JesterGameEvents(startEvent = null, moves = emptyList())
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
assertTrue(result is ReconstructionResult.Error)
|
||||
assertEquals("No start event found", result.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconstruct with empty events - should fail`() {
|
||||
val events = JesterGameEvents.empty()
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
assertTrue(result is ReconstructionResult.Error)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 6. CASTLING AND SPECIAL MOVES TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `reconstruct game with kingside castling`() {
|
||||
// 1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O
|
||||
val finalMove =
|
||||
createMoveEvent(
|
||||
id = "move-007",
|
||||
pubKey = whitePubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = "move-006",
|
||||
move = "O-O",
|
||||
fen = "r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQ1RK1 b kq - 5 4",
|
||||
history = listOf("e4", "e5", "Nf3", "Nc6", "Bc4", "Bc5", "O-O"),
|
||||
opponentPubkey = blackPubkey,
|
||||
)
|
||||
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
|
||||
moves = listOf(finalMove),
|
||||
)
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
assertTrue(result.isSuccess())
|
||||
|
||||
val state = result.getOrNull()!!
|
||||
assertEquals(7, state.moveHistory.size)
|
||||
assertEquals("O-O", state.moveHistory[6])
|
||||
assertEquals(Color.BLACK, state.currentPosition.activeColor, "Black's turn after white castled")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 7. DETERMINISM TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `same events always produce same state`() {
|
||||
val events = createBasicGameEvents()
|
||||
|
||||
// Reconstruct multiple times
|
||||
val results =
|
||||
(1..5).map {
|
||||
ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
}
|
||||
|
||||
// All should succeed
|
||||
assertTrue(results.all { it.isSuccess() })
|
||||
|
||||
// All states should be identical
|
||||
val states = results.map { it.getOrNull()!! }
|
||||
val first = states.first()
|
||||
|
||||
states.forEach { state ->
|
||||
assertEquals(first.startEventId, state.startEventId)
|
||||
assertEquals(first.moveHistory, state.moveHistory)
|
||||
assertEquals(first.gameStatus, state.gameStatus)
|
||||
assertEquals(first.currentPosition.activeColor, state.currentPosition.activeColor)
|
||||
assertEquals(first.currentPosition.moveNumber, state.currentPosition.moveNumber)
|
||||
assertEquals(first.appliedMoveNumbers, state.appliedMoveNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different move list order produces same state`() {
|
||||
val move1 =
|
||||
createMoveEvent(
|
||||
id = "move-001",
|
||||
pubKey = whitePubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = startEventId,
|
||||
move = "e4",
|
||||
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
|
||||
history = listOf("e4"),
|
||||
opponentPubkey = blackPubkey,
|
||||
)
|
||||
val move2 =
|
||||
createMoveEvent(
|
||||
id = "move-002",
|
||||
pubKey = blackPubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = "move-001",
|
||||
move = "e5",
|
||||
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2",
|
||||
history = listOf("e4", "e5"),
|
||||
opponentPubkey = whitePubkey,
|
||||
)
|
||||
val move3 =
|
||||
createMoveEvent(
|
||||
id = "move-003",
|
||||
pubKey = whitePubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = "move-002",
|
||||
move = "Nf3",
|
||||
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2",
|
||||
history = listOf("e4", "e5", "Nf3"),
|
||||
opponentPubkey = blackPubkey,
|
||||
)
|
||||
|
||||
val startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey)
|
||||
|
||||
// Order 1: moves in order
|
||||
val events1 = JesterGameEvents(startEvent, listOf(move1, move2, move3))
|
||||
|
||||
// Order 2: moves reversed (but move3 has longest history, so result is same)
|
||||
val events2 = JesterGameEvents(startEvent, listOf(move3, move1, move2))
|
||||
|
||||
val result1 = ChessStateReconstructor.reconstruct(events1, whitePubkey)
|
||||
val result2 = ChessStateReconstructor.reconstruct(events2, whitePubkey)
|
||||
|
||||
assertTrue(result1.isSuccess())
|
||||
assertTrue(result2.isSuccess())
|
||||
|
||||
val state1 = result1.getOrNull()!!
|
||||
val state2 = result2.getOrNull()!!
|
||||
|
||||
assertEquals(state1.moveHistory, state2.moveHistory, "Move history should be identical")
|
||||
assertEquals(state1.appliedMoveNumbers, state2.appliedMoveNumbers)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 8. OPEN CHALLENGE TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `reconstruct open challenge - no specific opponent`() {
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, null), // Open challenge
|
||||
moves = emptyList(),
|
||||
)
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
assertTrue(result.isSuccess())
|
||||
|
||||
val state = result.getOrNull()!!
|
||||
assertTrue(state.isPendingChallenge)
|
||||
assertEquals(whitePubkey, state.whitePubkey)
|
||||
assertEquals(null, state.blackPubkey, "No opponent assigned yet for open challenge")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconstruct open challenge with first opponent move`() {
|
||||
// When an unknown player makes the first move, they become the opponent
|
||||
val move1 =
|
||||
createMoveEvent(
|
||||
id = "move-001",
|
||||
pubKey = whitePubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = startEventId,
|
||||
move = "e4",
|
||||
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
|
||||
history = listOf("e4"),
|
||||
opponentPubkey = blackPubkey, // Now we know the opponent
|
||||
)
|
||||
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, null), // Open challenge
|
||||
moves = listOf(move1),
|
||||
)
|
||||
|
||||
val result = ChessStateReconstructor.reconstruct(events, whitePubkey)
|
||||
assertTrue(result.isSuccess())
|
||||
|
||||
val state = result.getOrNull()!!
|
||||
assertFalse(state.isPendingChallenge)
|
||||
assertEquals(whitePubkey, state.whitePubkey)
|
||||
// Opponent is determined from move events when not specified in start
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ==========================================================================
|
||||
|
||||
private fun createBasicGameEvents(): JesterGameEvents {
|
||||
val move1 =
|
||||
createMoveEvent(
|
||||
id = "move-001",
|
||||
pubKey = whitePubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = startEventId,
|
||||
move = "e4",
|
||||
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
|
||||
history = listOf("e4"),
|
||||
opponentPubkey = blackPubkey,
|
||||
)
|
||||
val move2 =
|
||||
createMoveEvent(
|
||||
id = "move-002",
|
||||
pubKey = blackPubkey,
|
||||
startEventId = startEventId,
|
||||
headEventId = "move-001",
|
||||
move = "e5",
|
||||
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2",
|
||||
history = listOf("e4", "e5"),
|
||||
opponentPubkey = whitePubkey,
|
||||
)
|
||||
|
||||
return JesterGameEvents(
|
||||
startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey),
|
||||
moves = listOf(move1, move2),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createStartEvent(
|
||||
id: String,
|
||||
challengerPubkey: String,
|
||||
challengerColor: Color,
|
||||
opponentPubkey: String?,
|
||||
createdAt: Long = 1000,
|
||||
): JesterEvent {
|
||||
val tags =
|
||||
mutableListOf(
|
||||
arrayOf("e", JesterProtocol.START_POSITION_HASH),
|
||||
)
|
||||
opponentPubkey?.let { tags.add(arrayOf("p", it)) }
|
||||
|
||||
val colorString = if (challengerColor == Color.WHITE) "white" else "black"
|
||||
val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"test123","playerColor":"$colorString"}"""
|
||||
|
||||
return JesterEvent(
|
||||
id = id,
|
||||
pubKey = challengerPubkey,
|
||||
createdAt = createdAt,
|
||||
tags = tags.toTypedArray(),
|
||||
content = content,
|
||||
sig = "sig-start",
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMoveEvent(
|
||||
id: String,
|
||||
pubKey: String,
|
||||
startEventId: String,
|
||||
headEventId: String,
|
||||
move: String,
|
||||
fen: String,
|
||||
history: List<String>,
|
||||
opponentPubkey: String,
|
||||
createdAt: Long = 2000,
|
||||
): JesterEvent {
|
||||
val historyJson = history.joinToString(",") { "\"$it\"" }
|
||||
val content = """{"version":"0","kind":1,"fen":"$fen","move":"$move","history":[$historyJson]}"""
|
||||
|
||||
return JesterEvent(
|
||||
id = id,
|
||||
pubKey = pubKey,
|
||||
createdAt = createdAt,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", headEventId),
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "sig-move",
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMoveEventWithResult(
|
||||
id: String,
|
||||
pubKey: String,
|
||||
startEventId: String,
|
||||
headEventId: String,
|
||||
move: String,
|
||||
fen: String,
|
||||
history: List<String>,
|
||||
opponentPubkey: String,
|
||||
result: String,
|
||||
termination: String,
|
||||
createdAt: Long = 2000,
|
||||
): JesterEvent {
|
||||
val historyJson = history.joinToString(",") { "\"$it\"" }
|
||||
val content = """{"version":"0","kind":1,"fen":"$fen","move":"$move","history":[$historyJson],"result":"$result","termination":"$termination"}"""
|
||||
|
||||
return JesterEvent(
|
||||
id = id,
|
||||
pubKey = pubKey,
|
||||
createdAt = createdAt,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", headEventId),
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "sig-move",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user