summaryrefslogtreecommitdiff
path: root/core/src
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-11-13 08:37:53 -0800
committerElizabeth Alexander Hunt <me@liz.coffee>2026-11-13 08:37:53 -0800
commitbf3e85b69811930d01573ae9fc2703f674c0c951 (patch)
tree375b3433e9e0a7d312cad7aac998e188a2fddab3 /core/src
parentf95457986531730095b00e2e894e9bf4d9df7166 (diff)
downloadrookery-bf3e85b69811930d01573ae9fc2703f674c0c951.tar.gz
rookery-bf3e85b69811930d01573ae9fc2703f674c0c951.zip
Redesign as a liz.coffee document; type-to-steer memo; image search
Look: drop the Win98 chrome for the site's own style: black page, serif text, sage links in [brackets], bordered tables, pink accent, Koch snow, a marquee of colony news, an 88x31 blinkie. A paper theme for daylight. Pixel fonts removed. Timer: drawn seven-segment LCD in a timer body with hands-down/ready lamps, sized to the space it has. Touch starts the timer only on touch devices. Big display mode (F). Pips at 8 and 12 s of inspection. Penguin: follows the pointer as on the site, pecks and sleeps when idle, watches during solves, says a word afterwards. Sprite sizes are now in dp (was raw pixels, hence tiny on hidpi). BLD: - Fix: BLD scrambles end in wide moves; hold the cube by its centres so tracing and checking use the solver's real frame. - Memo trainer: typing a target turns the view to that sticker (eight fixed corner views, animated); arrows step through the memo without showing letters; optional pause for execution; recall pass; results separate tracing slips from memory slips (Tracer.firstMistake). - Password fields show the last typed letter briefly. - Pair cards show where the pair sits on the cube. Sticker drill uses the unfolded cube for "where is it". - Letter pairs live under BLD; BLD setup lives in settings. - Reviews reload when the store changes (other devices' practice). Pictures: Brave image search in the pair editor; chosen images are shrunk to WebP and synced. Key read from the local, unsynced config. Dev: `--shots <dir>` renders every screen offscreen with sample data. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Diffstat (limited to 'core/src')
-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
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/BldTest.kt66
4 files changed, 168 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'))
+ }
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/BldTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/BldTest.kt
index cae88a1..4c39921 100644
--- a/core/src/commonTest/kotlin/coffee/liz/rookery/BldTest.kt
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/BldTest.kt
@@ -73,6 +73,58 @@ class BldTest {
assertEquals(listOf("'B' is on the buffer piece"), t.parse("B", PieceType.EDGE).second)
}
+ @Test fun bldScramblesWithWideMovesAreHeldByTheirCentres() {
+ // Official 3BLD scrambles end in random wide moves that move the centres.
+ val rng = Random(11)
+ val setup = BldSetup()
+ val t = Tracer(setup)
+ val faces = listOf("U", "R", "F", "D", "L", "B")
+ val sfx = listOf("", "'", "2")
+ val wide = listOf("", "Rw", "Rw'", "Rw2", "Uw", "Uw'", "Uw2", "Fw", "Fw'")
+ repeat(200) {
+ val scr = (List(20) { faces[rng.nextInt(6)] + sfx[rng.nextInt(3)] } + wide[rng.nextInt(wide.size)] + wide[rng.nextInt(wide.size)])
+ .filter { it.isNotEmpty() }.joinToString(" ")
+ val s = setup.stateOf(scr)
+ assertEquals(setup.solvedColors[4], s[4], "U centre after $scr")
+ assertEquals(setup.solvedColors[22], s[22], "F centre after $scr")
+ val after = t.execute(s, t.trace(s, PieceType.CORNER) + t.trace(s, PieceType.EDGE))
+ assertTrue(t.isSolved(after, PieceType.CORNER) && t.isSolved(after, PieceType.EDGE), scr)
+ }
+ }
+
+ @Test fun firstMistakePinpointsWhereATraceGoesWrong() {
+ val rng = Random(3)
+ val setup = BldSetup()
+ val t = Tracer(setup)
+ val faces = listOf("U", "R", "F", "D", "L", "B"); val sfx = listOf("", "'", "2")
+ repeat(100) {
+ val s = setup.stateOf(List(22) { faces[rng.nextInt(6)] + sfx[rng.nextInt(3)] }.joinToString(" "))
+ for (type in PieceType.entries) {
+ val good = t.trace(s, type)
+ assertEquals(null, t.firstMistake(s, good, type))
+ if (good.size < 2) continue
+ // stopping early is reported at the end
+ assertEquals(good.size - 1, t.firstMistake(s, good.dropLast(1), type)?.index)
+ // (if the buffer piece starts at home, the first target is a free choice: nothing to catch)
+ val buf = setup.buffer(type)
+ if (Cube3.pieceId(type, t.homes(s, type)[buf]) == Cube3.pieceId(type, buf)) continue
+ // swapping the first target for a different piece is caught at index 0, with the right answer
+ val other = good.first { Cube3.pieceId(type, it.pos) != Cube3.pieceId(type, good[0].pos) }
+ val m = t.firstMistake(s, listOf(other) + good.drop(1), type)!!
+ assertEquals(0, m.index)
+ assertEquals(good[0].pos, m.expected)
+ }
+ }
+ }
+
+ @Test fun viewCornerShowsTheWholePiece() {
+ // A is the U sticker of UBL: look from up-back-left. C (UFR): the default front view.
+ assertEquals(Triple(-1, 1, -1), Cube3.viewCorner(Cube3.slotOf(PieceType.CORNER, 0)))
+ assertEquals(Triple(1, 1, 1), Cube3.viewCorner(Cube3.slotOf(PieceType.CORNER, 2)))
+ // UR edge leaves front/back to the current view
+ assertEquals(Triple(1, 1, -1), Cube3.viewCorner(Cube3.slotOf(PieceType.EDGE, 1), Triple(-1, -1, -1)))
+ }
+
@Test fun userFrameHasYellowOnTopAndOrangeInFront() {
val solved = BldSetup().solvedColors
assertEquals(NxnCube.Face.D.ordinal, solved[4]) // U centre shows the yellow (WCA D) colour
@@ -94,3 +146,17 @@ class CubeMappingTest {
}
}
}
+
+class BraveImagesTest {
+ @Test fun parsesResultsAndEncodesQueries() {
+ val body = """{"type":"images","results":[
+ {"title":"a penguin","source":"example.org","thumbnail":{"src":"https://imgs.search.brave.com/t1"},"properties":{"url":"https://example.org/p.jpg"}},
+ {"title":"no props","thumbnail":{"src":"https://imgs.search.brave.com/t2"}},
+ {"title":"insecure","thumbnail":{"src":"http://x/t3"}}]}"""
+ val hits = coffee.liz.rookery.images.BraveImages.parse(body)
+ assertEquals(2, hits.size)
+ assertEquals("https://example.org/p.jpg", hits[0].full)
+ assertEquals(hits[1].thumbnail, hits[1].full)
+ assertTrue(coffee.liz.rookery.images.BraveImages.url("bernie sanders & co").contains("q=bernie%20sanders%20%26%20co"))
+ }
+}