diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 08:37:53 -0800 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 08:37:53 -0800 |
| commit | bf3e85b69811930d01573ae9fc2703f674c0c951 (patch) | |
| tree | 375b3433e9e0a7d312cad7aac998e188a2fddab3 /core/src | |
| parent | f95457986531730095b00e2e894e9bf4d9df7166 (diff) | |
| download | rookery-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')
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")) + } +} |
