summaryrefslogtreecommitdiff
path: root/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/ScrambleTracker.kt
blob: 68890b484d1ab45a641404992feb6da6ff63848d (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
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<String> {
        val orient = NxnCube(3) // only its centres matter: which physical face sits where
        val out = mutableListOf<String>()
        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<String> = 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<String> = 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<String> = emptyList()
        private set
    /** Indices into [sequence] already turned. */
    var done: Set<Int> = emptySet()
        private set
    /** Index in [sequence] of the move in hand, or -1. */
    val current: Int get() = remaining.firstOrNull() ?: -1
    private var remaining = mutableListOf<Int>()

    private fun begin(moves: List<String>) { 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]
}