summaryrefslogtreecommitdiff
path: root/core/src/jvmMain
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/jvmMain
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/jvmMain')
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt6
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt17
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt50
-rw-r--r--core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt96
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
- }
-}