diff options
Diffstat (limited to 'core/src/commonMain/kotlin/coffee')
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')) + } + } +} |
