summaryrefslogtreecommitdiff
path: root/core/src/commonMain
diff options
context:
space:
mode:
Diffstat (limited to 'core/src/commonMain')
-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
29 files changed, 2266 insertions, 0 deletions
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')
+}