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/commonMain/kotlin/coffee/liz/rookery | |
| 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/commonMain/kotlin/coffee/liz/rookery')
10 files changed, 424 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 index 954e423..efcb3c0 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt @@ -9,5 +9,8 @@ expect fun monoMillis(): Long /** A random identifier, unique enough to name devices and new entities. */ expect fun randomId(): String +/** Today's date here, as yyyy-mm-dd. */ +expect fun today(): 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/algs/AlgData.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgData.kt new file mode 100644 index 0000000..b12c033 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgData.kt @@ -0,0 +1,89 @@ +package coffee.liz.rookery.algs + +// Case names, groups and algorithms as listed on jperm.net/algs (J Perm's algorithm sheets). +// Generated from that site's data; edit AlgSets.kt for behaviour, not this file. + +internal val ALG_DATA: Map<String, List<AlgCase>> = mapOf( + "pll" to listOf( + AlgCase("pll", "H", "Edges Only", 1, listOf("M2 U M2 U2 M2 U M2", "M2 U' M2 U2 M2 U' M2")), + AlgCase("pll", "Z", "Edges Only", 2, listOf("M' U M2 U M2 U M' U2 M2", "y M' U' M2 U' M2 U' M' U2 M2", "y M2 U M2 U M' U2 M2 U2 M'", "M2 U' M2 U' M' U2 M2 U2 M'")), + AlgCase("pll", "Ua", "Edges Only", 4, listOf("M2 U M U2 M' U M2", "R U' R U R U R U' R' U' R2", "y2 R2 U' R' U' R U R U R U' R")), + AlgCase("pll", "Ub", "Edges Only", 4, listOf("M2 U' M U2 M' U' M2", "R2 U (R U R' U') R' U' R' U R'", "y2 R' U R' U' R' U' (R' U R U) R2", "y2 R' U R' U' R3 U' (R' U R U) R2")), + AlgCase("pll", "Aa", "Adjacent Corner Swap", 4, listOf("x L2 D2 L' U' L D2 L' U L'", "y' x' L' U L' D2 L U' L' D2 L2", "y x R' U R' D2 R U' R' D2 R2", "y2 x' R2 D2 R' U' R D2 R' U R'")), + AlgCase("pll", "Ab", "Adjacent Corner Swap", 4, listOf("x' L2 D2 L U L' D2 L U' L", "y x L U' L D2 L' U L D2 L2", "y2 x R2 D2 R U R' D2 R U' R", "y' x' R U' R D2 R' U R D2 R2")), + AlgCase("pll", "E", "Diagonal Corner Swap", 2, listOf("x' L' U L D' L' U' L D L' U' L D' L' U L D", "x' R U' R' D R U R' D' R U R' D R U' R' D'")), + AlgCase("pll", "F", "Adjacent Corner Swap", 4, listOf("R' U' F' R U R' U' R' F R2 U' R' U' R U R' U R")), + AlgCase("pll", "Ja", "Adjacent Corner Swap", 4, listOf("x R2 F R F' R U2 r' U r U2", "y2 L' U' L F L' U' L U L F' L2 U L", "y' R' U L' U2 R U' R' U2 R L")), + AlgCase("pll", "Jb", "Adjacent Corner Swap", 4, listOf("R U R' F' R U R' U' R' F R2 U' R'")), + AlgCase("pll", "Ra", "Adjacent Corner Swap", 4, listOf("R U' R' U' R U R D R' U' R D' R' U2 R'", "R U R' F' R U2 R' U2 R' F R U R U2 R'", "y' L U2 L' U2 L F' L' U' L U L F L2")), + AlgCase("pll", "Rb", "Adjacent Corner Swap", 4, listOf("R2 F R U R U' R' F' R U2 R' U2 R", "y' R' U2 R U2 R' F R U R' U' R' F' R2", "R' U2 R' D' R U' R' D R U R U' R' U' R")), + AlgCase("pll", "T", "Adjacent Corner Swap", 4, listOf("R U R' U' R' F R2 U' R' U' (R U R') F'")), + AlgCase("pll", "Y", "Diagonal Corner Swap", 4, listOf("F R U' R' U' R U R' F' R U R' U' R' F R F'", "F R' F R2 U' R' U' R U R' F' R U R' U' F'")), + AlgCase("pll", "V", "Diagonal Corner Swap", 4, listOf("R' U R' U' y R' F' R2 U' R' U R' F R F", "R' U R' U' R D' R' D R' U D' R2 U' R2 D R2", "z D' R2 D R2 U R' D' R U' R U R' D R U' z'", "R U2 R' D R U' R U' R U R2 D R' U' R D2", "x' R' F R F' U R U2 R' U' R U' R' U2 R U R' U'")), + AlgCase("pll", "Na", "Diagonal Corner Swap", 1, listOf("R U R' U R U R' F' R U R' U' R' F R2 U' R' U2 R U' R'", "z U R' D R2 U' R D' U R' D R2 U' R D'")), + AlgCase("pll", "Nb", "Diagonal Corner Swap", 1, listOf("R' (U R U' R') F' U' F R U R' F R' F' R U' R", "z D' R U' R2 D R' U D' R U' R2 D R' U")), + AlgCase("pll", "Ga", "Adjacent Corner Swap", 4, listOf("R2 U R' U R' U' R U' R2 (U' D) R' U R D'", "R2 u R' U R' U' R u' R2 y' R' U R")), + AlgCase("pll", "Gb", "Adjacent Corner Swap", 4, listOf("R' U' R (U D') R2 U R' U R U' R U' R2 D", "y F' U' F R2 u R' U R U' R u' R2")), + AlgCase("pll", "Gc", "Adjacent Corner Swap", 4, listOf("R2 U' R U' R U R' U R2 (U D') R U' R' D", "y2 R2 F2 R U2 R U2 R' F R U R' U' R' F R2", "R2 u' R U' R U R' u R2 y R U' R'")), + AlgCase("pll", "Gd", "Adjacent Corner Swap", 4, listOf("R U R' (U' D) R2 U' R U' R' U R' U R2 D'", "R U R' y' R2 u' R U' R' U R' u R2")), + ), + "oll" to listOf( + AlgCase("oll", "1", "Dot", 2, listOf("R U2 R' R' F R F' U2 R' F R F'")), + AlgCase("oll", "2", "Dot", 4, listOf("r U r' U2 r U2 R' U2 R U' r'", "y' F R U R' U' F' f R U R' U' f'", "y' F R U R' U' S R U R' U' f'")), + AlgCase("oll", "3", "Dot", 4, listOf("r' R2 U R' U r U2 r' U M'", "y F U R U' R' F' U F R U R' U' F'", "y' f R U R' U' f' U' F R U R' U' F'")), + AlgCase("oll", "4", "Dot", 4, listOf("M U' r U2 r' U' R U' R' M'", "y F U R U' R' F' U' F R U R' U' F'", "y' f R U R' U' f' U F R U R' U' F'")), + AlgCase("oll", "5", "Square Shape", 4, listOf("l' U2 L U L' U l", "y2 r' U2 R U R' U r")), + AlgCase("oll", "6", "Square Shape", 4, listOf("r U2 R' U' R U' r'")), + AlgCase("oll", "7", "Small Lightning Bolt", 4, listOf("r U R' U R U2 r'")), + AlgCase("oll", "8", "Small Lightning Bolt", 4, listOf("l' U' L U' L' U2 l", "R U2 R' U2 R' F R F'", "y2 r' U' R U' R' U2 r")), + AlgCase("oll", "9", "Fish Shape", 4, listOf("R U R' U' R' F R2 U R' U' F'")), + AlgCase("oll", "10", "Fish Shape", 4, listOf("R U R' U R' F R F' R U2 R'", "y2 r U R' U R U' R' U' r' R (U R U' R')")), + AlgCase("oll", "11", "Small Lightning Bolt", 4, listOf("r U R' U R' F R F' R U2 r'", "y2 r' R2 U R' U R U2 R' U M'")), + AlgCase("oll", "12", "Small Lightning Bolt", 4, listOf("M' R' U' R U' R' U2 R U' R r'")), + AlgCase("oll", "13", "Knight Move Shape", 4, listOf("F U R U' R2 F' R U R U' R'", "r U' r' U' r U r' y' R' U R")), + AlgCase("oll", "14", "Knight Move Shape", 4, listOf("R' F R U R' F' R F U' F'")), + AlgCase("oll", "15", "Knight Move Shape", 4, listOf("l' U' l L' U' L U l' U l", "y2 r' U' r R' U' R U r' U r")), + AlgCase("oll", "16", "Knight Move Shape", 4, listOf("r U r' R U R' U' r U' r'")), + AlgCase("oll", "17", "Dot", 4, listOf("F R' F' R2 r' U R U' R' U' M'", "y2 R U R' U R' F R F' U2 R' F R F'")), + AlgCase("oll", "18", "Dot", 4, listOf("r U R' U R U2 r' r' U' R U' R' U2 r", "y R U2 R' R' F R F' U2 M' (U R U' r')")), + AlgCase("oll", "19", "Dot", 4, listOf("r' R U R U R' U' M' R' F R F'")), + AlgCase("oll", "20", "Dot", 1, listOf("r U R' U' M2 U R U' R' U' M'", "r' R U (R U R' U') M2 U R U' r'")), + AlgCase("oll", "21", "Cross", 2, listOf("R U2 R' U' R U R' U' R U' R'", "y R U R' U R U' R' U R U2 R'")), + AlgCase("oll", "22", "Cross", 4, listOf("R U2 (R2 U' R2 U' R2) U2 R")), + AlgCase("oll", "23", "Cross", 4, listOf("R2 D' R U2 R' D R U2 R", "y2 R2 D R' U2 R D' R' U2 R'")), + AlgCase("oll", "24", "Cross", 4, listOf("r U R' U' r' F R F'", "y R U R D R' U' R D' R2")), + AlgCase("oll", "25", "Cross", 4, listOf("F' r U R' U' r' F R", "y' R' F R B' R' F' R B")), + AlgCase("oll", "26", "Cross", 4, listOf("(R U2 R') U' R U' R'", "y' R' U' R U' R' U2 R")), + AlgCase("oll", "27", "Cross", 4, listOf("R U R' U R U2 R'", "y' R' U2 (R U R' U) R")), + AlgCase("oll", "28", "Corners Oriented", 4, listOf("r U R' U' r' R U R U' R'")), + AlgCase("oll", "29", "Awkward Shape", 4, listOf("R U R' U' R U' R' F' U' F R U R'")), + AlgCase("oll", "30", "Awkward Shape", 4, listOf("F R' F R2 U' R' U' R U R' F2", "F U (R U2 R' U') R U2 R' U' F'")), + AlgCase("oll", "31", "P Shape", 4, listOf("R' U' F U R U' R' F' R")), + AlgCase("oll", "32", "P Shape", 4, listOf("L U F' U' L' U L F L'", "y2 S R U R' U' R' F R f'")), + AlgCase("oll", "33", "T Shape", 4, listOf("R U R' U' R' F R F'")), + AlgCase("oll", "34", "C Shape", 4, listOf("R U R2 U' R' F R U R U' F'", "R U R' U' B' R' F R F' B")), + AlgCase("oll", "35", "Fish Shape", 4, listOf("R U2 R' R' F R F' R U2 R'")), + AlgCase("oll", "36", "W Shape", 4, listOf("L' U' L U' L' U L U L F' L' F", "y2 R' U' R U' R' U R U R B' R' B")), + AlgCase("oll", "37", "Fish Shape", 4, listOf("F R' F' R U R U' R'", "F R U' R' U' R U R' F'")), + AlgCase("oll", "38", "W Shape", 4, listOf("R U R' U R U' R' U' R' F R F'")), + AlgCase("oll", "39", "Big Lightning Bolt", 4, listOf("L F' L' U' L U F U' L'", "y2 R B' R' U' R U B U' R'")), + AlgCase("oll", "40", "Big Lightning Bolt", 4, listOf("R' F R U R' U' F' U R")), + AlgCase("oll", "41", "Awkward Shape", 4, listOf("R U R' U R U2 R' F R U R' U' F'")), + AlgCase("oll", "42", "Awkward Shape", 4, listOf("R' U' R U' R' U2 R F R U R' U' F'")), + AlgCase("oll", "43", "P Shape", 4, listOf("F' U' L' U L F", "R' U' F R' F' R U R")), + AlgCase("oll", "44", "P Shape", 4, listOf("F U R U' R' F'", "y2 f R U R' U' f'")), + AlgCase("oll", "45", "T Shape", 4, listOf("F R U R' U' F'")), + AlgCase("oll", "46", "C Shape", 4, listOf("R' U' R' F R F' U R")), + AlgCase("oll", "47", "Small L Shape", 4, listOf("R' U' R' F R F' R' F R F' U R", "F' L' U' L U L' U' L U F", "y' F U R U' R' F' R U R' U R U2 R'")), + AlgCase("oll", "48", "Small L Shape", 4, listOf("F R U R' U' R U R' U' F'")), + AlgCase("oll", "49", "Small L Shape", 4, listOf("r U' r2 U r2 U r2 U' r")), + AlgCase("oll", "50", "Small L Shape", 4, listOf("r' U r2 U' r2 U' r2 U r'")), + AlgCase("oll", "51", "I Shape", 4, listOf("F U R U' R' U R U' R' F'", "y2 f R U R' U' R U R' U' f'")), + AlgCase("oll", "52", "I Shape", 4, listOf("R U R' U R U' B U' B' R'", "y2 R' F' U' F U' (R U R' U) R", "R U R' U R U' y R U' R' F'")), + AlgCase("oll", "53", "Small L Shape", 4, listOf("l' U2 L U L' U' L U L' U l", "y2 r' U2 (R U R' U') R U R' U r", "y r' U' R U' R' U R U' R' U2 r")), + AlgCase("oll", "54", "Small L Shape", 4, listOf("(r U2 R' U') R U R' U' R U' r'", "y r U R' U R U' R' U R U2 r'")), + AlgCase("oll", "55", "I Shape", 2, listOf("R' F R U R U' R2 F' R2 U' R' U R U R'", "y R U2 R2 U' R U' R' U2 F R F'")), + AlgCase("oll", "56", "I Shape", 2, listOf("(r' U' r) U' R' U R U' R' U R r' U r", "(r U r') U R U' R' U R U' R' (r U' r')", "(r U r') U R U' R' U R U' M' U' r'")), + AlgCase("oll", "57", "Corners Oriented", 2, listOf("R U R' U' M' U R U' r'")), + ), +) diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgSets.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgSets.kt new file mode 100644 index 0000000..2309d97 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgSets.kt @@ -0,0 +1,54 @@ +package coffee.liz.rookery.algs + +import coffee.liz.rookery.puzzle.Notation +import coffee.liz.rookery.puzzle.NxnCube + +/** One case of an algorithm set, with the algorithms people use for it (the first is the usual one). */ +data class AlgCase(val set: String, val name: String, val group: String, val probability: Int, val algs: List<String>) { + val id: String get() = "$set:$name" + + /** + * The cube showing this case, last layer up, as you would hold it before doing [alg]: a solved + * cube with the algorithm undone. An algorithm with a net rotation (say it starts with `x`) + * leaves the centres somewhere else, so colours are renamed after whichever centre they sit on. + */ + fun state(alg: String = algs.first()): NxnCube { + val c = NxnCube(3).apply(Notation.invert(clean(alg))) + val rename = IntArray(6) + for (f in 0 until 6) rename[c.stickers[f * 9 + 4]] = f + for (i in c.stickers.indices) c.stickers[i] = rename[c.stickers[i]] + return c + } + + /** Moves that set this case up from a solved cube. */ + fun setup(alg: String = algs.first()): String = Notation.invert(clean(alg)) + + companion object { + fun clean(alg: String) = alg.replace("(", " ").replace(")", " ").trim().replace(Regex("\\s+"), " ") + } +} + +enum class AlgStatus { NEW, LEARNING, KNOWN } + +object AlgSets { + val names: List<String> get() = ALG_DATA.keys.toList() + fun cases(set: String): List<AlgCase> = ALG_DATA[set].orEmpty() + fun find(id: String): AlgCase? = cases(id.substringBefore(':')).firstOrNull { it.id == id } + + /** + * The last layer seen from above, as 5x5 cells of colour indices (-1 = empty corner): the U face + * in the middle and the top row of each side around it, front at the bottom. + */ + fun plan(c: NxnCube): Array<IntArray> { + val s = c.stickers + val g = Array(5) { IntArray(5) { -1 } } + for (r in 0 until 3) for (k in 0 until 3) g[r + 1][k + 1] = s[r * 3 + k] // U + for (k in 0 until 3) { + g[4][k + 1] = s[2 * 9 + k] // F, left to right + g[0][k + 1] = s[5 * 9 + (2 - k)] // B, seen from above + g[k + 1][4] = s[1 * 9 + (2 - k)] // R, back to front + g[k + 1][0] = s[4 * 9 + k] // L, back to front + } + return g + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt index f27079d..614cc53 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt @@ -67,6 +67,15 @@ object Cube3 { return Triple(pick(c.first, prefer.first), pick(c.second, prefer.second), pick(c.third, prefer.third)) } + /** The corner view (signs along x, y, z) from which the most of [slots] can be seen, preferring the usual front-top-right. */ + fun bestView(slots: List<Int>): Triple<Int, Int, Int> { + val corners = listOf(1, -1).flatMap { x -> listOf(1, -1).flatMap { y -> listOf(1, -1).map { z -> Triple(x, y, z) } } } + return corners.maxBy { c -> + slots.count { s -> val n = geo.normalOf(s); n.first * c.first + n.second * c.second + n.third * c.third > 0 } * 8 + + (if (c.second > 0) 4 else 0) + (if (c.third > 0) 2 else 0) + (if (c.first > 0) 1 else 0) + } + } + /** The piece's lowest position, as a stable id for the piece. */ fun pieceId(type: PieceType, pos: Int): Int = pieceFrom(type, pos).min() diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/KableTransport.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/KableTransport.kt new file mode 100644 index 0000000..9c36517 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/KableTransport.kt @@ -0,0 +1,73 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package coffee.liz.rookery.bt + +import com.juul.kable.Peripheral +import com.juul.kable.PlatformAdvertisement +import com.juul.kable.Scanner +import com.juul.kable.State +import com.juul.kable.WriteType +import com.juul.kable.characteristicOf +import com.juul.kable.write +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Bluetooth LE through Kable, which speaks CoreBluetooth on macOS, WinRT on Windows, BlueZ on Linux + * and the platform stack on Android, behind one API. + * + * Addresses are whatever the platform uses to name a device: a MAC on Linux, Windows and Android, + * an opaque UUID on macOS. Callers that need the real MAC (GAN cubes do) must get it elsewhere. + */ +class KableTransport : BleTransport { + private val seen = HashMap<String, PlatformAdvertisement>() + + override fun scan(): Flow<BleAdvertisement> = Scanner().advertisements.map { a -> + val id = a.identifier.toString() + synchronized(seen) { seen[id] = a } + BleAdvertisement( + address = id, name = a.name ?: a.peripheralName, rssi = a.rssi, + manufacturerData = a.manufacturerData?.let { mapOf(it.code to it.data) } ?: emptyMap(), + serviceUuids = a.uuids.map { it.toString() }, + ) + } + + override suspend fun connect(address: String): BleConnection { + // Peripherals are made from an advertisement, so one must have been heard (now or just before). + val adv = synchronized(seen) { seen[address] } + ?: withTimeoutOrNull(12_000) { Scanner().advertisements.first { it.identifier.toString() == address } } + ?: error("can't hear that cube. turn it to wake it, then try again") + val peripheral = Peripheral(adv) + try { peripheral.connect() } catch (e: Exception) { runCatching { peripheral.close() }; throw e } + return KableConnection(peripheral, address, adv.name ?: adv.peripheralName) + } + + private fun <T> synchronized(lock: Any, block: () -> T): T = coffee.liz.rookery.locked(lock, block) +} + +private fun char(service: String, characteristic: String) = characteristicOf(kotlin.uuid.Uuid.parse(service), kotlin.uuid.Uuid.parse(characteristic)) + +private class KableConnection(private val p: Peripheral, override val address: String, override val name: String?) : BleConnection { + override suspend fun discoverServices(): List<BleService> = p.services.value.orEmpty().map { s -> + BleService(s.serviceUuid.toString(), s.characteristics.map { BleCharacteristic(it.characteristicUuid.toString()) }) + } + + override suspend fun read(service: String, characteristic: String): ByteArray = p.read(char(service, characteristic)) + + override suspend fun write(service: String, characteristic: String, value: ByteArray) { + val known = p.services.value.orEmpty().firstOrNull { it.serviceUuid.toString().equals(service, true) } + ?.characteristics?.firstOrNull { it.characteristicUuid.toString().equals(characteristic, true) } + // prefer an acknowledged write where the characteristic allows one + val type = if (known != null && !known.properties.write) WriteType.WithoutResponse else WriteType.WithResponse + p.write(char(service, characteristic), value, type) + } + + override suspend fun subscribe(service: String, characteristic: String): Flow<ByteArray> = p.observe(char(service, characteristic)) + + override val disconnected: Flow<Unit> = p.state.filterIsInstance<State.Disconnected>().map { } + + override suspend fun disconnect() { runCatching { p.disconnect() }; runCatching { p.close() } } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt index 5090ee0..8aa1b6b 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt @@ -23,6 +23,8 @@ data class Solve( val at: Long, /** Smart-cube reconstruction, csTimer style: `R@0 U'@120 ...` */ val moves: String? = null, + /** Blindfolded with a smart cube: time until the first turn. */ + val memoMs: Long? = null, ) { val result: Long get() = when (penalty) { Penalty.NONE -> ms diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/ScrambleTracker.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/ScrambleTracker.kt new file mode 100644 index 0000000..68890b4 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/ScrambleTracker.kt @@ -0,0 +1,156 @@ +package coffee.liz.rookery.puzzle + +/** 3x3 helpers in the frame a smart cube reports in: centres fixed, face turns only. */ +object Cube3x3 { + private const val FACES = "URFDLB" + const val SOLVED = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB" + + fun facelets(c: NxnCube): String = buildString(54) { for (s in c.stickers) append(FACES[s]) } + + /** + * Rewrite a scramble as plain face turns. A wide move is the opposite face plus a whole-cube + * rotation (Rw = L then x), and a smart cube can't see rotations, so later moves are renamed + * through them. The result reaches the same cube, just held differently. + */ + fun faceTurns(scramble: String): List<String> { + val orient = NxnCube(3) // only its centres matter: which physical face sits where + val out = mutableListOf<String>() + val re = Regex("""^([URFDLB])(w?)([2']?)$|^([xyz])([2']?)$""") + for (t in scramble.split(Regex("\\s+")).filter { it.isNotEmpty() }) { + val m = re.matchEntire(t) ?: throw IllegalArgumentException("can't follow move '$t' on a smart cube") + if (m.groupValues[4].isNotEmpty()) { orient.apply(t); continue } + val face = FACES.indexOf(m.groupValues[1]); val suffix = m.groupValues[3] + if (m.groupValues[2].isEmpty()) out += "${FACES[orient.stickers[face * 9 + 4]]}$suffix" + else { + val opposite = (face + 3) % 6 + out += "${FACES[orient.stickers[opposite * 9 + 4]]}$suffix" + val (rot, flip) = when (FACES[face]) { 'R' -> "x" to false; 'L' -> "x" to true; 'U' -> "y" to false; 'D' -> "y" to true; 'F' -> "z" to false; else -> "z" to true } + val sfx = if (!flip) suffix else when (suffix) { "" -> "'"; "'" -> ""; else -> "2" } + orient.apply(rot + sfx) + } + } + return out + } +} + +/** + * Follows a smart cube towards a scramble, the way csTimer does: shows what is left to turn, takes + * each move as it comes, and when the cube strays works out the shortest way from where it is to the + * scrambled state. The plan is kept until the next stray move, so it doesn't change under your hands. + * + * [solver] returns moves that solve a facelet string (null if it can't). + */ +class ScrambleTracker(private val solver: (facelets: String) -> String?) { + private var target: List<String> = emptyList() + private var cube = NxnCube(3) + private var targetFacelets = Cube3x3.SOLVED + + /** Moves still to do. Empty with [known] true means the cube is scrambled. */ + var plan: List<String> = emptyList() + private set(value) { field = value } + + /** + * The whole sequence being worked through (the scramble, or the current fix), which stays put + * while [done] fills in, so a display can mark progress without the text moving. + */ + var sequence: List<String> = emptyList() + private set + /** Indices into [sequence] already turned. */ + var done: Set<Int> = emptySet() + private set + /** Index in [sequence] of the move in hand, or -1. */ + val current: Int get() = remaining.firstOrNull() ?: -1 + private var remaining = mutableListOf<Int>() + + private fun begin(moves: List<String>) { plan = moves; sequence = moves; done = emptySet(); remaining = moves.indices.toMutableList() } + /** False while the plan is the scramble as written; true once it is a computed correction. */ + var corrected = false + private set + /** False if we couldn't work out a path (cube state unknown or unsolvable). */ + var known = false + private set + val scrambled: Boolean get() = known && plan.isEmpty() && target.isNotEmpty() + val total: Int get() = target.size + + /** + * Begin tracking a new [scramble]. If we have been following the cube we already know where it + * is; otherwise [facelets] (the cube's last report, possibly null) says. + */ + fun reset(scramble: String, facelets: String?) { + target = runCatching { Cube3x3.faceTurns(scramble) }.getOrDefault(emptyList()) + targetFacelets = Cube3x3.facelets(NxnCube(3).apply(target.joinToString(" "))) + if (known) start() else sync(facelets) + } + + /** Learn the cube's state from a facelets report. Only needed while it is not [known]. */ + fun sync(facelets: String?) { + if (facelets == null) { known = false; begin(emptyList()); return } + val solution = solver(facelets) + if (solution == null) { known = false; begin(emptyList()); return } + cube = NxnCube(3).apply(Notation.invert(solution)) + known = true + start() + } + + private fun start() { + if (target.isEmpty()) { begin(emptyList()); return } + if (cube.isSolved) { begin(target); corrected = false } else replan() + } + + fun move(m: String) { + if (!known) return + cube.apply(m) + val face = m[0]; val amount = amount(m) + // the next move, or the one after it if the two are opposite faces (they commute) + val at = when { + plan.isNotEmpty() && plan[0][0] == face -> 0 + plan.size > 1 && plan[1][0] == face && opposite(plan[0][0]) == face -> 1 + else -> -1 + } + if (at < 0) { replan(); return } + val left = ((amount(plan[at]) - amount) % 4 + 4) % 4 + plan = plan.toMutableList().also { if (left == 0) it.removeAt(at) else it[at] = face + suffix(left) } + if (left == 0) done = done + remaining.removeAt(at) + } + + /** The cube as we believe it is, 54 facelets U R F D L B; null while unknown. */ + val facelets: String? get() = if (known) Cube3x3.facelets(cube) else null + + /** + * The cube itself says where it is, and that outranks our move-by-move bookkeeping (a turn can be + * lost over Bluetooth). Returns true if we were wrong. The plan is left alone: use [reset] or + * [move] afterwards if a scramble is in progress. + */ + fun correct(reported: String): Boolean { + if (known && Cube3x3.facelets(cube) == reported) return false + if (reported == Cube3x3.SOLVED) { cube = NxnCube(3); known = true; return true } + val solution = solver(reported) ?: return false + cube = NxnCube(3).apply(Notation.invert(solution)); known = true + return true + } + + /** Work out the plan again from wherever the cube is now. */ + fun replanFromHere() { if (known) start() } + + /** A turn made while solving: keep the cube up to date, leave the plan alone. */ + fun turn(m: String) { if (known) cube.apply(m) } + + val solved: Boolean get() = known && cube.isSolved + + private fun replan() { + if (Cube3x3.facelets(cube) == targetFacelets) { plan = emptyList(); done = sequence.indices.toSet(); remaining.clear(); known = true; return } + // the cube that, once solved, has taken ours to the target: undo the target, then redo what we have + val relative = NxnCube(3).apply(Notation.invert(target.joinToString(" "))) + val here = solver(Cube3x3.facelets(cube)) + if (here == null) { known = false; begin(emptyList()); return } + relative.apply(Notation.invert(here)) + val path = solver(Cube3x3.facelets(relative)) + if (path == null) { known = false; begin(emptyList()); return } + begin(path.split(Regex("\\s+")).filter { it.isNotEmpty() }) + corrected = true; known = true + } + + private fun amount(m: String) = when { m.endsWith("2") -> 2; m.endsWith("'") -> 3; else -> 1 } + private fun suffix(n: Int) = when (n) { 2 -> "2"; 3 -> "'"; else -> "" } + private fun opposite(f: Char) = "URFDLB"[("URFDLB".indexOf(f) + 3) % 6] +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt index b056e91..c112d7b 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt @@ -9,6 +9,9 @@ interface Scrambler { /** An SVG drawing of the scrambled puzzle, or null if unavailable. */ fun previewSvg(event: Event, scramble: String): String? + /** Moves that solve a 3x3 given as 54 facelets (U R F D L B), or null if unavailable or unsolvable. */ + fun solve3x3(facelets: String): String? = null + /** Start building solver tables / a scramble ahead of time. */ fun warm(event: Event) {} } diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt index a7a0994..21cee38 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt @@ -26,6 +26,8 @@ object Coll { const val PREFS = "prefs" /** Append-only trainer answers; one entity per answer so concurrent devices never conflict. */ const val REVIEW = "review" + /** Per-case algorithm choices and learning status. */ + const val ALG = "alg" } private fun Map<String, JsonElement>.str(f: String): String? = (this[f] as? JsonPrimitive)?.takeIf { this[f] !is JsonNull }?.contentOrNull @@ -56,6 +58,7 @@ class Repo(val store: Store) { comment = f.str("note").orEmpty(), at = f.long("at") ?: 0, moves = f.str("moves"), + memoMs = f.long("memo"), ) }.sortedWith(compareBy({ it.at }, { it.id })) } @@ -87,6 +90,7 @@ class Repo(val store: Store) { 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) + if (s.memoMs != null) set(Coll.SOLVE, s.id, "memo", s.memoMs) } fun setPenalty(id: String, p: Penalty) = store.transact { set(Coll.SOLVE, id, "pen", p.code) } @@ -160,6 +164,17 @@ class Repo(val store: Store) { }.toMap() } + // ---- algorithms ---- + + /** For each case id (`pll:T`): how well you know it, and which algorithm you've chosen (null = the usual one). */ + fun algPrefs(): Map<String, Pair<coffee.liz.rookery.algs.AlgStatus, String?>> = store.read { r -> + r.entities(Coll.ALG).mapValues { (_, f) -> + (coffee.liz.rookery.algs.AlgStatus.entries.firstOrNull { it.name == f.str("status") } ?: coffee.liz.rookery.algs.AlgStatus.NEW) to f.str("alg")?.takeIf { it.isNotBlank() } + } + } + fun setAlgStatus(id: String, status: coffee.liz.rookery.algs.AlgStatus) = store.transact { set(Coll.ALG, id, "status", status.name) } + fun setAlgChoice(id: String, alg: String?) = store.transact { set(Coll.ALG, id, "alg", alg) } + // ---- BLD setup ---- fun bldSetup(): coffee.liz.rookery.bld.BldSetup { diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt index f08a550..aab40db 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt @@ -63,6 +63,26 @@ class TimerMachine(var inspection: Boolean = true, var holdMs: Long = 300) { fun cancel() { state = State.Idle } + /** Start inspection without a key press (a smart cube has just been scrambled). */ + fun beginInspection(now: Long) { if (state is State.Idle || state is State.Stopped) state = State.Inspecting(now) } + + /** Start the clock without a key press (a smart cube made its first move, or blind memo began). */ + fun beginRunning(now: Long) { + state = when (val s = state) { + is State.Inspecting -> State.Running(now, inspectionPenalty(s.since, now)) + State.Idle, is State.Stopped -> State.Running(now, Penalty.NONE) + else -> return + } + } + + /** Stop the clock without a key press (the smart cube is solved). */ + fun finish(now: Long) { + val s = state as? State.Running ?: return + val ms = now - s.since + state = State.Stopped(ms, s.penalty, awaitingRelease = false) + onFinish(ms, s.penalty) + } + private fun inspectionPenalty(since: Long?, now: Long): Penalty { if (since == null) return Penalty.NONE val t = now - since |
