From 3afb04de43726d71580b626e856c68b6ed79984e Mon Sep 17 00:00:00 2001 From: Elizabeth Alexander Hunt Date: Thu, 12 Nov 2026 21:11:08 -0800 Subject: Rookery: penguin-themed cubing toolkit, phases 0-1 and GAN BLE Kotlin Multiplatform + Compose desktop app. - Sync: per-device append-only op logs (HLC-stamped LWW) in a plain folder, safe under Syncthing/Drive; content-addressed image blobs. - Timer: WCA inspection, +2/DNF, solve table, WCA-correct aoN/moN, per-event lifetime PBs with a pixel-penguin party and synthesized 8-bit chime. Mascot sprite from liz.coffee. - Scrambles for all WCA events + FTO via tnoodle-lib, with previews. - Importers: csTimer exports (incl. smart-cube reconstructions and cube MACs) and the Letter Pair Gym prototype (words, notes, images, history, practice stats). Idempotent; headless `--import`. - Letter-pair grid with miss heatmap, glance lookup and editor. - 3D puzzle engine (polyhedron + cut planes) and Canvas renderer: NxN 2-7, Pyraminx, Skewb, verified sticker-for-sticker against tnoodle; 3D replay of smart-cube solves. - GAN Gen2/3/4 smart-cube protocol (ported from gan-web-bluetooth, MIT) with a BlueZ D-Bus transport and `--cube scan|monitor` CLI. Not yet tested against hardware. - lizCI pipeline (.ci) building a test+package Docker target. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/coffee/liz/rookery/Platform.kt | 13 + .../kotlin/coffee/liz/rookery/audio/Chime.kt | 69 +++++ .../kotlin/coffee/liz/rookery/bt/BleTransport.kt | 57 ++++ .../kotlin/coffee/liz/rookery/bt/GanCrypto.kt | 108 ++++++++ .../coffee/liz/rookery/bt/GanCubeConnector.kt | 139 ++++++++++ .../kotlin/coffee/liz/rookery/bt/GanProtocol.kt | 307 +++++++++++++++++++++ .../kotlin/coffee/liz/rookery/bt/GanUtil.kt | 138 +++++++++ .../kotlin/coffee/liz/rookery/bt/SmartCube.kt | 58 ++++ .../coffee/liz/rookery/importer/CsTimerImport.kt | 104 +++++++ .../coffee/liz/rookery/importer/EventGuess.kt | 58 ++++ .../kotlin/coffee/liz/rookery/importer/Importer.kt | 81 ++++++ .../liz/rookery/importer/LetterPairGymImport.kt | 78 ++++++ .../kotlin/coffee/liz/rookery/model/Domain.kt | 50 ++++ .../kotlin/coffee/liz/rookery/model/Event.kt | 37 +++ .../kotlin/coffee/liz/rookery/model/Format.kt | 19 ++ .../kotlin/coffee/liz/rookery/puzzle/Geometry.kt | 60 ++++ .../kotlin/coffee/liz/rookery/puzzle/NxnCube.kt | 128 +++++++++ .../kotlin/coffee/liz/rookery/puzzle/Puzzles.kt | 102 +++++++ .../coffee/liz/rookery/puzzle/TwistyPuzzle.kt | 91 ++++++ .../coffee/liz/rookery/scramble/Scrambler.kt | 14 + .../kotlin/coffee/liz/rookery/stats/Stats.kt | 95 +++++++ .../kotlin/coffee/liz/rookery/store/Hlc.kt | 39 +++ .../kotlin/coffee/liz/rookery/store/Op.kt | 26 ++ .../kotlin/coffee/liz/rookery/store/Replica.kt | 32 +++ .../kotlin/coffee/liz/rookery/store/Repo.kt | 166 +++++++++++ .../kotlin/coffee/liz/rookery/store/Storage.kt | 36 +++ .../kotlin/coffee/liz/rookery/store/Store.kt | 61 ++++ .../coffee/liz/rookery/timer/TimerMachine.kt | 80 ++++++ .../kotlin/coffee/liz/rookery/util/Hash.kt | 20 ++ 29 files changed, 2266 insertions(+) create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/audio/Chime.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/bt/BleTransport.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCrypto.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanUtil.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/importer/CsTimerImport.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/importer/EventGuess.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/importer/Importer.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/importer/LetterPairGymImport.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/model/Event.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/model/Format.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Geometry.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/NxnCube.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Puzzles.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/TwistyPuzzle.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/stats/Stats.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/store/Hlc.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/store/Op.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/store/Replica.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/store/Storage.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/store/Store.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/util/Hash.kt (limited to 'core/src/commonMain/kotlin/coffee/liz/rookery') diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt new file mode 100644 index 0000000..954e423 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt @@ -0,0 +1,13 @@ +package coffee.liz.rookery + +/** Wall-clock time in Unix epoch milliseconds. */ +expect fun epochMillis(): Long + +/** Monotonic milliseconds for measuring durations; never goes backwards. */ +expect fun monoMillis(): Long + +/** A random identifier, unique enough to name devices and new entities. */ +expect fun randomId(): String + +/** Mutual exclusion on [lock] (reentrant). */ +expect fun locked(lock: Any, block: () -> T): T diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/audio/Chime.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/audio/Chime.kt new file mode 100644 index 0000000..6fe59f9 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/audio/Chime.kt @@ -0,0 +1,69 @@ +package coffee.liz.rookery.audio + +import kotlin.math.PI +import kotlin.math.exp +import kotlin.math.pow +import kotlin.math.sin + +/** + * A tiny 8-bit synth: pulse and triangle voices with a plucky envelope, rendered to 16-bit mono PCM. + * Generated in code so there's no audio asset to ship or license. + */ +object Chime { + const val SAMPLE_RATE = 44_100 + + enum class Voice { PULSE25, PULSE50, TRIANGLE } + + /** A note: MIDI pitch (null = rest), start and length in sixteenth notes. */ + data class Note(val midi: Int?, val start: Int, val len: Int, val voice: Voice = Voice.PULSE25, val vol: Double = 0.5) + + /** The single-PB jingle: a rising arpeggio and a sparkly resolution. */ + val PB = listOf( + Note(72, 0, 1), Note(76, 1, 1), Note(79, 2, 1), Note(84, 3, 3, Voice.PULSE50), + Note(83, 6, 1), Note(84, 7, 4, Voice.PULSE50), + Note(48, 0, 3, Voice.TRIANGLE, 0.6), Note(55, 3, 3, Voice.TRIANGLE, 0.6), Note(60, 6, 5, Voice.TRIANGLE, 0.6), + Note(91, 8, 1, Voice.PULSE25, 0.25), Note(96, 9, 2, Voice.PULSE25, 0.2), + ) + + /** Longer fanfare for average PBs. */ + val AVERAGE_PB = listOf( + Note(67, 0, 1), Note(72, 1, 1), Note(76, 2, 1), Note(79, 3, 2), + Note(76, 5, 1), Note(79, 6, 4, Voice.PULSE50), + Note(77, 10, 1), Note(81, 11, 1), Note(84, 12, 1), Note(89, 13, 5, Voice.PULSE50), + Note(43, 0, 5, Voice.TRIANGLE, 0.6), Note(48, 5, 5, Voice.TRIANGLE, 0.6), Note(53, 10, 3, Voice.TRIANGLE, 0.6), Note(48, 13, 5, Voice.TRIANGLE, 0.6), + Note(96, 14, 1, Voice.PULSE25, 0.2), Note(100, 15, 1, Voice.PULSE25, 0.2), Note(103, 16, 2, Voice.PULSE25, 0.2), + ) + + /** Short descending blip for a DNF. */ + val DNF = listOf(Note(64, 0, 1, Voice.PULSE50, 0.35), Note(57, 1, 2, Voice.PULSE50, 0.35)) + + fun render(notes: List, bpm: Int = 150, volume: Double = 0.35): ShortArray { + val sixteenth = SAMPLE_RATE * 60.0 / bpm / 4 + val total = ((notes.maxOf { it.start + it.len } + 2) * sixteenth).toInt() + val mix = DoubleArray(total) + for (note in notes) { + val midi = note.midi ?: continue + val freq = 440.0 * 2.0.pow((midi - 69) / 12.0) + val start = (note.start * sixteenth).toInt() + val len = (note.len * sixteenth).toInt() + val release = (0.04 * SAMPLE_RATE).toInt() + for (i in 0 until len + release) { + val idx = start + i + if (idx >= total) break + val phase = (i * freq / SAMPLE_RATE) % 1.0 + val wave = when (note.voice) { + Voice.PULSE25 -> if (phase < 0.25) 1.0 else -1.0 + Voice.PULSE50 -> if (phase < 0.5) 1.0 else -1.0 + Voice.TRIANGLE -> 4 * kotlin.math.abs(phase - 0.5) - 1 + } + val attack = minOf(1.0, i / (0.004 * SAMPLE_RATE)) + val decay = exp(-i / (len * 0.9 + 1)) + val tail = if (i < len) 1.0 else 1.0 - (i - len).toDouble() / release + // a dash of vibrato on long notes, NES-style + val vib = if (i > SAMPLE_RATE / 10) 1 + 0.003 * sin(2 * PI * 6 * i / SAMPLE_RATE) else 1.0 + mix[idx] += wave * vib * note.vol * attack * (0.35 + 0.65 * decay) * tail + } + } + return ShortArray(total) { i -> (mix[i] * volume * Short.MAX_VALUE).coerceIn(-32767.0, 32767.0).toInt().toShort() } + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/BleTransport.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/BleTransport.kt new file mode 100644 index 0000000..7db19a8 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/BleTransport.kt @@ -0,0 +1,57 @@ +package coffee.liz.rookery.bt + +import kotlinx.coroutines.flow.Flow + +/** + * One advertisement / discovery result. [manufacturerData] maps company id (16-bit) to its payload. + * UUIDs are lower-case 128-bit strings. + */ +data class BleAdvertisement( + val address: String, + val name: String?, + val rssi: Int?, + val manufacturerData: Map = emptyMap(), + val serviceUuids: List = emptyList(), +) + +data class BleCharacteristic(val uuid: String, val properties: Set = emptySet()) + +data class BleService(val uuid: String, val characteristics: List) + +/** + * Minimal platform-neutral BLE central API (BlueZ on Linux, BluetoothGatt on Android later). + * All UUIDs are lower-case 128-bit strings. + */ +interface BleTransport { + /** Scans until the collector cancels. May emit the same device repeatedly as RSSI updates. */ + fun scan(): Flow + + suspend fun connect(address: String): BleConnection +} + +interface BleConnection { + val address: String + val name: String? + + suspend fun discoverServices(): List + suspend fun read(service: String, characteristic: String): ByteArray + /** Writes a value; the transport picks write-with-response when the characteristic supports it. */ + suspend fun write(service: String, characteristic: String, value: ByteArray) + + /** + * Enables notifications and returns once they are on. Values arriving from then on are buffered + * until collected (single collector); the flow completes when the connection closes. + */ + suspend fun subscribe(service: String, characteristic: String): Flow + + /** Emits once when the link drops, whether by [disconnect] or remotely. */ + val disconnected: Flow + + suspend fun disconnect() +} + +/** Raw AES-128 single-block primitive so the protocol code stays pure common Kotlin. */ +interface Aes128 { + fun encryptBlock(key: ByteArray, block: ByteArray): ByteArray + fun decryptBlock(key: ByteArray, block: ByteArray): ByteArray +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCrypto.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCrypto.kt new file mode 100644 index 0000000..a67563e --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCrypto.kt @@ -0,0 +1,108 @@ +// Protocol details ported from gan-web-bluetooth by Andy Fedotov, MIT License +// (https://github.com/afedotov/gan-web-bluetooth). +package coffee.liz.rookery.bt + +/** GATT UUIDs and key material for GAN smart cubes. */ +object GanDefinitions { + const val GEN2_SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dc4179" + const val GEN2_COMMAND = "28be4a4a-cd67-11e9-a32f-2a2ae2dbcce4" + const val GEN2_STATE = "28be4cb6-cd67-11e9-a32f-2a2ae2dbcce4" + + const val GEN3_SERVICE = "8653000a-43e6-47b7-9cb0-5fc21d4ae340" + const val GEN3_COMMAND = "8653000c-43e6-47b7-9cb0-5fc21d4ae340" + const val GEN3_STATE = "8653000b-43e6-47b7-9cb0-5fc21d4ae340" + + const val GEN4_SERVICE = "00000010-0000-fff7-fff6-fff5fff4fff0" + const val GEN4_COMMAND = "0000fff5-0000-1000-8000-00805f9b34fb" + const val GEN4_STATE = "0000fff6-0000-1000-8000-00805f9b34fb" + + /** Advertised name prefixes of GAN-protocol cubes (MoYu AI 2023 = "AiCube" uses Gen2 with its own key). */ + val NAME_PREFIXES = listOf("GAN", "MG", "AiCube") + + /** Company identifier codes GAN cubes use: 0x0001, 0x0101, ... 0xFF01. */ + fun isGanCic(cic: Int): Boolean = (cic and 0xFF) == 0x01 + + class KeyIv(val key: ByteArray, val iv: ByteArray) + + private fun bytes(vararg v: Int) = ByteArray(v.size) { v[it].toByte() } + + /** Key used by GAN Gen2, Gen3 and Gen4 cubes. */ + val GAN_KEY = KeyIv( + bytes(0x01, 0x02, 0x42, 0x28, 0x31, 0x91, 0x16, 0x07, 0x20, 0x05, 0x18, 0x54, 0x42, 0x11, 0x12, 0x53), + bytes(0x11, 0x03, 0x32, 0x28, 0x21, 0x01, 0x76, 0x27, 0x20, 0x95, 0x78, 0x14, 0x32, 0x12, 0x02, 0x43), + ) + + /** Key used by MoYu AI 2023 (advertises as "AiCube", speaks GAN Gen2). */ + val MOYU_AI_KEY = KeyIv( + bytes(0x05, 0x12, 0x02, 0x45, 0x02, 0x01, 0x29, 0x56, 0x12, 0x78, 0x12, 0x76, 0x81, 0x01, 0x08, 0x03), + bytes(0x01, 0x44, 0x28, 0x06, 0x86, 0x21, 0x22, 0x28, 0x51, 0x05, 0x08, 0x31, 0x82, 0x02, 0x21, 0x06), + ) + + /** Encryption salt: the 6 MAC bytes in reverse order. Accepts "AA:BB:...", "AA-BB-..." or spaces. */ + fun saltFromMac(mac: String): ByteArray { + val parts = mac.trim().split(Regex("[:\\-\\s]+")) + require(parts.size == 6) { "MAC must have 6 bytes: $mac" } + return ByteArray(6) { parts[5 - it].toInt(16).toByte() } + } + + /** + * The cube's MAC from GAN manufacturer-specific data: the last 6 of the first 9 payload bytes, reversed. + * Returns null if no GAN company id is present. + */ + fun macFromManufacturerData(data: Map): String? { + val payload = data.entries.firstOrNull { isGanCic(it.key) }?.value ?: return null + val view = payload.copyOfRange(0, minOf(9, payload.size)) + if (view.size < 6) return null + return (1..6).joinToString(":") { i -> (view[view.size - i].toInt() and 0xFF).toString(16).uppercase().padStart(2, '0') } + } +} + +/** + * GAN's message encryption (identical for Gen2/3/4): AES-128-CBC with a fresh IV on each 16-byte chunk, + * applied to the chunk at the start of the message and then to the (possibly overlapping) chunk at its end. + * Key and IV are salted with the reversed MAC: `k[i] = (k[i] + salt[i]) % 0xFF` for i < 6 + * (sic: modulo 255, which is what the cube firmware does). + */ +class GanEncrypter(private val aes: Aes128, baseKey: ByteArray, baseIv: ByteArray, salt: ByteArray) { + val key: ByteArray = baseKey.copyOf() + val iv: ByteArray = baseIv.copyOf() + + init { + require(baseKey.size == 16) { "Key must be 16 bytes (128-bit) long" } + require(baseIv.size == 16) { "Iv must be 16 bytes (128-bit) long" } + require(salt.size == 6) { "Salt must be 6 bytes (48-bit) long" } + for (i in 0 until 6) { + key[i] = (((baseKey[i].toInt() and 0xFF) + (salt[i].toInt() and 0xFF)) % 0xFF).toByte() + iv[i] = (((baseIv[i].toInt() and 0xFF) + (salt[i].toInt() and 0xFF)) % 0xFF).toByte() + } + } + + constructor(aes: Aes128, keyIv: GanDefinitions.KeyIv, mac: String) : + this(aes, keyIv.key, keyIv.iv, GanDefinitions.saltFromMac(mac)) + + private fun encryptChunk(buf: ByteArray, off: Int) { + val block = ByteArray(16) { (buf[off + it].toInt() xor iv[it].toInt()).toByte() } + aes.encryptBlock(key, block).copyInto(buf, off) + } + + private fun decryptChunk(buf: ByteArray, off: Int) { + val plain = aes.decryptBlock(key, buf.copyOfRange(off, off + 16)) + for (i in 0 until 16) buf[off + i] = (plain[i].toInt() xor iv[i].toInt()).toByte() + } + + fun encrypt(data: ByteArray): ByteArray { + require(data.size >= 16) { "Data must be at least 16 bytes long" } + val res = data.copyOf() + encryptChunk(res, 0) + if (res.size > 16) encryptChunk(res, res.size - 16) + return res + } + + fun decrypt(data: ByteArray): ByteArray { + require(data.size >= 16) { "Data must be at least 16 bytes long" } + val res = data.copyOf() + if (res.size > 16) decryptChunk(res, res.size - 16) + decryptChunk(res, 0) + return res + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt new file mode 100644 index 0000000..c6991d4 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt @@ -0,0 +1,139 @@ +// Protocol details ported from gan-web-bluetooth by Andy Fedotov, MIT License +// (https://github.com/afedotov/gan-web-bluetooth). +package coffee.liz.rookery.bt + +import coffee.liz.rookery.monoMillis +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +enum class GanGeneration(val service: String, val command: String, val state: String) { + GEN2(GanDefinitions.GEN2_SERVICE, GanDefinitions.GEN2_COMMAND, GanDefinitions.GEN2_STATE), + GEN3(GanDefinitions.GEN3_SERVICE, GanDefinitions.GEN3_COMMAND, GanDefinitions.GEN3_STATE), + GEN4(GanDefinitions.GEN4_SERVICE, GanDefinitions.GEN4_COMMAND, GanDefinitions.GEN4_STATE); + + companion object { + fun fromServiceUuids(uuids: Collection): GanGeneration? { + val lower = uuids.map { it.lowercase() }.toSet() + return entries.firstOrNull { it.service in lower } + } + } +} + +/** Connects to GAN smart cubes over a [BleTransport]. */ +class GanCubeConnector( + private val transport: BleTransport, + private val aes: Aes128, + private val clock: () -> Long = ::monoMillis, +) { + /** + * Connects to [address], detects the protocol generation from its GATT services, turns on notifications + * and returns the cube. [mac] (salt for the encryption key) defaults to [address]; pass it explicitly if the + * platform exposes a different address than the cube's real MAC. Events are processed in [scope]. + */ + suspend fun connect(scope: CoroutineScope, address: String, mac: String = address): SmartCube { + val conn = transport.connect(address) + try { + val services = conn.discoverServices() + val gen = GanGeneration.fromServiceUuids(services.map { it.uuid }) + ?: throw IllegalStateException("No GAN cube service found on $address - wrong or unsupported cube model") + val keyIv = if (gen == GanGeneration.GEN2 && conn.name?.startsWith("AiCube") == true) GanDefinitions.MOYU_AI_KEY else GanDefinitions.GAN_KEY + val encrypter = GanEncrypter(aes, keyIv, mac) + val driver = when (gen) { + GanGeneration.GEN2 -> GanGen2ProtocolDriver(clock) + GanGeneration.GEN3 -> GanGen3ProtocolDriver(clock) + GanGeneration.GEN4 -> GanGen4ProtocolDriver(clock) + } + val notifications = conn.subscribe(gen.service, gen.state) + return GanSmartCube(scope, conn, gen, encrypter, driver, notifications) + } catch (e: Throwable) { + withContext(NonCancellable) { runCatching { conn.disconnect() } } + throw e + } + } + + companion object { + /** + * Whether an advertisement looks like a GAN-protocol cube: a known name prefix, a GAN service UUID, + * or GAN-style manufacturer data whose embedded MAC matches the advertiser's address. + */ + fun isGanCube(adv: BleAdvertisement): Boolean = + GanDefinitions.NAME_PREFIXES.any { adv.name?.startsWith(it) == true } || + GanGeneration.fromServiceUuids(adv.serviceUuids) != null || + GanDefinitions.macFromManufacturerData(adv.manufacturerData)?.equals(adv.address, ignoreCase = true) == true + } +} + +/** A connected GAN cube: decrypts notifications, runs them through the generation's driver and publishes events. */ +class GanSmartCube internal constructor( + parent: CoroutineScope, + private val conn: BleConnection, + val generation: GanGeneration, + private val encrypter: GanEncrypter, + private val driver: GanProtocolDriver, + notifications: Flow, +) : SmartCube, GanRawConnection { + override val name: String? get() = conn.name + override val address: String get() = conn.address + + private val scope = CoroutineScope(parent.coroutineContext + SupervisorJob(parent.coroutineContext[Job])) + private val _events = MutableSharedFlow(extraBufferCapacity = 1024, onBufferOverflow = BufferOverflow.DROP_OLDEST) + override val events: SharedFlow = _events.asSharedFlow() + private val writeLock = Mutex() + private var closed = false + + init { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + notifications.collect { raw -> + if (raw.size >= 16) { + val plain = encrypter.decrypt(raw) + driver.handleStateEvent(this@GanSmartCube, plain).forEach { _events.emit(it) } + } + } + } + scope.launch(start = CoroutineStart.UNDISPATCHED) { + conn.disconnected.first() + finish() + } + } + + private fun finish() { + if (closed) return + closed = true + _events.tryEmit(SmartCubeEvent.Disconnected) + scope.cancel() + } + + override suspend fun sendCommandMessage(message: ByteArray) { + writeLock.withLock { conn.write(generation.service, generation.command, encrypter.encrypt(message)) } + } + + private suspend fun send(command: GanCommand) { + driver.createCommandMessage(command)?.let { sendCommandMessage(it) } + } + + override suspend fun requestFacelets() = send(GanCommand.REQUEST_FACELETS) + override suspend fun requestBattery() = send(GanCommand.REQUEST_BATTERY) + override suspend fun requestHardware() = send(GanCommand.REQUEST_HARDWARE) + override suspend fun resetToSolved() = send(GanCommand.REQUEST_RESET) + + override suspend fun disconnect() { + withContext(NonCancellable) { + runCatching { conn.disconnect() } + finish() + } + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt new file mode 100644 index 0000000..1aef3cd --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt @@ -0,0 +1,307 @@ +// Protocol details ported from gan-web-bluetooth by Andy Fedotov, MIT License +// (https://github.com/afedotov/gan-web-bluetooth). +package coffee.liz.rookery.bt + +import coffee.liz.rookery.bt.SmartCubeEvent.Move + +enum class GanCommand { REQUEST_HARDWARE, REQUEST_FACELETS, REQUEST_BATTERY, REQUEST_RESET } + +/** What a protocol driver may do back to the cube while handling an event (move-history requests, disconnect). */ +interface GanRawConnection { + /** Encrypts and writes a plaintext command message. */ + suspend fun sendCommandMessage(message: ByteArray) + suspend fun disconnect() +} + +/** Encodes commands and decodes decrypted state notifications for one GAN protocol generation. */ +interface GanProtocolDriver { + fun createCommandMessage(command: GanCommand): ByteArray? + suspend fun handleStateEvent(conn: GanRawConnection, message: ByteArray): List +} + +private const val FACES = "URFDLB" +private fun moveName(face: Int, direction: Int) = FACES[face].toString() + (if (direction == 1) "'" else "") + +private fun msgOf(size: Int, vararg head: Int) = ByteArray(size).also { b -> head.forEachIndexed { i, v -> b[i] = v.toByte() } } + +private fun signed15(q: Int): Double = (1 - (q shr 15) * 2) * (q and 0x7FFF) / 0x7FFF.toDouble() +private fun signed3(v: Int): Int = (1 - (v shr 3) * 2) * (v and 0x7) + +private fun readState(r: GanBitReader, cpAt: Int, coAt: Int, epAt: Int, eoAt: Int): CubeState = GanFacelets.complete( + cp7 = List(7) { r.bits(cpAt + it * 3, 3) }, + co7 = List(7) { r.bits(coAt + it * 2, 2) }, + ep11 = List(11) { r.bits(epAt + it * 4, 4) }, + eo11 = List(11) { r.bits(eoAt + it, 1) }, +) + +private fun facelets(state: CubeState, serial: Int) = SmartCubeEvent.Facelets(GanFacelets.toKociemba(state), serial, state) + +private fun gyro(r: GanBitReader, at: Int) = SmartCubeEvent.Gyro( + Quaternion( + x = signed15(r.bits(at + 16, 16)), + y = signed15(r.bits(at + 32, 16)), + z = signed15(r.bits(at + 48, 16)), + w = signed15(r.bits(at, 16)), + ), + AngularVelocity(signed3(r.bits(at + 64, 4)), signed3(r.bits(at + 68, 4)), signed3(r.bits(at + 72, 4))), +) + +/** + * GAN Gen2: GAN Mini ui FreePlay, GAN12 ui (FreePlay), GAN356 i Carry (S), GAN356 i 3, Monster Go 3Ai, MoYu AI 2023. + * 20-byte messages; event type in the first nibble. + */ +class GanGen2ProtocolDriver(private val clock: () -> Long) : GanProtocolDriver { + private var lastSerial = -1 + private var lastMoveTimestamp = 0L + private var cubeTimestamp = 0L + + override fun createCommandMessage(command: GanCommand): ByteArray = when (command) { + GanCommand.REQUEST_FACELETS -> msgOf(20, 0x04) + GanCommand.REQUEST_HARDWARE -> msgOf(20, 0x05) + GanCommand.REQUEST_BATTERY -> msgOf(20, 0x09) + GanCommand.REQUEST_RESET -> msgOf(20, 0x0A, 0x05, 0x39, 0x77, 0x00, 0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB) + } + + override suspend fun handleStateEvent(conn: GanRawConnection, message: ByteArray): List { + val timestamp = clock() + val events = mutableListOf() + val r = GanBitReader(message) + when (r.bits(0, 4)) { + 0x01 -> events += gyro(r, 4) + 0x02 -> if (lastSerial != -1) { // moves are accepted only after the first facelets event + val serial = r.bits(4, 8) + val diff = minOf((serial - lastSerial) and 0xFF, 7) + lastSerial = serial + if (diff > 0) { + for (i in diff - 1 downTo 0) { + val face = r.bits(12 + 5 * i, 4) + val direction = r.bits(16 + 5 * i, 1) + var elapsed = r.bits(47 + 16 * i, 16).toLong() + if (elapsed == 0L) elapsed = timestamp - lastMoveTimestamp // 16-bit cube timer overflow + cubeTimestamp += elapsed + if (face < FACES.length) { + events += Move(moveName(face, direction), cubeTimestamp, timestamp, (serial - i) and 0xFF, recovered = i != 0) + } + } + lastMoveTimestamp = timestamp + } + } + 0x04 -> { + val serial = r.bits(4, 8) + if (lastSerial == -1) lastSerial = serial + events += facelets(readState(r, 12, 33, 47, 91), serial) + } + 0x05 -> { + val hwMajor = r.bits(8, 8); val hwMinor = r.bits(16, 8) + val swMajor = r.bits(24, 8); val swMinor = r.bits(32, 8) + val gyroSupported = r.bits(104, 1) == 1 + val name = (0 until 8).map { r.bits(it * 8 + 40, 8).toChar() }.joinToString("") + events += SmartCubeEvent.Hardware(name.trimEnd('\u0000'), "$hwMajor.$hwMinor", "$swMajor.$swMinor", null, gyroSupported) + } + 0x09 -> events += SmartCubeEvent.Battery(minOf(r.bits(8, 8), 100)) + 0x0D -> conn.disconnect() + } + return events + } +} + +/** + * Shared move FIFO with gap detection and move-history recovery used by Gen3 and Gen4: + * live moves carry a serial; when one is skipped, the driver asks the cube for its move history + * and holds later moves back until the gap is filled. + */ +abstract class GanBufferedProtocolDriver(protected val clock: () -> Long) : GanProtocolDriver { + protected var serial = -1 + protected var lastSerial = -1 + protected var lastLocalTimestamp: Long? = null + private val moveBuffer = ArrayDeque() + + protected abstract fun moveHistoryMessage(serial: Int, count: Int): ByteArray + + private suspend fun requestMoveHistory(conn: GanRawConnection, serialIn: Int, countIn: Int) { + // History data is byte-aligned and always starts at an odd serial: request an odd-aligned, even-sized window, + // and never across the 255 -> 0 edge (firmware spoofs those as 'D'). + var s = serialIn + var count = countIn + if (s % 2 == 0) s = (s - 1) and 0xFF + if (count % 2 == 1) count++ + count = minOf(count, s + 1) + try { + conn.sendCommandMessage(moveHistoryMessage(s, count)) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + // Ignored: retried on the next move event. + } + } + + protected suspend fun evictMoveBuffer(conn: GanRawConnection?): List { + val evicted = mutableListOf() + while (moveBuffer.isNotEmpty()) { + val head = moveBuffer.first() + val diff = if (lastSerial == -1) 1 else (head.serial - lastSerial) and 0xFF + if (diff > 1) { + if (conn != null) requestMoveHistory(conn, head.serial, diff) + break + } else { + evicted += moveBuffer.removeFirst() + lastSerial = head.serial + } + } + // Something went wrong and the buffer no longer drains: drop the connection. + if (conn != null && moveBuffer.size > 16) conn.disconnect() + return evicted + } + + private fun isSerialInRange(start: Int, end: Int, serial: Int, closedStart: Boolean = false, closedEnd: Boolean = false) = + ((end - start) and 0xFF) >= ((serial - start) and 0xFF) && + (closedStart || ((start - serial) and 0xFF) > 0) && + (closedEnd || ((end - serial) and 0xFF) > 0) + + private fun injectMissedMove(move: Move) { + if (moveBuffer.isNotEmpty()) { + val head = moveBuffer.first() + if (moveBuffer.any { it.serial == move.serial }) return + if (!isSerialInRange(lastSerial, head.serial, move.serial)) return + // History is injected in reverse order, so only the move right before the head fits. + if (move.serial == ((head.serial - 1) and 0xFF)) moveBuffer.addFirst(move) + } else if (isSerialInRange(lastSerial, serial, move.serial, closedEnd = true)) { + // A lost move recovered via the periodic facelets event, into an empty buffer. + moveBuffer.addFirst(move) + } + } + + private suspend fun checkIfMoveMissed(conn: GanRawConnection) { + val diff = (serial - lastSerial) and 0xFF + if (diff > 0 && serial != 0) { // serial 0: firmware bug with facelets event at counter 255 + val startSerial = moveBuffer.firstOrNull()?.serial ?: ((serial + 1) and 0xFF) + requestMoveHistory(conn, startSerial, diff + 1) + } + } + + protected suspend fun onLiveMove(conn: GanRawConnection, timestamp: Long, cubeTimestamp: Long, moveSerial: Int, direction: Int, faceBits: Int): List { + if (lastSerial == -1) return emptyList() // moves are accepted only after the first facelets event + lastLocalTimestamp = timestamp + serial = moveSerial + val face = LIVE_FACE_BITS.indexOf(faceBits) + if (face >= 0) moveBuffer.addLast(Move(moveName(face, direction), cubeTimestamp, timestamp, moveSerial)) + return evictMoveBuffer(conn) + } + + protected suspend fun onMoveHistory(r: GanBitReader, timestamp: Long, startSerial: Int, dataLength: Int, movesAt: Int): List { + val count = (dataLength - 1) * 2 + for (i in 0 until count) { + val face = HISTORY_FACE_BITS.indexOf(r.bits(movesAt + 4 * i, 3)) + val direction = r.bits(movesAt + 3 + 4 * i, 1) + if (face >= 0) injectMissedMove(Move(moveName(face, direction), null, timestamp, (startSerial - i) and 0xFF, recovered = true)) + } + return evictMoveBuffer(null) + } + + protected suspend fun onFaceletsSerial(conn: GanRawConnection, timestamp: Long, faceletsSerial: Int) { + serial = faceletsSerial + // The cube sends facelets periodically; use them to detect missed moves, debounced while turning. + if (lastSerial != -1) { + val last = lastLocalTimestamp + if (last != null && timestamp - last > 500) checkIfMoveMissed(conn) + } + if (lastSerial == -1) lastSerial = faceletsSerial + } + + private companion object { + val LIVE_FACE_BITS = listOf(2, 32, 8, 1, 16, 4) + val HISTORY_FACE_BITS = listOf(1, 5, 3, 0, 4, 2) + } +} + +/** GAN Gen3: GAN356 i Carry 2. 16-byte messages starting with magic 0x55, type, length. */ +class GanGen3ProtocolDriver(clock: () -> Long) : GanBufferedProtocolDriver(clock) { + override fun createCommandMessage(command: GanCommand): ByteArray = when (command) { + GanCommand.REQUEST_FACELETS -> msgOf(16, 0x68, 0x01) + GanCommand.REQUEST_HARDWARE -> msgOf(16, 0x68, 0x04) + GanCommand.REQUEST_BATTERY -> msgOf(16, 0x68, 0x07) + GanCommand.REQUEST_RESET -> msgOf(16, 0x68, 0x05, 0x05, 0x39, 0x77, 0x00, 0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB) + } + + override fun moveHistoryMessage(serial: Int, count: Int) = msgOf(16, 0x68, 0x03, serial, 0, count, 0) + + override suspend fun handleStateEvent(conn: GanRawConnection, message: ByteArray): List { + val timestamp = clock() + val r = GanBitReader(message) + val magic = r.bits(0, 8) + val eventType = r.bits(8, 8) + val dataLength = r.bits(16, 8) + if (magic != 0x55 || dataLength <= 0) return emptyList() + return when (eventType) { + 0x01 -> onLiveMove(conn, timestamp, r.u32(24, true), r.bits(56, 16, true) and 0xFF, r.bits(72, 2), r.bits(74, 6)) + 0x06 -> onMoveHistory(r, timestamp, r.bits(24, 8), dataLength, 32) + 0x02 -> { + val s = r.bits(24, 16, true) and 0xFF + onFaceletsSerial(conn, timestamp, s) + listOf(facelets(readState(r, 40, 61, 77, 121), s)) + } + 0x07 -> { + val swMajor = r.bits(72, 4); val swMinor = r.bits(76, 4) + val hwMajor = r.bits(80, 4); val hwMinor = r.bits(84, 4) + val name = (0 until 5).map { r.bits(it * 8 + 32, 8).toChar() }.joinToString("") + listOf(SmartCubeEvent.Hardware(name.trimEnd('\u0000'), "$hwMajor.$hwMinor", "$swMajor.$swMinor", null, false)) + } + 0x10 -> listOf(SmartCubeEvent.Battery(minOf(r.bits(24, 8), 100))) + 0x11 -> { conn.disconnect(); emptyList() } + else -> emptyList() + } + } +} + +/** GAN Gen4: GAN12 ui Maglev, GAN14 ui FreePlay. 20-byte messages starting with type, length. */ +class GanGen4ProtocolDriver(clock: () -> Long) : GanBufferedProtocolDriver(clock) { + private val hwInfo = mutableMapOf() + + override fun createCommandMessage(command: GanCommand): ByteArray = when (command) { + GanCommand.REQUEST_FACELETS -> msgOf(20, 0xDD, 0x04, 0x00, 0xED, 0x00, 0x00) + GanCommand.REQUEST_HARDWARE -> { hwInfo.clear(); msgOf(20, 0xDF, 0x03, 0x00, 0x00, 0x00) } + GanCommand.REQUEST_BATTERY -> msgOf(20, 0xDD, 0x04, 0x00, 0xEF, 0x00, 0x00) + GanCommand.REQUEST_RESET -> msgOf(20, 0xD2, 0x0D, 0x05, 0x39, 0x77, 0x00, 0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB) + } + + override fun moveHistoryMessage(serial: Int, count: Int) = msgOf(20, 0xD1, 0x04, serial, 0, count, 0) + + override suspend fun handleStateEvent(conn: GanRawConnection, message: ByteArray): List { + val timestamp = clock() + val r = GanBitReader(message) + val eventType = r.bits(0, 8) + val dataLength = r.bits(8, 8) + return when (eventType) { + 0x01 -> onLiveMove(conn, timestamp, r.u32(16, true), r.bits(48, 16, true) and 0xFF, r.bits(64, 2), r.bits(66, 6)) + 0xD1 -> onMoveHistory(r, timestamp, r.bits(16, 8), dataLength, 24) + 0xED -> { + val s = r.bits(16, 16, true) and 0xFF + onFaceletsSerial(conn, timestamp, s) + listOf(facelets(readState(r, 32, 53, 69, 113), s)) + } + in 0xFA..0xFE -> { + when (eventType) { + 0xFA -> { + val year = r.bits(24, 16, true); val month = r.bits(40, 8); val day = r.bits(48, 8) + hwInfo[eventType] = "${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}" + } + 0xFC -> hwInfo[eventType] = (0 until maxOf(0, minOf(dataLength - 1, (message.size * 8 - 24) / 8))) + .map { r.bits(it * 8 + 24, 8).toChar() }.joinToString("") + 0xFD -> hwInfo[eventType] = "${r.bits(24, 4)}.${r.bits(28, 4)}" + 0xFE -> hwInfo[eventType] = "${r.bits(24, 4)}.${r.bits(28, 4)}" + } + if (hwInfo.size == 4) { + val name = hwInfo[0xFC] + listOf(SmartCubeEvent.Hardware(name, hwInfo[0xFE], hwInfo[0xFD], hwInfo[0xFA], name in GYRO_MODELS)) + } else emptyList() + } + 0xEC -> listOf(gyro(r, 16)) + 0xEF -> listOf(SmartCubeEvent.Battery(minOf(r.bits(8 + dataLength * 8, 8), 100))) + 0xEA -> { conn.disconnect(); emptyList() } + else -> emptyList() + } + } + + private companion object { + val GYRO_MODELS = setOf("GAN12uiM") + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanUtil.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanUtil.kt new file mode 100644 index 0000000..c5521f0 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanUtil.kt @@ -0,0 +1,138 @@ +// Protocol details ported from gan-web-bluetooth by Andy Fedotov, MIT License +// (https://github.com/afedotov/gan-web-bluetooth). +package coffee.liz.rookery.bt + +import kotlin.math.roundToLong + +/** + * Reads arbitrary bit words from a message, MSB-first; 16/32-bit words may be little-endian. + * Bits past the end read as 0. + */ +class GanBitReader(private val msg: ByteArray) { + private fun bit(i: Int): Int = if (i ushr 3 >= msg.size) 0 else (msg[i ushr 3].toInt() ushr (7 - (i and 7))) and 1 + + fun bits(start: Int, length: Int, littleEndian: Boolean = false): Int = when { + length <= 8 -> { + var v = 0 + for (i in start until start + length) v = (v shl 1) or bit(i) + v + } + length == 16 || length == 32 -> { + val n = length / 8 + val bytes = IntArray(n) { bits(start + 8 * it, 8) } + var v = 0L + for (i in 0 until n) v = (v shl 8) or (if (littleEndian) bytes[n - 1 - i] else bytes[i]).toLong() + v.toInt() + } + else -> throw IllegalArgumentException("Unsupported bit word length $length") + } + + /** Unsigned 32-bit word as Long. */ + fun u32(start: Int, littleEndian: Boolean = false): Long = bits(start, 32, littleEndian).toLong() and 0xFFFFFFFFL +} + +/** Writes bit words MSB-first; the inverse of [GanBitReader] (used by tests and encoders). */ +class GanBitWriter(size: Int) { + val bytes = ByteArray(size) + + fun put(start: Int, length: Int, value: Long, littleEndian: Boolean = false): GanBitWriter { + if (length == 16 || length == 32) { + val n = length / 8 + for (i in 0 until n) { + val shift = if (littleEndian) 8 * i else 8 * (n - 1 - i) + put(start + 8 * i, 8, (value ushr shift) and 0xFF) + } + return this + } + require(length <= 8) + for (i in 0 until length) { + val b = ((value ushr (length - 1 - i)) and 1L).toInt() + val pos = start + i + val idx = pos ushr 3 + val mask = 1 shl (7 - (pos and 7)) + bytes[idx] = (if (b == 1) bytes[idx].toInt() or mask else bytes[idx].toInt() and mask.inv()).toByte() + } + return this + } + + fun put(start: Int, length: Int, value: Int, littleEndian: Boolean = false) = put(start, length, value.toLong() and 0xFFFFFFFFL, littleEndian) +} + +object GanFacelets { + private val CORNER_FACELET_MAP = arrayOf( + intArrayOf(8, 9, 20), intArrayOf(6, 18, 38), intArrayOf(0, 36, 47), intArrayOf(2, 45, 11), + intArrayOf(29, 26, 15), intArrayOf(27, 44, 24), intArrayOf(33, 53, 42), intArrayOf(35, 17, 51), + ) + private val EDGE_FACELET_MAP = arrayOf( + intArrayOf(5, 10), intArrayOf(7, 19), intArrayOf(3, 37), intArrayOf(1, 46), + intArrayOf(32, 16), intArrayOf(28, 25), intArrayOf(30, 43), intArrayOf(34, 52), + intArrayOf(23, 12), intArrayOf(21, 41), intArrayOf(50, 39), intArrayOf(48, 14), + ) + private const val FACES = "URFDLB" + + const val SOLVED = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB" + + /** Corner/edge permutation+orientation to a Kociemba facelets string. */ + fun toKociemba(s: CubeState): String { + val f = CharArray(54) { FACES[it / 9] } + for (i in 0 until 8) for (p in 0 until 3) + f[CORNER_FACELET_MAP[i][(p + s.co[i]) % 3]] = FACES[CORNER_FACELET_MAP[s.cp[i]][p] / 9] + for (i in 0 until 12) for (p in 0 until 2) + f[EDGE_FACELET_MAP[i][(p + s.eo[i]) % 2]] = FACES[EDGE_FACELET_MAP[s.ep[i]][p] / 9] + return f.concatToString() + } + + /** Fill in the 8th corner / 12th edge from the first 7/11 (the cube only sends those). */ + fun complete(cp7: List, co7: List, ep11: List, eo11: List): CubeState = CubeState( + cp = cp7 + (28 - cp7.sum()), + co = co7 + ((3 - co7.sum() % 3) % 3), + ep = ep11 + (66 - ep11.sum()), + eo = eo11 + ((2 - eo11.sum() % 2) % 2), + ) +} + +/** A move with optional timestamps, for [GanTimestampFit]. */ +data class TimedMove(val move: String, val hostTimestampMs: Long?, val cubeTimestampMs: Long?) + +object GanTimestampFit { + private fun linregress(x: List, y: List): Pair { + var sumX = 0.0; var sumY = 0.0; var sumXY = 0.0; var sumXX = 0.0; var n = 0 + for (i in x.indices) { + val xi = x[i]?.toDouble() ?: continue + val yi = y[i]?.toDouble() ?: continue + n++; sumX += xi; sumY += yi; sumXY += xi * yi; sumXX += xi * xi + } + val varX = n * sumXX - sumX * sumX + val covXY = n * sumXY - sumX * sumY + val slope = if (varX < 1e-3) 1.0 else covXY / varX + val intercept = if (n < 1) 0.0 else sumY / n - slope * sumX / n + return slope to intercept + } + + /** + * Fit cube-clock timestamps to host timestamps by linear regression, first filling missing cube + * timestamps from neighbours (next -50 ms, then previous +50 ms). Returned cube timestamps are in + * host-clock milliseconds relative to the first move (so the first is 0). + */ + fun linearFit(moves: List): List { + if (moves.isEmpty()) return emptyList() + val cube = moves.map { it.cubeTimestampMs }.toMutableList() + if (cube.size >= 2) { + for (i in cube.size - 1 downTo 1) if (cube[i] != null && cube[i - 1] == null) cube[i - 1] = cube[i]!! - 50 + for (i in 0 until cube.size - 1) if (cube[i] != null && cube[i + 1] == null) cube[i + 1] = cube[i]!! + 50 + } + val (slope, intercept) = linregress(cube, moves.map { it.hostTimestampMs }) + val first = cube[0]?.let { (slope * it + intercept).roundToLong() } + return moves.mapIndexed { i, m -> + val c = cube[i] + m.copy(cubeTimestampMs = if (c == null || first == null) null else (slope * c + intercept).roundToLong() - first) + } + } + + /** Skew of the cube clock vs the host clock, in percent. */ + fun skewPercent(moves: List): Double { + if (moves.isEmpty()) return 0.0 + val (slope, _) = linregress(moves.map { it.hostTimestampMs }, moves.map { it.cubeTimestampMs }) + return ((slope - 1) * 100000).roundToLong() / 1000.0 + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt new file mode 100644 index 0000000..747a391 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt @@ -0,0 +1,58 @@ +package coffee.liz.rookery.bt + +import kotlinx.coroutines.flow.Flow + +/** Orientation quaternion; right-handed, +X red, +Y blue, +Z white (GAN convention). */ +data class Quaternion(val x: Double, val y: Double, val z: Double, val w: Double) + +/** Coarse angular velocity per axis as reported by the cube (small signed integers). */ +data class AngularVelocity(val x: Int, val y: Int, val z: Int) + +/** Corner/edge permutation and orientation as reported by the cube. */ +data class CubeState(val cp: List, val co: List, val ep: List, val eo: List) + +sealed interface SmartCubeEvent { + /** + * A face turn in WCA notation ("R", "U'"). + * [cubeTimestampMs] is the cube's own clock (null if unknown, e.g. a move recovered from history); + * [hostTimestampMs] is the host's monotonic clock when the notification arrived. + * [recovered] marks moves that were missed live and filled in from the cube's move history, + * whose host timestamp is therefore not meaningful. + */ + data class Move( + val move: String, + val cubeTimestampMs: Long?, + val hostTimestampMs: Long, + val serial: Int = -1, + val recovered: Boolean = false, + ) : SmartCubeEvent + + /** Kociemba-style 54-char facelets in U R F D L B order. */ + data class Facelets(val facelets: String, val serial: Int = -1, val state: CubeState? = null) : SmartCubeEvent + + data class Battery(val percent: Int) : SmartCubeEvent + + data class Hardware( + val name: String?, + val hardwareVersion: String?, + val softwareVersion: String?, + val productDate: String? = null, + val gyroSupported: Boolean = false, + ) : SmartCubeEvent + + data class Gyro(val quaternion: Quaternion, val velocity: AngularVelocity? = null) : SmartCubeEvent + + data object Disconnected : SmartCubeEvent +} + +/** A connected smart cube. [events] is hot: collect it before issuing requests to see their replies. */ +interface SmartCube { + val name: String? + val address: String + val events: Flow + suspend fun requestFacelets() + suspend fun requestBattery() + suspend fun requestHardware() + suspend fun resetToSolved() + suspend fun disconnect() +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/importer/CsTimerImport.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/CsTimerImport.kt new file mode 100644 index 0000000..5d398bd --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/CsTimerImport.kt @@ -0,0 +1,104 @@ +package coffee.liz.rookery.importer + +import coffee.liz.rookery.model.CubeDevice +import coffee.liz.rookery.model.Event +import coffee.liz.rookery.model.Penalty +import coffee.liz.rookery.model.Session +import coffee.liz.rookery.model.Solve +import coffee.liz.rookery.util.fnv64 +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +/** + * Parses a csTimer export (the `.txt` from Export → to file). + * + * Shape: `{"session1": [solve...], ..., "properties": {"sessionData": "", "giiMacMap": ""}}` + * where a solve is `[[penalty, ms], scramble, comment, unixSeconds, [moves, puzzle]?]` and penalty is + * 0, 2000 (+2) or -1 (DNF). + * + * csTimer lets one session hold several puzzles, so each (session, event) pair becomes its own session + * here. Ids are hashes of the source data, so importing the same file twice changes nothing. + */ +object CsTimerImport { + data class Result(val sessions: List, val solves: List, val devices: List, val warnings: List) + + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + fun parse(text: String): Result { + val root = json.parseToJsonElement(text).jsonObject + val props = root["properties"] as? JsonObject + val sessionData = props?.get("sessionData")?.let(::embedded) + val warnings = mutableListOf() + + val sessions = LinkedHashMap() + val solves = mutableListOf() + + val keys = root.keys.filter { it.startsWith("session") }.sortedBy { it.removePrefix("session").toIntOrNull() ?: 0 } + for (key in keys) { + val num = key.removePrefix("session") + val meta = sessionData?.get(num) as? JsonObject + val name = (meta?.get("name") as? JsonPrimitive)?.contentOrNull ?: num + val scrType = ((meta?.get("opt") as? JsonObject)?.get("scrType") as? JsonPrimitive)?.contentOrNull + val sessionEvent = EventGuess.fromCsTimerType(scrType) + if (scrType != null && sessionEvent == null) warnings += "session $name: unknown scramble type '$scrType', guessing per solve" + + for ((i, el) in (root[key] as? JsonArray ?: continue).withIndex()) { + val arr = el as? JsonArray ?: continue + try { + val timeArr = arr[0].jsonArray + val pen = timeArr[0].jsonPrimitive.longOrNull ?: 0 + val ms = timeArr[1].jsonPrimitive.longOrNull ?: continue + val scramble = arr.getOrNull(1)?.jsonPrimitive?.contentOrNull.orEmpty() + val comment = arr.getOrNull(2)?.jsonPrimitive?.contentOrNull.orEmpty() + val at = (arr.getOrNull(3)?.jsonPrimitive?.longOrNull ?: 0) * 1000 + val moves = (arr.getOrNull(4) as? JsonArray)?.getOrNull(0)?.jsonPrimitive?.contentOrNull + + // The scramble wins when it clearly names a different puzzle than the session's type. + val sniffed = EventGuess.fromScramble(scramble) + val event = when { + sniffed != null && sniffed.puzzle != sessionEvent?.puzzle && (sessionEvent == null || EventGuess.isUnambiguous(sniffed)) -> sniffed + sessionEvent != null -> sessionEvent + else -> sniffed ?: Event.E333 + } + val sid = "cst-" + fnv64(num, event.id) + sessions.getOrPut(sid) { + val suffix = if (sessionEvent == null || event != sessionEvent) " (${event.short})" else "" + Session(sid, "csTimer $name$suffix", event, at) + }.let { if (at < it.createdAt) sessions[sid] = it.copy(createdAt = at) } + + solves += Solve( + id = "cst-" + fnv64(at.toString(), ms.toString(), scramble), + sessionId = sid, event = event, ms = ms, + penalty = when { pen < 0 -> Penalty.DNF; pen > 0 -> Penalty.PLUS2; else -> Penalty.NONE }, + scramble = scramble, comment = comment, at = at, moves = moves, + ) + } catch (e: Exception) { + warnings += "$key #${i + 1}: skipped (${e.message})" + } + } + } + + val devices = props?.get("giiMacMap")?.let(::embedded)?.values + ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + ?.filter { it.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) } + ?.distinct() + ?.map { CubeDevice(it.uppercase(), "Smart cube (from csTimer)", "gan") } + .orEmpty() + + return Result(sessions.values.toList(), solves, devices, warnings) + } + + /** csTimer stores some properties as JSON-encoded strings inside the JSON. */ + private fun embedded(e: kotlinx.serialization.json.JsonElement): JsonObject? = when (e) { + is JsonObject -> e + is JsonPrimitive -> e.contentOrNull?.let { runCatching { json.parseToJsonElement(it).jsonObject }.getOrNull() } + else -> null + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/importer/EventGuess.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/EventGuess.kt new file mode 100644 index 0000000..c8c5624 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/EventGuess.kt @@ -0,0 +1,58 @@ +package coffee.liz.rookery.importer + +import coffee.liz.rookery.model.Event + +/** Best-effort mapping from csTimer scramble types and raw scrambles to events. */ +object EventGuess { + /** csTimer `scrType` values → events. Unknown types fall back to scramble sniffing. */ + fun fromCsTimerType(t: String?): Event? = when (t) { + null, "" -> null + "333", "333o", "333oh", "333ft" -> if (t == "333oh") Event.E333OH else Event.E333 + "222so", "222o", "2223", "222eg" -> Event.E222 + "444wca", "444m", "444o", "444" -> Event.E444 + "555wca", "555", "555bld" -> if (t == "555bld") Event.E555BF else Event.E555 + "666wca", "666si", "666" -> Event.E666 + "777wca", "777si", "777" -> Event.E777 + "333ni", "333bld" -> Event.E333BF + "444bld" -> Event.E444BF + "r3ni" -> Event.E333MBF + "333fm" -> Event.E333FM + "clkwca", "clk", "clko" -> Event.CLOCK + "mgmp", "mgmc", "mgmo" -> Event.MINX + "pyrso", "pyrm", "pyro" -> Event.PYRAM + "skbso", "skbo" -> Event.SKEWB + "sqrs", "sq1h", "sq1t" -> Event.SQ1 + "ftoso", "fto" -> Event.FTO + else -> null + } + + /** + * Whether a scramble-text guess is distinctive enough to override what the session says. + * Short 2x2 / Pyraminx / Skewb scrambles look alike, so those never override. + */ + fun isUnambiguous(e: Event): Boolean = e.puzzle !in setOf( + coffee.liz.rookery.model.Puzzle.CUBE2, coffee.liz.rookery.model.Puzzle.PYRAMINX, coffee.liz.rookery.model.Puzzle.SKEWB, + ) + + /** Guess from the scramble text alone. Returns null when it looks like a plain 3x3 scramble. */ + fun fromScramble(scr: String): Event? { + val s = scr.trim() + if (s.isEmpty()) return null + if (s.contains('/') && s.contains('(')) return Event.SQ1 + if (Regex("""\b(UR|DR|DL|UL|ALL)\d[+-]""").containsMatchIn(s) || s.contains("y2 ")) return Event.CLOCK + if (s.contains("++") || s.contains("--")) return Event.MINX + val tokens = s.split(Regex("\\s+")) + if (tokens.any { it.matches(Regex("""(BR|BL)'?""")) }) return Event.FTO + if (tokens.all { it.matches(Regex("""[ULRBulrb]'?""")) }) { + return if (tokens.any { it[0].isLowerCase() }) Event.PYRAM else Event.SKEWB + } + val wide3 = tokens.any { it.matches(Regex("""3[URFDLB]w.*""")) } + val wide = tokens.any { it.contains('w') } + return when { + wide3 -> if (tokens.size >= 90) Event.E777 else Event.E666 + wide -> if (tokens.size >= 55) Event.E555 else Event.E444 + tokens.size in 8..12 && tokens.all { it.matches(Regex("""[URF][2']?""")) } -> Event.E222 + else -> null + } + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/importer/Importer.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/Importer.kt new file mode 100644 index 0000000..25b39a6 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/Importer.kt @@ -0,0 +1,81 @@ +package coffee.liz.rookery.importer + +import coffee.liz.rookery.store.Coll +import coffee.liz.rookery.store.Repo +import coffee.liz.rookery.util.fnv64 +import kotlinx.serialization.json.JsonPrimitive + +/** Writes parsed imports into the store. Every id is derived from source data, so re-running is a no-op. */ +class Importer(private val repo: Repo) { + data class Summary(val lines: List, val warnings: List = emptyList()) + + fun csTimer(text: String): Summary { + val r = CsTimerImport.parse(text) + val existingSessions = repo.sessions().map { it.id }.toSet() + val existingSolves = repo.solves().map { it.id }.toSet() + val existingDevices = repo.devices().map { it.mac }.toSet() + val newSessions = r.sessions.filter { it.id !in existingSessions } + val newSolves = r.solves.filter { it.id !in existingSolves } + val newDevices = r.devices.filter { it.mac !in existingDevices } + repo.store.transact { + for (s in newSessions) { + set(Coll.SESSION, s.id, "name", s.name); set(Coll.SESSION, s.id, "event", s.event.id); set(Coll.SESSION, s.id, "created", s.createdAt) + } + with(repo) { for (s in newSolves) writeSolve(s) } + for (d in newDevices) { + set(Coll.DEVICE, d.mac, "name", d.name); set(Coll.DEVICE, d.mac, "proto", d.protocol) + } + } + val byEvent = newSolves.groupingBy { it.event.short }.eachCount().entries.joinToString { "${it.value} ${it.key}" } + return Summary( + listOfNotNull( + "${newSolves.size} new solves" + (if (byEvent.isNotEmpty()) " ($byEvent)" else "") + ", ${r.solves.size - newSolves.size} already here", + "${newSessions.size} new sessions", + "${newSolves.count { it.moves != null }} with smart-cube reconstructions", + if (newDevices.isNotEmpty()) "remembered smart cube ${newDevices.joinToString { it.mac }}" else null, + ), + r.warnings, + ) + } + + /** [image] loads a file from the export by its relative path (null if missing). */ + fun letterPairGym(text: String, image: (String) -> ByteArray?): Summary { + val r = LetterPairGymImport.parse(text) + val existing = repo.letterPairs() + val warnings = mutableListOf() + var images = 0 + val blobByPair = r.images.mapNotNull { (pair, path) -> + val bytes = image(path) + if (bytes == null) { warnings += "$pair: image $path missing from export"; null } + else pair to repo.store.blobs.put(bytes, path.substringAfterLast('.', "jpg").lowercase()) + }.toMap() + + repo.store.transact { + for ((pair, word) in r.words) { + val cur = existing[pair] + if (cur?.primary != word) set(Coll.PAIR, pair, "primary", word) + if (cur == null || word !in cur.words) set(Coll.PAIR, pair, "w:$word", true) + } + for ((pair, note) in r.notes) if (existing[pair]?.note != note) set(Coll.PAIR, pair, "note", note) + for ((pair, blob) in blobByPair) if (existing[pair]?.image != blob) { set(Coll.PAIR, pair, "image", blob); images++ } + for (h in r.history) { + set(Coll.PAIR_HISTORY, fnv64(h.pair, h.from.orEmpty(), h.to.orEmpty(), h.at.toString()), "v", Repo.historyJson(h)) + } + for ((pair, s) in r.stats) set(Coll.PAIR, pair, "gymStats", s.raw) + for (m in r.memo) { + val id = "lpg-" + m.at + set(Coll.MEMO, id, "at", m.at); set(Coll.MEMO, id, "pairs", m.pairs); set(Coll.MEMO, id, "ms", m.memoMs); set(Coll.MEMO, id, "ok", m.recalled) + } + r.settings?.let { set(Coll.PREFS, "global", "importedLetterPairGymSettings", JsonPrimitive(it.toString())) } + } + return Summary( + listOf( + "${r.words.size} letter pairs (${r.words.keys.count { it !in existing }} new)", + "${r.notes.size} notes, $images images", + "${r.history.size} word changes, practice stats for ${r.stats.size} pairs", + "${r.memo.size} memo sessions", + ), + warnings, + ) + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/importer/LetterPairGymImport.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/LetterPairGymImport.kt new file mode 100644 index 0000000..b6a84a1 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/importer/LetterPairGymImport.kt @@ -0,0 +1,78 @@ +package coffee.liz.rookery.importer + +import coffee.liz.rookery.model.MemoSession +import coffee.liz.rookery.model.WordChange +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.longOrNull + +/** + * Reads the Letter Pair Gym prototype's `backup.json` (app = "letter-pair-gym", v = 1). + * + * Known stats fields per pair: n = reps, m = blanks, s = slow, l = recall latencies (ms), + * r = recent results oldest→newest (g good, s slow, m miss), ms = total time. `k`, `t`, `x` are + * kept verbatim until we know what they mean. + */ +object LetterPairGymImport { + data class PairStats(val reps: Int, val blanks: Int, val slow: Int, val latenciesMs: List, val recent: String, val raw: JsonObject) + + data class Result( + val words: Map, + val notes: Map, + /** pair → path inside the export, e.g. `images/AB - abacus.jpg` */ + val images: Map, + val history: List, + val stats: Map, + val memo: List, + /** Raw settings, for the scheme-confirmation step. */ + val settings: JsonObject?, + val exportedAt: String?, + ) + + private val json = Json { ignoreUnknownKeys = true } + + fun parse(text: String): Result { + val root = json.parseToJsonElement(text).jsonObject + val app = root.str("app") + require(app == "letter-pair-gym") { "not a Letter Pair Gym backup (app = $app)" } + + fun strMap(key: String): Map = (root[key] as? JsonObject).orEmpty() + .mapNotNull { (k, v) -> (v as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }?.let { k.uppercase() to it.trim() } } + .toMap() + + val history = (root["wordHist"] as? JsonArray).orEmpty().mapNotNull { e -> + val o = e as? JsonObject ?: return@mapNotNull null + WordChange(o.str("p")?.uppercase() ?: return@mapNotNull null, o.str("from"), o.str("to"), o.long("d") ?: 0, "letter-pair-gym") + } + + val stats = (root["stats"] as? JsonObject).orEmpty().mapNotNull { (pair, e) -> + val o = e as? JsonObject ?: return@mapNotNull null + pair.uppercase() to PairStats( + reps = o.long("n")?.toInt() ?: 0, + blanks = o.long("m")?.toInt() ?: 0, + slow = o.long("s")?.toInt() ?: 0, + latenciesMs = (o["l"] as? JsonArray).orEmpty().mapNotNull { (it as? JsonPrimitive)?.doubleOrNull?.toLong() }, + recent = o.str("r").orEmpty(), + raw = o, + ) + }.toMap() + + val memo = (root["memoHist"] as? JsonArray).orEmpty().mapNotNull { e -> + val o = e as? JsonObject ?: return@mapNotNull null + MemoSession(o.long("d") ?: return@mapNotNull null, o.long("p")?.toInt() ?: 0, o.long("ms") ?: 0, o.long("ok")?.toInt() ?: 0) + } + + return Result(strMap("words"), strMap("notes"), strMap("images"), history, stats, memo, root["settings"] as? JsonObject, root.str("at")) + } + + private fun JsonObject.str(k: String): String? = (this[k] as? JsonPrimitive)?.contentOrNull + private fun JsonObject.long(k: String): Long? = (this[k] as? JsonPrimitive)?.let { it.longOrNull ?: it.doubleOrNull?.toLong() } +} + +internal fun JsonElement?.orEmptyObject(): JsonObject = this as? JsonObject ?: JsonObject(emptyMap()) diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt new file mode 100644 index 0000000..5090ee0 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt @@ -0,0 +1,50 @@ +package coffee.liz.rookery.model + +enum class Penalty(val code: String) { + NONE("ok"), PLUS2("+2"), DNF("dnf"); + companion object { fun of(code: String?) = entries.firstOrNull { it.code == code } ?: NONE } +} + +/** Result in ms after penalties; [DNF] sorts after every real time. */ +const val DNF: Long = Long.MAX_VALUE + +data class Session(val id: String, val name: String, val event: Event, val createdAt: Long) + +data class Solve( + val id: String, + val sessionId: String, + val event: Event, + /** Raw timer reading, before penalties. */ + val ms: Long, + val penalty: Penalty, + val scramble: String, + val comment: String, + /** Epoch ms when the solve finished. */ + val at: Long, + /** Smart-cube reconstruction, csTimer style: `R@0 U'@120 ...` */ + val moves: String? = null, +) { + val result: Long get() = when (penalty) { + Penalty.NONE -> ms + Penalty.PLUS2 -> ms + 2000 + Penalty.DNF -> DNF + } +} + +data class LetterPair( + val pair: String, + val primary: String?, + val words: Set, + val note: String?, + /** Blob name of the chosen image. */ + val image: String?, + /** 0 (easy) .. 1 (always missed) from practice history, null if never drilled. */ + val difficulty: Double? = null, +) + +data class WordChange(val pair: String, val from: String?, val to: String?, val at: Long, val source: String) + +data class MemoSession(val at: Long, val pairs: Int, val memoMs: Long, val recalled: Int) + +/** A smart cube we have paired with on some device. Synced so every device knows its MAC. */ +data class CubeDevice(val mac: String, val name: String, val protocol: String) diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/model/Event.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Event.kt new file mode 100644 index 0000000..db7fcb8 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Event.kt @@ -0,0 +1,37 @@ +package coffee.liz.rookery.model + +/** Puzzles the engine knows how to scramble (and, over time, render and simulate). */ +enum class Puzzle { CUBE2, CUBE3, CUBE4, CUBE5, CUBE6, CUBE7, PYRAMINX, SKEWB, MEGAMINX, SQUARE1, CLOCK, FTO; + /** Layers per side for NxN puzzles, otherwise null. */ + val cubeSize: Int? get() = when (this) { + CUBE2 -> 2; CUBE3 -> 3; CUBE4 -> 4; CUBE5 -> 5; CUBE6 -> 6; CUBE7 -> 7; else -> null + } +} + +/** Events, keyed by WCA event id where one exists. */ +enum class Event(val id: String, val title: String, val short: String, val puzzle: Puzzle, val wca: Boolean = true) { + E333("333", "3x3x3", "3x3", Puzzle.CUBE3), + E222("222", "2x2x2", "2x2", Puzzle.CUBE2), + E444("444", "4x4x4", "4x4", Puzzle.CUBE4), + E555("555", "5x5x5", "5x5", Puzzle.CUBE5), + E666("666", "6x6x6", "6x6", Puzzle.CUBE6), + E777("777", "7x7x7", "7x7", Puzzle.CUBE7), + E333BF("333bf", "3x3x3 Blindfolded", "3BLD", Puzzle.CUBE3), + E333FM("333fm", "3x3x3 Fewest Moves", "FMC", Puzzle.CUBE3), + E333OH("333oh", "3x3x3 One-Handed", "OH", Puzzle.CUBE3), + CLOCK("clock", "Clock", "Clock", Puzzle.CLOCK), + MINX("minx", "Megaminx", "Mega", Puzzle.MEGAMINX), + PYRAM("pyram", "Pyraminx", "Pyra", Puzzle.PYRAMINX), + SKEWB("skewb", "Skewb", "Skewb", Puzzle.SKEWB), + SQ1("sq1", "Square-1", "Sq-1", Puzzle.SQUARE1), + E444BF("444bf", "4x4x4 Blindfolded", "4BLD", Puzzle.CUBE4), + E555BF("555bf", "5x5x5 Blindfolded", "5BLD", Puzzle.CUBE5), + E333MBF("333mbf", "3x3x3 Multi-Blind", "MBLD", Puzzle.CUBE3), + FTO("fto", "Face-Turning Octahedron", "FTO", Puzzle.FTO, wca = false); + + val blind: Boolean get() = this == E333BF || this == E444BF || this == E555BF || this == E333MBF + + companion object { + fun byId(id: String): Event? = entries.firstOrNull { it.id == id } + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/model/Format.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Format.kt new file mode 100644 index 0000000..923b8da --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Format.kt @@ -0,0 +1,19 @@ +package coffee.liz.rookery.model + +/** `12.34`, `1:02.34`, or `DNF`. Singles truncate to centiseconds, like the WCA does. */ +fun formatTime(ms: Long?, round: Boolean = false): String { + if (ms == null) return "-" + if (ms == DNF) return "DNF" + val cs = if (round) (ms + 5) / 10 else ms / 10 + val minutes = cs / 6000 + val seconds = (cs / 100) % 60 + val hundredths = cs % 100 + val tail = "." + hundredths.toString().padStart(2, '0') + return if (minutes > 0) "$minutes:" + seconds.toString().padStart(2, '0') + tail else "$seconds$tail" +} + +fun formatSolve(s: Solve): String = when (s.penalty) { + Penalty.NONE -> formatTime(s.ms) + Penalty.PLUS2 -> formatTime(s.ms + 2000) + "+" + Penalty.DNF -> "DNF(" + formatTime(s.ms) + ")" +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Geometry.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Geometry.kt new file mode 100644 index 0000000..9bda5f7 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Geometry.kt @@ -0,0 +1,60 @@ +package coffee.liz.rookery.puzzle + +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +data class V3(val x: Double, val y: Double, val z: Double) { + operator fun plus(o: V3) = V3(x + o.x, y + o.y, z + o.z) + operator fun minus(o: V3) = V3(x - o.x, y - o.y, z - o.z) + operator fun times(k: Double) = V3(x * k, y * k, z * k) + infix fun dot(o: V3) = x * o.x + y * o.y + z * o.z + infix fun cross(o: V3) = V3(y * o.z - z * o.y, z * o.x - x * o.z, x * o.y - y * o.x) + val length: Double get() = sqrt(this dot this) + fun unit(): V3 = this * (1.0 / length) + + /** Rotate about unit axis [k] by [angle] radians, right-handed (counter-clockwise seen from +k). */ + fun rotate(k: V3, angle: Double): V3 { + val c = cos(angle); val s = sin(angle) + return this * c + (k cross this) * s + k * ((k dot this) * (1 - c)) + } + + companion object { val ZERO = V3(0.0, 0.0, 0.0) } +} + +/** A convex planar polygon, vertices counter-clockwise seen from outside. */ +class Polygon(val points: List) { + val centroid: V3 = points.fold(V3.ZERO) { a, p -> a + p } * (1.0 / points.size) + val normal: V3 get() = ((points[1] - points[0]) cross (points[2] - points[0])).unit() + val area: Double get() { + var acc = V3.ZERO + for (i in points.indices) acc += points[i] cross points[(i + 1) % points.size] + return acc.length / 2 + } + + /** Sutherland–Hodgman split by the plane `dot(p, n) = c`: (part above, part below), either may be null. */ + fun split(n: V3, c: Double, eps: Double = 1e-9): Pair { + val above = mutableListOf() + val below = mutableListOf() + for (i in points.indices) { + val a = points[i]; val b = points[(i + 1) % points.size] + val da = (a dot n) - c; val db = (b dot n) - c + if (da >= -eps) above += a + if (da <= eps) below += a + if ((da > eps && db < -eps) || (da < -eps && db > eps)) { + val p = a + (b - a) * (da / (da - db)) + above += p; below += p + } + } + fun mk(ps: List) = if (ps.size >= 3) Polygon(ps).takeIf { it.area > 1e-7 } else null + return mk(above) to mk(below) + } + + fun map(f: (V3) -> V3) = Polygon(points.map(f)) + + /** Shrink toward the centroid, for drawing stickers with plastic showing between them. */ + fun inset(k: Double) = map { centroid + (it - centroid) * k } +} + +internal fun approxEq(a: V3, b: V3, eps: Double = 1e-6) = abs(a.x - b.x) < eps && abs(a.y - b.y) < eps && abs(a.z - b.z) < eps diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/NxnCube.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/NxnCube.kt new file mode 100644 index 0000000..54b4cda --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/NxnCube.kt @@ -0,0 +1,128 @@ +package coffee.liz.rookery.puzzle + +/** + * Sticker-level model of an NxN cube, enough to draw scramble previews and replay notation. + * + * Faces are stored U R F D L B (Kociemba order), each n*n stickers row-major as you look at the + * face on a standard net. Geometry uses doubled integer coordinates: cubie centres sit on odd/even + * steps in [-(n-1), n-1] and stickers poke out to ±n, so quarter turns stay exact integers. + * Axes: x→R, y→U, z→F. + */ +class NxnCube(val n: Int) { + init { require(n in 1..12) } + + enum class Face { U, R, F, D, L, B } + + /** Colour index per sticker slot; the colour index is the face it started on. */ + val stickers = IntArray(6 * n * n) { it / (n * n) } + + val isSolved: Boolean get() = (0 until 6).all { f -> (0 until n * n).all { stickers[f * n * n + it] == f } } + + fun copy(): NxnCube = NxnCube(n).also { stickers.copyInto(it.stickers) } + + fun apply(alg: String): NxnCube { for (m in Notation.parse(alg, n)) turn(m); return this } + + fun turn(m: Move) { + val perm = permutation(m.axis, m.layers, m.quarter) + val old = stickers.copyOf() + for (i in perm.indices) stickers[perm[i]] = old[i] + } + + // ---- geometry ---- + + private class P(val x: Int, val y: Int, val z: Int) + + private val positions: Array

= Array(6 * n * n) { slotPosition(it) } + private val index: Map, Int> = positions.withIndex().associate { (i, p) -> Triple(p.x, p.y, p.z) to i } + + private fun slotPosition(slot: Int): P { + val face = Face.entries[slot / (n * n)] + val r = (slot % (n * n)) / n + val c = slot % n + val a = 2 * c - (n - 1) // left→right + val b = 2 * r - (n - 1) // top→bottom + return when (face) { + Face.U -> P(a, n, b) + Face.D -> P(a, -n, -b) + Face.F -> P(a, -b, n) + Face.B -> P(-a, -b, -n) + Face.R -> P(n, -b, -a) + Face.L -> P(-n, -b, a) + } + } + + private val cache = HashMap, Int>, IntArray>() + + /** + * Where each slot goes for a turn of [layers] (0 = the +axis face layer, n-1 = the -axis face + * layer) about [axis] (0=x,1=y,2=z), clockwise looking from +axis, [quarter] times (1..3). + */ + private fun permutation(axis: Int, layers: Set, quarter: Int): IntArray = cache.getOrPut(Triple(axis, layers, quarter)) { + IntArray(positions.size) { i -> + var p = positions[i] + val coord = when (axis) { 0 -> p.x; 1 -> p.y; else -> p.z }.coerceIn(-(n - 1), n - 1) + val layer = ((n - 1) - coord) / 2 + if (layer in layers) repeat(quarter) { p = rotate(p, axis) } + index.getValue(Triple(p.x, p.y, p.z)) + } + } + + private fun rotate(p: P, axis: Int): P = when (axis) { + 0 -> P(p.x, p.z, -p.y) + 1 -> P(-p.z, p.y, p.x) + else -> P(p.y, -p.x, p.z) + } + + fun face(f: Face): IntArray = stickers.copyOfRange(f.ordinal * n * n, (f.ordinal + 1) * n * n) +} + +/** A turn of a set of layers about an axis, clockwise from the +axis side. */ +data class Move(val axis: Int, val layers: Set, val quarter: Int) + +object Notation { + private val token = Regex("""^(\d*)([URFDLBurfdlbxyzMES])(w?)(\d*)('?)$""") + + /** Parse WCA/SiGN notation (R, U2', Rw, 3Rw, r, 2R, x, M…) into layer turns for an n-cube. */ + fun parse(alg: String, n: Int): List = alg + .replace("(", " ").replace(")", " ") + .split(Regex("\\s+")).filter { it.isNotEmpty() } + .map { parseMove(it, n) ?: throw IllegalArgumentException("bad move '$it' for ${n}x$n") } + + fun parseMove(t: String, n: Int): Move? { + val m = token.matchEntire(t) ?: return null + val (prefix, letter, w, amountStr, prime) = m.destructured + var amount = if (amountStr.isEmpty()) 1 else amountStr.toInt() + if (prime.isNotEmpty()) amount = -amount + val all = (0 until n).toSet() + val lower = letter[0].isLowerCase() && letter[0] !in "xyz" + val face = letter.uppercase()[0] + val depth: IntRange = when { + face in "XYZ" -> 0 until n + face in "MES" -> if (n % 2 == 1 && n > 1) (n / 2)..(n / 2) else return null + w.isNotEmpty() || lower -> 0 until (if (prefix.isEmpty()) 2 else prefix.toInt()) + prefix.isNotEmpty() -> (prefix.toInt() - 1)..(prefix.toInt() - 1) + else -> 0..0 + } + if (depth.last >= n) return null + // axis, and whether this face sits on the -axis side (so layers count from the other end and direction flips) + val (axis, negative) = when (face) { + 'R', 'X' -> 0 to false; 'L', 'M' -> 0 to true + 'U', 'Y' -> 1 to false; 'D', 'E' -> 1 to true + 'F', 'Z', 'S' -> 2 to false; 'B' -> 2 to true + else -> return null + } + val layers = if (face in "XYZ") all else depth.map { if (negative) n - 1 - it else it }.toSet() + val q = ((if (negative) -amount else amount) % 4 + 4) % 4 + return Move(axis, layers, if (q == 0) 4 else q) + } + + /** Inverse of a move sequence, as text. */ + fun invert(alg: String): String = alg.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }.reversed().joinToString(" ") { t -> + when { + t.endsWith("2'") -> t.dropLast(1) + t.endsWith("2") -> t + t.endsWith("'") -> t.dropLast(1) + else -> "$t'" + } + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Puzzles.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Puzzles.kt new file mode 100644 index 0000000..3b54f4b --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Puzzles.kt @@ -0,0 +1,102 @@ +package coffee.liz.rookery.puzzle + +import coffee.liz.rookery.model.Puzzle +import kotlin.math.sqrt + +/** Ready-made [TwistyPuzzle]s, each matching tnoodle's (WCA) orientation and notation. */ +object Puzzles { + private val cache = HashMap() + + /** The 3D model for [p], or null if it has none yet (Square-1, Clock, FTO, Megaminx). */ + fun of(p: Puzzle): TwistyPuzzle? = cache.getOrPut(p) { + when (p) { + Puzzle.CUBE2, Puzzle.CUBE3, Puzzle.CUBE4, Puzzle.CUBE5, Puzzle.CUBE6, Puzzle.CUBE7 -> cube(p.cubeSize!!) + Puzzle.PYRAMINX -> pyraminx() + Puzzle.SKEWB -> skewb() + else -> null + } + } + + /** + * Face colours as 0xRRGGBB, indexed like each puzzle's faces. + * Cubes and Skewb: U R F D L B = white red green yellow orange blue (WCA scheme). + * Pyraminx: F L R D = green red blue yellow. + */ + fun colors(p: Puzzle): List = when (p) { + Puzzle.PYRAMINX -> listOf(0x2DB84D, 0xE02424, 0x2266E0, 0xFFD500) + else -> listOf(0xF4F4F4, 0xE02424, 0x2DB84D, 0xFFD500, 0xFF8A00, 0x2266E0) + }.map { it.toLong() } + + private fun tokens(alg: String) = alg.replace("(", " ").replace(")", " ").split(Regex("\\s+")).filter { it.isNotEmpty() } + + // ---- NxN ---- + + fun cube(n: Int): TwistyPuzzle { + val v = { x: Int, y: Int, z: Int -> V3(x.toDouble(), y.toDouble(), z.toDouble()) } + // U R F D L B, counter-clockwise seen from outside + val faces = listOf( + Polygon(listOf(v(-1, 1, -1), v(-1, 1, 1), v(1, 1, 1), v(1, 1, -1))), + Polygon(listOf(v(1, 1, 1), v(1, -1, 1), v(1, -1, -1), v(1, 1, -1))), + Polygon(listOf(v(-1, 1, 1), v(-1, -1, 1), v(1, -1, 1), v(1, 1, 1))), + Polygon(listOf(v(-1, -1, 1), v(-1, -1, -1), v(1, -1, -1), v(1, -1, 1))), + Polygon(listOf(v(-1, 1, -1), v(-1, -1, -1), v(-1, -1, 1), v(-1, 1, 1))), + Polygon(listOf(v(1, 1, -1), v(1, -1, -1), v(-1, -1, -1), v(-1, 1, -1))), + ) + val cuts = (1 until n).map { -1.0 + 2.0 * it / n } + val axes = listOf(Axis("x", v(1, 0, 0), cuts, 4), Axis("y", v(0, 1, 0), cuts, 4), Axis("z", v(0, 0, 1), cuts, 4)) + // NxnCube's Notation already speaks "axis 0/1/2, layer 0 = + side, quarter turns clockwise from +axis". + return TwistyPuzzle("${n}x$n", faces, axes) { alg -> + Notation.parse(alg, n).map { Turn(it.axis, it.layers, it.quarter % 4) } + } + } + + // ---- Pyraminx: vertex U up, face F towards you ---- + + fun pyraminx(): TwistyPuzzle { + val r = 2 * sqrt(2.0) / 3 + val u = V3(0.0, 1.0, 0.0) + val l = V3(-r * sqrt(3.0) / 2, -1.0 / 3, r / 2) + val rr = V3(r * sqrt(3.0) / 2, -1.0 / 3, r / 2) + val b = V3(0.0, -1.0 / 3, -r) + val faces = listOf(oriented(u, l, rr), oriented(u, b, l), oriented(u, rr, b), oriented(l, b, rr)) + // Opposite faces sit at -1/3 along each vertex axis; thirds of the height give tips and middles. + val cuts = listOf(1.0 / 9, 5.0 / 9) + val axes = listOf(Axis("U", u, cuts, 3), Axis("L", l, cuts, 3), Axis("R", rr, cuts, 3), Axis("B", b, cuts, 3)) + return TwistyPuzzle("pyraminx", faces, axes) { alg -> + tokens(alg).map { t -> + val m = Regex("""^([ULRBulrb])(')?$""").matchEntire(t) ?: throw IllegalArgumentException("bad pyraminx move '$t'") + val c = m.groupValues[1][0] + val axis = "ULRB".indexOf(c.uppercaseChar()) + Turn(axis, if (c.isLowerCase()) setOf(0) else setOf(0, 1), if (m.groupValues[2].isEmpty()) 1 else 2) + } + } + } + + // ---- Skewb (WCA notation: R = DRB corner, U = ULB, L = DLF, B = DLB) ---- + + fun skewb(): TwistyPuzzle { + val cubeFaces = cube(1) + val k = 1 / sqrt(3.0) + fun corner(x: Int, y: Int, z: Int) = V3(x * k, y * k, z * k) + val axes = listOf( + Axis("R", corner(1, -1, -1), listOf(0.0), 3), + Axis("U", corner(-1, 1, -1), listOf(0.0), 3), + Axis("L", corner(-1, -1, 1), listOf(0.0), 3), + Axis("B", corner(-1, -1, -1), listOf(0.0), 3), + ) + return TwistyPuzzle("skewb", cubeFaces.faces(), axes) { alg -> + tokens(alg).map { t -> + val m = Regex("""^([RULB])(')?$""").matchEntire(t) ?: throw IllegalArgumentException("bad skewb move '$t'") + Turn("RULB".indexOf(m.groupValues[1][0]), setOf(0), if (m.groupValues[2].isEmpty()) 1 else 2) + } + } + } + + /** Re-derive whole (uncut) faces from a 1x1 model. */ + private fun TwistyPuzzle.faces(): List = stickers + + private fun oriented(a: V3, b: V3, c: V3): Polygon { + val p = Polygon(listOf(a, b, c)) + return if ((p.normal dot p.centroid) > 0) p else Polygon(listOf(a, c, b)) + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/TwistyPuzzle.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/TwistyPuzzle.kt new file mode 100644 index 0000000..cdb8d85 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/TwistyPuzzle.kt @@ -0,0 +1,91 @@ +package coffee.liz.rookery.puzzle + +import kotlin.math.PI + +/** + * A turning axis: unit [normal], parallel cut planes at signed distances [cuts], and the rotational + * order of a turn (4 for cubes, 3 for tetra/octa/corner-turners, 5 for dodecahedra). + * + * Slabs are numbered from the +normal side: slab 0 lies beyond the largest cut. + */ +class Axis(val name: String, val normal: V3, cuts: List, val order: Int) { + val cuts: List = cuts.sortedDescending() + fun slabOf(p: V3): Int { val d = p dot normal; return cuts.count { it > d } } + val slabs: Int get() = cuts.size + 1 +} + +/** Rotate [layers] of [axis] by [amount] steps of 2π/order, clockwise as seen from the +normal side. */ +data class Turn(val axis: Int, val layers: Set, val amount: Int) + +/** + * Any "polyhedron cut by planes" twisty puzzle. Stickers are the face polygons clipped by every + * cut plane; a turn permutes sticker slots. State is just a colour per slot (see [PuzzleState]). + */ +class TwistyPuzzle( + val name: String, + faces: List, + val axes: List, + /** Parses this puzzle's notation into turns; throws on unknown moves. */ + private val notation: (String) -> List, +) { + val stickers: List + /** Which original face (= colour index) each slot belongs to when solved. */ + val stickerFace: IntArray + val faceCount: Int = faces.size + + init { + val out = mutableListOf() + val faceOf = mutableListOf() + for ((fi, face) in faces.withIndex()) { + var parts = listOf(face) + for (axis in axes) for (c in axis.cuts) { + parts = parts.flatMap { p -> p.split(axis.normal, c).toList().filterNotNull() } + } + out += parts; repeat(parts.size) { faceOf += fi } + } + stickers = out + stickerFace = faceOf.toIntArray() + } + + private val slab: Array = Array(axes.size) { a -> IntArray(stickers.size) { axes[a].slabOf(stickers[it].centroid) } } + + fun slabOf(axis: Int, sticker: Int): Int = slab[axis][sticker] + + private val perms = HashMap() + + /** perm[i] = slot that the sticker at slot i moves to. */ + fun permutation(t: Turn): IntArray = perms.getOrPut(t) { + val axis = axes[t.axis] + val angle = -2 * PI * t.amount / axis.order + IntArray(stickers.size) { i -> + if (slab[t.axis][i] !in t.layers) i + else { + val target = stickers[i].centroid.rotate(axis.normal, angle) + var best = -1; var bestD = Double.MAX_VALUE + for (j in stickers.indices) { + val d = (stickers[j].centroid - target).length + if (d < bestD) { bestD = d; best = j } + } + check(bestD < 1e-6) { "$name: turn $t doesn't map sticker $i onto a sticker (off by $bestD)" } + best + } + } + } + + fun parse(alg: String): List = notation(alg) + + fun solved(): PuzzleState = PuzzleState(this, stickerFace.copyOf()) +} + +class PuzzleState(val puzzle: TwistyPuzzle, val colors: IntArray) { + fun apply(t: Turn): PuzzleState { + val p = puzzle.permutation(t) + val next = IntArray(colors.size) + for (i in colors.indices) next[p[i]] = colors[i] + return PuzzleState(puzzle, next) + } + + fun apply(alg: String): PuzzleState = puzzle.parse(alg).fold(this) { s, t -> s.apply(t) } + + val isSolved: Boolean get() = colors.contentEquals(puzzle.stickerFace) +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt new file mode 100644 index 0000000..b056e91 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt @@ -0,0 +1,14 @@ +package coffee.liz.rookery.scramble + +import coffee.liz.rookery.model.Event + +interface Scrambler { + /** A fresh scramble. May block while solver tables warm up; call off the UI thread. */ + fun next(event: Event): String + + /** An SVG drawing of the scrambled puzzle, or null if unavailable. */ + fun previewSvg(event: Event, scramble: String): String? + + /** Start building solver tables / a scramble ahead of time. */ + fun warm(event: Event) {} +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/stats/Stats.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/stats/Stats.kt new file mode 100644 index 0000000..59a2a93 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/stats/Stats.kt @@ -0,0 +1,95 @@ +package coffee.liz.rookery.stats + +import coffee.liz.rookery.model.DNF + +/** Averages the way the WCA (and csTimer) compute them. Inputs are results in ms, [DNF] for DNFs. */ +object Averages { + /** Solves trimmed from each end of an average of [n]: 1 for ao5/ao12, 5% beyond that. */ + fun trim(n: Int): Int = if (n < 5) 0 else maxOf(1, (n + 19) / 20) + + /** Trimmed mean of a window of results; DNF if more DNFs than the trim allows. */ + fun average(window: List): Long { + if (window.isEmpty()) return DNF + val sorted = window.sorted() + return trimmedMean(sorted, trim(window.size)) + } + + /** Plain mean; any DNF makes it DNF. Used for mo3. */ + fun mean(window: List): Long = + if (window.isEmpty() || window.any { it == DNF }) DNF else window.sum() / window.size + + private fun trimmedMean(sorted: List, t: Int): Long { + val kept = sorted.subList(t, sorted.size - t) + if (kept.any { it == DNF }) return DNF + return (kept.sum() + kept.size / 2) / kept.size + } + + /** + * Best aoN (or moN when [mean]) over all windows, oldest first. O(len * n), fine up to ao1000 + * on six-figure solve counts. + */ + fun best(results: List, n: Int, mean: Boolean = false): Long? { + if (results.size < n) return null + val window = ArrayList(n + 1) + var best = DNF + for ((i, r) in results.withIndex()) { + window.add(lowerBound(window, r), r) + if (i >= n) window.removeAt(lowerBound(window, results[i - n])) + if (i >= n - 1) { + val v = if (mean) (if (window.last() == DNF) DNF else window.sum() / n) else trimmedMean(window, trim(n)) + if (v < best) best = v + } + } + return best + } + + private fun lowerBound(xs: List, v: Long): Int { + var lo = 0; var hi = xs.size + while (lo < hi) { val m = (lo + hi) ushr 1; if (xs[m] < v) lo = m + 1 else hi = m } + return lo + } +} + +enum class StatKind(val label: String, val n: Int, val mean: Boolean = false) { + SINGLE("single", 1), MO3("mo3", 3, true), AO5("ao5", 5), AO12("ao12", 12), AO50("ao50", 50), AO100("ao100", 100), AO1000("ao1000", 1000); +} + +data class StatLine(val kind: StatKind, val current: Long?, val best: Long?) + +/** Summary of a chronological list of results. */ +data class StatSummary(val count: Int, val solved: Int, val mean: Long?, val lines: List) { + fun line(k: StatKind): StatLine? = lines.firstOrNull { it.kind == k } + + companion object { + fun of(results: List): StatSummary { + val solved = results.filter { it != DNF } + val lines = StatKind.entries.mapNotNull { k -> + if (results.size < k.n) return@mapNotNull null + val last = results.subList(results.size - k.n, results.size) + val current = when { + k == StatKind.SINGLE -> last[0] + k.mean -> Averages.mean(last) + else -> Averages.average(last) + } + val best = if (k == StatKind.SINGLE) results.min() else Averages.best(results, k.n, k.mean) + StatLine(k, current, best) + } + return StatSummary(results.size, solved.size, if (solved.isEmpty()) null else solved.sum() / solved.size, lines) + } + } +} + +/** A personal best set by the most recent solve. */ +data class PersonalBest(val kind: StatKind, val value: Long, val previous: Long?) + +/** + * PBs earned by appending one solve: the new current value strictly beats the best from before. + * Only meaningful right after an add; edits to old solves don't throw parties. + */ +fun newPersonalBests(before: StatSummary, after: StatSummary): List = + after.lines.mapNotNull { line -> + val cur = line.current ?: return@mapNotNull null + if (cur == DNF) return@mapNotNull null + val prev = before.line(line.kind)?.best + if (prev == null || cur < prev) PersonalBest(line.kind, cur, prev) else null + } diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Hlc.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Hlc.kt new file mode 100644 index 0000000..c4750ed --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Hlc.kt @@ -0,0 +1,39 @@ +package coffee.liz.rookery.store + +/** + * Hybrid logical clock timestamp. Totally ordered: wall time, then counter, then node id, + * so two devices can never produce equal stamps. The string form sorts the same way. + */ +data class Hlc(val wall: Long, val counter: Int, val node: String) : Comparable { + override fun compareTo(other: Hlc): Int = + compareValuesBy(this, other, Hlc::wall, Hlc::counter, Hlc::node) + + override fun toString(): String = + wall.toString().padStart(13, '0') + "-" + counter.toString(16).padStart(4, '0') + "-" + node + + companion object { + fun parse(s: String): Hlc { + val a = s.indexOf('-') + val b = s.indexOf('-', a + 1) + require(a > 0 && b > a) { "bad hlc: $s" } + return Hlc(s.substring(0, a).toLong(), s.substring(a + 1, b).toInt(16), s.substring(b + 1)) + } + } +} + +class HlcClock(private val node: String, private val now: () -> Long) { + private var last = Hlc(0, 0, node) + + fun tick(): Hlc { + val wall = now() + last = if (wall > last.wall) Hlc(wall, 0, node) else Hlc(last.wall, last.counter + 1, node) + return last + } + + /** Fold in a remote stamp so our next tick sorts after everything we have seen. */ + fun observe(remote: Hlc) { + if (remote.wall > last.wall || (remote.wall == last.wall && remote.counter > last.counter)) { + last = Hlc(remote.wall, remote.counter, node) + } + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Op.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Op.kt new file mode 100644 index 0000000..5c74fb0 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Op.kt @@ -0,0 +1,26 @@ +package coffee.liz.rookery.store + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +/** + * One last-writer-wins assignment: collection/id/field := value, stamped with an HLC. + * Every piece of synced state is built from these, which makes merging a plain union: + * apply every op from every device in any order and all replicas converge. + * + * Conventions: + * - `_del` = true tombstones an entity. + * - Set-like data uses one field per element (e.g. `w:penguin` = true/false). + */ +@Serializable +data class Op(val t: String, val c: String, val id: String, val f: String, val v: JsonElement) { + val hlc: Hlc get() = Hlc.parse(t) + + fun encode(): String = json.encodeToString(serializer(), this) + + companion object { + val json = Json { ignoreUnknownKeys = true } + fun decode(line: String): Op? = runCatching { json.decodeFromString(serializer(), line) }.getOrNull() + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Replica.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Replica.kt new file mode 100644 index 0000000..46685fb --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Replica.kt @@ -0,0 +1,32 @@ +package coffee.liz.rookery.store + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull + +/** Materialized LWW state: collection -> id -> field -> (stamp, value). */ +class Replica { + class Cell(val t: String, val v: JsonElement) + + private val data = HashMap>>() + + /** Returns true if the op changed visible state. */ + fun apply(op: Op): Boolean { + val fields = data.getOrPut(op.c) { HashMap() }.getOrPut(op.id) { HashMap() } + val cur = fields[op.f] + // Stamps are fixed-width-prefixed strings that sort like the Hlc itself. + if (cur != null && Hlc.parse(cur.t) >= op.hlc) return false + fields[op.f] = Cell(op.t, op.v) + return true + } + + /** Live (non-tombstoned) entities in a collection. */ + fun entities(c: String): Map> = + data[c].orEmpty() + .filterValues { f -> (f["_del"]?.v as? JsonPrimitive)?.booleanOrNull != true } + .mapValues { (_, f) -> f.mapValues { it.value.v } } + + fun entity(c: String, id: String): Map? = entities(c)[id] + + fun fieldStamp(c: String, id: String, f: String): String? = data[c]?.get(id)?.get(f)?.t +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt new file mode 100644 index 0000000..6c0fafa --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt @@ -0,0 +1,166 @@ +package coffee.liz.rookery.store + +import coffee.liz.rookery.model.CubeDevice +import coffee.liz.rookery.model.Event +import coffee.liz.rookery.model.LetterPair +import coffee.liz.rookery.model.MemoSession +import coffee.liz.rookery.model.Penalty +import coffee.liz.rookery.model.Session +import coffee.liz.rookery.model.Solve +import coffee.liz.rookery.model.WordChange +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull + +/** Collection names in the op log. Renaming one is a data migration, so they stay short and fixed. */ +object Coll { + const val SESSION = "session" + const val SOLVE = "solve" + const val PAIR = "pair" + const val PAIR_HISTORY = "pairHist" + const val MEMO = "memo" + const val DEVICE = "device" + const val PREFS = "prefs" +} + +private fun Map.str(f: String): String? = (this[f] as? JsonPrimitive)?.takeIf { this[f] !is JsonNull }?.contentOrNull +private fun Map.long(f: String): Long? = (this[f] as? JsonPrimitive)?.longOrNull +private fun Map.bool(f: String): Boolean? = (this[f] as? JsonPrimitive)?.booleanOrNull + +/** Typed reads and writes over the [Store]. */ +class Repo(val store: Store) { + + // ---- sessions & solves ---- + + fun sessions(): List = store.read { r -> + r.entities(Coll.SESSION).mapNotNull { (id, f) -> + val event = Event.byId(f.str("event") ?: return@mapNotNull null) ?: return@mapNotNull null + Session(id, f.str("name") ?: event.short, event, f.long("created") ?: 0) + }.sortedBy { it.createdAt } + } + + fun solves(): List = store.read { r -> + r.entities(Coll.SOLVE).mapNotNull { (id, f) -> + Solve( + id = id, + sessionId = f.str("session") ?: return@mapNotNull null, + event = Event.byId(f.str("event") ?: return@mapNotNull null) ?: return@mapNotNull null, + ms = f.long("ms") ?: return@mapNotNull null, + penalty = Penalty.of(f.str("pen")), + scramble = f.str("scr").orEmpty(), + comment = f.str("note").orEmpty(), + at = f.long("at") ?: 0, + moves = f.str("moves"), + ) + }.sortedWith(compareBy({ it.at }, { it.id })) + } + + fun createSession(id: String, name: String, event: Event, createdAt: Long) = store.transact { + set(Coll.SESSION, id, "name", name) + set(Coll.SESSION, id, "event", event.id) + set(Coll.SESSION, id, "created", createdAt) + } + + fun renameSession(id: String, name: String) = store.transact { set(Coll.SESSION, id, "name", name) } + + fun deleteSession(id: String) { + val doomed = solves().filter { it.sessionId == id } + store.transact { + delete(Coll.SESSION, id) + for (s in doomed) delete(Coll.SOLVE, s.id) + } + } + + fun addSolve(s: Solve) = store.transact { writeSolve(s) } + + fun Store.Tx.writeSolve(s: Solve) { + set(Coll.SOLVE, s.id, "session", s.sessionId) + set(Coll.SOLVE, s.id, "event", s.event.id) + set(Coll.SOLVE, s.id, "ms", s.ms) + set(Coll.SOLVE, s.id, "pen", s.penalty.code) + set(Coll.SOLVE, s.id, "scr", s.scramble) + if (s.comment.isNotEmpty()) set(Coll.SOLVE, s.id, "note", s.comment) + set(Coll.SOLVE, s.id, "at", s.at) + if (s.moves != null) set(Coll.SOLVE, s.id, "moves", s.moves) + } + + fun setPenalty(id: String, p: Penalty) = store.transact { set(Coll.SOLVE, id, "pen", p.code) } + fun setComment(id: String, note: String) = store.transact { set(Coll.SOLVE, id, "note", note) } + fun deleteSolve(id: String) = store.transact { delete(Coll.SOLVE, id) } + + // ---- letter pairs ---- + + fun letterPairs(): Map = store.read { r -> + r.entities(Coll.PAIR).mapValues { (pair, f) -> + LetterPair( + pair = pair, + primary = f.str("primary"), + words = f.filter { (k, v) -> k.startsWith("w:") && (v as? JsonPrimitive)?.booleanOrNull == true } + .keys.map { it.removePrefix("w:") }.toSet(), + note = f.str("note"), + image = f.str("image"), + difficulty = difficulty(f["gymStats"]), + ) + } + } + + /** Make [word] the main word for [pair], keeping it in the pair's word set. Logs the change. */ + fun setPrimaryWord(pair: String, word: String, at: Long, source: String = "edit") { + val old = letterPairs()[pair]?.primary + if (old == word) return + store.transact { + set(Coll.PAIR, pair, "primary", word) + set(Coll.PAIR, pair, "w:$word", true) + set(Coll.PAIR_HISTORY, coffee.liz.rookery.util.fnv64(pair, old.orEmpty(), word, at.toString()), "v", + historyJson(WordChange(pair, old, word, at, source))) + } + } + + fun setPairNote(pair: String, note: String?) = store.transact { set(Coll.PAIR, pair, "note", note) } + fun setPairImage(pair: String, blob: String?) = store.transact { set(Coll.PAIR, pair, "image", blob) } + fun setWordInSet(pair: String, word: String, present: Boolean) = store.transact { set(Coll.PAIR, pair, "w:$word", present) } + + fun wordHistory(): List = store.read { r -> + r.entities(Coll.PAIR_HISTORY).values.mapNotNull { f -> f["v"]?.let(::parseHistory) } + }.sortedByDescending { it.at } + + fun memoSessions(): List = store.read { r -> + r.entities(Coll.MEMO).values.mapNotNull { f -> + MemoSession(f.long("at") ?: return@mapNotNull null, f.long("pairs")?.toInt() ?: 0, f.long("ms") ?: 0, f.long("ok")?.toInt() ?: 0) + } + }.sortedBy { it.at } + + // ---- devices & prefs ---- + + fun devices(): List = store.read { r -> + r.entities(Coll.DEVICE).map { (mac, f) -> CubeDevice(mac, f.str("name") ?: mac, f.str("proto") ?: "unknown") } + } + + fun pref(key: String): String? = store.read { r -> r.entity(Coll.PREFS, "global")?.str(key) } + fun boolPref(key: String, default: Boolean): Boolean = + store.read { r -> r.entity(Coll.PREFS, "global")?.bool(key) } ?: default + fun setPref(key: String, value: String?) = store.transact { set(Coll.PREFS, "global", key, value) } + fun setPref(key: String, value: Boolean) = store.transact { set(Coll.PREFS, "global", key, value) } + + companion object { + /** Share of recent attempts that were slow (half weight) or missed (full weight). */ + private fun difficulty(e: JsonElement?): Double? { + val recent = ((e as? kotlinx.serialization.json.JsonObject)?.get("r") as? JsonPrimitive)?.contentOrNull ?: return null + if (recent.isEmpty()) return null + return recent.sumOf { c -> when (c) { 'm' -> 1.0; 's' -> 0.5; else -> 0.0 } } / recent.length + } + + internal fun historyJson(w: WordChange): JsonElement = kotlinx.serialization.json.buildJsonObject { + put("pair", JsonPrimitive(w.pair)); put("from", JsonPrimitive(w.from)); put("to", JsonPrimitive(w.to)) + put("at", JsonPrimitive(w.at)); put("src", JsonPrimitive(w.source)) + } + + private fun parseHistory(e: JsonElement): WordChange? { + val o = e as? kotlinx.serialization.json.JsonObject ?: return null + return WordChange(o.str("pair") ?: return null, o.str("from"), o.str("to"), o.long("at") ?: 0, o.str("src") ?: "edit") + } + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Storage.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Storage.kt new file mode 100644 index 0000000..7049549 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Storage.kt @@ -0,0 +1,36 @@ +package coffee.liz.rookery.store + +/** + * Where op lines live. The contract that makes dumb file sync (Syncthing, Drive, a USB stick) safe: + * a device only ever appends to its own log files, and readers tolerate a torn final line. + */ +interface LogStorage { + /** Append lines (no trailing newlines) to this device's log, durably, in one write. */ + fun append(lines: List) + + /** Lines from any device's log that this reader has not returned before. */ + fun readNew(): List +} + +/** Content-addressed binary storage (images), synced alongside the log. */ +interface BlobStore { + /** Stores bytes and returns their name, `.`. Idempotent. */ + fun put(bytes: ByteArray, ext: String): String + fun get(name: String): ByteArray? +} + +class MemoryLogStorage(private val shared: MutableList = mutableListOf()) : LogStorage { + private var read = 0 + override fun append(lines: List) { shared.addAll(lines) } + override fun readNew(): List = shared.subList(read, shared.size).toList().also { read = shared.size } +} + +class MemoryBlobStore : BlobStore { + private val blobs = HashMap() + override fun put(bytes: ByteArray, ext: String): String { + val name = coffee.liz.rookery.util.fnv64(bytes) + "." + ext + blobs[name] = bytes + return name + } + override fun get(name: String) = blobs[name] +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Store.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Store.kt new file mode 100644 index 0000000..7873b76 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Store.kt @@ -0,0 +1,61 @@ +package coffee.liz.rookery.store + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive + +/** The synced database: an HLC, a replica, and a log to persist ops to. Thread-confined via [lock]. */ +class Store( + val nodeId: String, + private val log: LogStorage, + val blobs: BlobStore, + now: () -> Long, +) { + private val clock = HlcClock(nodeId, now) + private val replica = Replica() + private val lock = Any() + private val _version = MutableStateFlow(0L) + + /** Bumps whenever visible state changes; UI re-derives from the replica when it does. */ + val version: StateFlow = _version.asStateFlow() + + init { sync() } + + class Tx internal constructor(private val clock: HlcClock) { + internal val ops = mutableListOf() + fun set(c: String, id: String, f: String, v: JsonElement) { + ops += Op(clock.tick().toString(), c, id, f, v) + } + fun set(c: String, id: String, f: String, v: String?) = set(c, id, f, JsonPrimitive(v)) + fun set(c: String, id: String, f: String, v: Number) = set(c, id, f, JsonPrimitive(v)) + fun set(c: String, id: String, f: String, v: Boolean) = set(c, id, f, JsonPrimitive(v)) + fun delete(c: String, id: String) = set(c, id, "_del", true) + } + + fun transact(block: Tx.() -> Unit) { + coffee.liz.rookery.locked(lock) { + val tx = Tx(clock).apply(block) + if (tx.ops.isEmpty()) return@locked + log.append(tx.ops.map { it.encode() }) + var changed = false + for (op in tx.ops) changed = replica.apply(op) || changed + if (changed) _version.value++ + } + } + + /** Pull ops other devices wrote since last time. Returns how many changed state. */ + fun sync(): Int = coffee.liz.rookery.locked(lock) { + var changed = 0 + for (line in log.readNew()) { + val op = Op.decode(line) ?: continue + clock.observe(op.hlc) + if (replica.apply(op)) changed++ + } + if (changed > 0) _version.value++ + changed + } + + fun read(block: (Replica) -> T): T = coffee.liz.rookery.locked(lock) { block(replica) } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt new file mode 100644 index 0000000..f08a550 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt @@ -0,0 +1,80 @@ +package coffee.liz.rookery.timer + +import coffee.liz.rookery.model.Penalty + +/** + * The spacebar/touch timer as a pure state machine. The UI feeds it press/release/tick with a + * monotonic clock and renders [state]; nothing in here touches real time. + */ +class TimerMachine(var inspection: Boolean = true, var holdMs: Long = 300) { + sealed interface State { + data object Idle : State + data class Inspecting(val since: Long) : State + /** Finger down, waiting for the hold time. [inspectingSince] carries over inspection. */ + data class Holding(val since: Long, val inspectingSince: Long?) : State + data class Ready(val inspectingSince: Long?) : State + data class Running(val since: Long, val penalty: Penalty) : State + data class Stopped(val ms: Long, val penalty: Penalty, val awaitingRelease: Boolean) : State + } + + var state: State = State.Idle + private set + + /** Fired once per finished solve. */ + var onFinish: (ms: Long, penalty: Penalty) -> Unit = { _, _ -> } + + fun press(now: Long) { + state = when (val s = state) { + State.Idle -> State.Holding(now, null) + is State.Stopped -> if (s.awaitingRelease) s else State.Holding(now, null) + is State.Inspecting -> State.Holding(now, s.since) + is State.Running -> { + val ms = now - s.since + onFinish(ms, s.penalty) + State.Stopped(ms, s.penalty, awaitingRelease = true) + } + is State.Holding, is State.Ready -> s + } + } + + fun release(now: Long) { + state = when (val s = state) { + is State.Holding -> when { + s.inspectingSince != null -> State.Inspecting(s.inspectingSince) // let go early: keep inspecting + inspection -> State.Inspecting(now) + else -> State.Idle + } + is State.Ready -> State.Running(now, inspectionPenalty(s.inspectingSince, now)) + is State.Stopped -> s.copy(awaitingRelease = false) + else -> s + } + } + + fun tick(now: Long) { + val s = state + if (s is State.Holding && now - s.since >= holdMs && (s.inspectingSince != null || !inspection)) { + state = State.Ready(s.inspectingSince) + } + if (s is State.Inspecting && now - s.since > INSPECTION_DNF_MS) { + onFinish(0, Penalty.DNF) + state = State.Stopped(0, Penalty.DNF, awaitingRelease = false) + } + } + + fun cancel() { state = State.Idle } + + private fun inspectionPenalty(since: Long?, now: Long): Penalty { + if (since == null) return Penalty.NONE + val t = now - since + return when { + t > INSPECTION_DNF_MS -> Penalty.DNF + t > INSPECTION_MS -> Penalty.PLUS2 + else -> Penalty.NONE + } + } + + companion object { + const val INSPECTION_MS = 15_000L + const val INSPECTION_DNF_MS = 17_000L + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/util/Hash.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/util/Hash.kt new file mode 100644 index 0000000..8c7c447 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/util/Hash.kt @@ -0,0 +1,20 @@ +package coffee.liz.rookery.util + +/** FNV-1a 64-bit, hex encoded. Used for deterministic ids so re-imports are idempotent. */ +fun fnv64(vararg parts: String): String { + var h = -0x340d631b7bdddcdbL // 0xcbf29ce484222325 + for ((i, p) in parts.withIndex()) { + if (i > 0) { h = h xor 0x1f; h *= 0x100000001b3L } + for (b in p.encodeToByteArray()) { + h = h xor (b.toLong() and 0xff) + h *= 0x100000001b3L + } + } + return h.toULong().toString(16).padStart(16, '0') +} + +fun fnv64(bytes: ByteArray): String { + var h = -0x340d631b7bdddcdbL + for (b in bytes) { h = h xor (b.toLong() and 0xff); h *= 0x100000001b3L } + return h.toULong().toString(16).padStart(16, '0') +} -- cgit v1.3