package coffee.liz.rookery.puzzle /** 3x3 helpers in the frame a smart cube reports in: centres fixed, face turns only. */ object Cube3x3 { private const val FACES = "URFDLB" const val SOLVED = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB" fun facelets(c: NxnCube): String = buildString(54) { for (s in c.stickers) append(FACES[s]) } /** * Rewrite a scramble as plain face turns. A wide move is the opposite face plus a whole-cube * rotation (Rw = L then x), and a smart cube can't see rotations, so later moves are renamed * through them. The result reaches the same cube, just held differently. */ fun faceTurns(scramble: String): List { val orient = NxnCube(3) // only its centres matter: which physical face sits where val out = mutableListOf() val re = Regex("""^([URFDLB])(w?)([2']?)$|^([xyz])([2']?)$""") for (t in scramble.split(Regex("\\s+")).filter { it.isNotEmpty() }) { val m = re.matchEntire(t) ?: throw IllegalArgumentException("can't follow move '$t' on a smart cube") if (m.groupValues[4].isNotEmpty()) { orient.apply(t); continue } val face = FACES.indexOf(m.groupValues[1]); val suffix = m.groupValues[3] if (m.groupValues[2].isEmpty()) out += "${FACES[orient.stickers[face * 9 + 4]]}$suffix" else { val opposite = (face + 3) % 6 out += "${FACES[orient.stickers[opposite * 9 + 4]]}$suffix" val (rot, flip) = when (FACES[face]) { 'R' -> "x" to false; 'L' -> "x" to true; 'U' -> "y" to false; 'D' -> "y" to true; 'F' -> "z" to false; else -> "z" to true } val sfx = if (!flip) suffix else when (suffix) { "" -> "'"; "'" -> ""; else -> "2" } orient.apply(rot + sfx) } } return out } } /** * Follows a smart cube towards a scramble, the way csTimer does: shows what is left to turn, takes * each move as it comes, and when the cube strays works out the shortest way from where it is to the * scrambled state. The plan is kept until the next stray move, so it doesn't change under your hands. * * [solver] returns moves that solve a facelet string (null if it can't). */ class ScrambleTracker(private val solver: (facelets: String) -> String?) { private var target: List = emptyList() private var cube = NxnCube(3) private var targetFacelets = Cube3x3.SOLVED /** Moves still to do. Empty with [known] true means the cube is scrambled. */ var plan: List = emptyList() private set(value) { field = value } /** * The whole sequence being worked through (the scramble, or the current fix), which stays put * while [done] fills in, so a display can mark progress without the text moving. */ var sequence: List = emptyList() private set /** Indices into [sequence] already turned. */ var done: Set = emptySet() private set /** Index in [sequence] of the move in hand, or -1. */ val current: Int get() = remaining.firstOrNull() ?: -1 private var remaining = mutableListOf() private fun begin(moves: List) { plan = moves; sequence = moves; done = emptySet(); remaining = moves.indices.toMutableList() } /** False while the plan is the scramble as written; true once it is a computed correction. */ var corrected = false private set /** False if we couldn't work out a path (cube state unknown or unsolvable). */ var known = false private set val scrambled: Boolean get() = known && plan.isEmpty() && target.isNotEmpty() val total: Int get() = target.size /** * Begin tracking a new [scramble]. If we have been following the cube we already know where it * is; otherwise [facelets] (the cube's last report, possibly null) says. */ fun reset(scramble: String, facelets: String?) { target = runCatching { Cube3x3.faceTurns(scramble) }.getOrDefault(emptyList()) targetFacelets = Cube3x3.facelets(NxnCube(3).apply(target.joinToString(" "))) if (known) start() else sync(facelets) } /** Learn the cube's state from a facelets report. Only needed while it is not [known]. */ fun sync(facelets: String?) { if (facelets == null) { known = false; begin(emptyList()); return } val solution = solver(facelets) if (solution == null) { known = false; begin(emptyList()); return } cube = NxnCube(3).apply(Notation.invert(solution)) known = true start() } private fun start() { if (target.isEmpty()) { begin(emptyList()); return } if (cube.isSolved) { begin(target); corrected = false } else replan() } fun move(m: String) { if (!known) return cube.apply(m) val face = m[0]; val amount = amount(m) // the next move, or the one after it if the two are opposite faces (they commute) val at = when { plan.isNotEmpty() && plan[0][0] == face -> 0 plan.size > 1 && plan[1][0] == face && opposite(plan[0][0]) == face -> 1 else -> -1 } if (at < 0) { replan(); return } val left = ((amount(plan[at]) - amount) % 4 + 4) % 4 plan = plan.toMutableList().also { if (left == 0) it.removeAt(at) else it[at] = face + suffix(left) } if (left == 0) done = done + remaining.removeAt(at) } /** The cube as we believe it is, 54 facelets U R F D L B; null while unknown. */ val facelets: String? get() = if (known) Cube3x3.facelets(cube) else null /** * The cube itself says where it is, and that outranks our move-by-move bookkeeping (a turn can be * lost over Bluetooth). Returns true if we were wrong. The plan is left alone: use [reset] or * [move] afterwards if a scramble is in progress. */ fun correct(reported: String): Boolean { if (known && Cube3x3.facelets(cube) == reported) return false if (reported == Cube3x3.SOLVED) { cube = NxnCube(3); known = true; return true } val solution = solver(reported) ?: return false cube = NxnCube(3).apply(Notation.invert(solution)); known = true return true } /** Work out the plan again from wherever the cube is now. */ fun replanFromHere() { if (known) start() } /** A turn made while solving: keep the cube up to date, leave the plan alone. */ fun turn(m: String) { if (known) cube.apply(m) } val solved: Boolean get() = known && cube.isSolved private fun replan() { if (Cube3x3.facelets(cube) == targetFacelets) { plan = emptyList(); done = sequence.indices.toSet(); remaining.clear(); known = true; return } // the cube that, once solved, has taken ours to the target: undo the target, then redo what we have val relative = NxnCube(3).apply(Notation.invert(target.joinToString(" "))) val here = solver(Cube3x3.facelets(cube)) if (here == null) { known = false; begin(emptyList()); return } relative.apply(Notation.invert(here)) val path = solver(Cube3x3.facelets(relative)) if (path == null) { known = false; begin(emptyList()); return } begin(path.split(Regex("\\s+")).filter { it.isNotEmpty() }) corrected = true; known = true } private fun amount(m: String) = when { m.endsWith("2") -> 2; m.endsWith("'") -> 3; else -> 1 } private fun suffix(n: Int) = when (n) { 2 -> "2"; 3 -> "'"; else -> "" } private fun opposite(f: Char) = "URFDLB"[("URFDLB".indexOf(f) + 3) % 6] }