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/audio/Chime.kt4
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt59
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/images/BraveImages.kt41
3 files changed, 102 insertions, 2 deletions
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/audio/Chime.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/audio/Chime.kt
index 6fe59f9..67b4303 100644
--- a/core/src/commonMain/kotlin/coffee/liz/rookery/audio/Chime.kt
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/audio/Chime.kt
@@ -34,6 +34,10 @@ object Chime {
Note(96, 14, 1, Voice.PULSE25, 0.2), Note(100, 15, 1, Voice.PULSE25, 0.2), Note(103, 16, 2, Voice.PULSE25, 0.2),
)
+ /** Inspection calls: one pip at eight seconds, two at twelve. */
+ val EIGHT = listOf(Note(81, 0, 1, Voice.PULSE50, 0.3))
+ val TWELVE = listOf(Note(81, 0, 1, Voice.PULSE50, 0.3), Note(81, 2, 1, Voice.PULSE50, 0.3))
+
/** Short descending blip for a DNF. */
val DNF = listOf(Note(64, 0, 1, Voice.PULSE50, 0.35), Note(57, 1, 2, Voice.PULSE50, 0.35))
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 936b103..f27079d 100644
--- a/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt
@@ -56,6 +56,17 @@ object Cube3 {
}
}
+ /**
+ * Which corner of the cube to look at (signs along x→R, y→U, z→F) so the whole piece holding
+ * [slot] is in view: you need its other stickers to tell which piece it is. Axes the piece
+ * doesn't constrain keep [prefer], so the view moves as little as possible.
+ */
+ fun viewCorner(slot: Int, prefer: Triple<Int, Int, Int> = Triple(1, 1, 1)): Triple<Int, Int, Int> {
+ val c = geo.cubieOf(slot)
+ fun pick(v: Int, keep: Int) = if (v > 0) 1 else if (v < 0) -1 else keep
+ return Triple(pick(c.first, prefer.first), pick(c.second, prefer.second), pick(c.third, prefer.third))
+ }
+
/** The piece's lowest position, as a stable id for the piece. */
fun pieceId(type: PieceType, pos: Int): Int = pieceFrom(type, pos).min()
@@ -99,13 +110,30 @@ data class BldSetup(
) {
fun buffer(type: PieceType) = if (type == PieceType.CORNER) cornerBuffer else edgeBuffer
- /** Sticker colours (by original face) for a scrambled cube, seen in the solver's frame. */
- fun stateOf(scramble: String): IntArray = NxnCube(3).apply(scramble).apply(rotation).stickers.copyOf()
+ /**
+ * Sticker colours (by original face) for a scrambled cube, held the way the solver holds it:
+ * picked up by its centres, so scrambles that end in wide moves or rotations (every official BLD
+ * scramble does) still come out with your top/front colours on top/front.
+ */
+ fun stateOf(scramble: String): IntArray {
+ val scrambled = NxnCube(3).apply(scramble)
+ val wantU = solvedColors[4]
+ val wantF = solvedColors[2 * 9 + 4]
+ for (r in WHOLE_CUBE_ROTATIONS) {
+ val c = scrambled.copy().apply(r)
+ if (c.stickers[4] == wantU && c.stickers[2 * 9 + 4] == wantF) return c.stickers.copyOf()
+ }
+ error("no rotation puts colour $wantU on top and $wantF in front") // impossible for a legal cube
+ }
/** The solved cube as seen in the solver's frame (which colour belongs on each sticker). */
val solvedColors: IntArray by lazy { NxnCube(3).apply(rotation).stickers.copyOf() }
}
+/** All 24 orientations of a cube, as rotation moves: 6 choices of top, then 4 of front. */
+internal val WHOLE_CUBE_ROTATIONS: List<String> =
+ listOf("", "x", "x2", "x'", "z", "z'").flatMap { up -> listOf("", "y", "y2", "y'").map { "$up $it".trim() } }
+
/** A memo target: a position of a piece type. */
data class Target(val type: PieceType, val pos: Int)
@@ -182,6 +210,33 @@ class Tracer(private val setup: BldSetup) {
return s
}
+ /** Where a memo first stops following the cube. [expected] is the position that should have been typed, if there was exactly one. */
+ data class Mistake(val index: Int, val expected: Int?, val brokeIntoSolved: Boolean = false)
+
+ /**
+ * Checks a memo target by target against what the cube shows: after each shot, the sticker now in
+ * the buffer says where to go next; when the buffer piece comes home, any unsolved piece may be
+ * broken into. Returns null for a complete, valid trace; `index == targets.size` means it stopped early.
+ */
+ fun firstMistake(state: IntArray, targets: List<Target>, type: PieceType): Mistake? {
+ var s = state
+ val buffer = setup.buffer(type)
+ val bufferPiece = Cube3.pieceId(type, buffer)
+ for ((i, t) in targets.withIndex()) {
+ val home = homes(s, type)
+ val next = home[buffer]
+ if (Cube3.pieceId(type, next) != bufferPiece) {
+ if (t.pos != next) return Mistake(i, next)
+ } else if (Cube3.pieceFrom(type, t.pos).all { home[it] == it }) {
+ return Mistake(i, null, brokeIntoSolved = true)
+ }
+ s = execute(s, listOf(t))
+ }
+ if (isSolved(s, type)) return null
+ val next = homes(s, type)[buffer]
+ return Mistake(targets.size, next.takeIf { Cube3.pieceId(type, it) != bufferPiece })
+ }
+
/** Pieces of [type] not solved in [state], by name. */
fun wrongPieces(state: IntArray, type: PieceType): List<String> =
(0 until 24).map { Cube3.pieceId(type, it) }.distinct().filter { id ->
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/images/BraveImages.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/images/BraveImages.kt
new file mode 100644
index 0000000..33b3a22
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/images/BraveImages.kt
@@ -0,0 +1,41 @@
+package coffee.liz.rookery.images
+
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonObject
+
+/** One image search result: a small [thumbnail] to show in the picker and the [full] image to keep. */
+data class ImageHit(val title: String, val thumbnail: String, val full: String, val source: String)
+
+/** Brave Search image API: request building and response parsing (the HTTP call itself is the host's job). */
+object BraveImages {
+ const val KEY_HEADER = "X-Subscription-Token"
+
+ fun url(query: String, count: Int = 12): String =
+ "https://api.search.brave.com/res/v1/images/search?q=${encode(query)}&count=$count&safesearch=strict"
+
+ fun parse(body: String): List<ImageHit> {
+ val root = Json.parseToJsonElement(body).jsonObject
+ return (root["results"] as? JsonArray).orEmpty().mapNotNull { e ->
+ val o = e as? JsonObject ?: return@mapNotNull null
+ val thumb = (o["thumbnail"] as? JsonObject)?.str("src")
+ val full = (o["properties"] as? JsonObject)?.str("url")
+ val any = thumb ?: full ?: return@mapNotNull null
+ if (!any.startsWith("https://")) return@mapNotNull null
+ ImageHit(o.str("title").orEmpty(), thumb ?: any, full?.takeIf { it.startsWith("https://") } ?: any, o.str("source").orEmpty())
+ }
+ }
+
+ private fun JsonObject.str(k: String) = (this[k] as? JsonPrimitive)?.contentOrNull
+
+ private fun encode(s: String): String = buildString {
+ for (b in s.encodeToByteArray()) {
+ val c = b.toInt().toChar()
+ if (c.isLetterOrDigit() && b >= 0 || c in "-_.~") append(c)
+ else append('%').append((b.toInt() and 0xFF).toString(16).uppercase().padStart(2, '0'))
+ }
+ }
+}