From 009525bd46edfc77068da212e36f38d410503f2c Mon Sep 17 00:00:00 2001 From: Elizabeth Alexander Hunt Date: Fri, 13 Nov 2026 15:28:48 -0800 Subject: 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 -. 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 --- .../kotlin/coffee/liz/rookery/Platform.jvm.kt | 7 ++ .../kotlin/coffee/liz/rookery/bt/JvmAes128.kt | 17 ++++ .../liz/rookery/scramble/TnoodleScrambler.kt | 58 +++++++++++++ .../coffee/liz/rookery/store/FolderStorage.kt | 96 ++++++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 core/src/jvmShared/kotlin/coffee/liz/rookery/Platform.jvm.kt create mode 100644 core/src/jvmShared/kotlin/coffee/liz/rookery/bt/JvmAes128.kt create mode 100644 core/src/jvmShared/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt create mode 100644 core/src/jvmShared/kotlin/coffee/liz/rookery/store/FolderStorage.kt (limited to 'core/src/jvmShared/kotlin/coffee/liz/rookery') 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 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>() + + 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 = pool.submit { 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: + * + * ``` + * /devices//ops-000001.jsonl only ever writes here + * /blobs/. 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() + + 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) { + 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 { + if (!devices.exists()) return emptyList() + val out = mutableListOf() + 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 + } +} -- cgit v1.3