summaryrefslogtreecommitdiff
path: root/core/src/jvmShared/kotlin/coffee
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-11-13 15:28:48 -0800
committerElizabeth Alexander Hunt <me@liz.coffee>2026-11-13 15:37:09 -0800
commit009525bd46edfc77068da212e36f38d410503f2c (patch)
treef042565bb01d070f626213b72865800ec0dbf576 /core/src/jvmShared/kotlin/coffee
parent60426ce45763d0a4a5572db6926e8e677cbc0dc0 (diff)
downloadrookery-009525bd46edfc77068da212e36f38d410503f2c.tar.gz
rookery-009525bd46edfc77068da212e36f38d410503f2c.zip
Smart-cube timing, macOS Bluetooth, Android app, stackmat, pairing UI
- Timing with a smart cube: the scramble stays put and is marked off move by move (ScrambleTracker); a stray turn gets the shortest path to the same scrambled state from the two-phase solver, kept until the next stray turn. Wide moves in BLD scrambles become face turns. Once scrambled, inspection (or blind memo) starts by itself after a configurable delay; the first turn starts the clock and the solved cube stops it. The cube's own state reports are authoritative, so a move lost over Bluetooth can't keep the timer running. Blind solves show a MEMO/EXEC annunciator and store the memo split. A live view of the cube sits beside the timer. - Bluetooth through Kable on macOS and Windows (Linux keeps BlueZ) and on Android; the cube's MAC is read from its advertisement where the platform hides it. Scan/pair/connect/forget UI in settings. - Android app (:android): core and ui gain Android targets, with a jvmShared source set for code desktop and Android share. Android host for storage, sound, file import, images, Bluetooth and audio. Phone layouts: stacked panes and a full-screen timer with touch start. Scramble drawings (SVG) are desktop-only for now. - Stackmat: audio decoder and session; the mat can drive the timer. - Timer readout always shows the tens-of-seconds digit. - UI copy reduced to plain labels; marquee, snow, pointer-following and penguin speech removed. Brave key can be entered in settings. - Phone timer: one-line header, session picker and averages on one line, solves/stats in the footer; sessions can be renamed and deleted and are named <event>-<date>. Display mode fills the screen (cut-out included), fits the scramble on one line, shows the live cube, and the screen stays awake. - Readout: digits keep one size (inspection included), whole seconds while running unless set otherwise; previous scramble shown; the scramble no longer blanks while the next is generated. - Fixes: stackmat and smart cube no longer both drive the timer; a solved cube or a sub-quarter-second tap can't record a solve. - Blind memo indicator is an LCD penguin pacing the glass. - Android: system-bar insets, folder picker (all files access), Brave key synced with the data. - Algorithm trainer: PLL and OLL from J Perm's sheets, each algorithm verified by simulation; case plans, your choice of algorithm (or your own, checked against the case), new/learning/known, and a drill weighted towards weak cases. - Pair cards show the pair on a 3D see-through cube with an arrow; the memo cube can be dragged between targets. - CI image installs the Android SDK and runs core tests + desktop packaging (untested). Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Diffstat (limited to 'core/src/jvmShared/kotlin/coffee')
-rw-r--r--core/src/jvmShared/kotlin/coffee/liz/rookery/Platform.jvm.kt7
-rw-r--r--core/src/jvmShared/kotlin/coffee/liz/rookery/bt/JvmAes128.kt17
-rw-r--r--core/src/jvmShared/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt58
-rw-r--r--core/src/jvmShared/kotlin/coffee/liz/rookery/store/FolderStorage.kt96
4 files changed, 178 insertions, 0 deletions
diff --git a/core/src/jvmShared/kotlin/coffee/liz/rookery/Platform.jvm.kt b/core/src/jvmShared/kotlin/coffee/liz/rookery/Platform.jvm.kt
new file mode 100644
index 0000000..14d35d5
--- /dev/null
+++ b/core/src/jvmShared/kotlin/coffee/liz/rookery/Platform.jvm.kt
@@ -0,0 +1,7 @@
+package coffee.liz.rookery
+
+actual fun today(): String = java.time.LocalDate.now().toString()
+actual fun epochMillis(): Long = System.currentTimeMillis()
+actual fun monoMillis(): Long = System.nanoTime() / 1_000_000
+actual fun randomId(): String = java.util.UUID.randomUUID().toString().replace("-", "").take(16)
+actual fun <T> locked(lock: Any, block: () -> T): T = synchronized(lock, block)
diff --git a/core/src/jvmShared/kotlin/coffee/liz/rookery/bt/JvmAes128.kt b/core/src/jvmShared/kotlin/coffee/liz/rookery/bt/JvmAes128.kt
new file mode 100644
index 0000000..b96748c
--- /dev/null
+++ b/core/src/jvmShared/kotlin/coffee/liz/rookery/bt/JvmAes128.kt
@@ -0,0 +1,17 @@
+package coffee.liz.rookery.bt
+
+import javax.crypto.Cipher
+import javax.crypto.spec.SecretKeySpec
+
+/** [Aes128] over javax.crypto (AES/ECB/NoPadding); works on desktop JVMs and Android. */
+object JvmAes128 : Aes128 {
+ private fun run(mode: Int, key: ByteArray, block: ByteArray): ByteArray {
+ require(key.size == 16 && block.size == 16) { "AES-128 needs a 16-byte key and block" }
+ val c = Cipher.getInstance("AES/ECB/NoPadding")
+ c.init(mode, SecretKeySpec(key, "AES"))
+ return c.doFinal(block)
+ }
+
+ override fun encryptBlock(key: ByteArray, block: ByteArray): ByteArray = run(Cipher.ENCRYPT_MODE, key, block)
+ override fun decryptBlock(key: ByteArray, block: ByteArray): ByteArray = run(Cipher.DECRYPT_MODE, key, block)
+}
diff --git a/core/src/jvmShared/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt b/core/src/jvmShared/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt
new file mode 100644
index 0000000..4427177
--- /dev/null
+++ b/core/src/jvmShared/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt
@@ -0,0 +1,58 @@
+package coffee.liz.rookery.scramble
+
+import coffee.liz.rookery.model.Event
+import org.worldcubeassociation.tnoodle.scrambles.PuzzleRegistry
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+
+/**
+ * WCA-official random-state scrambles from tnoodle-lib, with one scramble generated ahead per event
+ * so pressing "next" is instant even for 4x4 (whose tables take a few seconds to build).
+ */
+class TnoodleScrambler : Scrambler {
+ private val pool = Executors.newSingleThreadExecutor { r -> Thread(r, "scrambler").apply { isDaemon = true; priority = Thread.MIN_PRIORITY } }
+ private val ahead = ConcurrentHashMap<Event, Future<String>>()
+
+ private fun registry(e: Event): PuzzleRegistry = when (e) {
+ Event.E333, Event.E333OH -> PuzzleRegistry.THREE
+ Event.E222 -> PuzzleRegistry.TWO
+ Event.E444 -> PuzzleRegistry.FOUR
+ Event.E555 -> PuzzleRegistry.FIVE
+ Event.E666 -> PuzzleRegistry.SIX
+ Event.E777 -> PuzzleRegistry.SEVEN
+ Event.E333BF, Event.E333MBF -> PuzzleRegistry.THREE_NI
+ Event.E444BF -> PuzzleRegistry.FOUR_NI
+ Event.E555BF -> PuzzleRegistry.FIVE_NI
+ Event.E333FM -> PuzzleRegistry.THREE_FM
+ Event.CLOCK -> PuzzleRegistry.CLOCK
+ Event.MINX -> PuzzleRegistry.MEGA
+ Event.PYRAM -> PuzzleRegistry.PYRA
+ Event.SKEWB -> PuzzleRegistry.SKEWB
+ Event.SQ1 -> PuzzleRegistry.SQ1
+ Event.FTO -> PuzzleRegistry.FTO
+ }
+
+ private fun submit(e: Event): Future<String> = pool.submit<String> { registry(e).scrambler.generateScramble() }
+
+ override fun warm(event: Event) { ahead.computeIfAbsent(event, ::submit) }
+
+ override fun next(event: Event): String {
+ val f = ahead.remove(event) ?: submit(event)
+ val s = f.get()
+ ahead[event] = submit(event)
+ return s
+ }
+
+ private val search by lazy { cs.min2phase.Search() }
+
+ override fun solve3x3(facelets: String): String? {
+ if (facelets == "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB") return ""
+ val s = synchronized(search) { search.solution(facelets, 21, 100_000, 0, 0) }
+ return if (s.startsWith("Error")) null else s.trim().replace(Regex("\\s+"), " ")
+ }
+
+ override fun previewSvg(event: Event, scramble: String): String? = runCatching {
+ registry(event).scrambler.drawScramble(scramble, null).toString()
+ }.getOrNull()
+}
diff --git a/core/src/jvmShared/kotlin/coffee/liz/rookery/store/FolderStorage.kt b/core/src/jvmShared/kotlin/coffee/liz/rookery/store/FolderStorage.kt
new file mode 100644
index 0000000..64064b5
--- /dev/null
+++ b/core/src/jvmShared/kotlin/coffee/liz/rookery/store/FolderStorage.kt
@@ -0,0 +1,96 @@
+package coffee.liz.rookery.store
+
+import java.io.RandomAccessFile
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.StandardOpenOption
+import java.security.MessageDigest
+import kotlin.io.path.createDirectories
+import kotlin.io.path.exists
+import kotlin.io.path.isDirectory
+import kotlin.io.path.listDirectoryEntries
+import kotlin.io.path.name
+import kotlin.io.path.readBytes
+import kotlin.io.path.writeBytes
+
+/**
+ * Op log in a plain folder, safe under any file-syncing tool:
+ *
+ * ```
+ * <root>/devices/<nodeId>/ops-000001.jsonl only <nodeId> ever writes here
+ * <root>/blobs/<sha256>.<ext> write-once, content addressed
+ * ```
+ *
+ * Readers remember a byte offset per file and only consume complete lines, so a half-synced
+ * file is simply read again later.
+ */
+class FolderLogStorage(private val root: Path, private val nodeId: String, private val segmentBytes: Long = 8L shl 20) : LogStorage {
+ private val devices = root.resolve("devices")
+ private val ownDir = devices.resolve(nodeId).also { it.createDirectories() }
+ private val offsets = HashMap<Path, Long>()
+
+ private fun currentSegment(): Path {
+ val segs = ownDir.listDirectoryEntries("ops-*.jsonl").sorted()
+ val last = segs.lastOrNull()
+ if (last != null && Files.size(last) < segmentBytes) return last
+ val next = (last?.name?.removePrefix("ops-")?.removeSuffix(".jsonl")?.toIntOrNull() ?: 0) + 1
+ return ownDir.resolve("ops-" + next.toString().padStart(6, '0') + ".jsonl")
+ }
+
+ override fun append(lines: List<String>) {
+ if (lines.isEmpty()) return
+ val bytes = lines.joinToString("\n", postfix = "\n").toByteArray()
+ val seg = currentSegment()
+ Files.newByteChannel(seg, StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE).use { ch ->
+ val buf = java.nio.ByteBuffer.wrap(bytes)
+ while (buf.hasRemaining()) ch.write(buf)
+ (ch as? java.nio.channels.FileChannel)?.force(false)
+ }
+ // Our own writes are already applied by the Store; skip them on the next read.
+ offsets[seg] = (offsets[seg] ?: 0) + bytes.size
+ }
+
+ override fun readNew(): List<String> {
+ if (!devices.exists()) return emptyList()
+ val out = mutableListOf<String>()
+ for (dev in devices.listDirectoryEntries().filter { it.isDirectory() }.sorted()) {
+ for (seg in dev.listDirectoryEntries("ops-*.jsonl").sorted()) {
+ val from = offsets[seg] ?: 0
+ val size = Files.size(seg)
+ if (size <= from) continue
+ RandomAccessFile(seg.toFile(), "r").use { f ->
+ f.seek(from)
+ val buf = ByteArray((size - from).toInt())
+ f.readFully(buf)
+ val end = buf.lastIndexOf('\n'.code.toByte())
+ if (end < 0) return@use // torn line still syncing
+ String(buf, 0, end, Charsets.UTF_8).split('\n').filterTo(out) { it.isNotBlank() }
+ offsets[seg] = from + end + 1
+ }
+ }
+ }
+ return out
+ }
+}
+
+class FolderBlobStore(root: Path) : BlobStore {
+ private val dir = root.resolve("blobs").also { it.createDirectories() }
+
+ override fun put(bytes: ByteArray, ext: String): String {
+ val hash = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
+ val name = "$hash.${ext.lowercase().filter(Char::isLetterOrDigit).ifEmpty { "bin" }}"
+ val path = dir.resolve(name)
+ if (!path.exists()) {
+ val tmp = dir.resolve(".$name.tmp")
+ tmp.writeBytes(bytes)
+ Files.move(tmp, path, java.nio.file.StandardCopyOption.ATOMIC_MOVE)
+ }
+ return name
+ }
+
+ override fun get(name: String): ByteArray? {
+ if (name.contains('/') || name.contains("..")) return null
+ val p = dir.resolve(name)
+ return if (p.exists()) p.readBytes() else null
+ }
+}