summaryrefslogtreecommitdiff
path: root/core/src/commonMain/kotlin/coffee/liz/rookery
diff options
context:
space:
mode:
Diffstat (limited to 'core/src/commonMain/kotlin/coffee/liz/rookery')
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt213
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/CubeMapping.kt32
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/NxnCube.kt12
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/srs/Srs.kt93
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt55
5 files changed, 405 insertions, 0 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 ->