summaryrefslogtreecommitdiff
path: root/core/src/commonMain/kotlin/coffee/liz
diff options
context:
space:
mode:
Diffstat (limited to 'core/src/commonMain/kotlin/coffee/liz')
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt3
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgData.kt89
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgSets.kt54
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt9
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/KableTransport.kt73
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt2
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/ScrambleTracker.kt156
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt3
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt15
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt20
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