diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-12 21:41:10 -0800 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-12 21:41:10 -0800 |
| commit | f95457986531730095b00e2e894e9bf4d9df7166 (patch) | |
| tree | 8be48ec41f882dba1f55407c6fc38b8bc7c3dcfc | |
| parent | 2a9a3126a4955dffc40bc04ab88a5ccdb3da77c9 (diff) | |
| download | rookery-f95457986531730095b00e2e894e9bf4d9df7166.tar.gz rookery-f95457986531730095b00e2e894e9bf4d9df7166.zip | |
BLD trainers: OP tracing/checking, FSRS drilling, BLD tab
- bld: 3x3 sticker geometry in Speffz order, lettering schemes, the
solver's frame (default yellow top / orange front), OP tracing with
cycle breaks and twists, and memo checking by executing the typed
targets as OP swaps, so any valid memo passes and errors are reported
as the pieces left wrong.
- srs: FSRS-4.5 memory model and a weighted drill picker that favours
forgotten, hard and recently missed items. Reviews are stored one
entity per answer so concurrent devices merge cleanly; Letter Pair
Gym history seeds pair difficulty.
- BLD tab: pair->word (three at a time), word->pair typing, target
drills (name the outlined sticker / find a letter's sticker on a
scrambled 3D cube), memo trainer on official 3BLD scrambles with
hidden recall fields, and a setup page with orientation, buffers and
a click-to-edit lettering net.
- PuzzleView: tap-to-pick stickers, highlight outline, face cameras.
- CubeMapping between renderer stickers and NxN slots.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 files changed, 1327 insertions, 4 deletions
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt new file mode 100644 index 0000000..936b103 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt @@ -0,0 +1,213 @@ +package coffee.liz.rookery.bld + +import coffee.liz.rookery.puzzle.NxnCube + +enum class PieceType { CORNER, EDGE } + +/** + * 3x3 blindfolded geometry in the solver's frame (the cube as held while solving, after any + * rotation away from WCA scrambling orientation). + * + * Sticker slots are [NxnCube] slots (faces U R F D L B, 9 stickers each, row-major on a standard net). + * Each piece type has 24 "positions" in Speffz order: faces U L F R B D, and on each face the + * corners/edges clockwise from the top-left corner / top edge as seen on the net. + */ +object Cube3 { + private val geo = NxnCube(3) + private const val U = 0; private const val R = 1; private const val F = 2; private const val D = 3; private const val L = 4; private const val B = 5 + + /** Speffz face order, as NxnCube face indices. */ + private val speffzFaces = intArrayOf(U, L, F, R, B, D) + private val cornerCells = listOf(0 to 0, 0 to 2, 2 to 2, 2 to 0) + private val edgeCells = listOf(0 to 1, 1 to 2, 2 to 1, 1 to 0) + + /** Position index (0..23, Speffz order) → sticker slot. */ + val positions: Map<PieceType, IntArray> = mapOf( + PieceType.CORNER to IntArray(24) { i -> slot(speffzFaces[i / 4], cornerCells[i % 4]) }, + PieceType.EDGE to IntArray(24) { i -> slot(speffzFaces[i / 4], edgeCells[i % 4]) }, + ) + + private fun slot(face: Int, cell: Pair<Int, Int>) = face * 9 + cell.first * 3 + cell.second + + fun slotOf(type: PieceType, pos: Int): Int = positions.getValue(type)[pos] + fun positionOf(type: PieceType, slot: Int): Int? = positions.getValue(type).indexOf(slot).takeIf { it >= 0 } + + /** + * Positions of the piece holding [pos], starting at [pos] and going clockwise around a corner + * (seen from outside), or to the other sticker of an edge. Swapping two pieces sticker-by-sticker + * in these orders is exactly what one Old Pochmann "shot" does. + */ + fun pieceFrom(type: PieceType, pos: Int): IntArray = pieceCache.getValue(type)[pos] + + private val pieceCache: Map<PieceType, Array<IntArray>> = PieceType.entries.associateWith { type -> + val slots = positions.getValue(type) + Array(24) { pos -> + val cubie = geo.cubieOf(slots[pos]) + val mates = (0 until 24).filter { geo.cubieOf(slots[it]) == cubie } + if (type == PieceType.EDGE) intArrayOf(pos, mates.first { it != pos }) + else { + // clockwise from outside: (n_a × n_b) · corner < 0 + val others = mates.filter { it != pos } + val n0 = geo.normalOf(slots[pos]); val n1 = geo.normalOf(slots[others[0]]) + val cross = Triple(n0.second * n1.third - n0.third * n1.second, n0.third * n1.first - n0.first * n1.third, n0.first * n1.second - n0.second * n1.first) + val dot = cross.first * cubie.first + cross.second * cubie.second + cross.third * cubie.third + if (dot < 0) intArrayOf(pos, others[0], others[1]) else intArrayOf(pos, others[1], others[0]) + } + } + } + + /** The piece's lowest position, as a stable id for the piece. */ + fun pieceId(type: PieceType, pos: Int): Int = pieceFrom(type, pos).min() + + /** "UFR", "UF", … in the solver's frame, U/D first, then F/B, then R/L. */ + fun pieceName(type: PieceType, pos: Int): String { + val names = "URFDLB" + val order = "UDFBRL" + return pieceFrom(type, pos).map { names[slotOf(type, it) / 9] }.sortedBy { order.indexOf(it) }.joinToString("") + } + + /** Name of a single sticker: its face first, then the rest of the piece, e.g. "RDF". */ + fun stickerName(type: PieceType, pos: Int): String { + val names = "URFDLB" + val own = names[slotOf(type, pos) / 9] + return own + pieceName(type, pos).filter { it != own } + } +} + +/** + * A lettering scheme: one letter per position for each piece type, in Speffz position order. + * The default is Speffz itself. + */ +data class LetterScheme(val corners: String = SPEFFZ, val edges: String = SPEFFZ) { + init { require(corners.length == 24 && edges.length == 24) } + + fun letter(type: PieceType, pos: Int): Char = (if (type == PieceType.CORNER) corners else edges)[pos] + fun position(type: PieceType, letter: Char): Int? = + (if (type == PieceType.CORNER) corners else edges).indexOf(letter.uppercaseChar()).takeIf { it >= 0 } + + companion object { const val SPEFFZ = "ABCDEFGHIJKLMNOPQRSTUVWX" } +} + +/** How the solver holds and solves: orientation, buffers, method. */ +data class BldSetup( + /** Rotation from WCA scrambling orientation (white top, green front) to how you hold the cube. */ + val rotation: String = "x2 y'", + val scheme: LetterScheme = LetterScheme(), + /** Buffer positions (Speffz indices). Defaults: UBL corner (A), UR edge (B). */ + val cornerBuffer: Int = 0, + val edgeBuffer: Int = 1, +) { + 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() + + /** 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() } +} + +/** A memo target: a position of a piece type. */ +data class Target(val type: PieceType, val pos: Int) + +/** Tracing and checking Old Pochmann style memo on a scrambled cube. */ +class Tracer(private val setup: BldSetup) { + private val solved = setup.solvedColors + + /** home[pos] = the position the sticker currently at pos belongs to. */ + fun homes(state: IntArray, type: PieceType): IntArray = IntArray(24) { pos -> + val piece = Cube3.pieceFrom(type, pos) + val colors = piece.map { state[Cube3.slotOf(type, it)] } + // find the home piece: same colour set in the solved frame + val home = (0 until 24).first { h -> + val hp = Cube3.pieceFrom(type, h) + hp.map { solved[Cube3.slotOf(type, it)] } == colors + } + home + } + + fun isSolved(state: IntArray, type: PieceType): Boolean = + (0 until 24).all { state[Cube3.slotOf(type, it)] == solved[Cube3.slotOf(type, it)] } + + /** + * Memo targets in order, the way an OP solver traces: follow from the buffer; when the cycle + * closes early, break into the unsolved piece with the lowest letter; finish with twisted or + * flipped pieces (two targets each). + */ + fun trace(state: IntArray, type: PieceType): List<Target> { + val home = homes(state, type) + val buffer = setup.buffer(type) + val bufferPiece = Cube3.pieceId(type, buffer) + val done = HashSet<Int>().apply { add(bufferPiece) } + val out = mutableListOf<Int>() + fun pieceSolved(pos: Int) = Cube3.pieceFrom(type, pos).all { home[it] == it } + for (p in 0 until 24) if (pieceSolved(p)) done += Cube3.pieceId(type, p) + + // main cycle from the buffer + var cur = buffer + while (true) { + val next = home[cur] + if (Cube3.pieceId(type, next) == bufferPiece) break + out += next; done += Cube3.pieceId(type, next); cur = next + } + // cycle breaks (and twists/flips, which are just length-1 cycles) + while (true) { + val start = (0 until 24).filter { Cube3.pieceId(type, it) !in done } + .minByOrNull { setup.scheme.letter(type, it) } ?: break + val piece = Cube3.pieceId(type, start) + done += piece + out += start + cur = start + while (true) { + val next = home[cur] + out += next + done += Cube3.pieceId(type, next) + if (Cube3.pieceId(type, next) == piece) break + cur = next + } + } + return out.map { Target(type, it) } + } + + /** Apply OP shots: swap the buffer piece with each target piece, sticker for sticker. */ + fun execute(state: IntArray, targets: List<Target>): IntArray { + val s = state.copyOf() + for (t in targets) { + val a = Cube3.pieceFrom(t.type, setup.buffer(t.type)) + val b = Cube3.pieceFrom(t.type, t.pos) + for (k in a.indices) { + val sa = Cube3.slotOf(t.type, a[k]); val sb = Cube3.slotOf(t.type, b[k]) + val tmp = s[sa]; s[sa] = s[sb]; s[sb] = tmp + } + } + return s + } + + /** 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 -> + Cube3.pieceFrom(type, id).any { state[Cube3.slotOf(type, it)] != solved[Cube3.slotOf(type, it)] } + }.map { Cube3.pieceName(type, it) } + + fun letters(targets: List<Target>): String = targets.joinToString("") { setup.scheme.letter(it.type, it.pos).toString() } + + /** + * Parse typed memo letters (spaces/punctuation ignored). Returns targets, or the offending + * characters if some letters aren't in the scheme or name the buffer piece. + */ + fun parse(text: String, type: PieceType): Pair<List<Target>, List<String>> { + val errors = mutableListOf<String>() + val bufferPiece = Cube3.pieceId(type, setup.buffer(type)) + val targets = text.filter { it.isLetter() }.mapNotNull { c -> + val pos = setup.scheme.position(type, c) + when { + pos == null -> { errors += "'$c' isn't a ${type.name.lowercase()} letter"; null } + Cube3.pieceId(type, pos) == bufferPiece -> { errors += "'$c' is on the buffer piece"; null } + else -> Target(type, pos) + } + } + return targets to errors + } + + /** Whether corner targets have odd length, i.e. this solve needs the parity algorithm. */ + fun hasParity(cornerTargets: List<Target>): Boolean = cornerTargets.size % 2 == 1 +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/CubeMapping.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/CubeMapping.kt new file mode 100644 index 0000000..86069c4 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/CubeMapping.kt @@ -0,0 +1,32 @@ +package coffee.liz.rookery.puzzle + +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Bridges [TwistyPuzzle] cubes (what the renderer draws) and [NxnCube] slots (what BLD logic and + * notation-level code use): `slotOf[i]` is the NxnCube slot of generic sticker i. + */ +class CubeMapping(val puzzle: TwistyPuzzle, val n: Int) { + val slotOf: IntArray = IntArray(puzzle.stickers.size) { i -> slotFor(puzzle.stickers[i].centroid) } + private val stickerOf: IntArray = IntArray(6 * n * n).also { inv -> slotOf.forEachIndexed { i, s -> inv[s] = i } } + + fun stickerOfSlot(slot: Int): Int = stickerOf[slot] + + /** Render an NxnCube colour array (indexed by slot) as a generic puzzle state. */ + fun state(slotColors: IntArray): PuzzleState = PuzzleState(puzzle, IntArray(puzzle.stickers.size) { slotColors[slotOf[it]] }) + + // Inverse of NxnCube.slotPosition, on the unit cube ([-1, 1]^3). + private fun slotFor(c: V3): Int { + fun idx(v: Double) = ((v * n + (n - 1)) / 2).roundToInt().coerceIn(0, n - 1) + val (face, row, col) = when { + abs(c.y - 1) < 1e-6 -> Triple(0, idx(c.z), idx(c.x)) // U: a = x, b = z + abs(c.x - 1) < 1e-6 -> Triple(1, idx(-c.y), idx(-c.z)) // R: (n, -b, -a) + abs(c.z - 1) < 1e-6 -> Triple(2, idx(-c.y), idx(c.x)) // F: (a, -b, n) + abs(c.y + 1) < 1e-6 -> Triple(3, idx(-c.z), idx(c.x)) // D: (a, -n, -b) + abs(c.x + 1) < 1e-6 -> Triple(4, idx(-c.y), idx(c.z)) // L: (-n, -b, a) + else -> Triple(5, idx(-c.y), idx(-c.x)) // B: (-a, -b, -n) + } + return face * n * n + row * n + col + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/NxnCube.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/NxnCube.kt index 54b4cda..4d9cd1d 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/NxnCube.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/NxnCube.kt @@ -73,6 +73,18 @@ class NxnCube(val n: Int) { else -> P(p.y, -p.x, p.z) } + /** Cubie position of a sticker slot, in doubled coordinates (each axis in -(n-1)..n-1). */ + fun cubieOf(slot: Int): Triple<Int, Int, Int> { + val p = positions[slot] + return Triple(p.x.coerceIn(-(n - 1), n - 1), p.y.coerceIn(-(n - 1), n - 1), p.z.coerceIn(-(n - 1), n - 1)) + } + + /** Outward normal of a sticker slot as an axis-aligned unit vector. */ + fun normalOf(slot: Int): Triple<Int, Int, Int> { + val p = positions[slot] + return Triple(if (kotlin.math.abs(p.x) == n) p.x / n else 0, if (kotlin.math.abs(p.y) == n) p.y / n else 0, if (kotlin.math.abs(p.z) == n) p.z / n else 0) + } + fun face(f: Face): IntArray = stickers.copyOfRange(f.ordinal * n * n, (f.ordinal + 1) * n * n) } diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/srs/Srs.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/srs/Srs.kt new file mode 100644 index 0000000..a0907da --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/srs/Srs.kt @@ -0,0 +1,93 @@ +package coffee.liz.rookery.srs + +import kotlin.math.exp +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.random.Random + +/** Answer quality, FSRS-style. */ +enum class Grade(val value: Int) { AGAIN(1), HARD(2), GOOD(3), EASY(4) } + +/** One answered prompt. [ms] is how long the answer took (null if not timed). */ +data class Review(val item: String, val at: Long, val grade: Grade, val ms: Long? = null) + +/** + * Memory state of one item: stability (days until recall probability falls to 90%) and + * difficulty (1 easy .. 10 hard), per FSRS-4.5 with its published default weights. + */ +data class Memory(val stability: Double, val difficulty: Double, val lastReview: Long, val reps: Int, val lapses: Int) { + /** Probability of recalling the item at [now]. */ + fun retrievability(now: Long): Double { + val days = max(0.0, (now - lastReview) / DAY_MS) + return (1 + days / (9 * stability)).pow(-1.0) + } + + companion object { const val DAY_MS = 86_400_000.0 } +} + +object Fsrs { + private val w = doubleArrayOf( + 0.4872, 1.4003, 3.7145, 13.8206, 5.1618, 1.2298, 0.8975, 0.031, 1.6474, + 0.1367, 1.0461, 2.1072, 0.0793, 0.3246, 1.587, 0.2272, 2.8755, + ) + + private fun initDifficulty(g: Grade) = (w[4] - (g.value - 3) * w[5]).coerceIn(1.0, 10.0) + + fun first(r: Review) = Memory(w[r.grade.value - 1], initDifficulty(r.grade), r.at, 1, if (r.grade == Grade.AGAIN) 1 else 0) + + fun next(m: Memory, r: Review): Memory { + val ret = m.retrievability(r.at) + val d = m.difficulty - w[6] * (r.grade.value - 3) + val difficulty = (w[7] * initDifficulty(Grade.GOOD) + (1 - w[7]) * d).coerceIn(1.0, 10.0) + val stability = if (r.grade == Grade.AGAIN) { + min(m.stability, w[11] * difficulty.pow(-w[12]) * ((m.stability + 1).pow(w[13]) - 1) * exp(w[14] * (1 - ret))) + } else { + val hard = if (r.grade == Grade.HARD) w[15] else 1.0 + val easy = if (r.grade == Grade.EASY) w[16] else 1.0 + m.stability * (exp(w[8]) * (11 - difficulty) * m.stability.pow(-w[9]) * (exp(w[10] * (1 - ret)) - 1) * hard * easy + 1) + } + return Memory(max(0.01, stability), difficulty, r.at, m.reps + 1, m.lapses + if (r.grade == Grade.AGAIN) 1 else 0) + } + + /** Replay an item's reviews (any order) into its current memory state. */ + fun replay(reviews: List<Review>): Memory? { + var m: Memory? = null + for (r in reviews.sortedBy { it.at }) m = m?.let { next(it, r) } ?: first(r) + return m + } +} + +/** + * Grades a timed answer: wrong → AGAIN, otherwise by speed relative to [goodMs] + * (fast enough to use mid-solve is EASY, a long think is HARD). + */ +fun gradeAnswer(correct: Boolean, ms: Long, goodMs: Long = 2_500): Grade = when { + !correct -> Grade.AGAIN + ms <= goodMs / 2 -> Grade.EASY + ms <= goodMs -> Grade.GOOD + else -> Grade.HARD +} + +/** + * Picks what to drill next. Unlike day-based flashcards, a drill session runs continuously, so items + * are weighted rather than scheduled: forgotten-ish (low retrievability), hard, and recently-missed + * items come up most; never-seen items are mixed in steadily; the last few items are avoided. + */ +class DrillPicker(private val random: Random = Random.Default) { + fun weight(m: Memory?, now: Long, recentMisses: Int): Double { + if (m == null) return 1.5 // new: fairly likely, so the whole set gets introduced + val forget = 1 - m.retrievability(now) + val hard = (m.difficulty - 1) / 9 + return 0.05 + 2.0 * forget + 0.8 * hard + 1.2 * recentMisses + } + + fun <T> pick(items: List<T>, memory: (T) -> Memory?, recentMisses: (T) -> Int, avoid: Collection<T>, now: Long): T? { + val pool = items.filter { it !in avoid }.ifEmpty { items } + if (pool.isEmpty()) return null + val weights = pool.map { weight(memory(it), now, recentMisses(it)) } + var x = random.nextDouble() * weights.sum() + for ((i, wt) in weights.withIndex()) { x -= wt; if (x <= 0) return pool[i] } + return pool.last() + } +} 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 6c0fafa..3e04f11 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt @@ -24,6 +24,8 @@ object Coll { const val MEMO = "memo" const val DEVICE = "device" const val PREFS = "prefs" + /** Append-only trainer answers; one entity per answer so concurrent devices never conflict. */ + const val REVIEW = "review" } private fun Map<String, JsonElement>.str(f: String): String? = (this[f] as? JsonPrimitive)?.takeIf { this[f] !is JsonNull }?.contentOrNull @@ -133,6 +135,59 @@ class Repo(val store: Store) { } }.sortedBy { it.at } + // ---- trainer reviews ---- + + fun addReview(id: String, r: coffee.liz.rookery.srs.Review) = store.transact { + set(Coll.REVIEW, id, "v", kotlinx.serialization.json.buildJsonObject { + put("item", JsonPrimitive(r.item)); put("at", JsonPrimitive(r.at)); put("g", JsonPrimitive(r.grade.value)) + r.ms?.let { put("ms", JsonPrimitive(it)) } + }) + } + + fun reviews(): List<coffee.liz.rookery.srs.Review> = store.read { r -> + r.entities(Coll.REVIEW).values.mapNotNull { f -> + val o = f["v"] as? kotlinx.serialization.json.JsonObject ?: return@mapNotNull null + val grade = coffee.liz.rookery.srs.Grade.entries.firstOrNull { it.value.toLong() == o.long("g") } ?: return@mapNotNull null + coffee.liz.rookery.srs.Review(o.str("item") ?: return@mapNotNull null, o.long("at") ?: 0, grade, o.long("ms")) + } + } + + /** Practice history from the Letter Pair Gym prototype: recent results oldest→newest (g/s/m). */ + fun gymHistory(): Map<String, String> = store.read { r -> + r.entities(Coll.PAIR).mapNotNull { (pair, f) -> + val rec = ((f["gymStats"] as? kotlinx.serialization.json.JsonObject)?.get("r") as? JsonPrimitive)?.contentOrNull + rec?.takeIf { it.isNotEmpty() }?.let { pair to it } + }.toMap() + } + + // ---- BLD setup ---- + + fun bldSetup(): coffee.liz.rookery.bld.BldSetup { + val d = coffee.liz.rookery.bld.BldSetup() + val corners = pref("bld.corners")?.takeIf { it.length == 24 } ?: d.scheme.corners + val edges = pref("bld.edges")?.takeIf { it.length == 24 } ?: d.scheme.edges + return coffee.liz.rookery.bld.BldSetup( + rotation = pref("bld.rotation") ?: d.rotation, + scheme = coffee.liz.rookery.bld.LetterScheme(corners, edges), + cornerBuffer = pref("bld.cornerBuffer")?.toIntOrNull() ?: d.cornerBuffer, + edgeBuffer = pref("bld.edgeBuffer")?.toIntOrNull() ?: d.edgeBuffer, + ) + } + + fun setBldSetup(s: coffee.liz.rookery.bld.BldSetup) = store.transact { + set(Coll.PREFS, "global", "bld.rotation", s.rotation) + set(Coll.PREFS, "global", "bld.corners", s.scheme.corners) + set(Coll.PREFS, "global", "bld.edges", s.scheme.edges) + set(Coll.PREFS, "global", "bld.cornerBuffer", s.cornerBuffer.toString()) + set(Coll.PREFS, "global", "bld.edgeBuffer", s.edgeBuffer.toString()) + } + + fun addMemoSession(m: MemoSession, extra: Map<String, String> = emptyMap()) = store.transact { + val id = "memo-" + m.at + set(Coll.MEMO, id, "at", m.at); set(Coll.MEMO, id, "pairs", m.pairs); set(Coll.MEMO, id, "ms", m.memoMs); set(Coll.MEMO, id, "ok", m.recalled) + for ((k, v) in extra) set(Coll.MEMO, id, k, v) + } + // ---- devices & prefs ---- fun devices(): List<CubeDevice> = store.read { r -> diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/BldTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/BldTest.kt new file mode 100644 index 0000000..cae88a1 --- /dev/null +++ b/core/src/commonTest/kotlin/coffee/liz/rookery/BldTest.kt @@ -0,0 +1,96 @@ +package coffee.liz.rookery + +import coffee.liz.rookery.bld.BldSetup +import coffee.liz.rookery.bld.Cube3 +import coffee.liz.rookery.bld.PieceType +import coffee.liz.rookery.bld.Tracer +import coffee.liz.rookery.puzzle.NxnCube +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BldTest { + private val wca = BldSetup(rotation = "") + + @Test fun speffzPositionsAreTheRightStickers() { + assertEquals("UBL", Cube3.pieceName(PieceType.CORNER, 0)) // A + assertEquals("RDF", Cube3.stickerName(PieceType.CORNER, 15)) // P, the OP helper target + assertEquals("UFR", Cube3.pieceName(PieceType.CORNER, 2)) // C + assertEquals("UR", Cube3.pieceName(PieceType.EDGE, 1)) // B, edge buffer + assertEquals("UL", Cube3.pieceName(PieceType.EDGE, 3)) // D, T-perm target + assertEquals("UB", Cube3.pieceName(PieceType.EDGE, 0)) // A + assertEquals("UF", Cube3.pieceName(PieceType.EDGE, 2)) // C + // every piece has 3 (corner) / 2 (edge) stickers, 8 / 12 pieces + assertEquals(8, (0 until 24).map { Cube3.pieceId(PieceType.CORNER, it) }.distinct().size) + assertEquals(12, (0 until 24).map { Cube3.pieceId(PieceType.EDGE, it) }.distinct().size) + // UBL clockwise from U: U, L, B -> A, E, R in Speffz (the classic "AER" buffer) + assertEquals(listOf(0, 4, 17), Cube3.pieceFrom(PieceType.CORNER, 0).toList()) + // UFR clockwise from U: U, R, F -> C, M, J + assertEquals(listOf(2, 12, 9), Cube3.pieceFrom(PieceType.CORNER, 2).toList()) + } + + @Test fun solvedCubeHasNoTargets() { + val t = Tracer(wca) + val s = wca.stateOf("") + assertTrue(t.trace(s, PieceType.CORNER).isEmpty()) + assertTrue(t.trace(s, PieceType.EDGE).isEmpty()) + } + + @Test fun tPermIsOneEdgeTargetD() { + val t = Tracer(wca) + val s = wca.stateOf("R U R' U' R' F R2 U' R' U' R U R' F'") + assertEquals("D", t.letters(t.trace(s, PieceType.EDGE))) + } + + @Test fun tracedMemoAlwaysSolvesWhenExecuted() { + val rng = Random(7) + val faces = listOf("U", "R", "F", "D", "L", "B") + val sfx = listOf("", "'", "2") + for (setup in listOf(wca, BldSetup())) { // WCA frame and the user's yellow-top/orange-front frame + val t = Tracer(setup) + repeat(200) { + val scr = List(22) { faces[rng.nextInt(6)] + sfx[rng.nextInt(3)] }.joinToString(" ") + val s = setup.stateOf(scr) + val corners = t.trace(s, PieceType.CORNER) + val edges = t.trace(s, PieceType.EDGE) + val after = t.execute(s, corners + edges) + assertTrue(t.isSolved(after, PieceType.CORNER), "corners after $scr: ${t.wrongPieces(after, PieceType.CORNER)}") + assertTrue(t.isSolved(after, PieceType.EDGE), "edges after $scr: ${t.wrongPieces(after, PieceType.EDGE)}") + // corner and edge target counts share parity on a legal cube + assertEquals(corners.size % 2, edges.size % 2, scr) + } + } + } + + @Test fun wrongMemoReportsThePieces() { + val t = Tracer(wca) + val s = wca.stateOf("R U R' U' R' F R2 U' R' U' R U R' F'") + val (targets, errors) = t.parse("C", PieceType.EDGE) // should be D + assertTrue(errors.isEmpty()) + val after = t.execute(s, targets) + assertEquals(setOf("UF", "UL", "UR"), t.wrongPieces(after, PieceType.EDGE).toSet()) + assertEquals(listOf("'B' is on the buffer piece"), t.parse("B", PieceType.EDGE).second) + } + + @Test fun userFrameHasYellowOnTopAndOrangeInFront() { + val solved = BldSetup().solvedColors + assertEquals(NxnCube.Face.D.ordinal, solved[4]) // U centre shows the yellow (WCA D) colour + assertEquals(NxnCube.Face.L.ordinal, solved[2 * 9 + 4]) // F centre shows orange (WCA L) + assertEquals(NxnCube.Face.B.ordinal, solved[1 * 9 + 4]) // R centre shows blue + } +} + +class CubeMappingTest { + @Test fun genericStickersMapOntoNxnSlotsOneToOne() { + for (n in 2..5) { + val m = coffee.liz.rookery.puzzle.CubeMapping(coffee.liz.rookery.puzzle.Puzzles.cube(n), n) + assertEquals((0 until 6 * n * n).toSet(), m.slotOf.toSet()) + // same scramble through both models gives the same picture + val alg = "R U2 F' L D B2" + val generic = coffee.liz.rookery.puzzle.Puzzles.cube(n).solved().apply(alg) + val viaNxn = m.state(NxnCube(n).apply(alg).stickers) + assertTrue(generic.colors.contentEquals(viaNxn.colors), "${n}x$n") + } + } +} diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/SrsTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/SrsTest.kt new file mode 100644 index 0000000..5e76286 --- /dev/null +++ b/core/src/commonTest/kotlin/coffee/liz/rookery/SrsTest.kt @@ -0,0 +1,73 @@ +package coffee.liz.rookery + +import coffee.liz.rookery.srs.DrillPicker +import coffee.liz.rookery.srs.Fsrs +import coffee.liz.rookery.srs.Grade +import coffee.liz.rookery.srs.Memory +import coffee.liz.rookery.srs.Review +import coffee.liz.rookery.srs.gradeAnswer +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SrsTest { + private val day = Memory.DAY_MS.toLong() + + @Test fun goodReviewsGrowStabilityAndMissesShrinkIt() { + var m = Fsrs.first(Review("AB", 0, Grade.GOOD)) + val s0 = m.stability + m = Fsrs.next(m, Review("AB", 3 * day, Grade.GOOD)) + assertTrue(m.stability > s0) + val before = m.stability + m = Fsrs.next(m, Review("AB", 10 * day, Grade.AGAIN)) + assertTrue(m.stability < before) + assertEquals(1, m.lapses) + assertEquals(3, m.reps) + } + + @Test fun difficultyTracksGrades() { + val easy = Fsrs.replay(List(5) { Review("x", it * day, Grade.EASY) })!! + val hard = Fsrs.replay(List(5) { Review("x", it * day, Grade.AGAIN) })!! + assertTrue(easy.difficulty < hard.difficulty) + assertTrue(hard.difficulty <= 10.0 && easy.difficulty >= 1.0) + } + + @Test fun replayIsOrderIndependent() { + val rs = listOf(Review("x", 5 * day, Grade.HARD), Review("x", 0, Grade.GOOD), Review("x", 2 * day, Grade.AGAIN)) + assertEquals(Fsrs.replay(rs), Fsrs.replay(rs.reversed())) + } + + @Test fun retrievabilityIsNinetyPercentAfterStabilityDays() { + val m = Memory(stability = 4.0, difficulty = 5.0, lastReview = 0, reps = 1, lapses = 0) + assertEquals(0.9, m.retrievability(4 * day), 1e-9) + assertEquals(1.0, m.retrievability(0), 1e-9) + } + + @Test fun answerGrading() { + assertEquals(Grade.AGAIN, gradeAnswer(false, 100)) + assertEquals(Grade.EASY, gradeAnswer(true, 1_000)) + assertEquals(Grade.GOOD, gradeAnswer(true, 2_000)) + assertEquals(Grade.HARD, gradeAnswer(true, 6_000)) + } + + @Test fun pickerFavoursWeakAndMissedItems() { + val now = 30 * day + val strong = Memory(100.0, 2.0, now - day, 10, 0) + val weak = Memory(1.0, 9.0, now - 10 * day, 10, 6) + val items = listOf("strong", "weak") + val picker = DrillPicker(Random(1)) + val counts = items.associateWith { 0 }.toMutableMap() + repeat(2_000) { + val p = picker.pick(items, { if (it == "strong") strong else weak }, { if (it == "weak") 2 else 0 }, emptyList(), now)!! + counts[p] = counts.getValue(p) + 1 + } + assertTrue(counts.getValue("weak") > 8 * counts.getValue("strong"), "$counts") + } + + @Test fun pickerAvoidsRecentItemsWhenItCan() { + val picker = DrillPicker(Random(2)) + repeat(50) { assertEquals("b", picker.pick(listOf("a", "b"), { null }, { 0 }, listOf("a"), 0)) } + assertEquals("a", picker.pick(listOf("a"), { null }, { 0 }, listOf("a"), 0)) // nothing else to pick + } +} diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt index 60d7443..5a5de5e 100644 --- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt +++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt @@ -109,6 +109,7 @@ fun main(args: Array<String>) { AppModel(repo, scrambler, DesktopHost(config), scope).apply { // dev hooks for screenshots: ROOKERY_TAB=PAIRS, ROOKERY_PARTY=1, ROOKERY_REPLAY=1, ROOKERY_EVENT=pyram System.getenv("ROOKERY_TAB")?.let { t -> coffee.liz.rookery.ui.Tab.entries.firstOrNull { it.name == t }?.let { tab = it } } + System.getenv("ROOKERY_BLD")?.let { b -> coffee.liz.rookery.ui.BldMode.entries.firstOrNull { it.name == b }?.let { bldMode = it } } System.getenv("ROOKERY_EVENT")?.let { id -> Event.byId(id)?.let { e -> scope.launch { kotlinx.coroutines.delay(1500); createSession(e) } } } if (System.getenv("ROOKERY_REPLAY") != null) scope.launch { kotlinx.coroutines.delay(4000) diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt index 3cc79dd..40b799d 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt @@ -31,6 +31,7 @@ fun RookeryApp(m: AppModel) { when (m.tab) { Tab.TIMER -> TimerScreen(m) Tab.PAIRS -> PairsScreen(m) + Tab.BLD -> BldScreen(m) Tab.IMPORT -> ImportScreen(m) Tab.SETTINGS -> SettingsScreen(m) } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt index db34c7b..69e0947 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt @@ -46,7 +46,7 @@ interface Host { val deviceId: String } -enum class Tab(val label: String) { TIMER("Timer"), PAIRS("Letter pairs"), IMPORT("Import"), SETTINGS("Settings") } +enum class Tab(val label: String) { TIMER("Timer"), PAIRS("Letter pairs"), BLD("BLD"), IMPORT("Import"), SETTINGS("Settings") } enum class ThemeMode { SYSTEM, LIGHT, DARK } /** Everything derived from the store, recomputed off the UI thread when it changes. */ @@ -77,6 +77,7 @@ data class Replay( class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, private val scope: CoroutineScope) { var tab by mutableStateOf(Tab.TIMER) + var bldMode by mutableStateOf(BldMode.P2W) var derived by mutableStateOf(Derived()) private set var sessionId by mutableStateOf(repo.pref("lastSession")) @@ -302,4 +303,9 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, repo.setPairImage(pair, repo.store.blobs.put(bytes, path.substringAfterLast('.', "jpg"))) } fun blob(name: String): ByteArray? = repo.store.blobs.get(name) + + /** A fresh official scramble, generated off the UI thread. */ + suspend fun freshScramble(e: Event): String = withContext(Dispatchers.Default) { scrambler.next(e) } + + val bld: BldModel by lazy { BldModel(repo) } } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt new file mode 100644 index 0000000..e2c0760 --- /dev/null +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldModel.kt @@ -0,0 +1,77 @@ +package coffee.liz.rookery.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import coffee.liz.rookery.bld.BldSetup +import coffee.liz.rookery.bld.Cube3 +import coffee.liz.rookery.bld.PieceType +import coffee.liz.rookery.epochMillis +import coffee.liz.rookery.randomId +import coffee.liz.rookery.srs.DrillPicker +import coffee.liz.rookery.srs.Fsrs +import coffee.liz.rookery.srs.Grade +import coffee.liz.rookery.srs.Memory +import coffee.liz.rookery.srs.Review +import coffee.liz.rookery.srs.gradeAnswer +import coffee.liz.rookery.store.Repo + +/** + * Shared state for the BLD trainers: the solver's setup and every item's review history. + * + * Item ids: `p2w:AB` pair→word, `w2p:AB` word→pair, `name:c:15` / `find:e:3` target drills + * (type c/e, Speffz position of the sticker's home). + */ +class BldModel(private val repo: Repo) { + var setup by mutableStateOf(repo.bldSetup()) + private set + + private val history = HashMap<String, MutableList<Review>>() + /** Letter Pair Gym results become synthetic past reviews, so its weak pairs come up first. */ + private val seeds: Map<String, List<Review>> + private val picker = DrillPicker() + + init { + for (r in repo.reviews()) history.getOrPut(r.item) { mutableListOf() } += r + val dayMs = Memory.DAY_MS.toLong() + val start = epochMillis() - 30 * dayMs + seeds = repo.gymHistory().mapKeys { "p2w:${it.key}" }.mapValues { (item, rec) -> + rec.mapIndexed { i, c -> + Review(item, start + i * dayMs, when (c) { 'm' -> Grade.AGAIN; 's' -> Grade.HARD; else -> Grade.GOOD }) + } + } + } + + private fun reviewsOf(item: String): List<Review> = history[item] ?: seeds[item].orEmpty() + + fun memory(item: String): Memory? = Fsrs.replay(reviewsOf(item)) + + private fun recentMisses(item: String): Int = reviewsOf(item).sortedBy { it.at }.takeLast(3).count { it.grade == Grade.AGAIN } + + /** Next item to drill out of [items], avoiding the most recent few. */ + fun pick(items: List<String>, recent: List<String>): String? = + picker.pick(items, ::memory, ::recentMisses, recent.takeLast(minOf(4, items.size / 2)), epochMillis()) + + /** Record an answer; returns its grade. */ + fun record(item: String, correct: Boolean, ms: Long, goodMs: Long = 2_500): Grade { + val grade = gradeAnswer(correct, ms, goodMs) + val r = Review(item, epochMillis(), grade, ms) + repo.addReview(randomId(), r) + // the first real review replaces any seeded history + history.getOrPut(item) { seeds[item].orEmpty().toMutableList() } += r + return grade + } + + fun updateSetup(s: BldSetup) { repo.setBldSetup(s); setup = s } + + /** Positions you'd ever memo for a type: everything off the buffer piece. */ + fun memoPositions(type: PieceType): List<Int> { + val buffer = Cube3.pieceId(type, setup.buffer(type)) + return (0 until 24).filter { Cube3.pieceId(type, it) != buffer } + } + + /** Session accuracy for the status line. */ + var sessionRight by mutableStateOf(0) + var sessionTotal by mutableStateOf(0) + fun tally(correct: Boolean) { sessionTotal++; if (correct) sessionRight++ } +} diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt new file mode 100644 index 0000000..1d5d69d --- /dev/null +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt @@ -0,0 +1,619 @@ +package coffee.liz.rookery.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coffee.liz.rookery.bld.BldSetup +import coffee.liz.rookery.bld.Cube3 +import coffee.liz.rookery.bld.LetterScheme +import coffee.liz.rookery.bld.PieceType +import coffee.liz.rookery.bld.Target +import coffee.liz.rookery.bld.Tracer +import coffee.liz.rookery.model.Event +import coffee.liz.rookery.model.MemoSession +import coffee.liz.rookery.model.Puzzle +import coffee.liz.rookery.model.formatTime +import coffee.liz.rookery.monoMillis +import coffee.liz.rookery.epochMillis +import coffee.liz.rookery.puzzle.CubeMapping +import coffee.liz.rookery.puzzle.Puzzles +import coffee.liz.rookery.srs.Grade +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.decodeToImageBitmap +import kotlin.random.Random + +enum class BldMode(val label: String) { + P2W("Pair → word"), W2P("Word → pair"), TARGETS("Targets"), MEMO("Memo"), SETUP("Setup") +} + +@Composable +fun BldScreen(m: AppModel) { + val mode = m.bldMode + Column(Modifier.fillMaxSize().padding(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Tabs(BldMode.entries, mode, { it.label }, { m.bldMode = it }) + Spacer(Modifier.weight(1f)) + val b = m.bld + if (b.sessionTotal > 0) Label("this session: ${b.sessionRight}/${b.sessionTotal}", style = Rk.t.label, color = Rk.c.inkDim) + } + Box(Modifier.weight(1f).fillMaxWidth().background(Rk.c.panel).bevel(Rk.c).padding(10.dp)) { + when (mode) { + BldMode.P2W -> PairToWord(m) + BldMode.W2P -> WordToPair(m) + BldMode.TARGETS -> Targets(m) + BldMode.MEMO -> MemoTrainer(m) + BldMode.SETUP -> BldSetupScreen(m) + } + } + } +} + +@Composable +private fun pairImage(m: AppModel, blob: String?): ImageBitmap? = + remember(blob) { blob?.let { runCatching { m.blob(it)?.decodeToImageBitmap() }.getOrNull() } } + +/** A focus-holding box that routes keys to [onKey]; requests focus whenever [key] changes. */ +@Composable +private fun KeyBox(key: Any?, onKey: (Key) -> Boolean, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + val focus = remember { FocusRequester() } + LaunchedEffect(key) { runCatching { focus.requestFocus() } } + Box( + modifier.focusRequester(focus).focusable() + .onPreviewKeyEvent { if (it.type == KeyEventType.KeyDown) onKey(it.key) else false }, + ) { content() } +} + +// ---------------------------------------------------------------- pair → word + +@Composable +private fun PairToWord(m: AppModel) { + val bld = m.bld + val pairs = m.derived.pairs.filterValues { it.primary != null } + val keys = remember(pairs.keys) { pairs.keys.sorted() } + if (keys.isEmpty()) { Label("No letter pairs with words yet. Import some, or add words in Letter pairs."); return } + + val batchSize = 3 + val recent = remember { mutableStateListOf<String>() } + fun newBatch(): List<String> { + val out = mutableListOf<String>() + repeat(minOf(batchSize, keys.size)) { + bld.pick(keys.map { "p2w:$it" }, recent + out.map { "p2w:$it" })?.removePrefix("p2w:")?.let { out += it } + } + return out + } + var batch by remember { mutableStateOf(newBatch()) } + var started by remember(batch) { mutableLongStateOf(monoMillis()) } + var revealedAfter by remember(batch) { mutableStateOf<Long?>(null) } + val missed = remember(batch) { mutableStateListOf<String>() } + + fun reveal() { if (revealedAfter == null) revealedAfter = monoMillis() - started } + fun next() { + val ms = (revealedAfter ?: (monoMillis() - started)) / batch.size + for (p in batch) { val ok = p !in missed; bld.record("p2w:$p", ok, ms); bld.tally(ok) } + recent += batch.map { "p2w:$it" } + batch = newBatch() + } + + KeyBox(batch, { k -> + when { + revealedAfter == null && (k == Key.Spacebar || k == Key.Enter) -> { reveal(); true } + revealedAfter != null && k == Key.Enter -> { next(); true } + revealedAfter != null && k in listOf(Key.One, Key.Two, Key.Three) -> { + val p = batch.getOrNull(listOf(Key.One, Key.Two, Key.Three).indexOf(k)) ?: return@KeyBox true + if (p in missed) missed.remove(p) else missed.add(p); true + } + else -> false + } + }, Modifier.fillMaxSize()) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize()) { + Label( + if (revealedAfter == null) "Say each word to yourself, then press Space." + else "Mark any you blanked on (1–3 or click), then Enter. ${formatTime(revealedAfter)} for ${batch.size}.", + color = Rk.c.inkDim, + ) + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.weight(1f)) { + for ((i, p) in batch.withIndex()) { + val lp = pairs[p] + val miss = p in missed + Panel( + Modifier.width(260.dp).fillMaxSize() + .clickable(enabled = revealedAfter != null) { if (miss) missed.remove(p) else missed.add(p) }, + sunken = true, + ) { + Label("${i + 1}", style = Rk.t.label, color = Rk.c.inkDim) + BasicText(p, Modifier.fillMaxWidth(), style = Rk.t.mono.copy(fontSize = 84.sp, color = Rk.c.ink, textAlign = TextAlign.Center)) + if (revealedAfter != null && lp != null) { + val img = pairImage(m, lp.image) + Box(Modifier.fillMaxWidth().height(150.dp), Alignment.Center) { + if (img != null) Image(img, lp.primary, Modifier.fillMaxSize(), contentScale = ContentScale.Fit) + } + BasicText(lp.primary.orEmpty(), Modifier.fillMaxWidth(), style = Rk.t.mono.copy(fontSize = 34.sp, color = if (miss) Rk.c.bad else Rk.c.ink, textAlign = TextAlign.Center)) + val others = lp.words - setOfNotNull(lp.primary) + if (others.isNotEmpty()) Label(others.sorted().joinToString(" · "), Modifier.fillMaxWidth(), color = Rk.c.inkDim, style = Rk.t.body.copy(textAlign = TextAlign.Center)) + lp.note?.let { Label(it, Modifier.fillMaxWidth(), color = Rk.c.inkDim, style = Rk.t.body.copy(textAlign = TextAlign.Center)) } + Spacer(Modifier.height(6.dp)) + Label(if (miss) "✗ blanked" else "✓ got it", color = if (miss) Rk.c.bad else Rk.c.good, style = Rk.t.heading) + } + } + } + } + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (revealedAfter == null) RetroButton("Reveal (space)", { reveal() }) else RetroButton("Next (enter)", { next() }) + } + } + } +} + +// ---------------------------------------------------------------- word → pair + +@Composable +private fun WordToPair(m: AppModel) { + val bld = m.bld + val pairs = m.derived.pairs.filterValues { it.primary != null } + val keys = remember(pairs.keys) { pairs.keys.sorted() } + if (keys.isEmpty()) { Label("No letter pairs with words yet."); return } + val scope = rememberCoroutineScope() + val recent = remember { mutableStateListOf<String>() } + var current by remember { mutableStateOf(bld.pick(keys.map { "w2p:$it" }, emptyList())!!.removePrefix("w2p:")) } + var typed by remember(current) { mutableStateOf("") } + var started by remember(current) { mutableLongStateOf(monoMillis()) } + var verdict by remember(current) { mutableStateOf<Pair<Boolean, Grade>?>(null) } + val lp = pairs[current] + val word = lp?.primary.orEmpty() + + fun advance() { + recent += "w2p:$current" + current = bld.pick(keys.map { "w2p:$it" }, recent)!!.removePrefix("w2p:") + } + fun answer(guess: String?) { + if (verdict != null) return + // Any pair whose main word is this word counts (duplicate words happen). + val ok = guess != null && (guess == current || pairs[guess]?.primary?.equals(word, ignoreCase = true) == true) + val ms = monoMillis() - started + verdict = ok to bld.record("w2p:$current", ok, ms, goodMs = 2_000) + bld.tally(ok) + scope.launch { delay(if (ok) 600 else 1_600); advance() } + } + + val img = pairImage(m, lp?.image) + val focus = remember { FocusRequester() } + LaunchedEffect(current) { runCatching { focus.requestFocus() } } + Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + Label("Type the pair. Tab to give up and see it.", color = Rk.c.inkDim) + Spacer(Modifier.height(10.dp)) + Box(Modifier.size(320.dp, 240.dp).background(Rk.c.field).bevel(Rk.c, sunken = true), Alignment.Center) { + if (img != null) Image(img, word, Modifier.fillMaxSize().padding(4.dp), contentScale = ContentScale.Fit) + else Label("(no image)", color = Rk.c.inkDim, style = Rk.t.label) + } + BasicText(word, Modifier.padding(8.dp), style = Rk.t.mono.copy(fontSize = 56.sp, color = Rk.c.ink, textAlign = TextAlign.Center)) + RetroField( + typed, + { v -> + val clean = v.uppercase().filter { it in LETTERS }.take(2) + typed = clean + if (clean.length == 2) answer(clean) + }, + Modifier.width(140.dp).focusRequester(focus).onPreviewKeyEvent { + if (it.type == KeyEventType.KeyDown && it.key == Key.Tab) { answer(null); true } else false + }, + placeholder = "??", style = Rk.t.mono.copy(fontSize = 48.sp, textAlign = TextAlign.Center), description = "pair for $word", + ) + Spacer(Modifier.height(10.dp)) + verdict?.let { (ok, grade) -> + Label( + if (ok) "✓ $current (${grade.name.lowercase()})" else "✗ it's $current", + Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + color = if (ok) Rk.c.good else Rk.c.bad, style = Rk.t.heading.copy(fontSize = 22.sp), + ) + } + } +} + +// ---------------------------------------------------------------- targets + +private fun randomScramble(rng: Random = Random.Default): String { + val faces = "URFDLB"; val sfx = listOf("", "'", "2") + val out = mutableListOf<String>() + var last = -1 + while (out.size < 25) { + val f = rng.nextInt(6) + if (f == last) continue + last = f; out += "${faces[f]}${sfx[rng.nextInt(3)]}" + } + return out.joinToString(" ") +} + +@Composable +private fun Targets(m: AppModel) { + val bld = m.bld + val setup = bld.setup + val tracer = remember(setup) { Tracer(setup) } + val mapping = remember { CubeMapping(Puzzles.cube(3), 3) } + val colors = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) } + var find by remember { mutableStateOf(false) } + var types by remember { mutableStateOf(setOf(PieceType.CORNER, PieceType.EDGE)) } + val recent = remember { mutableStateListOf<String>() } + val scope = rememberCoroutineScope() + val camera = remember { Camera() } + + data class Question(val scramble: String, val state: IntArray, val type: PieceType, val home: Int, val where: Int) + fun newQuestion(): Question { + val items = types.flatMap { t -> bld.memoPositions(t).map { "${if (find) "find" else "name"}:${if (t == PieceType.CORNER) "c" else "e"}:$it" } } + val item = bld.pick(items, recent)!! + val parts = item.split(':') + val type = if (parts[1] == "c") PieceType.CORNER else PieceType.EDGE + val home = parts[2].toInt() + val scr = randomScramble() + val state = setup.stateOf(scr) + val where = tracer.homes(state, type).indexOf(home) + return Question(scr, state, type, home, where) + } + var q by remember(find, types, setup) { mutableStateOf(newQuestion()) } + var started by remember(q) { mutableLongStateOf(monoMillis()) } + var verdict by remember(q) { mutableStateOf<Boolean?>(null) } + var typed by remember(q) { mutableStateOf("") } + val item = "${if (find) "find" else "name"}:${if (q.type == PieceType.CORNER) "c" else "e"}:${q.home}" + val whereSlot = Cube3.slotOf(q.type, q.where) + LaunchedEffect(q) { + if (!find) cameraFacing(whereSlot / 9).let { (y, p) -> camera.yaw = y; camera.pitch = p } + } + + fun answer(ok: Boolean) { + if (verdict != null) return + verdict = ok + bld.record(item, ok, monoMillis() - started, goodMs = 2_000) + bld.tally(ok) + recent += item + scope.launch { delay(if (ok) 500 else 1_800); q = newQuestion() } + } + val letter = setup.scheme.letter(q.type, q.home) + val kind = if (q.type == PieceType.CORNER) "corner" else "edge" + + Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f)) { + PuzzleView( + mapping.state(q.state), colors, Modifier.fillMaxWidth().weight(1f), camera, + description = if (find) "scrambled cube: click where the $kind sticker $letter is" else "scrambled cube with one $kind sticker outlined", + highlight = if (!find || verdict == false) mapping.stickerOfSlot(whereSlot) else null, + highlightColor = if (find && verdict == false) Rk.c.bad else Color(0xFFFF2BD6), + onStickerTap = if (find) { i -> + val slot = mapping.slotOf[i] + val pos = Cube3.positionOf(q.type, slot) + // clicking the wrong piece type just doesn't count + if (pos != null || Cube3.positionOf(if (q.type == PieceType.CORNER) PieceType.EDGE else PieceType.CORNER, slot) != null) answer(slot == whereSlot) + } else null, + ) + Label("Drag to turn the cube. Shown as you hold it (your orientation from Setup).", color = Rk.c.inkDim, style = Rk.t.label) + } + Column(Modifier.width(320.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + GroupBox("Drill") { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + RetroButton("Name it", { find = false }, selected = !find, description = "see a sticker, type its letter") + RetroButton("Find it", { find = true }, selected = find, description = "see a letter, click the sticker") + } + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + for (t in PieceType.entries) RetroButton( + t.name.lowercase() + "s", + { types = if (t in types && types.size > 1) types - t else types + t }, + selected = t in types, + ) + } + } + GroupBox(if (find) "Find this $kind" else "What letter is the outlined $kind sticker?") { + if (find) { + BasicText("$letter", Modifier.fillMaxWidth(), style = Rk.t.mono.copy(fontSize = 96.sp, color = Rk.c.ink, textAlign = TextAlign.Center)) + Label("click where that sticker is now", Modifier.fillMaxWidth(), color = Rk.c.inkDim, style = Rk.t.body.copy(textAlign = TextAlign.Center)) + } else { + val focus = remember { FocusRequester() } + LaunchedEffect(q) { runCatching { focus.requestFocus() } } + RetroField( + typed, + { v -> + val c = v.uppercase().filter { it.isLetter() }.take(1) + typed = c + if (c.isNotEmpty()) answer(setup.scheme.position(q.type, c[0]) == q.home) + }, + Modifier.fillMaxWidth().focusRequester(focus), + placeholder = "?", style = Rk.t.mono.copy(fontSize = 64.sp, textAlign = TextAlign.Center), description = "letter for the outlined sticker", + ) + } + Spacer(Modifier.height(8.dp)) + verdict?.let { ok -> + Label( + if (ok) "✓ $letter — ${Cube3.stickerName(q.type, q.home)}" else "✗ that's $letter — ${Cube3.stickerName(q.type, q.home)}", + Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + color = if (ok) Rk.c.good else Rk.c.bad, style = Rk.t.heading, + ) + } + } + Label("Weak letters come up more often; every answer is saved and synced.", color = Rk.c.inkDim, style = Rk.t.label) + } + } +} + +// ---------------------------------------------------------------- memo + +private enum class MemoPhase { LOADING, MEMO, RECALL, RESULT } + +private data class MemoResult( + val memoMs: Long, + val ok: Map<PieceType, Boolean>, + val wrong: Map<PieceType, List<String>>, + val yours: Map<PieceType, String>, + val traced: Map<PieceType, String>, + val errors: List<String>, + val parity: Boolean, +) + +@Composable +private fun MemoTrainer(m: AppModel) { + val bld = m.bld + val setup = bld.setup + val tracer = remember(setup) { Tracer(setup) } + val mapping = remember { CubeMapping(Puzzles.cube(3), 3) } + val colors = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) } + val camera = remember { Camera() } + var round by remember { mutableStateOf(0) } + var scramble by remember(round) { mutableStateOf<String?>(null) } + var phase by remember(round) { mutableStateOf(MemoPhase.LOADING) } + var memoStart by remember(round) { mutableLongStateOf(0) } + var memoMs by remember(round) { mutableLongStateOf(0) } + var edges by remember(round) { mutableStateOf("") } + var corners by remember(round) { mutableStateOf("") } + var result by remember(round) { mutableStateOf<MemoResult?>(null) } + var now by remember { mutableLongStateOf(monoMillis()) } + + LaunchedEffect(round) { + scramble = m.freshScramble(Event.E333BF) + phase = MemoPhase.MEMO + memoStart = monoMillis() + } + LaunchedEffect(phase) { while (phase == MemoPhase.MEMO) { now = monoMillis(); delay(50) } } + val state = remember(scramble, setup) { scramble?.let { setup.stateOf(it) } } + + fun submit() { + val s = state ?: return + val (e, eErr) = tracer.parse(edges, PieceType.EDGE) + val (c, cErr) = tracer.parse(corners, PieceType.CORNER) + val after = tracer.execute(s, c + e) + val traced = PieceType.entries.associateWith { tracer.letters(tracer.trace(s, it)) } + val r = MemoResult( + memoMs, + PieceType.entries.associateWith { tracer.isSolved(after, it) }, + PieceType.entries.associateWith { tracer.wrongPieces(after, it) }, + mapOf(PieceType.EDGE to tracer.letters(e), PieceType.CORNER to tracer.letters(c)), + traced, + eErr + cErr, + tracer.hasParity(tracer.trace(s, PieceType.CORNER)), + ) + result = r + phase = MemoPhase.RESULT + val letters = traced.values.sumOf { it.length } + val pairsTotal = (letters + 1) / 2 + m.repo.addMemoSession( + MemoSession(epochMillis(), pairsTotal, memoMs, if (r.ok.values.all { it }) pairsTotal else 0), + mapOf("scramble" to (scramble ?: ""), "edges" to r.yours.getValue(PieceType.EDGE), "corners" to r.yours.getValue(PieceType.CORNER)), + ) + bld.tally(r.ok.values.all { it }) + } + + Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f)) { + when { + state == null -> Box(Modifier.fillMaxSize(), Alignment.Center) { Label("scrambling…", color = Rk.c.inkDim) } + phase == MemoPhase.RECALL -> Box(Modifier.fillMaxSize().background(Rk.c.field).bevel(Rk.c, sunken = true), Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Penguin(PenguinAnim.SLEEP, 96.dp, m.reducedMotion) + Label("cube hidden — blindfold on", color = Rk.c.inkDim) + } + } + else -> { + PuzzleView(mapping.state(state), colors, Modifier.fillMaxWidth().weight(1f), camera, description = "scrambled cube to memorise, drag to turn") + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Label("look at:", style = Rk.t.label) + for ((i, f) in "URFDLB".withIndex()) RetroButton("$f", { cameraFacing(i).let { (y, p) -> camera.yaw = y; camera.pitch = p } }, description = "turn the view to the $f face") + } + } + } + } + Column(Modifier.width(380.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { + GroupBox("Memo") { + Label(scramble ?: "…", style = Rk.t.mono.copy(fontSize = 16.sp), color = Rk.c.inkDim) + Spacer(Modifier.height(6.dp)) + val shown = when (phase) { MemoPhase.MEMO -> now - memoStart; else -> memoMs } + BasicText(formatTime(shown), style = Rk.t.mono.copy(fontSize = 48.sp, color = Rk.c.ink)) + when (phase) { + MemoPhase.LOADING -> {} + MemoPhase.MEMO -> RetroButton("Done memorising", { memoMs = monoMillis() - memoStart; phase = MemoPhase.RECALL }) + MemoPhase.RECALL -> { + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + Label("Edges", style = Rk.t.label) + RetroField(edges, { edges = it }, Modifier.fillMaxWidth().focusRequester(focus), password = true, style = Rk.t.mono.copy(fontSize = 28.sp), description = "edge memo") + Label("Corners", style = Rk.t.label) + RetroField(corners, { corners = it }, Modifier.fillMaxWidth(), password = true, style = Rk.t.mono.copy(fontSize = 28.sp), onSubmit = { submit() }, description = "corner memo") + Spacer(Modifier.height(6.dp)) + RetroButton("Check", { submit() }) + } + MemoPhase.RESULT -> RetroButton("Next cube", { round++ }) + } + } + result?.let { r -> MemoResultBox(r, setup) } + } + } +} + +@Composable +private fun MemoResultBox(r: MemoResult, setup: BldSetup) { + val allOk = r.ok.values.all { it } + GroupBox(if (allOk) "Solved!" else "Not quite") { + if (allOk) Row(verticalAlignment = Alignment.CenterVertically) { + Penguin(PenguinAnim.ALERT, 48.dp) + Label("Executed your memo and the cube came out solved.", color = Rk.c.good) + } + for (t in listOf(PieceType.EDGE, PieceType.CORNER)) { + val name = if (t == PieceType.EDGE) "Edges" else "Corners" + Spacer(Modifier.height(6.dp)) + Label("$name ${if (r.ok.getValue(t)) "✓" else "✗"}", style = Rk.t.heading, color = if (r.ok.getValue(t)) Rk.c.good else Rk.c.bad) + Label("you: ${r.yours.getValue(t).chunked(2).joinToString(" ").ifEmpty { "—" }}", style = Rk.t.mono) + Label("traced: ${r.traced.getValue(t).chunked(2).joinToString(" ").ifEmpty { "—" }}", style = Rk.t.mono, color = Rk.c.inkDim) + val wrong = r.wrong.getValue(t) + if (wrong.isNotEmpty()) Label("left wrong: ${wrong.joinToString(", ")}", color = Rk.c.bad) + } + if (r.errors.isNotEmpty()) { Spacer(Modifier.height(6.dp)); for (e in r.errors) Label("! $e", color = Rk.c.gold) } + Spacer(Modifier.height(6.dp)) + Label( + (if (r.parity) "Parity: yes (odd targets)." else "Parity: no.") + + " Traced memo is one valid answer; any memo that solves counts.", + color = Rk.c.inkDim, style = Rk.t.label, + ) + } +} + +// ---------------------------------------------------------------- setup + +@Composable +private fun BldSetupScreen(m: AppModel) { + val bld = m.bld + val setup = bld.setup + var editing by remember { mutableStateOf<Pair<PieceType, Int>?>(null) } + Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { + GroupBox("Lettering") { + Label("Click a sticker, then type its letter. Letters swap if already used. Shown in your solving orientation.", color = Rk.c.inkDim) + Spacer(Modifier.height(8.dp)) + LetterNet(setup, editing) { editing = it } + Spacer(Modifier.height(8.dp)) + editing?.let { (type, pos) -> + var v by remember(editing) { mutableStateOf("") } + val focus = remember { FocusRequester() } + LaunchedEffect(editing) { runCatching { focus.requestFocus() } } + Row(verticalAlignment = Alignment.CenterVertically) { + Label("${Cube3.stickerName(type, pos)} (${type.name.lowercase()}) is ${setup.scheme.letter(type, pos)} → ", style = Rk.t.body) + RetroField(v, { nv -> + val c = nv.uppercase().filter { it.isLetter() }.take(1) + v = c + if (c.isNotEmpty()) { bld.updateSetup(setup.copy(scheme = assign(setup.scheme, type, pos, c[0]))); editing = null } + }, Modifier.width(60.dp).focusRequester(focus), placeholder = "?", style = Rk.t.mono.copy(fontSize = 24.sp)) + } + } + Spacer(Modifier.height(6.dp)) + RetroButton("Reset to Speffz", { bld.updateSetup(setup.copy(scheme = LetterScheme())) }) + } + } + Column(Modifier.width(380.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { + GroupBox("Orientation") { + val presets = listOf("yellow top, orange front" to "x2 y'", "white top, green front (WCA)" to "", "yellow top, green front" to "z2", "white top, orange front" to "y'") + for ((label, rot) in presets) RetroButton(label, { bld.updateSetup(setup.copy(rotation = rot)) }, Modifier.fillMaxWidth().padding(bottom = 3.dp), selected = setup.rotation == rot) + Label("rotation from scrambling orientation: ${setup.rotation.ifEmpty { "none" }}", color = Rk.c.inkDim, style = Rk.t.label) + } + GroupBox("Buffers") { + for (t in listOf(PieceType.CORNER, PieceType.EDGE)) { + val buf = setup.buffer(t) + Label("${if (t == PieceType.CORNER) "Corner" else "Edge"} buffer: ${setup.scheme.letter(t, buf)} (${Cube3.stickerName(t, buf)})", style = Rk.t.body) + Row(horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.padding(bottom = 6.dp)) { + val options = if (t == PieceType.CORNER) listOf(0, 2, 1, 3) else listOf(1, 2, 0, 3) + for (p in options) RetroButton( + Cube3.pieceName(t, p), { bld.updateSetup(if (t == PieceType.CORNER) setup.copy(cornerBuffer = p) else setup.copy(edgeBuffer = p)) }, + selected = Cube3.pieceId(t, p) == Cube3.pieceId(t, buf), + ) + } + } + Label("Method: Old Pochmann corners (helper RDF/P) and OP edges (T/Ja/Jb), R-perm parity. M2 support comes with the solve analyser.", color = Rk.c.inkDim, style = Rk.t.label) + } + } + } +} + +private fun assign(s: LetterScheme, type: PieceType, pos: Int, letter: Char): LetterScheme { + val str = (if (type == PieceType.CORNER) s.corners else s.edges).toCharArray() + val old = str[pos] + val clash = str.indexOf(letter) + if (clash >= 0) str[clash] = old + str[pos] = letter + return if (type == PieceType.CORNER) s.copy(corners = str.concatToString()) else s.copy(edges = str.concatToString()) +} + +/** Flat net (U on top; L F R B; D below) showing every sticker's letter, coloured as you hold the cube. */ +@Composable +private fun LetterNet(setup: BldSetup, editing: Pair<PieceType, Int>?, onPick: (Pair<PieceType, Int>) -> Unit) { + val colors = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) } + val solved = setup.solvedColors + val cell = 38.dp + // face → (column, row) on the net, in faces U R F D L B + val at = listOf(1 to 0, 2 to 1, 1 to 1, 1 to 2, 0 to 1, 3 to 1) + Column { + for (netRow in 0 until 9) Row { + for (netCol in 0 until 12) { + val face = at.indexOfFirst { (c, r) -> c == netCol / 3 && r == netRow / 3 } + if (face < 0) { Spacer(Modifier.size(cell)); continue } + val slot = face * 9 + (netRow % 3) * 3 + netCol % 3 + val target = Cube3.positionOf(PieceType.CORNER, slot)?.let { PieceType.CORNER to it } + ?: Cube3.positionOf(PieceType.EDGE, slot)?.let { PieceType.EDGE to it } + val color = colors[solved[slot]] + val sel = target != null && target == editing + val isBuffer = target != null && Cube3.pieceId(target.first, target.second) == Cube3.pieceId(target.first, setup.buffer(target.first)) + Box( + Modifier.size(cell).padding(1.dp).background(Color(0xFF14181C)).padding(2.dp).background(color) + .then(if (sel) Modifier.outline(Color(0xFFFF2BD6)) else Modifier) + .then(if (target != null) Modifier.clickable { onPick(target) } else Modifier) + .semantics { if (target != null) contentDescription = "${Cube3.stickerName(target.first, target.second)} ${setup.scheme.letter(target.first, target.second)}" }, + Alignment.Center, + ) { + if (target != null) BasicText( + "${setup.scheme.letter(target.first, target.second)}", + style = Rk.t.mono.copy(fontSize = 22.sp, color = if (isBuffer) Color(0xFF888888) else Color.Black), + ) + } + } + } + } +} diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt index 41ea00f..f2a8b67 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt @@ -2,6 +2,7 @@ package coffee.liz.rookery.ui import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -46,11 +47,23 @@ fun PuzzleView( camera: Camera = remember { Camera() }, anim: TurnAnim? = null, description: String = "3D puzzle view", + /** Sticker index to outline, e.g. "what letter is this?" */ + highlight: Int? = null, + highlightColor: Color = Color(0xFFFF2BD6), + /** Called with the front-most sticker under a tap/click. */ + onStickerTap: ((Int) -> Unit)? = null, ) { val puzzle = state.puzzle val body = Color(0xFF14181C) + // Screen polygons from the last frame, front-most last, for hit testing taps. + val hits = remember { ArrayList<Pair<Int, List<Offset>>>() } Canvas( modifier + .then(if (onStickerTap == null) Modifier else Modifier.pointerInput(onStickerTap) { + detectTapGestures { pos -> + hits.lastOrNull { (_, poly) -> inside(pos, poly) }?.let { onStickerTap(it.first) } + } + }) .pointerInput(camera) { detectDragGestures { change, drag -> change.consume() @@ -86,7 +99,7 @@ fun PuzzleView( Triple(a.turn, axis.normal, angle) } - class Drawn(val tile: Polygon, val color: Color, val depth: Double, val shade: Float) + class Drawn(val index: Int, val tile: Polygon, val color: Color, val depth: Double, val shade: Float) val drawn = ArrayList<Drawn>(puzzle.stickers.size) for ((i, s) in puzzle.stickers.withIndex()) { var poly = s @@ -98,19 +111,49 @@ fun PuzzleView( val n = v.normal if ((n dot toCam) <= 0) continue // back face val lambert = maxOf(0.0, n dot light).toFloat() - drawn += Drawn(v, faceColors[state.colors[i] % faceColors.size], v.centroid.z, 0.62f + 0.38f * lambert) + drawn += Drawn(i, v, faceColors[state.colors[i] % faceColors.size], v.centroid.z, 0.62f + 0.38f * lambert) } drawn.sortBy { it.depth } + hits.clear() for (d in drawn) { - fill(d.tile.points.map(::project), body) + val tile = d.tile.points.map(::project) + fill(tile, body) val sticker = d.tile.inset(0.86).points.map(::project) fill(sticker, d.color.shaded(d.shade)) // a thin highlight on the sticker's upper edge for a slightly glossy, skeuomorphic feel drawPath(path(sticker), Color.White.copy(alpha = 0.10f * d.shade), style = Stroke(1f)) + hits += d.index to tile + } + if (highlight != null) drawn.firstOrNull { it.index == highlight }?.let { d -> + val ring = d.tile.inset(0.97).points.map(::project) + drawPath(path(ring), Color.Black, style = Stroke(7f)) + drawPath(path(ring), highlightColor, style = Stroke(4f)) } } } +/** Ray-casting point-in-polygon test. */ +private fun inside(p: Offset, poly: List<Offset>): Boolean { + var c = false + var j = poly.size - 1 + for (i in poly.indices) { + val a = poly[i]; val b = poly[j] + if ((a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x) c = !c + j = i + } + return c +} + +/** Camera angles that put a face of a cube (U R F D L B) in view, tilted a little for context. */ +fun cameraFacing(face: Int): Pair<Float, Float> = when (face) { + 0 -> -30f to 62f + 1 -> -68f to 22f + 2 -> -22f to 22f + 3 -> -30f to -62f + 4 -> 68f to 22f + else -> 158f to 22f +} + private fun path(pts: List<Offset>) = Path().apply { moveTo(pts[0].x, pts[0].y) for (k in 1 until pts.size) lineTo(pts[k].x, pts[k].y) diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt index 2fe6be5..8fb3a18 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt @@ -164,6 +164,7 @@ fun RetroField( singleLine: Boolean = true, onSubmit: (() -> Unit)? = null, description: String? = null, + password: Boolean = false, ) { val p = Rk.c val st = (style ?: Rk.t.body).copy(color = p.ink) @@ -172,6 +173,7 @@ fun RetroField( modifier.background(p.field).bevel(p, sunken = true).padding(horizontal = 8.dp, vertical = 6.dp) .semantics { if (description != null) contentDescription = description }, textStyle = st, singleLine = singleLine, cursorBrush = SolidColor(p.ink), + visualTransformation = if (password) androidx.compose.ui.text.input.PasswordVisualTransformation('•') else androidx.compose.ui.text.input.VisualTransformation.None, keyboardOptions = KeyboardOptions(imeAction = if (onSubmit != null) ImeAction.Done else ImeAction.Default), keyboardActions = KeyboardActions(onDone = { onSubmit?.invoke() }), decorationBox = { inner -> |
