diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 15:28:48 -0800 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 15:37:09 -0800 |
| commit | 009525bd46edfc77068da212e36f38d410503f2c (patch) | |
| tree | f042565bb01d070f626213b72865800ec0dbf576 /core/src/jvmMain/kotlin/coffee | |
| parent | 60426ce45763d0a4a5572db6926e8e677cbc0dc0 (diff) | |
| download | rookery-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/jvmMain/kotlin/coffee')
4 files changed, 0 insertions, 169 deletions
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt deleted file mode 100644 index bbccb57..0000000 --- a/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt +++ /dev/null @@ -1,6 +0,0 @@ -package coffee.liz.rookery - -actual fun epochMillis(): Long = System.currentTimeMillis() -actual fun monoMillis(): Long = System.nanoTime() / 1_000_000 -actual fun randomId(): String = java.util.UUID.randomUUID().toString().replace("-", "").take(16) -actual fun <T> locked(lock: Any, block: () -> T): T = synchronized(lock, block) diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt deleted file mode 100644 index b96748c..0000000 --- a/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt +++ /dev/null @@ -1,17 +0,0 @@ -package coffee.liz.rookery.bt - -import javax.crypto.Cipher -import javax.crypto.spec.SecretKeySpec - -/** [Aes128] over javax.crypto (AES/ECB/NoPadding); works on desktop JVMs and Android. */ -object JvmAes128 : Aes128 { - private fun run(mode: Int, key: ByteArray, block: ByteArray): ByteArray { - require(key.size == 16 && block.size == 16) { "AES-128 needs a 16-byte key and block" } - val c = Cipher.getInstance("AES/ECB/NoPadding") - c.init(mode, SecretKeySpec(key, "AES")) - return c.doFinal(block) - } - - override fun encryptBlock(key: ByteArray, block: ByteArray): ByteArray = run(Cipher.ENCRYPT_MODE, key, block) - override fun decryptBlock(key: ByteArray, block: ByteArray): ByteArray = run(Cipher.DECRYPT_MODE, key, block) -} diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt deleted file mode 100644 index e0e7b31..0000000 --- a/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt +++ /dev/null @@ -1,50 +0,0 @@ -package coffee.liz.rookery.scramble - -import coffee.liz.rookery.model.Event -import org.worldcubeassociation.tnoodle.scrambles.PuzzleRegistry -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executors -import java.util.concurrent.Future - -/** - * WCA-official random-state scrambles from tnoodle-lib, with one scramble generated ahead per event - * so pressing "next" is instant even for 4x4 (whose tables take a few seconds to build). - */ -class TnoodleScrambler : Scrambler { - private val pool = Executors.newSingleThreadExecutor { r -> Thread(r, "scrambler").apply { isDaemon = true; priority = Thread.MIN_PRIORITY } } - private val ahead = ConcurrentHashMap<Event, Future<String>>() - - private fun registry(e: Event): PuzzleRegistry = when (e) { - Event.E333, Event.E333OH -> PuzzleRegistry.THREE - Event.E222 -> PuzzleRegistry.TWO - Event.E444 -> PuzzleRegistry.FOUR - Event.E555 -> PuzzleRegistry.FIVE - Event.E666 -> PuzzleRegistry.SIX - Event.E777 -> PuzzleRegistry.SEVEN - Event.E333BF, Event.E333MBF -> PuzzleRegistry.THREE_NI - Event.E444BF -> PuzzleRegistry.FOUR_NI - Event.E555BF -> PuzzleRegistry.FIVE_NI - Event.E333FM -> PuzzleRegistry.THREE_FM - Event.CLOCK -> PuzzleRegistry.CLOCK - Event.MINX -> PuzzleRegistry.MEGA - Event.PYRAM -> PuzzleRegistry.PYRA - Event.SKEWB -> PuzzleRegistry.SKEWB - Event.SQ1 -> PuzzleRegistry.SQ1 - Event.FTO -> PuzzleRegistry.FTO - } - - private fun submit(e: Event): Future<String> = pool.submit<String> { registry(e).scrambler.generateScramble() } - - override fun warm(event: Event) { ahead.computeIfAbsent(event, ::submit) } - - override fun next(event: Event): String { - val f = ahead.remove(event) ?: submit(event) - val s = f.get() - ahead[event] = submit(event) - return s - } - - override fun previewSvg(event: Event, scramble: String): String? = runCatching { - registry(event).scrambler.drawScramble(scramble, null).toString() - }.getOrNull() -} diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt b/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt deleted file mode 100644 index 64064b5..0000000 --- a/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt +++ /dev/null @@ -1,96 +0,0 @@ -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 - } -} |
