cube-state-engine 1.3.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,736 +1,1281 @@
1
- class CubeEngine {
2
- MOVES = [];
3
-
4
- constructor(initialScramble = "") {
5
- // If an initial scramble string is provided, apply it without recording moves
6
- if (typeof initialScramble === "string" && initialScramble.trim().length > 0) {
7
- this.#applyMovesFromString(initialScramble, false);
8
- // Ensure history is empty for initial position
9
- this.MOVES = [];
10
- }
11
- }
1
+ // Move-sequence simplifier.
2
+ //
3
+ // Connects consecutive IDENTICAL quarter turns into a single double turn
4
+ // (R R -> R2, F F -> F2, y y -> y2, R' R' -> R2). It does NOT cancel opposite
5
+ // moves: F F' stays exactly as F F', and U2 U2 stays as U2 U2 — no reduction
6
+ // to identity, no net-rotation folding. The only transformation is "join two
7
+ // equal quarter turns into their double".
8
+ //
9
+ // Accepts a space-separated string, an array of token strings, or an array of
10
+ // timed moves (`[{ m, t }]`); returns the same shape. When merging timed moves
11
+ // the resulting double keeps the SECOND move's timestamp (the instant the
12
+ // double turn finishes), so it composes cleanly with analyzeSolution.
13
+
14
+ // Two tokens merge iff they are exactly equal AND are quarter turns (no "2").
15
+ // The merged token drops any prime and appends "2" (R'->R2, Rw->Rw2).
16
+ function canMerge(a, b) {
17
+ return typeof a === "string" && a === b && !a.includes("2");
18
+ }
19
+
20
+ function doubled(tok) {
21
+ return tok.replace("'", "") + "2";
22
+ }
23
+
24
+ // Core pass over plain token strings. A single left-to-right pass suffices:
25
+ // merging two quarters into a double can never create a new mergeable pair.
26
+ function simplifyTokens(tokens) {
27
+ const out = [];
28
+ for (let i = 0; i < tokens.length; ) {
29
+ const a = tokens[i];
30
+ const b = tokens[i + 1];
31
+ if (i + 1 < tokens.length && canMerge(a, b)) {
32
+ out.push(doubled(a));
33
+ i += 2;
34
+ } else {
35
+ out.push(a);
36
+ i += 1;
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ // Same pass over timed moves, keeping the second move's timestamp on a merge.
43
+ function simplifyTimed(moves) {
44
+ const out = [];
45
+ for (let i = 0; i < moves.length; ) {
46
+ const a = moves[i];
47
+ const b = moves[i + 1];
48
+ if (i + 1 < moves.length && canMerge(a?.m, b?.m)) {
49
+ out.push({ m: doubled(a.m), t: b.t });
50
+ i += 2;
51
+ } else {
52
+ out.push(a);
53
+ i += 1;
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * Simplifies a move sequence by joining consecutive identical quarter turns
61
+ * into double turns, without cancelling opposite or redundant moves.
62
+ *
63
+ * @param {string|string[]|Array<{m: string, t: number}>} moves - The sequence,
64
+ * as a space-separated string, an array of tokens, or timed moves.
65
+ * @returns {string|string[]|Array<{m: string, t: number}>} The simplified
66
+ * sequence, in the same shape as the input.
67
+ *
68
+ * @example
69
+ * simplifyMoves("U R R D R' B"); // "U R2 D R' B"
70
+ * simplifyMoves("F F'"); // "F F'" (unchanged)
71
+ * simplifyMoves("R R R"); // "R2 R" (no cancellation)
72
+ * simplifyMoves(["y'", "y'", "L"]); // ["y2", "L"]
73
+ */
74
+ function simplifyMoves(moves) {
75
+ if (typeof moves === "string") {
76
+ const tokens = moves.split(/\s+/).filter((t) => t.length > 0);
77
+ return simplifyTokens(tokens).join(" ");
78
+ }
79
+ if (Array.isArray(moves)) {
80
+ if (moves.length === 0) return [];
81
+ if (typeof moves[0] === "string") return simplifyTokens(moves);
82
+ return simplifyTimed(moves);
83
+ }
84
+ return moves;
85
+ }
86
+
87
+ // Solution analyzer: replays a recorded solve move-by-move and reports the
88
+ // timing of each method milestone (cross, the four F2L pairs, OLL and PLL),
89
+ // plus a best-effort guess of the solving method (currently CFOP).
90
+ //
91
+ // Input is just the solution as `[{ m, t }]` where `m` is a move token in the
92
+ // same notation the engine accepts (e.g. "R", "U'", "R2", "x", "M'") and `t`
93
+ // is the CUMULATIVE elapsed time (ms) up to and including that move. The
94
+ // scramble is not required: it is derived as the inverse of the solution.
95
+ //
96
+ // All milestone checks compare each sticker against the CURRENT center of its
97
+ // face, so detection is invariant to whole-cube rotations (x/y/z) and wide
98
+ // moves that may appear in real speedsolves.
12
99
 
13
- // States object for the rotation
14
- STATES = {
15
- UPPER: [
16
- // (White)
17
- [COLOR.W[0], COLOR.W[1], COLOR.W[2]],
18
- [COLOR.W[3], COLOR.W[4], COLOR.W[5]],
19
- [COLOR.W[6], COLOR.W[7], COLOR.W[8]],
20
- ],
21
- LEFT: [
22
- // (Orange)
23
- [COLOR.O[0], COLOR.O[1], COLOR.O[2]],
24
- [COLOR.O[3], COLOR.O[4], COLOR.O[5]],
25
- [COLOR.O[6], COLOR.O[7], COLOR.O[8]],
26
- ],
27
- FRONT: [
28
- // (Green)
29
- [COLOR.G[0], COLOR.G[1], COLOR.G[2]],
30
- [COLOR.G[3], COLOR.G[4], COLOR.G[5]],
31
- [COLOR.G[6], COLOR.G[7], COLOR.G[8]],
32
- ],
33
- RIGHT: [
34
- // (Red)
35
- [COLOR.R[0], COLOR.R[1], COLOR.R[2]],
36
- [COLOR.R[3], COLOR.R[4], COLOR.R[5]],
37
- [COLOR.R[6], COLOR.R[7], COLOR.R[8]],
38
- ],
39
- BACK: [
40
- // (Blue)
41
- [COLOR.B[0], COLOR.B[1], COLOR.B[2]],
42
- [COLOR.B[3], COLOR.B[4], COLOR.B[5]],
43
- [COLOR.B[6], COLOR.B[7], COLOR.B[8]],
44
- ],
45
- DOWN: [
46
- // (Yellow)
47
- [COLOR.Y[0], COLOR.Y[1], COLOR.Y[2]],
48
- [COLOR.Y[3], COLOR.Y[4], COLOR.Y[5]],
49
- [COLOR.Y[6], COLOR.Y[7], COLOR.Y[8]],
50
- ],
51
- };
52
100
 
53
- /**
54
- * Rotates the (UPPER) layer clockwise or counterclockwise.
55
- */
56
- rotateU(clockwise = true) {
57
- if (clockwise) {
58
- this.#rotateU(true);
59
- this.MOVES.push("U");
60
- } else {
61
- this.#rotateU(false);
62
- this.MOVES.push("U'");
63
- }
64
- }
101
+ // Flat layout face order (must match CubeEngine.state()).
102
+ const FACE_NAMES$1 = ["UPPER", "LEFT", "FRONT", "RIGHT", "BACK", "DOWN"];
65
103
 
66
- #rotateU(clockwise = true) {
67
- if (clockwise) {
68
- this.STATES.UPPER = this.#switchMatrix(this.STATES.UPPER, true);
69
-
70
- const tempFront = [...this.STATES.FRONT[0]];
71
- const tempRight = [...this.STATES.RIGHT[0]];
72
- const tempLeft = [...this.STATES.LEFT[0]];
73
- const tempBack = [...this.STATES.BACK[0]];
74
-
75
- this.STATES.FRONT[0] = [...tempRight];
76
- this.STATES.LEFT[0] = [...tempFront];
77
- this.STATES.BACK[0] = [...tempLeft];
78
- this.STATES.RIGHT[0] = [...tempBack];
79
- } else {
80
- this.STATES.UPPER = this.#switchMatrix(this.STATES.UPPER, false);
81
-
82
- const tempFront = [...this.STATES.FRONT[0]];
83
- const tempRight = [...this.STATES.RIGHT[0]];
84
- const tempLeft = [...this.STATES.LEFT[0]];
85
- const tempBack = [...this.STATES.BACK[0]];
86
-
87
- this.STATES.FRONT[0] = [...tempLeft];
88
- this.STATES.LEFT[0] = [...tempBack];
89
- this.STATES.BACK[0] = [...tempRight];
90
- this.STATES.RIGHT[0] = [...tempFront];
91
- }
92
- }
104
+ // Basic face moves mapped to their face index in the flat sticker layout.
105
+ const FACE_MOVE_TO_INDEX = { U: 0, L: 1, F: 2, R: 3, B: 4, D: 5 };
93
106
 
94
- /**
95
- * Rotates the (FRONT) layer clockwise or counterclockwise.
96
- */
97
- rotateF(clockwise = true) {
98
- if (clockwise) {
99
- this.#rotateF(true);
100
- this.MOVES.push("F");
101
- } else {
102
- this.#rotateF(false);
103
- this.MOVES.push("F'");
104
- }
105
- }
107
+ // Move bases the engine can actually apply (after normalization).
108
+ const SUPPORTED_BASES = new Set([
109
+ "U", "D", "L", "R", "F", "B",
110
+ "x", "y", "z",
111
+ "M", "E", "S",
112
+ "Uw", "Dw", "Rw", "Lw", "Fw",
113
+ ]);
106
114
 
107
- #rotateF(clockwise = true) {
108
- if (clockwise) {
109
- this.#rotateX(true);
110
- this.#rotateU(true);
111
- this.#rotateX(false);
112
- } else {
113
- this.#rotateX(true);
114
- this.#rotateU(false);
115
- this.#rotateX(false);
116
- }
117
- }
115
+ // Lowercase single-letter wide moves -> the engine's wide notation.
116
+ const WIDE_LOWER = { r: "Rw", u: "Uw", f: "Fw", l: "Lw", d: "Dw", b: "Bw" };
118
117
 
119
- /**
120
- * Rotates the (BACK) layer clockwise or counterclockwise.
121
- */
122
- rotateB(clockwise = true) {
123
- if (clockwise) {
124
- this.#rotateB(true);
125
- this.MOVES.push("B");
126
- } else {
127
- this.#rotateB(false);
128
- this.MOVES.push("B'");
129
- }
130
- }
118
+ // Normalizes one move token to the engine's notation and reports its base.
119
+ // Returns { token, base } where base is null if the token is unparseable.
120
+ function normalizeToken(raw) {
121
+ const m = String(raw).trim().match(/^([A-Za-z]w?)('?2?|2?'?)$/);
122
+ if (!m) return { token: raw, base: null };
123
+ let base = m[1];
124
+ if (base.length === 1 && WIDE_LOWER[base]) base = WIDE_LOWER[base];
125
+ return { token: base + m[2], base };
126
+ }
131
127
 
132
- #rotateB(clockwise = true) {
133
- // Implement B as y2 F y2
134
- // Clockwise/counterclockwise direction is preserved through y2 conjugation
135
- this.#rotateY(true);
136
- this.#rotateY(true);
137
- if (clockwise) {
138
- this.#rotateF(true);
139
- } else {
140
- this.#rotateF(false);
141
- }
142
- this.#rotateY(false);
143
- this.#rotateY(false);
144
- }
128
+ // Derived sticker geometry is identical for every cube of a given size.
129
+ const GEOMETRY_CACHE = new Map();
145
130
 
146
- /**
147
- * Rotates the (RIGHT) layer clockwise or counterclockwise.
148
- */
149
- rotateR(clockwise = true) {
150
- if (clockwise) {
151
- this.#rotateR(true);
152
- this.MOVES.push("R");
153
- } else {
154
- this.#rotateR(false);
155
- this.MOVES.push("R'");
156
- }
157
- }
131
+ /**
132
+ * Derives the sticker adjacency of a cube from the engine's permutation tables.
133
+ *
134
+ * A facelet is displaced by a face turn iff it physically belongs to that face,
135
+ * so the SET of basic face turns that move a facelet identifies the cubie it
136
+ * sits on: 1 face => center, 2 faces => edge, 3 faces => corner. Grouping
137
+ * facelets by that signature reconstructs every edge/corner slot without any
138
+ * hardcoded layout, and stays correct for any matrix convention the engine uses.
139
+ */
140
+ function buildGeometry(size) {
141
+ if (GEOMETRY_CACHE.has(size)) return GEOMETRY_CACHE.get(size);
158
142
 
159
- #rotateR(clockwise = true) {
160
- if (clockwise) {
161
- this.#rotateY(true);
162
- this.#rotateX(true);
163
- this.#rotateU(true);
164
- this.#rotateX(false);
165
- this.#rotateY(false);
166
- } else {
167
- this.#rotateY(true);
168
- this.#rotateX(true);
169
- this.#rotateU(false);
170
- this.#rotateX(false);
171
- this.#rotateY(false);
172
- }
173
- }
143
+ const perms = getMovePermutations(size);
144
+ const per = size * size;
145
+ const total = per * 6;
146
+ const faceMoves = Object.keys(FACE_MOVE_TO_INDEX);
174
147
 
175
- /**
176
- * Rotates the (LEFT) layer clockwise or counterclockwise.
177
- */
178
- rotateL(clockwise = true) {
179
- if (clockwise) {
180
- this.#rotateL(true);
181
- this.MOVES.push("L");
182
- } else {
183
- this.#rotateL(false);
184
- this.MOVES.push("L'");
148
+ // signature[i] = sorted face indices whose quarter turn displaces sticker i.
149
+ const edgeMap = new Map(); // "a,b" -> indices[]
150
+ const cornerMap = new Map(); // "a,b,c" -> indices[]
151
+ for (let i = 0; i < total; i++) {
152
+ const faces = [];
153
+ for (const mv of faceMoves) {
154
+ if (perms[mv].cw[i] !== i) faces.push(FACE_MOVE_TO_INDEX[mv]);
185
155
  }
186
- }
187
-
188
- #rotateL(clockwise = true) {
189
- if (clockwise) {
190
- this.#rotateY(false);
191
- this.#rotateX(true);
192
- this.#rotateU(true);
193
- this.#rotateX(false);
194
- this.#rotateY(true);
195
- } else {
196
- this.#rotateY(false);
197
- this.#rotateX(true);
198
- this.#rotateU(false);
199
- this.#rotateX(false);
200
- this.#rotateY(true);
156
+ faces.sort((a, b) => a - b);
157
+ const key = faces.join(",");
158
+ if (faces.length === 2) {
159
+ if (!edgeMap.has(key)) edgeMap.set(key, []);
160
+ edgeMap.get(key).push(i);
161
+ } else if (faces.length === 3) {
162
+ if (!cornerMap.has(key)) cornerMap.set(key, []);
163
+ cornerMap.get(key).push(i);
201
164
  }
202
165
  }
203
166
 
204
- /**
205
- * Rotates the (DOWN) layer clockwise or counterclockwise.
206
- */
207
- rotateD(clockwise = true) {
208
- if (clockwise) {
209
- this.#rotateD(true);
210
- this.MOVES.push("D");
211
- } else {
212
- this.#rotateD(false);
213
- this.MOVES.push("D'");
214
- }
215
- }
167
+ const edges = [...edgeMap.entries()].map(([key, indices]) => ({
168
+ faces: key.split(",").map(Number),
169
+ indices,
170
+ }));
171
+ const corners = [...cornerMap.entries()].map(([key, indices]) => ({
172
+ faces: key.split(",").map(Number),
173
+ indices,
174
+ }));
216
175
 
217
- #rotateD(clockwise = true) {
218
- if (clockwise) {
219
- this.#rotateX(true);
220
- this.#rotateF(true);
221
- this.#rotateX(false);
222
- } else {
223
- this.#rotateX(true);
224
- this.#rotateF(false);
225
- this.#rotateX(false);
226
- }
176
+ // Neighbor / opposite relationships between the six face positions.
177
+ const neighbors = Array.from({ length: 6 }, () => new Set());
178
+ for (const e of edges) {
179
+ const [a, b] = e.faces;
180
+ neighbors[a].add(b);
181
+ neighbors[b].add(a);
227
182
  }
228
-
229
- /**
230
- * Rotates the wide (DOWN two layers) clockwise or counterclockwise.
231
- */
232
- rotateDw(clockwise = true) {
233
- if (clockwise) {
234
- this.#rotateDw(true);
235
- this.MOVES.push("Dw");
236
- } else {
237
- this.#rotateDw(false);
238
- this.MOVES.push("Dw'");
183
+ const opposite = new Array(6).fill(-1);
184
+ for (let f = 0; f < 6; f++) {
185
+ for (let g = 0; g < 6; g++) {
186
+ if (g !== f && !neighbors[f].has(g)) {
187
+ opposite[f] = g;
188
+ break;
189
+ }
239
190
  }
240
191
  }
241
192
 
242
- #rotateDw(clockwise = true) {
243
- if (clockwise) {
244
- this.#rotateY(false);
245
- this.#rotateU(true);
246
- } else {
247
- this.#rotateY(true);
248
- this.#rotateU(false);
249
- }
250
- }
193
+ const geo = {
194
+ size,
195
+ per,
196
+ centerIndex: (f) => f * per + Math.floor(per / 2),
197
+ edges,
198
+ corners,
199
+ neighbors,
200
+ opposite,
201
+ edgesByFace: (f) => edges.filter((e) => e.faces.includes(f)),
202
+ cornersByFace: (f) => corners.filter((c) => c.faces.includes(f)),
203
+ edgeByPair: (a, b) =>
204
+ edges.find(
205
+ (e) =>
206
+ (e.faces[0] === a && e.faces[1] === b) ||
207
+ (e.faces[0] === b && e.faces[1] === a)
208
+ ),
209
+ };
251
210
 
252
- /**
253
- * Rotates the wide (UPPER two layers) clockwise or counterclockwise.
254
- */
255
- rotateUw(clockwise = true) {
256
- if (clockwise) {
257
- this.#rotateUw(true);
258
- this.MOVES.push("Uw");
259
- } else {
260
- this.#rotateUw(false);
261
- this.MOVES.push("Uw'");
262
- }
263
- }
211
+ GEOMETRY_CACHE.set(size, geo);
212
+ return geo;
213
+ }
264
214
 
265
- #rotateUw(clockwise = true) {
266
- if (clockwise) {
267
- this.#rotateY(true);
268
- this.#rotateD(true);
269
- } else {
270
- this.#rotateY(false);
271
- this.#rotateD(false);
272
- }
273
- }
215
+ /** Inverts a single move token (R -> R', R' -> R, R2 -> R2). */
216
+ function invertToken(tok) {
217
+ if (tok.endsWith("2")) return tok;
218
+ if (tok.endsWith("'")) return tok.slice(0, -1);
219
+ return tok + "'";
220
+ }
274
221
 
275
- /**
276
- * Rotates the wide (RIGHT two layers) clockwise or counterclockwise.
277
- */
278
- rotateRw(clockwise = true) {
279
- if (clockwise) {
280
- this.#rotateRw(true);
281
- this.MOVES.push("Rw");
282
- } else {
283
- this.#rotateRw(false);
284
- this.MOVES.push("Rw'");
285
- }
286
- }
222
+ /**
223
+ * Inverts a sequence of move tokens (reverse order, each token inverted).
224
+ * @param {string[]} tokens
225
+ * @returns {string[]}
226
+ */
227
+ function invertSequence(tokens) {
228
+ return tokens.slice().reverse().map(invertToken);
229
+ }
287
230
 
288
- #rotateRw(clockwise = true) {
289
- if (clockwise) {
290
- this.#rotateX(true);
291
- this.#rotateL(true);
292
- } else {
293
- this.#rotateX(false);
294
- this.#rotateL(false);
231
+ // Flattens CubeEngine.state() back into the flat sticker array layout.
232
+ function flattenState(state) {
233
+ const out = [];
234
+ for (const name of FACE_NAMES$1) {
235
+ const matrix = state[name];
236
+ for (const row of matrix) {
237
+ for (const v of row) out.push(v);
295
238
  }
296
239
  }
240
+ return out;
241
+ }
297
242
 
298
- /**
299
- * Rotates the wide (LEFT two layers) clockwise or counterclockwise.
300
- */
301
- rotateLw(clockwise = true) {
302
- if (clockwise) {
303
- this.#rotateLw(true);
304
- this.MOVES.push("Lw");
305
- } else {
306
- this.#rotateLw(false);
307
- this.MOVES.push("Lw'");
308
- }
309
- }
243
+ // Center color currently shown on each face position.
244
+ function centersOf(st, geo) {
245
+ const centers = new Array(6);
246
+ for (let f = 0; f < 6; f++) centers[f] = st[geo.centerIndex(f)];
247
+ return centers;
248
+ }
310
249
 
311
- #rotateLw(clockwise = true) {
312
- if (clockwise) {
313
- // Lw equals x' R
314
- this.#rotateX(false);
315
- this.#rotateR(true);
316
- } else {
317
- this.#rotateX(true);
318
- this.#rotateR(false);
319
- }
250
+ // A slot is correctly placed when every facelet matches its own face center.
251
+ function slotCorrect(st, centers, indices, per) {
252
+ for (const x of indices) {
253
+ if (st[x] !== centers[Math.floor(x / per)]) return false;
320
254
  }
255
+ return true;
256
+ }
321
257
 
322
- /**
323
- * Rotates the middle slice (M) parallel to L/R. Clockwise corresponds to Lw followed by L'.
324
- */
325
- rotateM(clockwise = true) {
326
- if (clockwise) {
327
- this.#rotateM(true);
328
- this.MOVES.push("M");
329
- } else {
330
- this.#rotateM(false);
331
- this.MOVES.push("M'");
332
- }
258
+ // Whole cube solved: every face is a single (uniform) color.
259
+ function isSolvedFlat(st, per) {
260
+ for (let f = 0; f < 6; f++) {
261
+ const base = f * per;
262
+ const c = st[base];
263
+ for (let i = 1; i < per; i++) if (st[base + i] !== c) return false;
333
264
  }
265
+ return true;
266
+ }
334
267
 
335
- #rotateM(clockwise = true) {
336
- if (clockwise) {
337
- this.#rotateLw(true);
338
- this.#rotateL(false);
339
- } else {
340
- this.#rotateLw(false);
341
- this.#rotateL(true);
342
- }
343
- }
268
+ // True when the cross of the given color is complete in this state.
269
+ function crossDone(st, geo, color) {
270
+ const centers = centersOf(st, geo);
271
+ const C = centers.indexOf(color);
272
+ if (C < 0) return false;
273
+ return geo
274
+ .edgesByFace(C)
275
+ .every((e) => slotCorrect(st, centers, e.indices, geo.per));
276
+ }
344
277
 
345
- /**
346
- * Rotates the (x) axis clockwise or counterclockwise.
347
- */
348
- rotateX(clockwise = true) {
349
- if (clockwise) {
350
- this.#rotateX(true);
351
- this.MOVES.push("x");
352
- } else {
353
- this.#rotateX(false);
354
- this.MOVES.push("x'");
355
- }
278
+ // Per-slot F2L completion keyed by the (stable) pair of side-face colors.
279
+ function f2lSlotStates(st, geo, color) {
280
+ const centers = centersOf(st, geo);
281
+ const C = centers.indexOf(color);
282
+ const result = {};
283
+ if (C < 0) return result;
284
+ for (const corner of geo.cornersByFace(C)) {
285
+ const sides = corner.faces.filter((f) => f !== C);
286
+ if (sides.length !== 2) continue;
287
+ const [a, b] = sides;
288
+ const edge = geo.edgeByPair(a, b);
289
+ const cornerOk = slotCorrect(st, centers, corner.indices, geo.per);
290
+ const edgeOk = edge
291
+ ? slotCorrect(st, centers, edge.indices, geo.per)
292
+ : false;
293
+ const key = [centers[a], centers[b]].sort().join("-");
294
+ result[key] = cornerOk && edgeOk;
356
295
  }
296
+ return result;
297
+ }
357
298
 
358
- #rotateX(clockwise = true) {
359
- const tempFront = structuredClone(this.STATES.FRONT);
360
- const tempDown = structuredClone(this.STATES.DOWN);
361
- const tempUpper = structuredClone(this.STATES.UPPER);
362
- const tempBack = structuredClone(this.STATES.BACK);
363
- const tempLeft = structuredClone(this.STATES.LEFT);
364
- const tempRight = structuredClone(this.STATES.RIGHT);
365
-
366
- if (clockwise) {
367
- // Balance the rotation
368
- this.STATES.LEFT = this.#switchMatrix(tempLeft, false);
369
- this.STATES.RIGHT = this.#switchMatrix(tempRight, true);
370
-
371
- // Rotate mid X axis
372
- this.STATES.FRONT = [...tempDown];
373
- this.STATES.UPPER = [...tempFront];
374
-
375
- // Special permutation (BACK view elements)
376
- this.STATES.BACK = this.#specialFlip(tempUpper);
377
- this.STATES.DOWN = this.#specialFlip(tempBack);
378
- } else {
379
- this.STATES.LEFT = this.#switchMatrix(tempLeft, true);
380
- this.STATES.RIGHT = this.#switchMatrix(tempRight, false);
381
-
382
- this.STATES.FRONT = [...tempUpper];
383
- this.STATES.DOWN = [...tempFront];
384
-
385
- this.STATES.BACK = this.#specialFlip(tempDown);
386
- this.STATES.UPPER = this.#specialFlip(tempBack);
387
- }
299
+ // Last layer (face opposite the cross) fully oriented = one color on its top.
300
+ function ollDone(st, geo, color) {
301
+ const centers = centersOf(st, geo);
302
+ const C = centers.indexOf(color);
303
+ if (C < 0) return false;
304
+ const O = geo.opposite[C];
305
+ const base = O * geo.per;
306
+ for (let i = 0; i < geo.per; i++) {
307
+ if (st[base + i] !== centers[O]) return false;
388
308
  }
309
+ return true;
310
+ }
389
311
 
390
- /**
391
- * Rotates the (z) axis clockwise or counterclockwise.
392
- */
393
- rotateZ(clockwise = true) {
394
- if (clockwise) {
395
- this.#rotateZ(true);
396
- this.MOVES.push("z");
397
- } else {
398
- this.#rotateZ(false);
399
- this.MOVES.push("z'");
400
- }
401
- }
312
+ // Index of the move that COMPLETES a stage: the first move after which the
313
+ // condition holds, provided the stage is genuinely achieved by the end of the
314
+ // solve. Later moves are allowed to break it transiently (a turn mid-algorithm
315
+ // momentarily disturbs an already-finished cross/pair), which is why we take
316
+ // the first occurrence rather than requiring it to hold continuously.
317
+ function completionIndex(bools) {
318
+ const n = bools.length;
319
+ if (n === 0 || !bools[n - 1]) return null;
320
+ for (let i = 0; i < n; i++) if (bools[i]) return i;
321
+ return null;
322
+ }
402
323
 
403
- #rotateZ(clockwise = true) {
404
- const tempUpper = structuredClone(this.STATES.UPPER);
405
- const tempRight = structuredClone(this.STATES.RIGHT);
406
- const tempDown = structuredClone(this.STATES.DOWN);
407
- const tempLeft = structuredClone(this.STATES.LEFT);
408
- const tempFront = structuredClone(this.STATES.FRONT);
409
- const tempBack = structuredClone(this.STATES.BACK);
410
-
411
- if (clockwise) {
412
- // Rotate faces on the rotation axis
413
- this.STATES.FRONT = this.#switchMatrix(tempFront, true);
414
- this.STATES.BACK = this.#switchMatrix(tempBack, false);
415
-
416
- // Cycle U -> R -> D -> L -> U with proper orientation
417
- this.STATES.RIGHT = this.#switchMatrix(tempUpper, true);
418
- this.STATES.DOWN = this.#switchMatrix(tempRight, true);
419
- this.STATES.LEFT = this.#switchMatrix(tempDown, true);
420
- this.STATES.UPPER = this.#switchMatrix(tempLeft, true);
421
- } else {
422
- // Counterclockwise
423
- this.STATES.FRONT = this.#switchMatrix(tempFront, false);
424
- this.STATES.BACK = this.#switchMatrix(tempBack, true);
425
-
426
- // Cycle U -> L -> D -> R -> U (inverse of clockwise), rotate CCW
427
- this.STATES.RIGHT = this.#switchMatrix(tempDown, false);
428
- this.STATES.DOWN = this.#switchMatrix(tempLeft, false);
429
- this.STATES.LEFT = this.#switchMatrix(tempUpper, false);
430
- this.STATES.UPPER = this.#switchMatrix(tempRight, false);
431
- }
432
- }
324
+ // Builds the milestone indices for one assumed cross color. Detection is
325
+ // cumulative: an F2L pair only counts while the cross is solved, and OLL only
326
+ // counts once the full F2L is solved. This rejects transient false positives.
327
+ function buildForCross(snapshots, geo, color) {
328
+ const n = snapshots.length;
329
+ const crossBools = snapshots.map((st) => crossDone(st, geo, color));
330
+ const crossIdx = completionIndex(crossBools);
433
331
 
434
- /**
435
- * Rotates the (y) axis clockwise or counterclockwise.
436
- */
437
- rotateY(clockwise = true) {
438
- if (clockwise) {
439
- this.#rotateY(true);
440
- this.MOVES.push("y");
441
- } else {
442
- this.#rotateY(false);
443
- this.MOVES.push("y'");
332
+ // Per-slot completion, gated by the cross being solved at the same instant.
333
+ const slotSeries = new Map();
334
+ for (let i = 0; i < n; i++) {
335
+ const states = f2lSlotStates(snapshots[i], geo, color);
336
+ for (const [key, val] of Object.entries(states)) {
337
+ if (!slotSeries.has(key)) slotSeries.set(key, new Array(n).fill(false));
338
+ slotSeries.get(key)[i] = crossBools[i] && val;
444
339
  }
445
340
  }
341
+ const f2lSlots = [...slotSeries.entries()]
342
+ .map(([slot, bools]) => ({ slot, idx: completionIndex(bools) }))
343
+ .filter((s) => s.idx != null)
344
+ .sort((a, b) => a.idx - b.idx);
446
345
 
447
- #rotateY(clockwise = true) {
448
- const tempFront = structuredClone(this.STATES.FRONT);
449
- const tempRight = structuredClone(this.STATES.RIGHT);
450
- const tempBack = structuredClone(this.STATES.BACK);
451
- const tempLeft = structuredClone(this.STATES.LEFT);
452
-
453
- if (clockwise) {
454
- this.STATES.UPPER = this.#switchMatrix(this.STATES.UPPER, true);
455
- this.STATES.DOWN = this.#switchMatrix(this.STATES.DOWN, false);
456
-
457
- this.STATES.FRONT = [...tempRight];
458
- this.STATES.RIGHT = [...tempBack];
459
- this.STATES.LEFT = [...tempFront];
460
- this.STATES.BACK = [...tempLeft];
461
- } else {
462
- this.STATES.UPPER = this.#switchMatrix(this.STATES.UPPER, false);
463
- this.STATES.DOWN = this.#switchMatrix(this.STATES.DOWN, true);
464
-
465
- this.STATES.FRONT = [...tempLeft];
466
- this.STATES.RIGHT = [...tempFront];
467
- this.STATES.LEFT = [...tempBack];
468
- this.STATES.BACK = [...tempRight];
346
+ // Full F2L solved = cross plus all four pairs solved at the same instant.
347
+ const f2lComplete = new Array(n).fill(false);
348
+ for (let i = 0; i < n; i++) {
349
+ if (!crossBools[i] || slotSeries.size < 4) continue;
350
+ let all = true;
351
+ for (const bools of slotSeries.values()) {
352
+ if (!bools[i]) {
353
+ all = false;
354
+ break;
355
+ }
469
356
  }
357
+ f2lComplete[i] = all;
470
358
  }
471
359
 
472
- /**
473
- * Rotate the entire face in the direction set
474
- */
475
- #switchMatrix(matrix, clockwise = true) {
476
- const clone = structuredClone(matrix);
477
-
478
- const tempMatrix = [...clone[0], ...clone[1], ...clone[2]];
479
-
480
- if (clockwise) {
481
- return [
482
- [tempMatrix[6], tempMatrix[3], tempMatrix[0]],
483
- [tempMatrix[7], tempMatrix[4], tempMatrix[1]],
484
- [tempMatrix[8], tempMatrix[5], tempMatrix[2]],
485
- ];
486
- } else {
487
- return [
488
- [tempMatrix[2], tempMatrix[5], tempMatrix[8]],
489
- [tempMatrix[1], tempMatrix[4], tempMatrix[7]],
490
- [tempMatrix[0], tempMatrix[3], tempMatrix[6]],
491
- ];
492
- }
493
- }
360
+ // OLL is only meaningful once F2L is done (last layer oriented on top of it).
361
+ const ollIdx = completionIndex(
362
+ snapshots.map((st, i) => f2lComplete[i] && ollDone(st, geo, color))
363
+ );
364
+ const pllIdx = completionIndex(snapshots.map((st) => isSolvedFlat(st, geo.per)));
494
365
 
495
- #specialFlip(matrix) {
496
- return structuredClone(matrix)
497
- .reverse()
498
- .map((row) => [...row].reverse());
499
- }
366
+ return { crossIdx, f2lSlots, ollIdx, pllIdx };
367
+ }
500
368
 
501
- /**
502
- * Logs the current state of the cube.
503
- */
504
- state() {
505
- return {
506
- ...this.STATES,
507
- };
508
- }
369
+ // Does this breakdown follow the CFOP order: cross -> 4x F2L -> OLL -> PLL?
370
+ function isCFOP(build) {
371
+ const { crossIdx, f2lSlots, ollIdx, pllIdx } = build;
372
+ if (crossIdx == null || pllIdx == null || ollIdx == null) return false;
373
+ if (f2lSlots.length !== 4) return false;
374
+ if (!f2lSlots.every((s) => s.idx >= crossIdx)) return false;
375
+ const lastF2L = f2lSlots[3].idx;
376
+ return ollIdx >= lastF2L && pllIdx >= ollIdx;
377
+ }
509
378
 
510
- /**
511
- * Indicates if the cube is solve or not in all layers.
512
- */
513
- isSolved() {
514
- const temp = {
515
- ...this.STATES,
516
- };
379
+ /**
380
+ * Analyzes a solution and returns the timing of each method milestone.
381
+ *
382
+ * @param {Array<{m: string, t: number}>} moves - Solution moves with cumulative
383
+ * timestamps. `m` is a move token; `t` is elapsed ms up to that move.
384
+ * @param {{size?: number}} [options] - Cube size (defaults to 3). CFOP staging
385
+ * is only computed for 3x3; other sizes report the solved (PLL) time only.
386
+ * @returns {object} Breakdown with `method`, `total`, `cross`, `f2l[]`, `oll`,
387
+ * `pll` and `allCrosses` (cross time per face color).
388
+ */
389
+ function analyzeSolution(moves, options = {}) {
390
+ const size = options.size === 2 ? 2 : 3;
517
391
 
518
- const layersSolved = Object.keys(temp).map((layer) => {
519
- const mixedMatrix = [
520
- ...temp[layer][0],
521
- ...temp[layer][1],
522
- ...temp[layer][2],
523
- ];
392
+ // Keep both the original token (`m`, for display) and the engine-normalized
393
+ // token (`mm`, used for replay). Collect anything we cannot parse so the
394
+ // caller can see dropped moves instead of getting silently wrong timings.
395
+ const unsupported = [];
396
+ const seq = (Array.isArray(moves) ? moves : [])
397
+ .map((x) => {
398
+ const m = String(x?.m ?? "").trim();
399
+ const { token, base } = normalizeToken(m);
400
+ const supported = base != null && SUPPORTED_BASES.has(base);
401
+ if (m.length > 0 && !supported) unsupported.push(m);
402
+ return { m, mm: supported ? token : "", t: Number(x?.t) };
403
+ })
404
+ .filter((x) => x.m.length > 0);
405
+ const n = seq.length;
524
406
 
525
- const centerColor = mixedMatrix[4];
407
+ const simplifiedMoves = simplifyMoves(
408
+ (Array.isArray(moves) ? moves : []).filter((x) => x?.m)
409
+ );
410
+ const simplifiedCount = simplifiedMoves.length;
526
411
 
527
- return mixedMatrix.every((currentColor) => currentColor === centerColor);
528
- });
412
+ const empty = {
413
+ size,
414
+ method: "unknown",
415
+ solved: false,
416
+ total: n > 0 ? seq[n - 1].t : 0,
417
+ tps: 0,
418
+ moves: simplifiedMoves,
419
+ cross: null,
420
+ f2l: [],
421
+ oll: null,
422
+ pll: null,
423
+ allCrosses: {},
424
+ unsupported,
425
+ };
426
+ if (n === 0) return empty;
529
427
 
530
- return layersSolved.every((isLayerSolved) => isLayerSolved);
531
- }
428
+ // Reproduce the scramble (inverse of the solution), then replay forward,
429
+ // capturing a flat snapshot after every solution move. Unsupported tokens
430
+ // contribute no move (mm === "") so the replay simply skips them.
431
+ const engine = new CubeEngine("", { size });
432
+ const scramble = invertSequence(seq.map((x) => x.mm).filter(Boolean)).join(" ");
433
+ engine.applyMoves(scramble, { record: false });
532
434
 
533
- /**
534
- * Returns the history of all movements made.
535
- *
536
- * @param {boolean} asString - If true, returns the history as a string; otherwise, returns it as an array.
537
- * @returns {string|array} The history of movements as an array or string.
538
- */
539
- getMoves(asString = true) {
540
- return asString ? this.MOVES.join(" ") : this.MOVES;
435
+ const geo = buildGeometry(size);
436
+ const snapshots = new Array(n);
437
+ for (let i = 0; i < n; i++) {
438
+ if (seq[i].mm) engine.applyMoves(seq[i].mm, { record: false });
439
+ snapshots[i] = flattenState(engine.state());
541
440
  }
542
441
 
543
- /**
544
- * Resets the cube to the solved state and clears the move history.
545
- */
546
- reset() {
547
- this.STATES = {
548
- UPPER: [
549
- [COLOR.W[0], COLOR.W[1], COLOR.W[2]],
550
- [COLOR.W[3], COLOR.W[4], COLOR.W[5]],
551
- [COLOR.W[6], COLOR.W[7], COLOR.W[8]],
552
- ],
553
- LEFT: [
554
- [COLOR.O[0], COLOR.O[1], COLOR.O[2]],
555
- [COLOR.O[3], COLOR.O[4], COLOR.O[5]],
556
- [COLOR.O[6], COLOR.O[7], COLOR.O[8]],
557
- ],
558
- FRONT: [
559
- [COLOR.G[0], COLOR.G[1], COLOR.G[2]],
560
- [COLOR.G[3], COLOR.G[4], COLOR.G[5]],
561
- [COLOR.G[6], COLOR.G[7], COLOR.G[8]],
562
- ],
563
- RIGHT: [
564
- [COLOR.R[0], COLOR.R[1], COLOR.R[2]],
565
- [COLOR.R[3], COLOR.R[4], COLOR.R[5]],
566
- [COLOR.R[6], COLOR.R[7], COLOR.R[8]],
567
- ],
568
- BACK: [
569
- [COLOR.B[0], COLOR.B[1], COLOR.B[2]],
570
- [COLOR.B[3], COLOR.B[4], COLOR.B[5]],
571
- [COLOR.B[6], COLOR.B[7], COLOR.B[8]],
572
- ],
573
- DOWN: [
574
- [COLOR.Y[0], COLOR.Y[1], COLOR.Y[2]],
575
- [COLOR.Y[3], COLOR.Y[4], COLOR.Y[5]],
576
- [COLOR.Y[6], COLOR.Y[7], COLOR.Y[8]],
577
- ],
442
+ const solved = isSolvedFlat(snapshots[n - 1], geo.per);
443
+ const pllIdxOnly = completionIndex(
444
+ snapshots.map((st) => isSolvedFlat(st, geo.per))
445
+ );
446
+
447
+ // Map a milestone index to a timed record, with duration since `prevAt`.
448
+ const milestone = (idx, prevAt) => {
449
+ if (idx == null) return { record: null, at: prevAt };
450
+ const at = seq[idx].t;
451
+ return {
452
+ record: { at, duration: at - prevAt, moveIndex: idx, move: seq[idx].m },
453
+ at,
454
+ };
455
+ };
456
+
457
+ // Non-3x3: only the solved (PLL) milestone is meaningful.
458
+ if (size !== 3) {
459
+ const pll = milestone(pllIdxOnly, 0);
460
+ const total = seq[n - 1].t;
461
+ return {
462
+ ...empty,
463
+ solved,
464
+ total,
465
+ tps: total > 0 ? simplifiedCount / (total / 1000) : 0,
466
+ pll: pll.record,
578
467
  };
579
- this.MOVES = [];
580
468
  }
581
469
 
582
- /**
583
- * Applies a sequence of moves provided as a string.
584
- * Supports: U, D, L, R, F, x, y, z; slice moves: M; and wide moves: Dw, Uw, Rw, Lw with optional ' for counterclockwise and 2 for double turns.
585
- * @param {string} sequence - e.g. "R U' F R2 D Dw Uw Rw Rw' Lw Lw2 M M' M2"
586
- * @param {object} options - { record: boolean } whether to record moves in history (default true)
587
- */
588
- applyMoves(sequence, options = { record: false }) {
589
- const record = options?.record !== false;
590
- this.#applyMovesFromString(sequence, record);
470
+ // Cross can complete on any of the six faces; record each, then pick the
471
+ // cross color that yields a valid CFOP staging (falling back to the earliest).
472
+ const finalCenters = centersOf(snapshots[n - 1], geo);
473
+ const colors = [...new Set(finalCenters)];
474
+
475
+ const allCrosses = {};
476
+ for (const color of colors) {
477
+ const idx = completionIndex(
478
+ snapshots.map((st) => crossDone(st, geo, color))
479
+ );
480
+ allCrosses[color] =
481
+ idx == null
482
+ ? null
483
+ : { at: seq[idx].t, moveIndex: idx, move: seq[idx].m };
591
484
  }
592
485
 
593
- // Internal: parses and applies moves. If record=false, uses private methods to avoid logging.
594
- #applyMovesFromString(sequence, record = true) {
595
- if (typeof sequence !== "string") return;
596
- const tokens = sequence
597
- .split(/\s+/)
598
- .map((t) => t.trim())
599
- .filter((t) => t.length > 0);
600
-
601
- for (const token of tokens) {
602
- const base = token[0];
603
- const rest = token.slice(1);
604
- const isDouble = rest.includes("2");
605
- const isPrime = rest.includes("'");
606
-
607
- const exec = (fnClockwise, fnCounter) => {
608
- if (isDouble) {
609
- // Double turns ignore prime; do two clockwise quarter-turns
610
- fnClockwise();
611
- fnClockwise();
612
- } else {
613
- if (isPrime) {
614
- fnCounter();
615
- } else {
616
- fnClockwise();
617
- }
618
- }
619
- };
620
-
621
- switch (base) {
622
- case 'U':
623
- {
624
- const isWide = /w/i.test(rest);
625
- if (isWide) {
626
- exec(
627
- () => (record ? this.rotateUw(true) : this.#rotateUw(true)),
628
- () => (record ? this.rotateUw(false) : this.#rotateUw(false))
629
- );
630
- } else {
631
- exec(
632
- () => (record ? this.rotateU(true) : this.#rotateU(true)),
633
- () => (record ? this.rotateU(false) : this.#rotateU(false))
634
- );
635
- }
636
- }
637
- break;
638
- case 'D':
639
- {
640
- const isWide = /w/i.test(rest);
641
- if (isWide) {
642
- exec(
643
- () => (record ? this.rotateDw(true) : this.#rotateDw(true)),
644
- () => (record ? this.rotateDw(false) : this.#rotateDw(false))
645
- );
646
- } else {
647
- exec(
648
- () => (record ? this.rotateD(true) : this.#rotateD(true)),
649
- () => (record ? this.rotateD(false) : this.#rotateD(false))
650
- );
651
- }
652
- }
653
- break;
654
- case 'L':
655
- {
656
- const isWide = /w/i.test(rest);
657
- if (isWide) {
658
- exec(
659
- () => (record ? this.rotateLw(true) : this.#rotateLw(true)),
660
- () => (record ? this.rotateLw(false) : this.#rotateLw(false))
661
- );
662
- } else {
663
- exec(
664
- () => (record ? this.rotateL(true) : this.#rotateL(true)),
665
- () => (record ? this.rotateL(false) : this.#rotateL(false))
666
- );
667
- }
668
- }
669
- break;
670
- case 'R':
671
- {
672
- const isWide = /w/i.test(rest);
673
- if (isWide) {
674
- exec(
675
- () => (record ? this.rotateRw(true) : this.#rotateRw(true)),
676
- () => (record ? this.rotateRw(false) : this.#rotateRw(false))
677
- );
678
- } else {
679
- exec(
680
- () => (record ? this.rotateR(true) : this.#rotateR(true)),
681
- () => (record ? this.rotateR(false) : this.#rotateR(false))
682
- );
683
- }
684
- }
685
- break;
686
- case 'F':
687
- exec(
688
- () => (record ? this.rotateF(true) : this.#rotateF(true)),
689
- () => (record ? this.rotateF(false) : this.#rotateF(false))
690
- );
691
- break;
692
- case 'B':
693
- exec(
694
- () => (record ? this.rotateB(true) : this.#rotateB(true)),
695
- () => (record ? this.rotateB(false) : this.#rotateB(false))
696
- );
697
- break;
698
- case 'x':
699
- exec(
700
- () => (record ? this.rotateX(true) : this.#rotateX(true)),
701
- () => (record ? this.rotateX(false) : this.#rotateX(false))
702
- );
703
- break;
704
- case 'y':
705
- exec(
706
- () => (record ? this.rotateY(true) : this.#rotateY(true)),
707
- () => (record ? this.rotateY(false) : this.#rotateY(false))
708
- );
709
- break;
710
- case 'z':
711
- exec(
712
- () => (record ? this.rotateZ(true) : this.#rotateZ(true)),
713
- () => (record ? this.rotateZ(false) : this.#rotateZ(false))
714
- );
715
- break;
716
- case 'M':
717
- exec(
718
- () => (record ? this.rotateM(true) : this.#rotateM(true)),
719
- () => (record ? this.rotateM(false) : this.#rotateM(false))
720
- );
721
- break;
722
- }
486
+ const ordered = colors
487
+ .map((color) => ({ color, build: buildForCross(snapshots, geo, color) }))
488
+ .sort((a, b) => {
489
+ const ai = a.build.crossIdx ?? Infinity;
490
+ const bi = b.build.crossIdx ?? Infinity;
491
+ return ai - bi;
492
+ });
493
+
494
+ let chosen = ordered.find((c) => isCFOP(c.build)) ?? ordered[0];
495
+ const method = chosen && isCFOP(chosen.build) ? "CFOP" : "unknown";
496
+ const { color: crossColor, build } = chosen;
497
+
498
+ // Assemble timed records in solve order so durations chain correctly.
499
+ const crossM = milestone(build.crossIdx, 0);
500
+ const cross = crossM.record
501
+ ? { color: crossColor, ...crossM.record }
502
+ : null;
503
+
504
+ let prevAt = crossM.at;
505
+ const f2l = [];
506
+ for (const slot of build.f2lSlots) {
507
+ const m = milestone(slot.idx, prevAt);
508
+ if (m.record) {
509
+ f2l.push({ slot: slot.slot, ...m.record });
510
+ prevAt = m.at;
723
511
  }
724
512
  }
725
- }
726
513
 
727
- const COLOR = {
728
- W: ["W", "W", "W", "W", "W", "W", "W", "W", "W"],
729
- G: ["G", "G", "G", "G", "G", "G", "G", "G", "G"],
730
- R: ["R", "R", "R", "R", "R", "R", "R", "R", "R"],
731
- B: ["B", "B", "B", "B", "B", "B", "B", "B", "B"],
732
- O: ["O", "O", "O", "O", "O", "O", "O", "O", "O"],
733
- Y: ["Y", "Y", "Y", "Y", "Y", "Y", "Y", "Y", "Y"],
514
+ const ollM = milestone(build.ollIdx, prevAt);
515
+ const oll = ollM.record;
516
+ prevAt = ollM.at;
517
+
518
+ const pllM = milestone(build.pllIdx, prevAt);
519
+ const pll = pllM.record;
520
+
521
+ const total = seq[n - 1].t;
522
+ return {
523
+ size,
524
+ method,
525
+ solved,
526
+ total,
527
+ tps: total > 0 ? simplifiedCount / (total / 1000) : 0,
528
+ moves: simplifiedMoves,
529
+ cross,
530
+ f2l,
531
+ oll,
532
+ pll,
533
+ allCrosses,
534
+ unsupported,
535
+ };
536
+ }
537
+
538
+ // Face order used across the flat sticker representation.
539
+ // Index: 0=UPPER 1=LEFT 2=FRONT 3=RIGHT 4=BACK 5=DOWN
540
+ const FACE_NAMES = ["UPPER", "LEFT", "FRONT", "RIGHT", "BACK", "DOWN"];
541
+ const FACE_COLORS = ["W", "O", "G", "R", "B", "Y"];
542
+
543
+ // Moves that only affect inner/double layers and therefore are no-ops on a 2x2.
544
+ const NOOP_SIZE2 = new Set(["Uw", "Dw", "Rw", "Lw", "Fw", "M", "E", "S"]);
545
+
546
+ // Maps each move key to the oracle method that performs it.
547
+ const MOVE_FNS = {
548
+ U: "rotateU",
549
+ D: "rotateD",
550
+ L: "rotateL",
551
+ R: "rotateR",
552
+ F: "rotateF",
553
+ B: "rotateB",
554
+ x: "rotateX",
555
+ y: "rotateY",
556
+ z: "rotateZ",
557
+ M: "rotateM",
558
+ E: "rotateE",
559
+ S: "rotateS",
560
+ Uw: "rotateUw",
561
+ Dw: "rotateDw",
562
+ Rw: "rotateRw",
563
+ Lw: "rotateLw",
564
+ Fw: "rotateFw",
565
+ };
566
+
567
+ // Permutation tables are derived once per cube size and shared across instances.
568
+ const PERM_CACHE = new Map();
569
+
570
+ /**
571
+ * Reference (matrix-based) cube used only to derive permutation tables.
572
+ *
573
+ * It reproduces the exact rotation algorithm the engine has always used, but
574
+ * operates on integer sticker tags (the flat index each sticker starts at).
575
+ * Applying a move and flattening the result yields a permutation array `perm`
576
+ * such that `newState[i] = oldState[perm[i]]`. This runs a handful of times per
577
+ * size at module load and is then cached, so the runtime hot path never touches
578
+ * matrices, structuredClone, or move composition.
579
+ */
580
+ class _OracleCube {
581
+ constructor(size) {
582
+ this.size = size;
583
+ this.STATES = {};
584
+ for (let f = 0; f < FACE_NAMES.length; f++) {
585
+ const face = [];
586
+ for (let r = 0; r < size; r++) {
587
+ const row = [];
588
+ for (let c = 0; c < size; c++) {
589
+ row.push(f * size * size + r * size + c);
590
+ }
591
+ face.push(row);
592
+ }
593
+ this.STATES[FACE_NAMES[f]] = face;
594
+ }
595
+ }
596
+
597
+ // Concatenate every face row-major into a single flat array of tags.
598
+ flatten() {
599
+ const out = [];
600
+ for (const name of FACE_NAMES) {
601
+ const face = this.STATES[name];
602
+ for (let r = 0; r < this.size; r++) {
603
+ for (let c = 0; c < this.size; c++) {
604
+ out.push(face[r][c]);
605
+ }
606
+ }
607
+ }
608
+ return out;
609
+ }
610
+
611
+ switchMatrix(matrix, clockwise = true) {
612
+ const clone = structuredClone(matrix);
613
+ const size = this.size;
614
+
615
+ let tempMatrix = [];
616
+ for (let i = 0; i < size; i++) {
617
+ tempMatrix = [...tempMatrix, ...clone[i]];
618
+ }
619
+
620
+ if (size === 2) {
621
+ if (clockwise) {
622
+ return [
623
+ [tempMatrix[2], tempMatrix[0]],
624
+ [tempMatrix[3], tempMatrix[1]],
625
+ ];
626
+ } else {
627
+ return [
628
+ [tempMatrix[1], tempMatrix[3]],
629
+ [tempMatrix[0], tempMatrix[2]],
630
+ ];
631
+ }
632
+ } else {
633
+ if (clockwise) {
634
+ return [
635
+ [tempMatrix[6], tempMatrix[3], tempMatrix[0]],
636
+ [tempMatrix[7], tempMatrix[4], tempMatrix[1]],
637
+ [tempMatrix[8], tempMatrix[5], tempMatrix[2]],
638
+ ];
639
+ } else {
640
+ return [
641
+ [tempMatrix[2], tempMatrix[5], tempMatrix[8]],
642
+ [tempMatrix[1], tempMatrix[4], tempMatrix[7]],
643
+ [tempMatrix[0], tempMatrix[3], tempMatrix[6]],
644
+ ];
645
+ }
646
+ }
647
+ }
648
+
649
+ specialFlip(matrix) {
650
+ return structuredClone(matrix)
651
+ .reverse()
652
+ .map((row) => [...row].reverse());
653
+ }
654
+
655
+ rotateU(clockwise = true) {
656
+ if (clockwise) {
657
+ this.STATES.UPPER = this.switchMatrix(this.STATES.UPPER, true);
658
+
659
+ const tempFront = [...this.STATES.FRONT[0]];
660
+ const tempRight = [...this.STATES.RIGHT[0]];
661
+ const tempLeft = [...this.STATES.LEFT[0]];
662
+ const tempBack = [...this.STATES.BACK[0]];
663
+
664
+ this.STATES.FRONT[0] = [...tempRight];
665
+ this.STATES.LEFT[0] = [...tempFront];
666
+ this.STATES.BACK[0] = [...tempLeft];
667
+ this.STATES.RIGHT[0] = [...tempBack];
668
+ } else {
669
+ this.STATES.UPPER = this.switchMatrix(this.STATES.UPPER, false);
670
+
671
+ const tempFront = [...this.STATES.FRONT[0]];
672
+ const tempRight = [...this.STATES.RIGHT[0]];
673
+ const tempLeft = [...this.STATES.LEFT[0]];
674
+ const tempBack = [...this.STATES.BACK[0]];
675
+
676
+ this.STATES.FRONT[0] = [...tempLeft];
677
+ this.STATES.LEFT[0] = [...tempBack];
678
+ this.STATES.BACK[0] = [...tempRight];
679
+ this.STATES.RIGHT[0] = [...tempFront];
680
+ }
681
+ }
682
+
683
+ rotateF(clockwise = true) {
684
+ if (clockwise) {
685
+ this.rotateX(true);
686
+ this.rotateU(true);
687
+ this.rotateX(false);
688
+ } else {
689
+ this.rotateX(true);
690
+ this.rotateU(false);
691
+ this.rotateX(false);
692
+ }
693
+ }
694
+
695
+ rotateB(clockwise = true) {
696
+ this.rotateY(true);
697
+ this.rotateY(true);
698
+ if (clockwise) {
699
+ this.rotateF(true);
700
+ } else {
701
+ this.rotateF(false);
702
+ }
703
+ this.rotateY(false);
704
+ this.rotateY(false);
705
+ }
706
+
707
+ rotateR(clockwise = true) {
708
+ if (clockwise) {
709
+ this.rotateY(true);
710
+ this.rotateX(true);
711
+ this.rotateU(true);
712
+ this.rotateX(false);
713
+ this.rotateY(false);
714
+ } else {
715
+ this.rotateY(true);
716
+ this.rotateX(true);
717
+ this.rotateU(false);
718
+ this.rotateX(false);
719
+ this.rotateY(false);
720
+ }
721
+ }
722
+
723
+ rotateL(clockwise = true) {
724
+ if (clockwise) {
725
+ this.rotateY(false);
726
+ this.rotateX(true);
727
+ this.rotateU(true);
728
+ this.rotateX(false);
729
+ this.rotateY(true);
730
+ } else {
731
+ this.rotateY(false);
732
+ this.rotateX(true);
733
+ this.rotateU(false);
734
+ this.rotateX(false);
735
+ this.rotateY(true);
736
+ }
737
+ }
738
+
739
+ rotateD(clockwise = true) {
740
+ if (clockwise) {
741
+ this.rotateX(true);
742
+ this.rotateF(true);
743
+ this.rotateX(false);
744
+ } else {
745
+ this.rotateX(true);
746
+ this.rotateF(false);
747
+ this.rotateX(false);
748
+ }
749
+ }
750
+
751
+ rotateDw(clockwise = true) {
752
+ if (this.size === 2) return;
753
+ if (clockwise) {
754
+ this.rotateY(false);
755
+ this.rotateU(true);
756
+ } else {
757
+ this.rotateY(true);
758
+ this.rotateU(false);
759
+ }
760
+ }
761
+
762
+ rotateUw(clockwise = true) {
763
+ if (this.size === 2) return;
764
+ if (clockwise) {
765
+ this.rotateY(true);
766
+ this.rotateD(true);
767
+ } else {
768
+ this.rotateY(false);
769
+ this.rotateD(false);
770
+ }
771
+ }
772
+
773
+ rotateRw(clockwise = true) {
774
+ if (this.size === 2) return;
775
+ if (clockwise) {
776
+ this.rotateX(true);
777
+ this.rotateL(true);
778
+ } else {
779
+ this.rotateX(false);
780
+ this.rotateL(false);
781
+ }
782
+ }
783
+
784
+ rotateLw(clockwise = true) {
785
+ if (this.size === 2) return;
786
+ if (clockwise) {
787
+ this.rotateX(false);
788
+ this.rotateR(true);
789
+ } else {
790
+ this.rotateX(true);
791
+ this.rotateR(false);
792
+ }
793
+ }
794
+
795
+ rotateM(clockwise = true) {
796
+ if (this.size === 2) return;
797
+ if (clockwise) {
798
+ this.rotateLw(true);
799
+ this.rotateL(false);
800
+ } else {
801
+ this.rotateLw(false);
802
+ this.rotateL(true);
803
+ }
804
+ }
805
+
806
+ rotateE(clockwise = true) {
807
+ if (this.size === 2) return;
808
+ if (clockwise) {
809
+ this.rotateDw(true);
810
+ this.rotateD(false);
811
+ } else {
812
+ this.rotateDw(false);
813
+ this.rotateD(true);
814
+ }
815
+ }
816
+
817
+ rotateFw(clockwise = true) {
818
+ if (this.size === 2) return;
819
+ if (clockwise) {
820
+ this.rotateZ(true);
821
+ this.rotateB(true);
822
+ } else {
823
+ this.rotateZ(false);
824
+ this.rotateB(false);
825
+ }
826
+ }
827
+
828
+ rotateS(clockwise = true) {
829
+ if (this.size === 2) return;
830
+ if (clockwise) {
831
+ this.rotateFw(true);
832
+ this.rotateF(false);
833
+ } else {
834
+ this.rotateFw(false);
835
+ this.rotateF(true);
836
+ }
837
+ }
838
+
839
+ rotateX(clockwise = true) {
840
+ const tempFront = structuredClone(this.STATES.FRONT);
841
+ const tempDown = structuredClone(this.STATES.DOWN);
842
+ const tempUpper = structuredClone(this.STATES.UPPER);
843
+ const tempBack = structuredClone(this.STATES.BACK);
844
+ const tempLeft = structuredClone(this.STATES.LEFT);
845
+ const tempRight = structuredClone(this.STATES.RIGHT);
846
+
847
+ if (clockwise) {
848
+ this.STATES.LEFT = this.switchMatrix(tempLeft, false);
849
+ this.STATES.RIGHT = this.switchMatrix(tempRight, true);
850
+
851
+ this.STATES.FRONT = [...tempDown];
852
+ this.STATES.UPPER = [...tempFront];
853
+
854
+ this.STATES.BACK = this.specialFlip(tempUpper);
855
+ this.STATES.DOWN = this.specialFlip(tempBack);
856
+ } else {
857
+ this.STATES.LEFT = this.switchMatrix(tempLeft, true);
858
+ this.STATES.RIGHT = this.switchMatrix(tempRight, false);
859
+
860
+ this.STATES.FRONT = [...tempUpper];
861
+ this.STATES.DOWN = [...tempFront];
862
+
863
+ this.STATES.BACK = this.specialFlip(tempDown);
864
+ this.STATES.UPPER = this.specialFlip(tempBack);
865
+ }
866
+ }
867
+
868
+ rotateZ(clockwise = true) {
869
+ const tempUpper = structuredClone(this.STATES.UPPER);
870
+ const tempRight = structuredClone(this.STATES.RIGHT);
871
+ const tempDown = structuredClone(this.STATES.DOWN);
872
+ const tempLeft = structuredClone(this.STATES.LEFT);
873
+ const tempFront = structuredClone(this.STATES.FRONT);
874
+ const tempBack = structuredClone(this.STATES.BACK);
875
+
876
+ if (clockwise) {
877
+ this.STATES.FRONT = this.switchMatrix(tempFront, true);
878
+ this.STATES.BACK = this.switchMatrix(tempBack, false);
879
+
880
+ this.STATES.RIGHT = this.switchMatrix(tempUpper, true);
881
+ this.STATES.DOWN = this.switchMatrix(tempRight, true);
882
+ this.STATES.LEFT = this.switchMatrix(tempDown, true);
883
+ this.STATES.UPPER = this.switchMatrix(tempLeft, true);
884
+ } else {
885
+ this.STATES.FRONT = this.switchMatrix(tempFront, false);
886
+ this.STATES.BACK = this.switchMatrix(tempBack, true);
887
+
888
+ this.STATES.RIGHT = this.switchMatrix(tempDown, false);
889
+ this.STATES.DOWN = this.switchMatrix(tempLeft, false);
890
+ this.STATES.LEFT = this.switchMatrix(tempUpper, false);
891
+ this.STATES.UPPER = this.switchMatrix(tempRight, false);
892
+ }
893
+ }
894
+
895
+ rotateY(clockwise = true) {
896
+ const tempFront = structuredClone(this.STATES.FRONT);
897
+ const tempRight = structuredClone(this.STATES.RIGHT);
898
+ const tempBack = structuredClone(this.STATES.BACK);
899
+ const tempLeft = structuredClone(this.STATES.LEFT);
900
+
901
+ if (clockwise) {
902
+ this.STATES.UPPER = this.switchMatrix(this.STATES.UPPER, true);
903
+ this.STATES.DOWN = this.switchMatrix(this.STATES.DOWN, false);
904
+
905
+ this.STATES.FRONT = [...tempRight];
906
+ this.STATES.RIGHT = [...tempBack];
907
+ this.STATES.LEFT = [...tempFront];
908
+ this.STATES.BACK = [...tempLeft];
909
+ } else {
910
+ this.STATES.UPPER = this.switchMatrix(this.STATES.UPPER, false);
911
+ this.STATES.DOWN = this.switchMatrix(this.STATES.DOWN, true);
912
+
913
+ this.STATES.FRONT = [...tempLeft];
914
+ this.STATES.RIGHT = [...tempFront];
915
+ this.STATES.LEFT = [...tempBack];
916
+ this.STATES.BACK = [...tempRight];
917
+ }
918
+ }
919
+ }
920
+
921
+ // Build a single move's permutation by applying it to a tagged oracle cube.
922
+ function buildPerm(size, fnName, clockwise) {
923
+ const oracle = new _OracleCube(size);
924
+ oracle[fnName](clockwise);
925
+ return oracle.flatten();
926
+ }
927
+
928
+ // Build (and cache) the full clockwise/counterclockwise permutation set for a size.
929
+ function getPerms(size) {
930
+ if (PERM_CACHE.has(size)) return PERM_CACHE.get(size);
931
+ const perms = {};
932
+ for (const key of Object.keys(MOVE_FNS)) {
933
+ perms[key] = {
934
+ cw: buildPerm(size, MOVE_FNS[key], true),
935
+ ccw: buildPerm(size, MOVE_FNS[key], false),
936
+ };
937
+ }
938
+ PERM_CACHE.set(size, perms);
939
+ return perms;
940
+ }
941
+
942
+ /**
943
+ * Exposes the (cached) permutation tables for a given cube size.
944
+ *
945
+ * Each entry maps a move key to `{ cw, ccw }` permutation arrays such that
946
+ * `newState[i] = oldState[perm[i]]`. This is primarily an advanced/introspection
947
+ * helper: the solution analyzer uses it to derive the cube's sticker adjacency
948
+ * (which stickers form each edge/corner) without hardcoding any geometry.
949
+ *
950
+ * @param {number} size - 2 or 3 (defaults to 3).
951
+ * @returns {Object<string, {cw: number[], ccw: number[]}>}
952
+ */
953
+ function getMovePermutations(size = 3) {
954
+ const allowedSizes = [2, 3];
955
+ return getPerms(allowedSizes.includes(size) ? size : 3);
956
+ }
957
+
958
+ class CubeEngine {
959
+ MOVES = [];
960
+ size = 3;
961
+ #stickers = [];
962
+ #perms = null;
963
+
964
+ constructor(initialScramble = "", options = { size: 3 }) {
965
+ const allowedSizes = [2, 3];
966
+ this.size = allowedSizes.includes(options.size) ? options.size : 3;
967
+ this.#perms = getPerms(this.size);
968
+
969
+ this.#initializeState();
970
+
971
+ // If an initial scramble string is provided, apply it without recording moves
972
+ if (typeof initialScramble === "string" && initialScramble.trim().length > 0) {
973
+ this.#applyMovesFromString(initialScramble, false);
974
+ this.MOVES = [];
975
+ }
976
+ }
977
+
978
+ #initializeState() {
979
+ const per = this.size * this.size;
980
+ const stickers = new Array(FACE_COLORS.length * per);
981
+ for (let f = 0; f < FACE_COLORS.length; f++) {
982
+ const color = FACE_COLORS[f];
983
+ const base = f * per;
984
+ for (let i = 0; i < per; i++) {
985
+ stickers[base + i] = color;
986
+ }
987
+ }
988
+ this.#stickers = stickers;
989
+ }
990
+
991
+ // Apply a precomputed permutation: newState[i] = oldState[perm[i]].
992
+ #applyPerm(perm) {
993
+ const current = this.#stickers;
994
+ const next = new Array(current.length);
995
+ for (let i = 0; i < current.length; i++) {
996
+ next[i] = current[perm[i]];
997
+ }
998
+ this.#stickers = next;
999
+ }
1000
+
1001
+ // Core move dispatch. dir is "cw" or "ccw"; record controls history logging.
1002
+ #apply(key, dir, record) {
1003
+ if (this.size === 2 && NOOP_SIZE2.has(key)) return;
1004
+ this.#applyPerm(this.#perms[key][dir]);
1005
+ if (record) this.MOVES.push(dir === "ccw" ? key + "'" : key);
1006
+ }
1007
+
1008
+ // Build a single face matrix from the flat sticker array.
1009
+ #faceMatrix(faceIndex) {
1010
+ const size = this.size;
1011
+ const base = faceIndex * size * size;
1012
+ const matrix = [];
1013
+ for (let r = 0; r < size; r++) {
1014
+ const row = [];
1015
+ for (let c = 0; c < size; c++) {
1016
+ row.push(this.#stickers[base + r * size + c]);
1017
+ }
1018
+ matrix.push(row);
1019
+ }
1020
+ return matrix;
1021
+ }
1022
+
1023
+ /**
1024
+ * Rotates the (UPPER) layer clockwise or counterclockwise.
1025
+ */
1026
+ rotateU(clockwise = true) {
1027
+ this.#apply("U", clockwise ? "cw" : "ccw", true);
1028
+ }
1029
+
1030
+ /**
1031
+ * Rotates the (FRONT) layer clockwise or counterclockwise.
1032
+ */
1033
+ rotateF(clockwise = true) {
1034
+ this.#apply("F", clockwise ? "cw" : "ccw", true);
1035
+ }
1036
+
1037
+ /**
1038
+ * Rotates the (BACK) layer clockwise or counterclockwise.
1039
+ */
1040
+ rotateB(clockwise = true) {
1041
+ this.#apply("B", clockwise ? "cw" : "ccw", true);
1042
+ }
1043
+
1044
+ /**
1045
+ * Rotates the (RIGHT) layer clockwise or counterclockwise.
1046
+ */
1047
+ rotateR(clockwise = true) {
1048
+ this.#apply("R", clockwise ? "cw" : "ccw", true);
1049
+ }
1050
+
1051
+ /**
1052
+ * Rotates the (LEFT) layer clockwise or counterclockwise.
1053
+ */
1054
+ rotateL(clockwise = true) {
1055
+ this.#apply("L", clockwise ? "cw" : "ccw", true);
1056
+ }
1057
+
1058
+ /**
1059
+ * Rotates the (DOWN) layer clockwise or counterclockwise.
1060
+ */
1061
+ rotateD(clockwise = true) {
1062
+ this.#apply("D", clockwise ? "cw" : "ccw", true);
1063
+ }
1064
+
1065
+ /**
1066
+ * Rotates the wide (DOWN two layers) clockwise or counterclockwise.
1067
+ */
1068
+ rotateDw(clockwise = true) {
1069
+ this.#apply("Dw", clockwise ? "cw" : "ccw", true);
1070
+ }
1071
+
1072
+ /**
1073
+ * Rotates the wide (UPPER two layers) clockwise or counterclockwise.
1074
+ */
1075
+ rotateUw(clockwise = true) {
1076
+ this.#apply("Uw", clockwise ? "cw" : "ccw", true);
1077
+ }
1078
+
1079
+ /**
1080
+ * Rotates the wide (RIGHT two layers) clockwise or counterclockwise.
1081
+ */
1082
+ rotateRw(clockwise = true) {
1083
+ this.#apply("Rw", clockwise ? "cw" : "ccw", true);
1084
+ }
1085
+
1086
+ /**
1087
+ * Rotates the wide (LEFT two layers) clockwise or counterclockwise.
1088
+ */
1089
+ rotateLw(clockwise = true) {
1090
+ this.#apply("Lw", clockwise ? "cw" : "ccw", true);
1091
+ }
1092
+
1093
+ /**
1094
+ * Rotates the middle slice (M) parallel to L/R. Clockwise corresponds to Lw followed by L'.
1095
+ */
1096
+ rotateM(clockwise = true) {
1097
+ this.#apply("M", clockwise ? "cw" : "ccw", true);
1098
+ }
1099
+
1100
+ /**
1101
+ * Rotates the equatorial slice (E) parallel to U/D. Clockwise follows the D direction (E = Dw D').
1102
+ */
1103
+ rotateE(clockwise = true) {
1104
+ this.#apply("E", clockwise ? "cw" : "ccw", true);
1105
+ }
1106
+
1107
+ /**
1108
+ * Rotates the wide (FRONT two layers) clockwise or counterclockwise. Equivalent to z B.
1109
+ */
1110
+ rotateFw(clockwise = true) {
1111
+ this.#apply("Fw", clockwise ? "cw" : "ccw", true);
1112
+ }
1113
+
1114
+ /**
1115
+ * Rotates the standing slice (S) parallel to F/B. Clockwise follows the F direction (S = Fw F').
1116
+ */
1117
+ rotateS(clockwise = true) {
1118
+ this.#apply("S", clockwise ? "cw" : "ccw", true);
1119
+ }
1120
+
1121
+ /**
1122
+ * Rotates the (x) axis clockwise or counterclockwise.
1123
+ */
1124
+ rotateX(clockwise = true) {
1125
+ this.#apply("x", clockwise ? "cw" : "ccw", true);
1126
+ }
1127
+
1128
+ /**
1129
+ * Rotates the (z) axis clockwise or counterclockwise.
1130
+ */
1131
+ rotateZ(clockwise = true) {
1132
+ this.#apply("z", clockwise ? "cw" : "ccw", true);
1133
+ }
1134
+
1135
+ /**
1136
+ * Rotates the (y) axis clockwise or counterclockwise.
1137
+ */
1138
+ rotateY(clockwise = true) {
1139
+ this.#apply("y", clockwise ? "cw" : "ccw", true);
1140
+ }
1141
+
1142
+ /**
1143
+ * Logs the current state of the cube.
1144
+ */
1145
+ state() {
1146
+ return {
1147
+ UPPER: this.#faceMatrix(0),
1148
+ LEFT: this.#faceMatrix(1),
1149
+ FRONT: this.#faceMatrix(2),
1150
+ RIGHT: this.#faceMatrix(3),
1151
+ BACK: this.#faceMatrix(4),
1152
+ DOWN: this.#faceMatrix(5),
1153
+ };
1154
+ }
1155
+
1156
+ /**
1157
+ * Indicates if the cube is solve or not in all layers.
1158
+ */
1159
+ isSolved() {
1160
+ const per = this.size * this.size;
1161
+ // 2x2 has no center, so use the first sticker; 3x3 uses the center (index 4).
1162
+ const centerOffset = this.size === 2 ? 0 : 4;
1163
+ for (let f = 0; f < FACE_COLORS.length; f++) {
1164
+ const base = f * per;
1165
+ const centerColor = this.#stickers[base + centerOffset];
1166
+ for (let i = 0; i < per; i++) {
1167
+ if (this.#stickers[base + i] !== centerColor) return false;
1168
+ }
1169
+ }
1170
+ return true;
1171
+ }
1172
+
1173
+ /**
1174
+ * Returns the history of all movements made.
1175
+ *
1176
+ * @param {boolean} asString - If true, returns the history as a string; otherwise, returns it as an array.
1177
+ * @returns {string|array} The history of movements as an array or string.
1178
+ */
1179
+ getMoves(asString = true) {
1180
+ return asString ? this.MOVES.join(" ") : this.MOVES;
1181
+ }
1182
+
1183
+ /**
1184
+ * Resets the cube to the solved state and clears the move history.
1185
+ */
1186
+ reset() {
1187
+ this.#initializeState();
1188
+ this.MOVES = [];
1189
+ }
1190
+
1191
+ /**
1192
+ * Applies a sequence of moves provided as a string.
1193
+ * Supports: U, D, L, R, F, B, x, y, z; slice moves: M, E, S; and wide moves: Dw, Uw, Rw, Lw, Fw with optional ' for counterclockwise and 2 for double turns.
1194
+ * @param {string} sequence - e.g. "R U' F R2 D Dw Uw Rw Rw' Lw Lw2 M M' M2 E E' S S2 Fw"
1195
+ * @param {object} options - { record: boolean } whether to record moves in history (default true)
1196
+ */
1197
+ applyMoves(sequence, options = { record: false }) {
1198
+ const record = options?.record !== false;
1199
+ this.#applyMovesFromString(sequence, record);
1200
+ }
1201
+
1202
+ // Internal: parses and applies moves, optionally recording them in history.
1203
+ #applyMovesFromString(sequence, record = true) {
1204
+ if (typeof sequence !== "string") return;
1205
+ const tokens = sequence
1206
+ .split(/\s+/)
1207
+ .map((t) => t.trim())
1208
+ .filter((t) => t.length > 0);
1209
+
1210
+ for (const token of tokens) {
1211
+ const base = token[0];
1212
+ const rest = token.slice(1);
1213
+ const isDouble = rest.includes("2");
1214
+ const isPrime = rest.includes("'");
1215
+ const isWide = /w/i.test(rest);
1216
+
1217
+ let key;
1218
+ switch (base) {
1219
+ case "U":
1220
+ key = isWide ? "Uw" : "U";
1221
+ break;
1222
+ case "D":
1223
+ key = isWide ? "Dw" : "D";
1224
+ break;
1225
+ case "L":
1226
+ key = isWide ? "Lw" : "L";
1227
+ break;
1228
+ case "R":
1229
+ key = isWide ? "Rw" : "R";
1230
+ break;
1231
+ case "F":
1232
+ key = isWide ? "Fw" : "F";
1233
+ break;
1234
+ case "B":
1235
+ key = "B";
1236
+ break;
1237
+ case "x":
1238
+ key = "x";
1239
+ break;
1240
+ case "y":
1241
+ key = "y";
1242
+ break;
1243
+ case "z":
1244
+ key = "z";
1245
+ break;
1246
+ case "M":
1247
+ key = "M";
1248
+ break;
1249
+ case "E":
1250
+ key = "E";
1251
+ break;
1252
+ case "S":
1253
+ key = "S";
1254
+ break;
1255
+ default:
1256
+ // Unsupported token. Ignore silently for now.
1257
+ continue;
1258
+ }
1259
+
1260
+ if (isDouble) {
1261
+ this.#apply(key, "cw", record);
1262
+ this.#apply(key, "cw", record);
1263
+ } else if (isPrime) {
1264
+ this.#apply(key, "ccw", record);
1265
+ } else {
1266
+ this.#apply(key, "cw", record);
1267
+ }
1268
+ }
1269
+ }
1270
+ }
1271
+
1272
+ const COLOR = {
1273
+ W: ["W", "W", "W", "W", "W", "W", "W", "W", "W"],
1274
+ G: ["G", "G", "G", "G", "G", "G", "G", "G", "G"],
1275
+ R: ["R", "R", "R", "R", "R", "R", "R", "R", "R"],
1276
+ B: ["B", "B", "B", "B", "B", "B", "B", "B", "B"],
1277
+ O: ["O", "O", "O", "O", "O", "O", "O", "O", "O"],
1278
+ Y: ["Y", "Y", "Y", "Y", "Y", "Y", "Y", "Y", "Y"],
734
1279
  };
735
1280
 
736
- export { COLOR, CubeEngine };
1281
+ export { COLOR, CubeEngine, analyzeSolution, getMovePermutations, invertSequence, simplifyMoves };