summaryrefslogtreecommitdiff
path: root/core
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-11-12 21:11:08 -0800
committerElizabeth Alexander Hunt <me@liz.coffee>2026-11-12 21:11:08 -0800
commit3afb04de43726d71580b626e856c68b6ed79984e (patch)
tree33f7bcb227ff8cfcb046a10036d2faa69c2edd1f /core
downloadrookery-3afb04de43726d71580b626e856c68b6ed79984e.tar.gz
rookery-3afb04de43726d71580b626e856c68b6ed79984e.zip
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 <noreply@anthropic.com>
Diffstat (limited to 'core')
-rw-r--r--core/build.gradle.kts28
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt13
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/audio/Chime.kt69
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/BleTransport.kt57
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCrypto.kt108
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt139
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt307
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanUtil.kt138
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt58
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/importer/CsTimerImport.kt104
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/importer/EventGuess.kt58
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/importer/Importer.kt81
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/importer/LetterPairGymImport.kt78
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt50
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/model/Event.kt37
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/model/Format.kt19
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Geometry.kt60
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/NxnCube.kt128
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/Puzzles.kt102
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/TwistyPuzzle.kt91
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt14
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/stats/Stats.kt95
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Hlc.kt39
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Op.kt26
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Replica.kt32
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt166
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Storage.kt36
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Store.kt61
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt80
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/util/Hash.kt20
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/ChimeTest.kt14
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/CubeTest.kt55
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/ImportTest.kt76
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/StatsTest.kt54
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/StoreTest.kt65
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/TimerTest.kt46
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/TwistyPuzzleTest.kt72
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt264
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt6
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/audio/ChimePlayer.kt21
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/bt/BlueZTransport.kt287
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt17
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt50
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt96
-rw-r--r--core/src/jvmTest/kotlin/coffee/liz/rookery/JvmTest.kt55
-rw-r--r--core/src/jvmTest/kotlin/coffee/liz/rookery/TnoodleAgreementTest.kt103
-rw-r--r--core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCryptoTest.kt40
-rw-r--r--core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCubeConnectorTest.kt118
48 files changed, 3733 insertions, 0 deletions
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
new file mode 100644
index 0000000..718fdaa
--- /dev/null
+++ b/core/build.gradle.kts
@@ -0,0 +1,28 @@
+plugins {
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(libs.plugins.kotlin.serialization)
+}
+
+kotlin {
+ jvmToolchain(21)
+ jvm()
+
+ sourceSets {
+ commonMain.dependencies {
+ implementation(libs.coroutines.core)
+ api(libs.serialization.json)
+ }
+ commonTest.dependencies {
+ implementation(kotlin("test"))
+ implementation(libs.coroutines.test)
+ }
+ jvmMain.dependencies {
+ implementation(libs.tnoodle.scrambles)
+ implementation(libs.dbus.java.core)
+ implementation(libs.dbus.java.transport.native)
+ }
+ }
+}
+
+// Same heap cap as the desktop app, so tests catch scramblers that outgrow it.
+tasks.withType<Test>().configureEach { maxHeapSize = "256m" }
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 <T> 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<Note>, 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<Int, ByteArray> = emptyMap(),
+ val serviceUuids: List<String> = emptyList(),
+)
+
+data class BleCharacteristic(val uuid: String, val properties: Set<String> = emptySet())
+
+data class BleService(val uuid: String, val characteristics: List<BleCharacteristic>)
+
+/**
+ * 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<BleAdvertisement>
+
+ suspend fun connect(address: String): BleConnection
+}
+
+interface BleConnection {
+ val address: String
+ val name: String?
+
+ suspend fun discoverServices(): List<BleService>
+ 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<ByteArray>
+
+ /** Emits once when the link drops, whether by [disconnect] or remotely. */
+ val disconnected: Flow<Unit>
+
+ 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<Int, ByteArray>): 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<String>): 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<ByteArray>,
+) : 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<SmartCubeEvent>(extraBufferCapacity = 1024, onBufferOverflow = BufferOverflow.DROP_OLDEST)
+ override val events: SharedFlow<SmartCubeEvent> = _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<SmartCubeEvent>
+}
+
+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<SmartCubeEvent> {
+ val timestamp = clock()
+ val events = mutableListOf<SmartCubeEvent>()
+ 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<Move>()
+
+ 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<SmartCubeEvent> {
+ val evicted = mutableListOf<SmartCubeEvent>()
+ 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<SmartCubeEvent> {
+ 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<SmartCubeEvent> {
+ 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<SmartCubeEvent> {
+ 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<Int, String>()
+
+ 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<SmartCubeEvent> {
+ 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<Int>, co7: List<Int>, ep11: List<Int>, eo11: List<Int>): 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<Long?>, y: List<Long?>): Pair<Double, Double> {
+ 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<TimedMove>): List<TimedMove> {
+ 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<TimedMove>): 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<Int>, val co: List<Int>, val ep: List<Int>, val eo: List<Int>)
+
+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<SmartCubeEvent>
+ 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": "<json string>", "giiMacMap": "<json string>"}}`
+ * 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<Session>, val solves: List<Solve>, val devices: List<CubeDevice>, val warnings: List<String>)
+
+ 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<String>()
+
+ val sessions = LinkedHashMap<String, Session>()
+ val solves = mutableListOf<Solve>()
+
+ 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<String>, val warnings: List<String> = 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<String>()
+ 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<Long>, val recent: String, val raw: JsonObject)
+
+ data class Result(
+ val words: Map<String, String>,
+ val notes: Map<String, String>,
+ /** pair → path inside the export, e.g. `images/AB - abacus.jpg` */
+ val images: Map<String, String>,
+ val history: List<WordChange>,
+ val stats: Map<String, PairStats>,
+ val memo: List<MemoSession>,
+ /** 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<String, String> = (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<String>,
+ 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<V3>) {
+ 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<Polygon?, Polygon?> {
+ val above = mutableListOf<V3>()
+ val below = mutableListOf<V3>()
+ 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<V3>) = 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<P> = Array(6 * n * n) { slotPosition(it) }
+ private val index: Map<Triple<Int, Int, Int>, 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<Triple<Int, Set<Int>, 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<Int>, 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<Int>, 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<Move> = 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<Puzzle, TwistyPuzzle?>()
+
+ /** 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<Long> = 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<Polygon> = 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<Double>, val order: Int) {
+ val cuts: List<Double> = 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<Int>, 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<Polygon>,
+ val axes: List<Axis>,
+ /** Parses this puzzle's notation into turns; throws on unknown moves. */
+ private val notation: (String) -> List<Turn>,
+) {
+ val stickers: List<Polygon>
+ /** Which original face (= colour index) each slot belongs to when solved. */
+ val stickerFace: IntArray
+ val faceCount: Int = faces.size
+
+ init {
+ val out = mutableListOf<Polygon>()
+ val faceOf = mutableListOf<Int>()
+ 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<IntArray> = 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<Turn, IntArray>()
+
+ /** 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<Turn> = 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>): 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>): Long =
+ if (window.isEmpty() || window.any { it == DNF }) DNF else window.sum() / window.size
+
+ private fun trimmedMean(sorted: List<Long>, 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<Long>, n: Int, mean: Boolean = false): Long? {
+ if (results.size < n) return null
+ val window = ArrayList<Long>(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<Long>, 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<StatLine>) {
+ fun line(k: StatKind): StatLine? = lines.firstOrNull { it.kind == k }
+
+ companion object {
+ fun of(results: List<Long>): 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<PersonalBest> =
+ 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<Hlc> {
+ 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<String, HashMap<String, HashMap<String, Cell>>>()
+
+ /** 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<String, Map<String, JsonElement>> =
+ 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<String, JsonElement>? = 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<String, JsonElement>.str(f: String): String? = (this[f] as? JsonPrimitive)?.takeIf { this[f] !is JsonNull }?.contentOrNull
+private fun Map<String, JsonElement>.long(f: String): Long? = (this[f] as? JsonPrimitive)?.longOrNull
+private fun Map<String, JsonElement>.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<Session> = 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<Solve> = 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<String, LetterPair> = 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<WordChange> = store.read { r ->
+ r.entities(Coll.PAIR_HISTORY).values.mapNotNull { f -> f["v"]?.let(::parseHistory) }
+ }.sortedByDescending { it.at }
+
+ fun memoSessions(): List<MemoSession> = 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<CubeDevice> = 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<String>)
+
+ /** Lines from any device's log that this reader has not returned before. */
+ fun readNew(): List<String>
+}
+
+/** Content-addressed binary storage (images), synced alongside the log. */
+interface BlobStore {
+ /** Stores bytes and returns their name, `<sha256>.<ext>`. Idempotent. */
+ fun put(bytes: ByteArray, ext: String): String
+ fun get(name: String): ByteArray?
+}
+
+class MemoryLogStorage(private val shared: MutableList<String> = mutableListOf()) : LogStorage {
+ private var read = 0
+ override fun append(lines: List<String>) { shared.addAll(lines) }
+ override fun readNew(): List<String> = shared.subList(read, shared.size).toList().also { read = shared.size }
+}
+
+class MemoryBlobStore : BlobStore {
+ private val blobs = HashMap<String, ByteArray>()
+ 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<Long> = _version.asStateFlow()
+
+ init { sync() }
+
+ class Tx internal constructor(private val clock: HlcClock) {
+ internal val ops = mutableListOf<Op>()
+ 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 <T> 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')
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/ChimeTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/ChimeTest.kt
new file mode 100644
index 0000000..7ab195c
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/ChimeTest.kt
@@ -0,0 +1,14 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.audio.Chime
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+class ChimeTest {
+ @Test fun rendersAudibleShortJingle() {
+ val pcm = Chime.render(Chime.PB)
+ val seconds = pcm.size.toDouble() / Chime.SAMPLE_RATE
+ assertTrue(seconds in 0.8..3.0, "length $seconds")
+ assertTrue(pcm.maxOf { it.toInt() } > 3000)
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/CubeTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/CubeTest.kt
new file mode 100644
index 0000000..829049e
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/CubeTest.kt
@@ -0,0 +1,55 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.puzzle.NxnCube
+import coffee.liz.rookery.puzzle.Notation
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class CubeTest {
+ @Test fun basicIdentities() {
+ for (n in 2..7) {
+ assertTrue(NxnCube(n).apply("R R R R").isSolved)
+ assertTrue(NxnCube(n).apply("R U R' U' R U R' U' R U R' U' R U R' U' R U R' U' R U R' U'").isSolved, "sexy^6 on $n")
+ assertFalse(NxnCube(n).apply("R U").isSolved)
+ }
+ }
+
+ @Test fun scrambleThenInverseSolves() {
+ val scr = "3Rw2 Uw' F 3Fw D2 Lw' B2 x y' 2R 3Uw2 z M"
+ val c = NxnCube(7).apply(scr)
+ assertFalse(c.isSolved)
+ c.apply(Notation.invert(scr))
+ assertTrue(c.isSolved)
+ }
+
+ @Test fun rMovesFrontToUp() {
+ val c = NxnCube(3).apply("R")
+ // U face right column now shows F's colour (index 2)
+ assertEquals(listOf(0, 0, 2, 0, 0, 2, 0, 0, 2), c.face(NxnCube.Face.U).toList())
+ // F face right column shows D (3)
+ assertEquals(listOf(2, 2, 3, 2, 2, 3, 2, 2, 3), c.face(NxnCube.Face.F).toList())
+ }
+
+ @Test fun uMovesFrontToLeft() {
+ val c = NxnCube(3).apply("U")
+ assertEquals(listOf(5, 5, 5), c.face(NxnCube.Face.R).take(3)) // B comes to R
+ assertEquals(listOf(2, 2, 2), c.face(NxnCube.Face.L).take(3)) // F goes to L
+ }
+
+ @Test fun wideEqualsFacePlusSlice() {
+ val a = NxnCube(5).apply("Rw")
+ val b = NxnCube(5).apply("R 2R")
+ assertContentEquals(a.stickers, b.stickers)
+ assertContentEquals(NxnCube(3).apply("x").stickers, NxnCube(3).apply("R M' L'").stickers)
+ }
+
+ @Test fun tPermIsOrderTwo() {
+ val t = "R U R' U' R' F R2 U' R' U' R U R' F'"
+ val c = NxnCube(3).apply(t)
+ assertFalse(c.isSolved)
+ assertTrue(c.apply(t).isSolved)
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/ImportTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/ImportTest.kt
new file mode 100644
index 0000000..ad7659c
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/ImportTest.kt
@@ -0,0 +1,76 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.importer.CsTimerImport
+import coffee.liz.rookery.importer.Importer
+import coffee.liz.rookery.importer.LetterPairGymImport
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.store.MemoryBlobStore
+import coffee.liz.rookery.store.MemoryLogStorage
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.store.Store
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class ImportTest {
+ private val csTimer = """
+ {"session1":[
+ [[0,114010],"Uw' L2 D L' R2 Dw D' R' F D Bw2 B2 Lw2 D2 Rw' Dw2 Lw' U' Dw' F2 B2 Rw D' Dw2 Lw2 R2 B2 Dw' Fw' Rw B2 Lw2 Uw' D F' Bw2 U Uw Bw2 F' R Dw2 Uw Rw' Dw D2 U Uw Lw' B2 F R' Uw2 B' R Lw2 B Lw L2 B'","",1794373390],
+ [[0,25058],"B' R' F2 U2 L2 F2 U2 B' L2 B2 U2 F' R2 U' F2 L D' L' B L2 F","",1794526179,["U'@0 L'@200","333"]],
+ [[2000,14039],"R U F","nice",1794526518],
+ [[-1,14245],"D2 F","",1794534484]],
+ "session2":[[[0,4000],"R U R' U'","",1794534500]],
+ "properties":{"sessionData":"{\"1\":{\"name\":1,\"opt\":{}},\"2\":{\"name\":\"pyra\",\"opt\":{\"scrType\":\"pyrso\"}}}","giiMacMap":"{\"null\":\"AA:BB:CC:DD:EE:FF\"}"}}
+ """.trimIndent()
+
+ @Test fun parsesCsTimer() {
+ val r = CsTimerImport.parse(csTimer)
+ assertEquals(5, r.solves.size)
+ val events = r.solves.map { it.event }
+ assertEquals(listOf(Event.E555, Event.E333, Event.E333, Event.E333, Event.PYRAM), events)
+ assertEquals(listOf(Penalty.NONE, Penalty.NONE, Penalty.PLUS2, Penalty.DNF), r.solves.take(4).map { it.penalty })
+ assertEquals("U'@0 L'@200", r.solves[1].moves)
+ assertEquals(1794373390_000, r.solves[0].at)
+ assertEquals(3, r.sessions.size)
+ assertTrue(r.sessions.any { it.name == "csTimer 1 (5x5)" })
+ assertEquals("AA:BB:CC:DD:EE:FF", r.devices.single().mac)
+ }
+
+ @Test fun importIsIdempotent() {
+ val repo = Repo(Store("t", MemoryLogStorage(), MemoryBlobStore()) { 1 })
+ Importer(repo).csTimer(csTimer)
+ Importer(repo).csTimer(csTimer)
+ assertEquals(5, repo.solves().size)
+ assertEquals(3, repo.sessions().size)
+ assertEquals(1, repo.devices().size)
+ }
+
+ private val gym = """
+ {"app":"letter-pair-gym","v":1,"at":"2026-11-13T03:28:27.903Z",
+ "settings":{"edgeScheme":"ABCDQRSTEFGHIJKLMNOPUVWX","eBuf":2},
+ "words":{"AB":"abacus","AD":"adelie","XW":""},
+ "notes":{"AD":"adelie penguin"},
+ "wordHist":[{"d":1794519658205,"from":"Peg","p":"PG","to":"Postgres"}],
+ "stats":{"AB":{"k":1,"l":[689],"m":5,"ms":61704.39,"n":11,"r":"gsmmsg","s":4}},
+ "memoHist":[{"d":1794505722395,"ms":154339,"ok":8,"p":10}],
+ "solves":[],
+ "images":{"AB":"images/AB - abacus.jpg","AD":"images/AD - missing.jpg"}}
+ """.trimIndent()
+
+ @Test fun importsLetterPairGym() {
+ val parsed = LetterPairGymImport.parse(gym)
+ assertEquals(mapOf("AB" to "abacus", "AD" to "adelie"), parsed.words)
+ assertEquals(listOf(689L), parsed.stats.getValue("AB").latenciesMs)
+
+ val repo = Repo(Store("t", MemoryLogStorage(), MemoryBlobStore()) { 1 })
+ val summary = Importer(repo).letterPairGym(gym) { path -> if (path.startsWith("images/AB")) byteArrayOf(1, 2, 3) else null }
+ val pairs = repo.letterPairs()
+ assertEquals("abacus", pairs.getValue("AB").primary)
+ assertEquals("adelie penguin", pairs.getValue("AD").note)
+ assertTrue(pairs.getValue("AB").image!!.endsWith(".jpg"))
+ assertEquals(1, summary.warnings.size)
+ assertEquals("Postgres", repo.wordHistory().single().to)
+ assertEquals(1, repo.memoSessions().size)
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/StatsTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/StatsTest.kt
new file mode 100644
index 0000000..35fc1fc
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/StatsTest.kt
@@ -0,0 +1,54 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.model.DNF
+import coffee.liz.rookery.model.formatTime
+import coffee.liz.rookery.stats.Averages
+import coffee.liz.rookery.stats.StatKind
+import coffee.liz.rookery.stats.StatSummary
+import coffee.liz.rookery.stats.newPersonalBests
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class StatsTest {
+ @Test fun ao5TrimsBestAndWorst() {
+ assertEquals(12000, Averages.average(listOf(10000, 11000, 12000, 13000, 20000)))
+ assertEquals(12000, Averages.average(listOf(10000, 11000, 12000, 13000, DNF)))
+ assertEquals(DNF, Averages.average(listOf(10000, 11000, 12000, DNF, DNF)))
+ }
+
+ @Test fun trimCounts() {
+ assertEquals(1, Averages.trim(5)); assertEquals(1, Averages.trim(12))
+ assertEquals(3, Averages.trim(50)); assertEquals(5, Averages.trim(100)); assertEquals(50, Averages.trim(1000))
+ }
+
+ @Test fun mo3AnyDnfIsDnf() {
+ assertEquals(DNF, Averages.mean(listOf(1, 2, DNF)))
+ assertEquals(2, Averages.mean(listOf(1, 2, 3)))
+ }
+
+ @Test fun slidingBestMatchesBruteForce() {
+ val rng = kotlin.random.Random(42)
+ val xs = List(300) { if (rng.nextInt(20) == 0) DNF else rng.nextLong(8000, 20000) }
+ for (n in listOf(5, 12, 100)) {
+ val brute = (0..xs.size - n).minOf { Averages.average(xs.subList(it, it + n)) }
+ assertEquals(brute, Averages.best(xs, n))
+ }
+ assertEquals((0..xs.size - 3).minOf { Averages.mean(xs.subList(it, it + 3)) }, Averages.best(xs, 3, mean = true))
+ }
+
+ @Test fun personalBestsFromLatestSolve() {
+ val before = listOf(15000L, 14000, 16000, 15500, 14500)
+ val after = before + 11000L
+ val pbs = newPersonalBests(StatSummary.of(before), StatSummary.of(after)).map { it.kind }
+ assertEquals(listOf(StatKind.SINGLE, StatKind.MO3, StatKind.AO5), pbs)
+ assertNull(newPersonalBests(StatSummary.of(after), StatSummary.of(after + 30000L)).firstOrNull { it.kind == StatKind.SINGLE })
+ }
+
+ @Test fun formatting() {
+ assertEquals("9.99", formatTime(9999))
+ assertEquals("10.00", formatTime(9999, round = true))
+ assertEquals("1:02.34", formatTime(62345))
+ assertEquals("DNF", formatTime(DNF))
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/StoreTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/StoreTest.kt
new file mode 100644
index 0000000..e5fc945
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/StoreTest.kt
@@ -0,0 +1,65 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.model.Solve
+import coffee.liz.rookery.store.Hlc
+import coffee.liz.rookery.store.HlcClock
+import coffee.liz.rookery.store.MemoryBlobStore
+import coffee.liz.rookery.store.MemoryLogStorage
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.store.Store
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class StoreTest {
+ @Test fun hlcOrdersAndRoundTrips() {
+ var now = 1000L
+ val c = HlcClock("a") { now }
+ val a = c.tick(); val b = c.tick()
+ assertTrue(b > a)
+ now = 500 // clock went backwards
+ assertTrue(c.tick() > b)
+ c.observe(Hlc(9999, 7, "z"))
+ assertTrue(c.tick() > Hlc(9999, 7, "z"))
+ assertEquals(a, Hlc.parse(a.toString()))
+ assertTrue(Hlc(5, 0, "a").toString() < Hlc(10, 0, "a").toString())
+ }
+
+ private fun solve(id: String, ms: Long) = Solve(id, "s", Event.E333, ms, Penalty.NONE, "R U", "", 1)
+
+ @Test fun twoDevicesConvergeRegardlessOfOrder() {
+ val shared = mutableListOf<String>()
+ var t = 0L
+ val a = Repo(Store("dev-a", MemoryLogStorage(shared), MemoryBlobStore()) { t })
+ val b = Repo(Store("dev-b", MemoryLogStorage(shared), MemoryBlobStore()) { t })
+
+ t = 10; a.addSolve(solve("x", 1000))
+ t = 11; b.addSolve(solve("y", 2000))
+ a.store.sync(); b.store.sync()
+ // concurrent edits to the same field: later stamp wins on both sides
+ t = 20; a.setPenalty("x", Penalty.PLUS2)
+ t = 21; b.setPenalty("x", Penalty.DNF)
+ t = 22; b.deleteSolve("y")
+ a.store.sync(); b.store.sync()
+
+ assertEquals(a.solves(), b.solves())
+ assertEquals(listOf("x"), a.solves().map { it.id })
+ assertEquals(Penalty.DNF, a.solves().single().penalty)
+ }
+
+ @Test fun letterPairWordsMergeAsSet() {
+ val shared = mutableListOf<String>()
+ var t = 0L
+ val a = Repo(Store("a", MemoryLogStorage(shared), MemoryBlobStore()) { t })
+ val b = Repo(Store("b", MemoryLogStorage(shared), MemoryBlobStore()) { t })
+ t = 1; a.setPrimaryWord("AB", "abacus", 1)
+ t = 2; b.setPrimaryWord("AB", "abba", 2)
+ a.store.sync(); b.store.sync()
+ val p = a.letterPairs().getValue("AB")
+ assertEquals("abba", p.primary)
+ assertEquals(setOf("abacus", "abba"), p.words)
+ assertEquals(2, a.wordHistory().size)
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/TimerTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/TimerTest.kt
new file mode 100644
index 0000000..2ee2421
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/TimerTest.kt
@@ -0,0 +1,46 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.timer.TimerMachine
+import coffee.liz.rookery.timer.TimerMachine.State
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+
+class TimerTest {
+ @Test fun inspectionFlowWithPlusTwo() {
+ val t = TimerMachine(inspection = true)
+ var result: Pair<Long, Penalty>? = null
+ t.onFinish = { ms, p -> result = ms to p }
+ t.press(0); t.tick(500); t.release(600)
+ assertIs<State.Inspecting>(t.state)
+ t.press(15_500); t.tick(15_700)
+ assertIs<State.Holding>(t.state)
+ t.tick(15_850)
+ assertIs<State.Ready>(t.state)
+ t.release(16_000)
+ assertEquals(State.Running(16_000, Penalty.PLUS2), t.state)
+ t.press(26_000)
+ assertEquals(10_000L to Penalty.PLUS2, result)
+ t.press(26_100) // still holding the stop key: nothing happens
+ assertIs<State.Stopped>(t.state)
+ t.release(26_200); t.press(27_000)
+ assertIs<State.Holding>(t.state)
+ }
+
+ @Test fun releasingEarlyDoesNotStart() {
+ val t = TimerMachine(inspection = false)
+ t.press(0); t.tick(100); t.release(150)
+ assertEquals(State.Idle, t.state)
+ t.press(200); t.tick(600); t.release(700)
+ assertIs<State.Running>(t.state)
+ }
+
+ @Test fun overInspectionIsDnf() {
+ val t = TimerMachine(inspection = true)
+ var p: Penalty? = null
+ t.onFinish = { _, pen -> p = pen }
+ t.press(0); t.release(10); t.tick(17_100)
+ assertEquals(Penalty.DNF, p)
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/TwistyPuzzleTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/TwistyPuzzleTest.kt
new file mode 100644
index 0000000..09a1aae
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/TwistyPuzzleTest.kt
@@ -0,0 +1,72 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.model.Puzzle
+import coffee.liz.rookery.puzzle.Notation
+import coffee.liz.rookery.puzzle.NxnCube
+import coffee.liz.rookery.puzzle.Puzzles
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class TwistyPuzzleTest {
+ @Test fun stickerCounts() {
+ for (n in 2..7) assertEquals(6 * n * n, Puzzles.cube(n).stickers.size, "${n}x$n")
+ assertEquals(36, Puzzles.pyraminx().stickers.size)
+ assertEquals(30, Puzzles.skewb().stickers.size)
+ }
+
+ @Test fun stickersFaceOutwards() {
+ for (p in listOf(Puzzles.cube(3), Puzzles.pyraminx(), Puzzles.skewb())) {
+ for (s in p.stickers) assertTrue((s.normal dot s.centroid) > 0, "${p.name} sticker at ${s.centroid} faces inward")
+ }
+ }
+
+ @Test fun cubeMatchesNxnModelFaceByFace() {
+ val alg = "R U2 F' 2L Dw B2 3Rw' x U' M2 y' S"
+ for (n in listOf(3, 5, 7)) {
+ val generic = Puzzles.cube(n).solved().apply(alg)
+ val reference = NxnCube(n).apply(alg)
+ for (f in NxnCube.Face.entries) {
+ val mine = generic.colors.filterIndexed { i, _ -> generic.puzzle.stickerFace[i] == f.ordinal }.sorted()
+ assertEquals(reference.face(f).sorted(), mine, "${n}x$n face $f")
+ }
+ assertTrue(generic.apply(Notation.invert(alg)).isSolved)
+ }
+ }
+
+ @Test fun cubeMoveOrders() {
+ val c = Puzzles.cube(3).solved()
+ assertTrue(c.apply("R R R R").isSolved)
+ assertTrue(c.apply("R U R' U' ".repeat(6)).isSolved)
+ val t = "R U R' U' R' F R2 U' R' U' R U R' F'"
+ assertFalse(c.apply(t).isSolved)
+ assertTrue(c.apply("$t $t").isSolved)
+ }
+
+ @Test fun pyraminxMoves() {
+ val p = Puzzles.pyraminx().solved()
+ assertTrue(p.apply("U U U").isSolved)
+ assertTrue(p.apply("u u'").isSolved)
+ assertFalse(p.apply("U").isSolved)
+ // a tip turn only moves the 3 tip stickers
+ assertEquals(3, p.apply("u").colors.indices.count { p.apply("u").colors[it] != p.colors[it] })
+ val scr = "U' L' R B U' L B' U r' b"
+ assertTrue(p.apply(scr).apply("b' r U' B L' U B' R' L U").isSolved)
+ }
+
+ @Test fun skewbMoves() {
+ val s = Puzzles.skewb().solved()
+ assertTrue(s.apply("R R R").isSolved)
+ assertFalse(s.apply("R").isSolved)
+ // a skewb turn moves half the puzzle: 3 faces touch the turning corner fully-ish; count changes
+ val moved = s.apply("R").colors.indices.count { s.apply("R").colors[it] != s.colors[it] }
+ assertEquals(15, moved)
+ assertTrue(s.apply("R U' L B' R' U").apply("U' R B L' U R'").isSolved)
+ }
+
+ @Test fun modelsExistForCubesPyraAndSkewb() {
+ for (p in listOf(Puzzle.CUBE2, Puzzle.CUBE7, Puzzle.PYRAMINX, Puzzle.SKEWB)) assertNotNull(Puzzles.of(p))
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt
new file mode 100644
index 0000000..5e45af3
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt
@@ -0,0 +1,264 @@
+package coffee.liz.rookery.bt
+
+import coffee.liz.rookery.bt.SmartCubeEvent.Battery
+import coffee.liz.rookery.bt.SmartCubeEvent.Facelets
+import coffee.liz.rookery.bt.SmartCubeEvent.Gyro
+import coffee.liz.rookery.bt.SmartCubeEvent.Hardware
+import coffee.liz.rookery.bt.SmartCubeEvent.Move
+import kotlinx.coroutines.test.runTest
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/** Records commands a driver sends back to the cube. */
+class RecordingRawConnection : GanRawConnection {
+ val sent = mutableListOf<ByteArray>()
+ var disconnects = 0
+ override suspend fun sendCommandMessage(message: ByteArray) { sent += message }
+ override suspend fun disconnect() { disconnects++ }
+}
+
+/** Cube state after F R (from gan-web-bluetooth's docs). */
+val F_R_STATE = CubeState(
+ cp = listOf(0, 5, 2, 1, 7, 4, 6, 3), co = listOf(1, 2, 0, 2, 1, 1, 0, 2),
+ ep = listOf(1, 9, 2, 3, 11, 8, 6, 7, 4, 5, 10, 0), eo = listOf(1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0),
+)
+const val F_R_FACELETS = "UUFUUFLLFUUURRRRRRFFRFFDFFDRRBDDBDDBLLDLLDLLDLBBUBBUBB"
+val SOLVED_STATE = CubeState(List(8) { it }, List(8) { 0 }, List(12) { it }, List(12) { 0 })
+
+/** Writes the first 7 corners / 11 edges of [s] at the given bit offsets, as the cube does. */
+fun GanBitWriter.state(s: CubeState, cpAt: Int, coAt: Int, epAt: Int, eoAt: Int): GanBitWriter {
+ for (i in 0 until 7) { put(cpAt + i * 3, 3, s.cp[i]); put(coAt + i * 2, 2, s.co[i]) }
+ for (i in 0 until 11) { put(epAt + i * 4, 4, s.ep[i]); put(eoAt + i, 1, s.eo[i]) }
+ return this
+}
+
+object Packets {
+ fun gen2Facelets(serial: Int, s: CubeState) = GanBitWriter(20).put(0, 4, 0x4).put(4, 8, serial).state(s, 12, 33, 47, 91).bytes
+
+ /** [moves] newest first: (face 0..5 URFDLB, direction 0/1, elapsed ms since previous move). */
+ fun gen2Moves(serial: Int, moves: List<Triple<Int, Int, Int>>): ByteArray {
+ val w = GanBitWriter(20).put(0, 4, 0x2).put(4, 8, serial)
+ moves.forEachIndexed { i, (face, dir, elapsed) -> w.put(12 + 5 * i, 4, face).put(16 + 5 * i, 1, dir).put(47 + 16 * i, 16, elapsed) }
+ return w.bytes
+ }
+
+ // Live-move face codes for Gen3/Gen4, indexed by face URFDLB.
+ val LIVE = listOf(2, 32, 8, 1, 16, 4)
+ val HISTORY = listOf(1, 5, 3, 0, 4, 2)
+
+ fun gen3Facelets(serial: Int, s: CubeState) =
+ GanBitWriter(20).put(0, 8, 0x55).put(8, 8, 0x02).put(16, 8, 0x10).put(24, 16, serial, true).state(s, 40, 61, 77, 121).bytes
+
+ fun gen3Move(serial: Int, face: Int, dir: Int, cubeTs: Long) =
+ GanBitWriter(16).put(0, 8, 0x55).put(8, 8, 0x01).put(16, 8, 0x08).put(24, 32, cubeTs, true)
+ .put(56, 16, serial, true).put(72, 2, dir).put(74, 6, LIVE[face]).bytes
+
+ fun gen4Facelets(serial: Int, s: CubeState) =
+ GanBitWriter(20).put(0, 8, 0xED).put(8, 8, 0x10).put(16, 16, serial, true).state(s, 32, 53, 69, 113).bytes
+
+ fun gen4Move(serial: Int, face: Int, dir: Int, cubeTs: Long) =
+ GanBitWriter(20).put(0, 8, 0x01).put(8, 8, 0x08).put(16, 32, cubeTs, true)
+ .put(48, 16, serial, true).put(64, 2, dir).put(66, 6, LIVE[face]).bytes
+
+ /** Gen4 move history: [moves] are (face, dir) for serials startSerial, startSerial-1, ... */
+ fun gen4History(startSerial: Int, moves: List<Pair<Int, Int>>): ByteArray {
+ val w = GanBitWriter(20).put(0, 8, 0xD1).put(8, 8, moves.size / 2 + 1).put(16, 8, startSerial)
+ moves.forEachIndexed { i, (face, dir) -> w.put(24 + 4 * i, 3, HISTORY[face]).put(27 + 4 * i, 1, dir) }
+ return w.bytes
+ }
+}
+
+class GanProtocolTest {
+ private var now = 1_000L
+ private val clock = { now }
+
+ @Test fun bitReaderRoundTripsAllWidths() {
+ val w = GanBitWriter(12).put(3, 5, 0b10110).put(8, 16, 0xBEEF).put(27, 16, 0x1234, true).put(43, 32, 0xDEADBEEFL, true)
+ val r = GanBitReader(w.bytes)
+ assertEquals(0b10110, r.bits(3, 5))
+ assertEquals(0xBEEF, r.bits(8, 16))
+ assertEquals(0x1234, r.bits(27, 16, true))
+ assertEquals(0xDEADBEEFL, r.u32(43, true))
+ assertEquals(0, r.bits(200, 8)) // past the end reads as zero
+ }
+
+ @Test fun kociembaFacelets() {
+ assertEquals(GanFacelets.SOLVED, GanFacelets.toKociemba(SOLVED_STATE))
+ assertEquals(F_R_FACELETS, GanFacelets.toKociemba(F_R_STATE))
+ val completed = GanFacelets.complete(F_R_STATE.cp.take(7), F_R_STATE.co.take(7), F_R_STATE.ep.take(11), F_R_STATE.eo.take(11))
+ assertEquals(F_R_STATE, completed)
+ }
+
+ @Test fun gen2Commands() {
+ val d = GanGen2ProtocolDriver(clock)
+ assertEquals(20, d.createCommandMessage(GanCommand.REQUEST_FACELETS).size)
+ assertEquals(0x04, d.createCommandMessage(GanCommand.REQUEST_FACELETS)[0].toInt())
+ assertEquals(0x05, d.createCommandMessage(GanCommand.REQUEST_HARDWARE)[0].toInt())
+ assertEquals(0x09, d.createCommandMessage(GanCommand.REQUEST_BATTERY)[0].toInt())
+ assertContentEquals(
+ byteArrayOf(0x0A, 0x05, 0x39, 0x77, 0, 0, 0x01, 0x23, 0x45, 0x67, 0x89.toByte(), 0xAB.toByte(), 0, 0, 0, 0, 0, 0, 0, 0),
+ d.createCommandMessage(GanCommand.REQUEST_RESET),
+ )
+ }
+
+ @Test fun gen2FaceletsMovesBatteryHardwareGyro() = runTest {
+ val d = GanGen2ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ // Moves before the first facelets event are ignored.
+ assertEquals(emptyList(), d.handleStateEvent(c, Packets.gen2Moves(5, listOf(Triple(1, 0, 100)))))
+
+ val f = d.handleStateEvent(c, Packets.gen2Facelets(10, F_R_STATE)).single() as Facelets
+ assertEquals(F_R_FACELETS, f.facelets)
+ assertEquals(10, f.serial)
+ assertEquals(F_R_STATE, f.state)
+
+ now = 2_000
+ // Serial 11: one new move R (face 1, CW), 120 ms after the previous.
+ val m1 = d.handleStateEvent(c, Packets.gen2Moves(11, listOf(Triple(1, 0, 120)))).single() as Move
+ assertEquals(Move("R", 120, 2_000, 11, recovered = false), m1)
+
+ // Serial 13: two moves; the older (U', serial 12) was missed live and comes with the newer (F, serial 13).
+ now = 2_500
+ val ms = d.handleStateEvent(c, Packets.gen2Moves(13, listOf(Triple(2, 0, 200), Triple(0, 1, 150))))
+ assertEquals(listOf(Move("U'", 270, 2_500, 12, true), Move("F", 470, 2_500, 13, false)), ms)
+
+ val battery = GanBitWriter(20).put(0, 4, 0x9).put(8, 8, 87).bytes
+ assertEquals(Battery(87), d.handleStateEvent(c, battery).single())
+ assertEquals(Battery(100), d.handleStateEvent(c, GanBitWriter(20).put(0, 4, 0x9).put(8, 8, 140).bytes).single())
+
+ val hw = GanBitWriter(20).put(0, 4, 0x5).put(8, 8, 1).put(16, 8, 2).put(24, 8, 3).put(32, 8, 4).put(104, 1, 1)
+ "GAN12ui!".forEachIndexed { i, ch -> hw.put(40 + 8 * i, 8, ch.code) }
+ assertEquals(Hardware("GAN12ui!", "1.2", "3.4", null, true), d.handleStateEvent(c, hw.bytes).single())
+
+ // Gyro: w = +1, x = -0.5 (sign bit + magnitude), velocity x = -3.
+ val g = GanBitWriter(20).put(0, 4, 0x1).put(4, 16, 0x7FFF).put(20, 16, 0x8000 or 0x3FFF).put(68, 4, 0b1011)
+ val gyro = d.handleStateEvent(c, g.bytes).single() as Gyro
+ assertEquals(1.0, gyro.quaternion.w)
+ assertTrue(abs(gyro.quaternion.x + 0.5) < 1e-3)
+ assertEquals(-3, gyro.velocity!!.x)
+
+ assertEquals(emptyList(), d.handleStateEvent(c, GanBitWriter(20).put(0, 4, 0xD).bytes))
+ assertEquals(1, c.disconnects)
+ }
+
+ @Test fun gen3Commands() {
+ val d = GanGen3ProtocolDriver(clock)
+ assertContentEquals(byteArrayOf(0x68, 0x01) + ByteArray(14), d.createCommandMessage(GanCommand.REQUEST_FACELETS))
+ assertContentEquals(byteArrayOf(0x68, 0x04) + ByteArray(14), d.createCommandMessage(GanCommand.REQUEST_HARDWARE))
+ assertContentEquals(byteArrayOf(0x68, 0x07) + ByteArray(14), d.createCommandMessage(GanCommand.REQUEST_BATTERY))
+ assertEquals(0x05, d.createCommandMessage(GanCommand.REQUEST_RESET)[2].toInt())
+ }
+
+ @Test fun gen3FaceletsMovesBatteryHardware() = runTest {
+ val d = GanGen3ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ assertEquals(emptyList(), d.handleStateEvent(c, Packets.gen3Move(1, 0, 0, 10)))
+ assertEquals(Facelets(GanFacelets.SOLVED, 7, SOLVED_STATE), d.handleStateEvent(c, Packets.gen3Facelets(7, SOLVED_STATE)).single())
+ assertEquals(Move("L'", 123_456, 1_000, 8), d.handleStateEvent(c, Packets.gen3Move(8, 4, 1, 123_456)).single())
+ assertEquals(Move("B", 0xFFFFFFF0L, 1_000, 9), d.handleStateEvent(c, Packets.gen3Move(9, 5, 0, 0xFFFFFFF0L)).single())
+ // Wrong magic is ignored.
+ assertEquals(emptyList(), d.handleStateEvent(c, Packets.gen3Move(10, 0, 0, 1).also { it[0] = 0x54 }))
+
+ val battery = GanBitWriter(16).put(0, 8, 0x55).put(8, 8, 0x10).put(16, 8, 1).put(24, 8, 64).bytes
+ assertEquals(Battery(64), d.handleStateEvent(c, battery).single())
+
+ val hw = GanBitWriter(16).put(0, 8, 0x55).put(8, 8, 0x07).put(16, 8, 9).put(72, 4, 2).put(76, 4, 5).put(80, 4, 1).put(84, 4, 0)
+ "GANic".forEachIndexed { i, ch -> hw.put(32 + 8 * i, 8, ch.code) }
+ assertEquals(Hardware("GANic", "1.0", "2.5", null, false), d.handleStateEvent(c, hw.bytes).single())
+ }
+
+ @Test fun gen4Commands() {
+ val d = GanGen4ProtocolDriver(clock)
+ assertContentEquals(byteArrayOf(0xDD.toByte(), 0x04, 0x00, 0xED.toByte(), 0, 0) + ByteArray(14), d.createCommandMessage(GanCommand.REQUEST_FACELETS))
+ assertContentEquals(byteArrayOf(0xDD.toByte(), 0x04, 0x00, 0xEF.toByte(), 0, 0) + ByteArray(14), d.createCommandMessage(GanCommand.REQUEST_BATTERY))
+ assertContentEquals(byteArrayOf(0xDF.toByte(), 0x03) + ByteArray(18), d.createCommandMessage(GanCommand.REQUEST_HARDWARE))
+ assertEquals(0xD2.toByte(), d.createCommandMessage(GanCommand.REQUEST_RESET)[0])
+ }
+
+ @Test fun gen4MovesRecoverMissedMovesFromHistory() = runTest {
+ val d = GanGen4ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ assertEquals(F_R_FACELETS, (d.handleStateEvent(c, Packets.gen4Facelets(10, F_R_STATE)).single() as Facelets).facelets)
+ assertEquals(listOf(Move("D", 5000, 1_000, 11)), d.handleStateEvent(c, Packets.gen4Move(11, 3, 0, 5000)))
+
+ // Serial 14 arrives but 12 and 13 were lost: it is held back and history is requested.
+ assertEquals(emptyList(), d.handleStateEvent(c, Packets.gen4Move(14, 1, 1, 5600)))
+ assertContentEquals(byteArrayOf(0xD1.toByte(), 0x04, 13, 0, 4, 0) + ByteArray(14), c.sent.single())
+
+ // History: serials 13 (F'), 12 (U), 11 (D), 10 (whatever) newest first.
+ now = 1_050
+ val recovered = d.handleStateEvent(c, Packets.gen4History(13, listOf(2 to 1, 0 to 0, 3 to 0, 5 to 0)))
+ assertEquals(
+ listOf(Move("U", null, 1_050, 12, true), Move("F'", null, 1_050, 13, true), Move("R'", 5600, 1_000, 14)),
+ recovered,
+ )
+ }
+
+ @Test fun gen4FaceletsTriggerHistoryWhenIdle() = runTest {
+ val d = GanGen4ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ d.handleStateEvent(c, Packets.gen4Facelets(20, SOLVED_STATE))
+ d.handleStateEvent(c, Packets.gen4Move(21, 0, 0, 100))
+ // Periodic facelets says serial 23 more than 500 ms after the last move: 22 and 23 were missed.
+ now += 600
+ d.handleStateEvent(c, Packets.gen4Facelets(23, SOLVED_STATE))
+ assertContentEquals(byteArrayOf(0xD1.toByte(), 0x04, 23, 0, 4, 0) + ByteArray(14), c.sent.single())
+ val got = d.handleStateEvent(c, Packets.gen4History(23, listOf(1 to 0, 2 to 0, 0 to 0, 0 to 0)))
+ assertEquals(listOf("F", "R"), got.map { (it as Move).move })
+ assertEquals(listOf(22, 23), got.map { (it as Move).serial })
+ }
+
+ @Test fun gen4BatteryHardwareGyro() = runTest {
+ val d = GanGen4ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ // Battery level sits right after the payload: byte 1 + dataLength.
+ val battery = GanBitWriter(20).put(0, 8, 0xEF).put(8, 8, 3).put(32, 8, 55).bytes
+ assertEquals(Battery(55), d.handleStateEvent(c, battery).single())
+
+ d.createCommandMessage(GanCommand.REQUEST_HARDWARE)
+ val date = GanBitWriter(20).put(0, 8, 0xFA).put(8, 8, 5).put(24, 16, 2023, true).put(40, 8, 7).put(48, 8, 9).bytes
+ val nameW = GanBitWriter(20).put(0, 8, 0xFC).put(8, 8, 9)
+ "GAN12uiM".forEachIndexed { i, ch -> nameW.put(24 + 8 * i, 8, ch.code) }
+ val sw = GanBitWriter(20).put(0, 8, 0xFD).put(8, 8, 2).put(24, 4, 1).put(28, 4, 6).bytes
+ val hw = GanBitWriter(20).put(0, 8, 0xFE).put(8, 8, 2).put(24, 4, 1).put(28, 4, 1).bytes
+ assertEquals(emptyList(), d.handleStateEvent(c, date))
+ assertEquals(emptyList(), d.handleStateEvent(c, nameW.bytes))
+ assertEquals(emptyList(), d.handleStateEvent(c, sw))
+ assertEquals(Hardware("GAN12uiM", "1.1", "1.6", "2023-07-09", true), d.handleStateEvent(c, hw).single())
+
+ val g = GanBitWriter(20).put(0, 8, 0xEC).put(8, 8, 0x0A).put(16, 16, 0x7FFF).put(48, 16, 0x8000 or 0x7FFF).put(88, 4, 0b0010).bytes
+ val gyro = d.handleStateEvent(c, g).single() as Gyro
+ assertEquals(Quaternion(0.0, -1.0, 0.0, 1.0), gyro.quaternion)
+ assertEquals(AngularVelocity(0, 0, 2), gyro.velocity)
+ }
+
+ @Test fun timestampLinearFit() {
+ // Cube clock runs 10% slow relative to the host.
+ val fit = GanTimestampFit.linearFit(listOf(TimedMove("R", 1000, 0), TimedMove("U", 1110, 100), TimedMove("R'", 1220, 200)))
+ assertEquals(listOf(0L, 110L, 220L), fit.map { it.cubeTimestampMs })
+ assertEquals(-9.091, GanTimestampFit.skewPercent(listOf(TimedMove("R", 1000, 0), TimedMove("U", 1110, 100), TimedMove("R'", 1220, 200))))
+
+ // A recovered move without timestamps is interpolated from its neighbours (next - 50 ms).
+ val gap = GanTimestampFit.linearFit(listOf(TimedMove("R", 1000, 0), TimedMove("U", null, null), TimedMove("R'", 1220, 200)))
+ assertEquals(listOf(0L, 165L, 220L), gap.map { it.cubeTimestampMs })
+ val lastMissing = GanTimestampFit.linearFit(listOf(TimedMove("R", 1000, 0), TimedMove("U", 1100, 100), TimedMove("R'", null, null)))
+ assertEquals(listOf(0L, 100L, 150L), lastMissing.map { it.cubeTimestampMs })
+ assertEquals(emptyList(), GanTimestampFit.linearFit(emptyList()))
+ }
+
+ @Test fun macAndScanHelpers() {
+ assertContentEquals(byteArrayOf(0x78, 0x56, 0x34, 0x12, 0xF0.toByte(), 0x6F), GanDefinitions.saltFromMac("6F:F0:12:34:56:78"))
+ assertContentEquals(GanDefinitions.saltFromMac("6f-f0-12-34-56-78"), GanDefinitions.saltFromMac("6F:F0:12:34:56:78"))
+ // Manufacturer data (company 0x1201): 3 bytes of something, then the MAC reversed.
+ val mfr = mapOf(0x1201 to byteArrayOf(1, 2, 3, 0x78, 0x56, 0x34, 0x12, 0xF0.toByte(), 0x6F, 9, 9))
+ assertEquals("6F:F0:12:34:56:78", GanDefinitions.macFromManufacturerData(mfr))
+ assertTrue(GanCubeConnector.isGanCube(BleAdvertisement("6F:F0:12:34:56:78", null, -60, mfr)))
+ assertTrue(GanCubeConnector.isGanCube(BleAdvertisement("00:11:22:33:44:55", "GAN12uiM_1A2B", -60)))
+ assertTrue(GanCubeConnector.isGanCube(BleAdvertisement("00:11:22:33:44:55", null, -60, serviceUuids = listOf(GanDefinitions.GEN3_SERVICE))))
+ assertTrue(!GanCubeConnector.isGanCube(BleAdvertisement("00:11:22:33:44:55", "Headphones", -60, mfr)))
+ assertEquals(GanGeneration.GEN4, GanGeneration.fromServiceUuids(listOf("0000180a-0000-1000-8000-00805f9b34fb", GanDefinitions.GEN4_SERVICE.uppercase())))
+ }
+}
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt
new file mode 100644
index 0000000..bbccb57
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt
@@ -0,0 +1,6 @@
+package coffee.liz.rookery
+
+actual fun epochMillis(): Long = System.currentTimeMillis()
+actual fun monoMillis(): Long = System.nanoTime() / 1_000_000
+actual fun randomId(): String = java.util.UUID.randomUUID().toString().replace("-", "").take(16)
+actual fun <T> locked(lock: Any, block: () -> T): T = synchronized(lock, block)
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/audio/ChimePlayer.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/audio/ChimePlayer.kt
new file mode 100644
index 0000000..3b70b67
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/audio/ChimePlayer.kt
@@ -0,0 +1,21 @@
+package coffee.liz.rookery.audio
+
+import javax.sound.sampled.AudioFormat
+import javax.sound.sampled.AudioSystem
+
+/** Plays [Chime] PCM on a daemon thread. Silently does nothing if there's no audio device. */
+object ChimePlayer {
+ fun play(notes: List<Chime.Note>) {
+ val pcm = Chime.render(notes)
+ Thread {
+ runCatching {
+ val format = AudioFormat(Chime.SAMPLE_RATE.toFloat(), 16, 1, true, false)
+ val bytes = ByteArray(pcm.size * 2)
+ for (i in pcm.indices) { bytes[2 * i] = pcm[i].toByte(); bytes[2 * i + 1] = (pcm[i].toInt() shr 8).toByte() }
+ AudioSystem.getSourceDataLine(format).use { line ->
+ line.open(format); line.start(); line.write(bytes, 0, bytes.size); line.drain()
+ }
+ }
+ }.apply { isDaemon = true; name = "chime" }.start()
+ }
+}
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/BlueZTransport.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/BlueZTransport.kt
new file mode 100644
index 0000000..db2ae70
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/BlueZTransport.kt
@@ -0,0 +1,287 @@
+@file:Suppress("FunctionName")
+
+package coffee.liz.rookery.bt
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlinx.coroutines.channels.awaitClose
+import org.freedesktop.dbus.DBusPath
+import org.freedesktop.dbus.annotations.DBusInterfaceName
+import org.freedesktop.dbus.connections.impl.DBusConnection
+import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder
+import org.freedesktop.dbus.interfaces.DBusInterface
+import org.freedesktop.dbus.interfaces.ObjectManager
+import org.freedesktop.dbus.interfaces.Properties
+import org.freedesktop.dbus.types.Variant
+import java.util.concurrent.CopyOnWriteArrayList
+
+@DBusInterfaceName("org.bluez.Adapter1")
+@JvmSuppressWildcards
+internal interface BluezAdapter1 : DBusInterface {
+ fun StartDiscovery()
+ fun StopDiscovery()
+ fun SetDiscoveryFilter(filter: Map<String, Variant<*>>)
+}
+
+@DBusInterfaceName("org.bluez.Device1")
+internal interface BluezDevice1 : DBusInterface {
+ fun Connect()
+ fun Disconnect()
+}
+
+@DBusInterfaceName("org.bluez.GattCharacteristic1")
+@JvmSuppressWildcards
+internal interface BluezGattCharacteristic1 : DBusInterface {
+ fun ReadValue(options: Map<String, Variant<*>>): ByteArray
+ fun WriteValue(value: ByteArray, options: Map<String, Variant<*>>)
+ fun StartNotify()
+ fun StopNotify()
+}
+
+/**
+ * [BleTransport] over BlueZ's D-Bus API (Linux). Only discovers, connects and disconnects:
+ * never pairs, trusts or changes adapter settings. Close it to release the D-Bus connection.
+ */
+class BlueZTransport(private val adapterName: String? = null) : BleTransport, AutoCloseable {
+ private val bus: DBusConnection by lazy {
+ DBusConnectionBuilder.forSystemBus().withShared(false).build().also { c ->
+ c.addSigHandler(Properties.PropertiesChanged::class.java) { s ->
+ // dbus-java can't match on a well-known sender name, so filter by BlueZ's object paths instead.
+ if (s.path.startsWith(BLUEZ_ROOT)) listeners.forEach { it(s.path, s.interfaceName, s.propertiesChanged) }
+ }
+ c.addSigHandler(ObjectManager.InterfacesAdded::class.java) { s ->
+ val path = s.signalSource.path
+ if (path.startsWith(BLUEZ_ROOT)) s.interfaces.forEach { (iface, props) -> listeners.forEach { it(path, iface, props) } }
+ }
+ }
+ }
+
+ /** (objectPath, interface, changedProperties) */
+ private val listeners = CopyOnWriteArrayList<(String, String, Map<String, Variant<*>>) -> Unit>()
+
+ private fun managedObjects(): Map<String, Map<String, Map<String, Variant<*>>>> =
+ bus.getRemoteObject(BLUEZ, "/", ObjectManager::class.java).GetManagedObjects().mapKeys { it.key.path }
+
+ private fun adapterPath(objects: Map<String, Map<String, Map<String, Variant<*>>>> = managedObjects()): String {
+ val adapters = objects.filterValues { ADAPTER in it }.keys.sorted()
+ if (adapterName != null) return adapters.firstOrNull { it.endsWith("/$adapterName") } ?: error("No Bluetooth adapter $adapterName")
+ return adapters.firstOrNull { objects[it]!![ADAPTER]!!["Powered"]?.value == true } ?: adapters.firstOrNull()
+ ?: error("No Bluetooth adapter found (is bluetoothd running?)")
+ }
+
+ private fun adapter(path: String) = bus.getRemoteObject(BLUEZ, path, BluezAdapter1::class.java)
+
+ private fun devicePath(adapter: String, address: String) = "$adapter/dev_" + address.uppercase().replace(':', '_')
+
+ override fun scan(): Flow<BleAdvertisement> = callbackFlow {
+ val objects = managedObjects()
+ val adapterPath = adapterPath(objects)
+ val devices = HashMap<String, MutableMap<String, Any?>>()
+ fun update(path: String, props: Map<String, Variant<*>>, emitIfNoRssi: Boolean) {
+ if (!path.startsWith("$adapterPath/dev_") || path.count { it == '/' } != adapterPath.count { it == '/' } + 1) return
+ val merged = synchronized(devices) {
+ devices.getOrPut(path) { HashMap() }.also { m -> props.forEach { (k, v) -> m[k] = v.value } }.toMap()
+ }
+ if (!emitIfNoRssi && merged["RSSI"] == null) return
+ toAdvertisement(merged)?.let { trySend(it) }
+ }
+ val listener: (String, String, Map<String, Variant<*>>) -> Unit = { path, iface, props -> if (iface == DEVICE) update(path, props, true) }
+ listeners += listener
+ // Devices BlueZ already knows about are only reported if they carry a recent RSSI.
+ objects.forEach { (path, ifaces) -> ifaces[DEVICE]?.let { update(path, it, false) } }
+ val adapter = adapter(adapterPath)
+ runCatching { adapter.SetDiscoveryFilter(mapOf("Transport" to Variant("le"), "DuplicateData" to Variant(true))) }
+ try {
+ adapter.StartDiscovery()
+ } catch (e: Exception) {
+ // Another client may already be discovering on this adapter; that still yields results.
+ if (e.message?.contains("InProgress") != true) {
+ listeners -= listener
+ throw e
+ }
+ }
+ awaitClose {
+ listeners -= listener
+ runCatching { adapter.StopDiscovery() }
+ }
+ }.flowOn(Dispatchers.IO)
+
+ override suspend fun connect(address: String): BleConnection = withContext(Dispatchers.IO) {
+ val objects = managedObjects()
+ val adapterPath = adapterPath(objects)
+ val path = devicePath(adapterPath, address)
+ if (path !in objects) {
+ // BlueZ only knows devices it has seen: discover until this one shows up.
+ withTimeoutOrNull(20_000) { scan().first { it.address.equals(address, ignoreCase = true) } }
+ ?: error("Device $address not found; is it awake and in range?")
+ }
+ val conn = BlueZConnection(path, address)
+ try {
+ conn.open()
+ } catch (e: Throwable) {
+ conn.close()
+ throw e
+ }
+ conn
+ }
+
+ override fun close() {
+ runCatching { bus.close() }
+ }
+
+ private fun props(path: String, iface: String): Map<String, Any?> =
+ bus.getRemoteObject(BLUEZ, path, Properties::class.java).GetAll(iface).mapValues { it.value.value }
+
+ private inner class BlueZConnection(private val path: String, override val address: String) : BleConnection {
+ private val device = bus.getRemoteObject(BLUEZ, path, BluezDevice1::class.java)
+ private val gone = CompletableDeferred<Unit>()
+ private val notifying = CopyOnWriteArrayList<String>()
+ private val channels = CopyOnWriteArrayList<Channel<ByteArray>>()
+ private val ownListeners = CopyOnWriteArrayList<(String, String, Map<String, Variant<*>>) -> Unit>()
+ private var chars: Map<Pair<String, String>, String> = emptyMap()
+ override var name: String? = null
+ private set
+
+ private fun listen(l: (String, String, Map<String, Variant<*>>) -> Unit) {
+ ownListeners += l
+ listeners += l
+ }
+
+ suspend fun open() {
+ listen { p, iface, changed ->
+ if (p == path && iface == DEVICE && changed["Connected"]?.value == false) markGone()
+ }
+ var last: Exception? = null
+ for (attempt in 1..3) {
+ try {
+ device.Connect()
+ last = null
+ break
+ } catch (e: Exception) {
+ // "le-connection-abort-by-local" and friends are common and usually transient.
+ last = e
+ delay(500)
+ }
+ }
+ last?.let { throw IllegalStateException("Connect to $address failed: ${it.message}", it) }
+ val resolved = withTimeoutOrNull(15_000) {
+ while (props(path, DEVICE)["ServicesResolved"] != true) delay(100)
+ true
+ }
+ check(resolved == true) { "GATT services of $address were not resolved in time" }
+ val p = props(path, DEVICE)
+ name = p["Name"] as? String
+ }
+
+ override suspend fun discoverServices(): List<BleService> = withContext(Dispatchers.IO) {
+ val objects = managedObjects().filterKeys { it.startsWith("$path/") }
+ val services = objects.mapNotNull { (p, ifaces) -> ifaces[GATT_SERVICE]?.let { p to (it["UUID"]!!.value as String).lowercase() } }.toMap()
+ val found = HashMap<Pair<String, String>, String>()
+ val byService = HashMap<String, MutableList<BleCharacteristic>>()
+ objects.forEach { (p, ifaces) ->
+ val c = ifaces[GATT_CHAR] ?: return@forEach
+ val servicePath = (c["Service"]?.value as? DBusPath)?.path ?: p.substringBeforeLast('/')
+ val serviceUuid = services[servicePath] ?: return@forEach
+ val uuid = (c["UUID"]!!.value as String).lowercase()
+ found[serviceUuid to uuid] = p
+ byService.getOrPut(servicePath) { mutableListOf() } += BleCharacteristic(uuid, stringList(c["Flags"]?.value).toSet())
+ }
+ chars = found
+ services.map { (p, uuid) -> BleService(uuid, byService[p].orEmpty()) }
+ }
+
+ private fun charPath(service: String, characteristic: String): String =
+ chars[service.lowercase() to characteristic.lowercase()] ?: error("Characteristic $characteristic of service $service not found")
+
+ private fun char(service: String, characteristic: String) =
+ bus.getRemoteObject(BLUEZ, charPath(service, characteristic), BluezGattCharacteristic1::class.java)
+
+ override suspend fun read(service: String, characteristic: String): ByteArray = withContext(Dispatchers.IO) {
+ char(service, characteristic).ReadValue(emptyMap())
+ }
+
+ override suspend fun write(service: String, characteristic: String, value: ByteArray) = withContext(Dispatchers.IO) {
+ char(service, characteristic).WriteValue(value, emptyMap())
+ }
+
+ override suspend fun subscribe(service: String, characteristic: String): Flow<ByteArray> = withContext(Dispatchers.IO) {
+ val cp = charPath(service, characteristic)
+ val ch = Channel<ByteArray>(Channel.UNLIMITED)
+ channels += ch
+ listen { p, iface, changed ->
+ if (p == cp && iface == GATT_CHAR) changed["Value"]?.let { v -> toBytes(v.value)?.let { ch.trySend(it) } }
+ }
+ bus.getRemoteObject(BLUEZ, cp, BluezGattCharacteristic1::class.java).StartNotify()
+ notifying += cp
+ ch.receiveAsFlow()
+ }
+
+ override val disconnected: Flow<Unit> = flow { gone.await(); emit(Unit) }
+
+ private fun markGone() {
+ channels.forEach { it.close() }
+ gone.complete(Unit)
+ }
+
+ fun close() {
+ ownListeners.forEach { listeners -= it }
+ markGone()
+ }
+
+ override suspend fun disconnect() = withContext(NonCancellable + Dispatchers.IO) {
+ notifying.forEach { cp -> runCatching { bus.getRemoteObject(BLUEZ, cp, BluezGattCharacteristic1::class.java).StopNotify() } }
+ notifying.clear()
+ runCatching { device.Disconnect() }
+ close()
+ }
+ }
+
+ private companion object {
+ const val BLUEZ = "org.bluez"
+ const val BLUEZ_ROOT = "/org/bluez/"
+ const val ADAPTER = "org.bluez.Adapter1"
+ const val DEVICE = "org.bluez.Device1"
+ const val GATT_SERVICE = "org.bluez.GattService1"
+ const val GATT_CHAR = "org.bluez.GattCharacteristic1"
+
+ fun toBytes(v: Any?): ByteArray? = when (v) {
+ is ByteArray -> v
+ is List<*> -> ByteArray(v.size) { (v[it] as Number).toByte() }
+ is Array<*> -> ByteArray(v.size) { (v[it] as Number).toByte() }
+ else -> null
+ }
+
+ fun stringList(v: Any?): List<String> = when (v) {
+ is List<*> -> v.map { it.toString() }
+ is Array<*> -> v.map { it.toString() }
+ else -> emptyList()
+ }
+
+ fun toAdvertisement(p: Map<String, Any?>): BleAdvertisement? {
+ val address = p["Address"] as? String ?: return null
+ val mfr = (p["ManufacturerData"] as? Map<*, *>)?.mapNotNull { (k, v) ->
+ val id = (k as? Number)?.toInt() ?: return@mapNotNull null
+ val bytes = toBytes((v as? Variant<*>)?.value ?: v) ?: return@mapNotNull null
+ id to bytes
+ }?.toMap().orEmpty()
+ return BleAdvertisement(
+ address = address,
+ name = p["Name"] as? String,
+ rssi = (p["RSSI"] as? Number)?.toInt(),
+ manufacturerData = mfr,
+ serviceUuids = stringList(p["UUIDs"]).map { it.lowercase() },
+ )
+ }
+ }
+}
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt
new file mode 100644
index 0000000..b96748c
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt
@@ -0,0 +1,17 @@
+package coffee.liz.rookery.bt
+
+import javax.crypto.Cipher
+import javax.crypto.spec.SecretKeySpec
+
+/** [Aes128] over javax.crypto (AES/ECB/NoPadding); works on desktop JVMs and Android. */
+object JvmAes128 : Aes128 {
+ private fun run(mode: Int, key: ByteArray, block: ByteArray): ByteArray {
+ require(key.size == 16 && block.size == 16) { "AES-128 needs a 16-byte key and block" }
+ val c = Cipher.getInstance("AES/ECB/NoPadding")
+ c.init(mode, SecretKeySpec(key, "AES"))
+ return c.doFinal(block)
+ }
+
+ override fun encryptBlock(key: ByteArray, block: ByteArray): ByteArray = run(Cipher.ENCRYPT_MODE, key, block)
+ override fun decryptBlock(key: ByteArray, block: ByteArray): ByteArray = run(Cipher.DECRYPT_MODE, key, block)
+}
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt
new file mode 100644
index 0000000..e0e7b31
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt
@@ -0,0 +1,50 @@
+package coffee.liz.rookery.scramble
+
+import coffee.liz.rookery.model.Event
+import org.worldcubeassociation.tnoodle.scrambles.PuzzleRegistry
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+
+/**
+ * WCA-official random-state scrambles from tnoodle-lib, with one scramble generated ahead per event
+ * so pressing "next" is instant even for 4x4 (whose tables take a few seconds to build).
+ */
+class TnoodleScrambler : Scrambler {
+ private val pool = Executors.newSingleThreadExecutor { r -> Thread(r, "scrambler").apply { isDaemon = true; priority = Thread.MIN_PRIORITY } }
+ private val ahead = ConcurrentHashMap<Event, Future<String>>()
+
+ private fun registry(e: Event): PuzzleRegistry = when (e) {
+ Event.E333, Event.E333OH -> PuzzleRegistry.THREE
+ Event.E222 -> PuzzleRegistry.TWO
+ Event.E444 -> PuzzleRegistry.FOUR
+ Event.E555 -> PuzzleRegistry.FIVE
+ Event.E666 -> PuzzleRegistry.SIX
+ Event.E777 -> PuzzleRegistry.SEVEN
+ Event.E333BF, Event.E333MBF -> PuzzleRegistry.THREE_NI
+ Event.E444BF -> PuzzleRegistry.FOUR_NI
+ Event.E555BF -> PuzzleRegistry.FIVE_NI
+ Event.E333FM -> PuzzleRegistry.THREE_FM
+ Event.CLOCK -> PuzzleRegistry.CLOCK
+ Event.MINX -> PuzzleRegistry.MEGA
+ Event.PYRAM -> PuzzleRegistry.PYRA
+ Event.SKEWB -> PuzzleRegistry.SKEWB
+ Event.SQ1 -> PuzzleRegistry.SQ1
+ Event.FTO -> PuzzleRegistry.FTO
+ }
+
+ private fun submit(e: Event): Future<String> = pool.submit<String> { registry(e).scrambler.generateScramble() }
+
+ override fun warm(event: Event) { ahead.computeIfAbsent(event, ::submit) }
+
+ override fun next(event: Event): String {
+ val f = ahead.remove(event) ?: submit(event)
+ val s = f.get()
+ ahead[event] = submit(event)
+ return s
+ }
+
+ override fun previewSvg(event: Event, scramble: String): String? = runCatching {
+ registry(event).scrambler.drawScramble(scramble, null).toString()
+ }.getOrNull()
+}
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt
new file mode 100644
index 0000000..64064b5
--- /dev/null
+++ b/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt
@@ -0,0 +1,96 @@
+package coffee.liz.rookery.store
+
+import java.io.RandomAccessFile
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.StandardOpenOption
+import java.security.MessageDigest
+import kotlin.io.path.createDirectories
+import kotlin.io.path.exists
+import kotlin.io.path.isDirectory
+import kotlin.io.path.listDirectoryEntries
+import kotlin.io.path.name
+import kotlin.io.path.readBytes
+import kotlin.io.path.writeBytes
+
+/**
+ * Op log in a plain folder, safe under any file-syncing tool:
+ *
+ * ```
+ * <root>/devices/<nodeId>/ops-000001.jsonl only <nodeId> ever writes here
+ * <root>/blobs/<sha256>.<ext> write-once, content addressed
+ * ```
+ *
+ * Readers remember a byte offset per file and only consume complete lines, so a half-synced
+ * file is simply read again later.
+ */
+class FolderLogStorage(private val root: Path, private val nodeId: String, private val segmentBytes: Long = 8L shl 20) : LogStorage {
+ private val devices = root.resolve("devices")
+ private val ownDir = devices.resolve(nodeId).also { it.createDirectories() }
+ private val offsets = HashMap<Path, Long>()
+
+ private fun currentSegment(): Path {
+ val segs = ownDir.listDirectoryEntries("ops-*.jsonl").sorted()
+ val last = segs.lastOrNull()
+ if (last != null && Files.size(last) < segmentBytes) return last
+ val next = (last?.name?.removePrefix("ops-")?.removeSuffix(".jsonl")?.toIntOrNull() ?: 0) + 1
+ return ownDir.resolve("ops-" + next.toString().padStart(6, '0') + ".jsonl")
+ }
+
+ override fun append(lines: List<String>) {
+ if (lines.isEmpty()) return
+ val bytes = lines.joinToString("\n", postfix = "\n").toByteArray()
+ val seg = currentSegment()
+ Files.newByteChannel(seg, StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE).use { ch ->
+ val buf = java.nio.ByteBuffer.wrap(bytes)
+ while (buf.hasRemaining()) ch.write(buf)
+ (ch as? java.nio.channels.FileChannel)?.force(false)
+ }
+ // Our own writes are already applied by the Store; skip them on the next read.
+ offsets[seg] = (offsets[seg] ?: 0) + bytes.size
+ }
+
+ override fun readNew(): List<String> {
+ if (!devices.exists()) return emptyList()
+ val out = mutableListOf<String>()
+ for (dev in devices.listDirectoryEntries().filter { it.isDirectory() }.sorted()) {
+ for (seg in dev.listDirectoryEntries("ops-*.jsonl").sorted()) {
+ val from = offsets[seg] ?: 0
+ val size = Files.size(seg)
+ if (size <= from) continue
+ RandomAccessFile(seg.toFile(), "r").use { f ->
+ f.seek(from)
+ val buf = ByteArray((size - from).toInt())
+ f.readFully(buf)
+ val end = buf.lastIndexOf('\n'.code.toByte())
+ if (end < 0) return@use // torn line still syncing
+ String(buf, 0, end, Charsets.UTF_8).split('\n').filterTo(out) { it.isNotBlank() }
+ offsets[seg] = from + end + 1
+ }
+ }
+ }
+ return out
+ }
+}
+
+class FolderBlobStore(root: Path) : BlobStore {
+ private val dir = root.resolve("blobs").also { it.createDirectories() }
+
+ override fun put(bytes: ByteArray, ext: String): String {
+ val hash = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
+ val name = "$hash.${ext.lowercase().filter(Char::isLetterOrDigit).ifEmpty { "bin" }}"
+ val path = dir.resolve(name)
+ if (!path.exists()) {
+ val tmp = dir.resolve(".$name.tmp")
+ tmp.writeBytes(bytes)
+ Files.move(tmp, path, java.nio.file.StandardCopyOption.ATOMIC_MOVE)
+ }
+ return name
+ }
+
+ override fun get(name: String): ByteArray? {
+ if (name.contains('/') || name.contains("..")) return null
+ val p = dir.resolve(name)
+ return if (p.exists()) p.readBytes() else null
+ }
+}
diff --git a/core/src/jvmTest/kotlin/coffee/liz/rookery/JvmTest.kt b/core/src/jvmTest/kotlin/coffee/liz/rookery/JvmTest.kt
new file mode 100644
index 0000000..ef76184
--- /dev/null
+++ b/core/src/jvmTest/kotlin/coffee/liz/rookery/JvmTest.kt
@@ -0,0 +1,55 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.model.Solve
+import coffee.liz.rookery.scramble.TnoodleScrambler
+import coffee.liz.rookery.store.FolderBlobStore
+import coffee.liz.rookery.store.FolderLogStorage
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.store.Store
+import java.nio.file.Files
+import kotlin.io.path.appendText
+import kotlin.io.path.listDirectoryEntries
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class JvmTest {
+ @Test fun folderSyncBetweenDevicesToleratesTornLines() {
+ val root = Files.createTempDirectory("rookery-sync")
+ fun open(node: String) = Repo(Store(node, FolderLogStorage(root, node), FolderBlobStore(root)) { System.currentTimeMillis() })
+ val a = open("phone")
+ a.addSolve(Solve("s1", "x", Event.E333, 9000, Penalty.NONE, "R", "", 1))
+
+ val b = open("laptop") // fresh device reads everything on startup
+ assertEquals(listOf("s1"), b.solves().map { it.id })
+
+ // simulate Syncthing delivering half of a line from phone
+ val seg = root.resolve("devices/phone").listDirectoryEntries().single()
+ val line = """{"t":"9999999999999-0000-phone","c":"solve","id":"s1","f":"pen","v":"dnf"}"""
+ seg.appendText(line.substring(0, 20))
+ b.store.sync()
+ assertEquals(Penalty.NONE, b.solves().single().penalty)
+ seg.appendText(line.substring(20) + "\n")
+ b.store.sync()
+ assertEquals(Penalty.DNF, b.solves().single().penalty)
+
+ // restart: state rebuilds from disk
+ assertEquals(Penalty.DNF, open("laptop").solves().single().penalty)
+ val blob = a.store.blobs.put(byteArrayOf(1, 2, 3), "jpg")
+ assertEquals(3, open("laptop").store.blobs.get(blob)?.size)
+ }
+
+ /** Runs under the app's 256m heap cap (see core/build.gradle.kts), so this also guards memory. */
+ @Test fun tnoodleScramblesAndDraws() {
+ val s = TnoodleScrambler()
+ for (e in Event.entries) {
+ val scr = s.next(e)
+ assertTrue(scr.isNotBlank(), "$e")
+ val svg = assertNotNull(s.previewSvg(e, scr), "$e svg")
+ assertTrue(svg.contains("<svg"), "$e svg: ${svg.take(80)}")
+ }
+ }
+}
diff --git a/core/src/jvmTest/kotlin/coffee/liz/rookery/TnoodleAgreementTest.kt b/core/src/jvmTest/kotlin/coffee/liz/rookery/TnoodleAgreementTest.kt
new file mode 100644
index 0000000..dd5e9a8
--- /dev/null
+++ b/core/src/jvmTest/kotlin/coffee/liz/rookery/TnoodleAgreementTest.kt
@@ -0,0 +1,103 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.puzzle.Puzzles
+import coffee.liz.rookery.puzzle.TwistyPuzzle
+import coffee.liz.rookery.puzzle.V3
+import org.worldcubeassociation.tnoodle.scrambles.PuzzleRegistry
+import kotlin.math.atan2
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * Our 3D models must agree sticker-for-sticker with tnoodle's official 2D drawings. Each model
+ * sticker is mapped onto tnoodle's net through its face (an affine map between the face's 3D and
+ * 2D corners); the colour found there must match ours.
+ */
+class TnoodleAgreementTest {
+ private data class P2(val x: Double, val y: Double)
+ private data class NetSticker(val c: P2, val color: String)
+
+ /** Sticker centroids and fills from a tnoodle SVG, applying each path's transform. */
+ private fun parse(svg: String): List<NetSticker> = Regex("""<path [^>]*>""").findAll(svg).mapNotNull { m ->
+ fun attr(n: String) = Regex("""\b$n="([^"]*)"""").find(m.value)?.groupValues?.get(1)
+ val d = attr("d") ?: return@mapNotNull null
+ val fill = attr("fill") ?: return@mapNotNull null
+ val t = attr("transform")?.let { Regex("""matrix\(([^)]*)\)""").find(it)?.groupValues?.get(1)?.split(',')?.map { v -> v.trim().toDouble() } }
+ ?: listOf(1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
+ val pts = Regex("""[ML] ([-\d.]+) ([-\d.]+)""").findAll(d).map {
+ val x = it.groupValues[1].toDouble(); val y = it.groupValues[2].toDouble()
+ P2(t[0] * x + t[2] * y + t[4], t[1] * x + t[3] * y + t[5])
+ }.toList().distinct()
+ NetSticker(P2(pts.sumOf { it.x } / pts.size, pts.sumOf { it.y } / pts.size), fill)
+ }.toList()
+
+ private fun d2(a: P2, b: P2) = (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)
+
+ /**
+ * Net face corners, in order around the face: the face's corner stickers pushed outwards from its
+ * centre (corner-sticker centroids sit 2/3 of the way to the corner for both puzzles here).
+ */
+ private fun netCorners(face: List<NetSticker>, n: Int): List<P2> {
+ val cx = face.sumOf { it.c.x } / face.size; val cy = face.sumOf { it.c.y } / face.size
+ return face.sortedByDescending { d2(it.c, P2(cx, cy)) }.take(n)
+ .sortedBy { atan2(it.c.y - cy, it.c.x - cx) }
+ .map { P2(cx + (it.c.x - cx) * 1.5, cy + (it.c.y - cy) * 1.5) }
+ }
+
+ /** Map a point on a 3D face to the net via the face's first three corners (affine). */
+ private fun toNet(p: V3, c3: List<V3>, c2: List<P2>): P2 {
+ val e1 = c3[1] - c3[0]; val e2 = c3[c3.size - 1] - c3[0]; val v = p - c3[0]
+ val d11 = e1 dot e1; val d12 = e1 dot e2; val d22 = e2 dot e2
+ val den = d11 * d22 - d12 * d12
+ val s = ((v dot e1) * d22 - (v dot e2) * d12) / den
+ val t = ((v dot e2) * d11 - (v dot e1) * d12) / den
+ val a = c2[0]; val b = c2[1]; val d = c2[c2.size - 1]
+ return P2(a.x + s * (b.x - a.x) + t * (d.x - a.x), a.y + s * (b.y - a.y) + t * (d.y - a.y))
+ }
+
+ private fun checkAgreement(reg: PuzzleRegistry, model: TwistyPuzzle, faceCorners: List<List<V3>>, faceColors: List<String>) {
+ val scrambler = reg.scrambler
+ val solvedNet = parse(scrambler.drawScramble("", null).toString())
+ val n = faceCorners[0].size
+ val colorIndex = faceColors.withIndex().associate { (i, c) -> c to i }
+
+ fun mismatches(scr: String, mapping: Map<Int, List<P2>>, only: Int? = null): Int {
+ val net = parse(scrambler.drawScramble(scr, null).toString())
+ val st = model.solved().apply(scr)
+ return model.stickers.indices.count { i ->
+ val f = model.stickerFace[i]
+ if (only != null && f != only) return@count false
+ val p = toNet(model.stickers[i].centroid, faceCorners[f], mapping.getValue(f))
+ colorIndex[net.minBy { d2(it.c, p) }.color] != st.colors[i]
+ }
+ }
+
+ // Which net corner is which 3D corner: pick the rotation/reflection that fits reference scrambles
+ // (several, since one scramble can leave a face symmetric enough to tie two alignments)…
+ val refs = List(4) { scrambler.generateScramble() }
+ val mapping = HashMap<Int, List<P2>>()
+ for ((color, face) in solvedNet.groupBy { it.color }) {
+ val f = colorIndex.getValue(color)
+ val c = netCorners(face, n)
+ val options = (0 until n).flatMap { r -> val rot = c.drop(r) + c.take(r); listOf(rot, rot.reversed()) }
+ mapping[f] = options.minBy { opt -> refs.sumOf { mismatches(it, mapping + (f to opt), f) } }
+ }
+ // …then that fixed alignment must hold for fresh scrambles.
+ repeat(15) {
+ val scr = scrambler.generateScramble()
+ assertEquals(0, mismatches(scr, mapping), "${model.name} disagrees with tnoodle on $scr")
+ }
+ }
+
+ @Test fun pyraminxMatchesTnoodle() {
+ val m = Puzzles.pyraminx()
+ val v = m.axes.associate { it.name to it.normal }
+ val corners = listOf(listOf("U", "L", "R"), listOf("U", "L", "B"), listOf("U", "R", "B"), listOf("L", "R", "B")).map { f -> f.map(v::getValue) }
+ checkAgreement(PuzzleRegistry.PYRA, m, corners, listOf("#00ff00", "#ff0000", "#0000ff", "#ffff00"))
+ }
+
+ @Test fun skewbMatchesTnoodle() {
+ val corners = Puzzles.cube(1).stickers.map { it.points }
+ checkAgreement(PuzzleRegistry.SKEWB, Puzzles.skewb(), corners, listOf("#ffffff", "#ff0000", "#00ff00", "#ffff00", "#ff8000", "#0000ff"))
+ }
+}
diff --git a/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCryptoTest.kt b/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCryptoTest.kt
new file mode 100644
index 0000000..a8ad88e
--- /dev/null
+++ b/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCryptoTest.kt
@@ -0,0 +1,40 @@
+package coffee.liz.rookery.bt
+
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertFailsWith
+
+@OptIn(ExperimentalStdlibApi::class)
+class GanCryptoTest {
+ // Vectors computed with a Node.js re-implementation of gan-web-bluetooth's GanGen2CubeEncrypter
+ // (aes-js CBC swapped for node:crypto aes-128-cbc, no padding). The MAC is made up; it is chosen so
+ // that key[5] + salt[5] = 0x100, exercising the firmware's `% 0xFF` (255, not 256) salting.
+ private val mac = "6F:F0:12:34:56:78"
+ private val enc = GanEncrypter(JvmAes128, GanDefinitions.GAN_KEY, mac)
+
+ @Test fun saltedKeyAndIv() {
+ assertContentEquals("7958763a220116072005185442111253".hexToByteArray(), enc.key)
+ assertContentEquals("8959663a127076272095781432120243".hexToByteArray(), enc.iv)
+ }
+
+ @Test fun encryptMatchesReference() {
+ val m20 = "030a11181f262d343b424950575e656c737a8188".hexToByteArray()
+ assertContentEquals("f8b5b4a9ac44104f5d5ce7635f869bf95bc95c02".hexToByteArray(), enc.encrypt(m20))
+ val m16 = "fffcf9f6f3f0edeae7e4e1dedbd8d5d2".hexToByteArray()
+ assertContentEquals("c4f8c0629684911cb703cd9a76f44ffb".hexToByteArray(), enc.encrypt(m16))
+ }
+
+ @Test fun roundTrips() {
+ val moyu = GanEncrypter(JvmAes128, GanDefinitions.MOYU_AI_KEY, mac)
+ for (size in listOf(16, 17, 19, 20, 31, 32)) {
+ val msg = ByteArray(size) { (it * 37 + size).toByte() }
+ assertContentEquals(msg, enc.decrypt(enc.encrypt(msg)))
+ assertContentEquals(msg, moyu.decrypt(moyu.encrypt(msg)))
+ }
+ }
+
+ @Test fun rejectsShortMessages() {
+ assertFailsWith<IllegalArgumentException> { enc.encrypt(ByteArray(15)) }
+ assertFailsWith<IllegalArgumentException> { enc.decrypt(ByteArray(15)) }
+ }
+}
diff --git a/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCubeConnectorTest.kt b/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCubeConnectorTest.kt
new file mode 100644
index 0000000..87c5eb4
--- /dev/null
+++ b/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCubeConnectorTest.kt
@@ -0,0 +1,118 @@
+package coffee.liz.rookery.bt
+
+import coffee.liz.rookery.bt.SmartCubeEvent.Facelets
+import coffee.liz.rookery.bt.SmartCubeEvent.Move
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+private class FakeConnection(override val address: String, override val name: String?, private val services: List<BleService>) : BleConnection {
+ val notifications = Channel<ByteArray>(Channel.UNLIMITED)
+ val writes = mutableListOf<Triple<String, String, ByteArray>>()
+ val subscribed = mutableListOf<Pair<String, String>>()
+ val gone = CompletableDeferred<Unit>()
+ var disconnectCalls = 0
+
+ override suspend fun discoverServices() = services
+ override suspend fun read(service: String, characteristic: String) = ByteArray(0)
+ override suspend fun write(service: String, characteristic: String, value: ByteArray) { writes += Triple(service, characteristic, value) }
+ override suspend fun subscribe(service: String, characteristic: String): Flow<ByteArray> {
+ subscribed += service to characteristic
+ return notifications.receiveAsFlow()
+ }
+ override val disconnected: Flow<Unit> = flow { gone.await(); emit(Unit) }
+ override suspend fun disconnect() { disconnectCalls++; gone.complete(Unit) }
+}
+
+private class FakeTransport(val conn: FakeConnection) : BleTransport {
+ override fun scan(): Flow<BleAdvertisement> = emptyFlow()
+ override suspend fun connect(address: String): BleConnection { check(address == conn.address); return conn }
+}
+
+class GanCubeConnectorTest {
+ private val mac = "6F:F0:12:34:56:78"
+ private val enc = GanEncrypter(JvmAes128, GanDefinitions.GAN_KEY, mac)
+
+ private fun service(g: GanGeneration) = BleService(g.service, listOf(BleCharacteristic(g.command), BleCharacteristic(g.state)))
+ private val deviceInfo = BleService("0000180a-0000-1000-8000-00805f9b34fb", emptyList())
+
+ private fun TestScope.collect(cube: SmartCube): MutableList<SmartCubeEvent> {
+ val got = mutableListOf<SmartCubeEvent>()
+ backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { cube.events.collect { got += it } }
+ return got
+ }
+
+ @Test fun gen3EndToEnd() = runTest {
+ val conn = FakeConnection(mac, "GANicV2_1234", listOf(deviceInfo, service(GanGeneration.GEN3)))
+ val cube = GanCubeConnector(FakeTransport(conn), JvmAes128) { 42L }.connect(backgroundScope, mac)
+ assertEquals(GanGeneration.GEN3, (cube as GanSmartCube).generation)
+ assertEquals(listOf(GanDefinitions.GEN3_SERVICE to GanDefinitions.GEN3_STATE), conn.subscribed)
+ val got = collect(cube)
+
+ cube.requestFacelets()
+ val (svc, chr, value) = conn.writes.single()
+ assertEquals(GanDefinitions.GEN3_SERVICE to GanDefinitions.GEN3_COMMAND, svc to chr)
+ assertContentEquals(byteArrayOf(0x68, 0x01) + ByteArray(14), enc.decrypt(value))
+
+ conn.notifications.send(enc.encrypt(Packets.gen3Facelets(3, F_R_STATE)))
+ conn.notifications.send(enc.encrypt(Packets.gen3Move(4, 1, 0, 777)))
+ conn.notifications.send(ByteArray(8)) // too short to decrypt: dropped
+ runCurrent()
+ assertEquals(listOf(Facelets(F_R_FACELETS, 3, F_R_STATE), Move("R", 777, 42, 4)), got)
+
+ conn.gone.complete(Unit) // the cube went away
+ runCurrent()
+ assertEquals(SmartCubeEvent.Disconnected, got.last())
+ }
+
+ @Test fun gen4AndGen2Detection() = runTest {
+ val c4 = FakeConnection(mac, "GAN12uiM_ABCD", listOf(service(GanGeneration.GEN4)))
+ val cube4 = GanCubeConnector(FakeTransport(c4), JvmAes128) { 0L }.connect(backgroundScope, mac) as GanSmartCube
+ assertEquals(GanGeneration.GEN4, cube4.generation)
+ val got = collect(cube4)
+ c4.notifications.send(enc.encrypt(Packets.gen4Facelets(1, SOLVED_STATE)))
+ runCurrent()
+ assertEquals(GanFacelets.SOLVED, (got.single() as Facelets).facelets)
+ cube4.resetToSolved()
+ assertEquals(0xD2.toByte(), enc.decrypt(c4.writes.single().third)[0])
+ cube4.disconnect()
+ runCurrent()
+ assertEquals(1, c4.disconnectCalls)
+ assertEquals(SmartCubeEvent.Disconnected, got.last())
+
+ val c2 = FakeConnection(mac, "GAN-1234", listOf(service(GanGeneration.GEN2)))
+ val cube2 = GanCubeConnector(FakeTransport(c2), JvmAes128) { 0L }.connect(backgroundScope, mac) as GanSmartCube
+ assertEquals(GanGeneration.GEN2, cube2.generation)
+ cube2.requestBattery()
+ assertEquals(0x09, enc.decrypt(c2.writes.single().third)[0].toInt())
+ cube2.disconnect()
+ }
+
+ @Test fun moyuAiUsesItsOwnKey() = runTest {
+ val c = FakeConnection(mac, "AiCube_0001", listOf(service(GanGeneration.GEN2)))
+ val cube = GanCubeConnector(FakeTransport(c), JvmAes128) { 0L }.connect(backgroundScope, mac)
+ cube.requestHardware()
+ val moyu = GanEncrypter(JvmAes128, GanDefinitions.MOYU_AI_KEY, mac)
+ assertEquals(0x05, moyu.decrypt(c.writes.single().third)[0].toInt())
+ cube.disconnect()
+ }
+
+ @Test fun unknownDeviceIsRejectedAndDisconnected() = runTest {
+ val c = FakeConnection(mac, "Speaker", listOf(deviceInfo))
+ assertFailsWith<IllegalStateException> { GanCubeConnector(FakeTransport(c), JvmAes128).connect(backgroundScope, mac) }
+ assertTrue(c.disconnectCalls == 1)
+ }
+}