cube-state-engine 1.4.0 → 1.6.0

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.ts CHANGED
@@ -1,721 +1,1453 @@
1
- class CubeEngine {
2
- MOVES = [];
3
- size = 3;
4
-
5
- constructor(initialScramble = "", options = { size: 3 }) {
6
- const allowedSizes = [2, 3];
7
- this.size = allowedSizes.includes(options.size) ? options.size : 3;
8
-
9
- this.#initializeState();
10
-
11
- // If an initial scramble string is provided, apply it without recording moves
12
- if (typeof initialScramble === "string" && initialScramble.trim().length > 0) {
13
- this.#applyMovesFromString(initialScramble, false);
14
- this.MOVES = [];
15
- }
16
- }
17
-
18
- #initializeState() {
19
- this.STATES = {
20
- UPPER: this.#createFace("W"),
21
- LEFT: this.#createFace("O"),
22
- FRONT: this.#createFace("G"),
23
- RIGHT: this.#createFace("R"),
24
- BACK: this.#createFace("B"),
25
- DOWN: this.#createFace("Y"),
26
- };
27
- }
28
-
29
- // Create a face matrix based on cube size
30
- #createFace(color) {
31
- const face = [];
32
- for (let i = 0; i < this.size; i++) {
33
- const row = [];
34
- for (let j = 0; j < this.size; j++) {
35
- row.push(color);
36
- }
37
- face.push(row);
38
- }
39
- return face;
40
- }
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.
99
+
100
+
101
+ // Flat layout face order (must match CubeEngine.state()).
102
+ const FACE_NAMES$1 = ["UPPER", "LEFT", "FRONT", "RIGHT", "BACK", "DOWN"];
103
+
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 };
106
+
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
+ ]);
114
+
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" };
117
+
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
+ }
41
127
 
42
- /**
43
- * Rotates the (UPPER) layer clockwise or counterclockwise.
44
- */
45
- rotateU(clockwise = true) {
46
- if (clockwise) {
47
- this.#rotateU(true);
48
- this.MOVES.push("U");
49
- } else {
50
- this.#rotateU(false);
51
- this.MOVES.push("U'");
128
+ // Derived sticker geometry is identical for every cube of a given size.
129
+ const GEOMETRY_CACHE = new Map();
130
+
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);
142
+
143
+ const perms = getMovePermutations(size);
144
+ const per = size * size;
145
+ const total = per * 6;
146
+ const faceMoves = Object.keys(FACE_MOVE_TO_INDEX);
147
+
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]);
52
155
  }
53
- }
54
-
55
- #rotateU(clockwise = true) {
56
- if (clockwise) {
57
- this.STATES.UPPER = this.#switchMatrix(this.STATES.UPPER, true);
58
-
59
- const tempFront = [...this.STATES.FRONT[0]];
60
- const tempRight = [...this.STATES.RIGHT[0]];
61
- const tempLeft = [...this.STATES.LEFT[0]];
62
- const tempBack = [...this.STATES.BACK[0]];
63
-
64
- this.STATES.FRONT[0] = [...tempRight];
65
- this.STATES.LEFT[0] = [...tempFront];
66
- this.STATES.BACK[0] = [...tempLeft];
67
- this.STATES.RIGHT[0] = [...tempBack];
68
- } else {
69
- this.STATES.UPPER = this.#switchMatrix(this.STATES.UPPER, false);
70
-
71
- const tempFront = [...this.STATES.FRONT[0]];
72
- const tempRight = [...this.STATES.RIGHT[0]];
73
- const tempLeft = [...this.STATES.LEFT[0]];
74
- const tempBack = [...this.STATES.BACK[0]];
75
-
76
- this.STATES.FRONT[0] = [...tempLeft];
77
- this.STATES.LEFT[0] = [...tempBack];
78
- this.STATES.BACK[0] = [...tempRight];
79
- this.STATES.RIGHT[0] = [...tempFront];
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);
80
164
  }
81
165
  }
82
166
 
83
- /**
84
- * Rotates the (FRONT) layer clockwise or counterclockwise.
85
- */
86
- rotateF(clockwise = true) {
87
- if (clockwise) {
88
- this.#rotateF(true);
89
- this.MOVES.push("F");
90
- } else {
91
- this.#rotateF(false);
92
- this.MOVES.push("F'");
93
- }
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
+ }));
175
+
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);
94
182
  }
95
-
96
- #rotateF(clockwise = true) {
97
- if (clockwise) {
98
- this.#rotateX(true);
99
- this.#rotateU(true);
100
- this.#rotateX(false);
101
- } else {
102
- this.#rotateX(true);
103
- this.#rotateU(false);
104
- this.#rotateX(false);
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
+ }
105
190
  }
106
191
  }
107
192
 
108
- /**
109
- * Rotates the (BACK) layer clockwise or counterclockwise.
110
- */
111
- rotateB(clockwise = true) {
112
- if (clockwise) {
113
- this.#rotateB(true);
114
- this.MOVES.push("B");
115
- } else {
116
- this.#rotateB(false);
117
- this.MOVES.push("B'");
118
- }
119
- }
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
+ };
210
+
211
+ GEOMETRY_CACHE.set(size, geo);
212
+ return geo;
213
+ }
120
214
 
121
- #rotateB(clockwise = true) {
122
- // Implement B as y2 F y2
123
- // Clockwise/counterclockwise direction is preserved through y2 conjugation
124
- this.#rotateY(true);
125
- this.#rotateY(true);
126
- if (clockwise) {
127
- this.#rotateF(true);
128
- } else {
129
- this.#rotateF(false);
130
- }
131
- this.#rotateY(false);
132
- this.#rotateY(false);
133
- }
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
+ }
134
221
 
135
- /**
136
- * Rotates the (RIGHT) layer clockwise or counterclockwise.
137
- */
138
- rotateR(clockwise = true) {
139
- if (clockwise) {
140
- this.#rotateR(true);
141
- this.MOVES.push("R");
142
- } else {
143
- this.#rotateR(false);
144
- this.MOVES.push("R'");
145
- }
146
- }
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
+ }
147
230
 
148
- #rotateR(clockwise = true) {
149
- if (clockwise) {
150
- this.#rotateY(true);
151
- this.#rotateX(true);
152
- this.#rotateU(true);
153
- this.#rotateX(false);
154
- this.#rotateY(false);
155
- } else {
156
- this.#rotateY(true);
157
- this.#rotateX(true);
158
- this.#rotateU(false);
159
- this.#rotateX(false);
160
- this.#rotateY(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);
161
238
  }
162
239
  }
240
+ return out;
241
+ }
163
242
 
164
- /**
165
- * Rotates the (LEFT) layer clockwise or counterclockwise.
166
- */
167
- rotateL(clockwise = true) {
168
- if (clockwise) {
169
- this.#rotateL(true);
170
- this.MOVES.push("L");
171
- } else {
172
- this.#rotateL(false);
173
- this.MOVES.push("L'");
174
- }
175
- }
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
+ }
176
249
 
177
- #rotateL(clockwise = true) {
178
- if (clockwise) {
179
- this.#rotateY(false);
180
- this.#rotateX(true);
181
- this.#rotateU(true);
182
- this.#rotateX(false);
183
- this.#rotateY(true);
184
- } else {
185
- this.#rotateY(false);
186
- this.#rotateX(true);
187
- this.#rotateU(false);
188
- this.#rotateX(false);
189
- this.#rotateY(true);
190
- }
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;
191
254
  }
255
+ return true;
256
+ }
192
257
 
193
- /**
194
- * Rotates the (DOWN) layer clockwise or counterclockwise.
195
- */
196
- rotateD(clockwise = true) {
197
- if (clockwise) {
198
- this.#rotateD(true);
199
- this.MOVES.push("D");
200
- } else {
201
- this.#rotateD(false);
202
- this.MOVES.push("D'");
203
- }
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;
204
264
  }
265
+ return true;
266
+ }
205
267
 
206
- #rotateD(clockwise = true) {
207
- if (clockwise) {
208
- this.#rotateX(true);
209
- this.#rotateF(true);
210
- this.#rotateX(false);
211
- } else {
212
- this.#rotateX(true);
213
- this.#rotateF(false);
214
- this.#rotateX(false);
215
- }
216
- }
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
+ }
217
277
 
218
- /**
219
- * Rotates the wide (DOWN two layers) clockwise or counterclockwise.
220
- */
221
- rotateDw(clockwise = true) {
222
- if (this.size === 2) return;
223
- if (clockwise) {
224
- this.#rotateDw(true);
225
- this.MOVES.push("Dw");
226
- } else {
227
- this.#rotateDw(false);
228
- this.MOVES.push("Dw'");
229
- }
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;
230
295
  }
296
+ return result;
297
+ }
231
298
 
232
- #rotateDw(clockwise = true) {
233
- if (clockwise) {
234
- this.#rotateY(false);
235
- this.#rotateU(true);
236
- } else {
237
- this.#rotateY(true);
238
- this.#rotateU(false);
239
- }
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;
240
308
  }
309
+ return true;
310
+ }
241
311
 
242
- /**
243
- * Rotates the wide (UPPER two layers) clockwise or counterclockwise.
244
- */
245
- rotateUw(clockwise = true) {
246
- if (this.size === 2) return;
247
- if (clockwise) {
248
- this.#rotateUw(true);
249
- this.MOVES.push("Uw");
250
- } else {
251
- this.#rotateUw(false);
252
- this.MOVES.push("Uw'");
253
- }
254
- }
312
+ // --- Roux geometry --------------------------------------------------------
313
+
314
+ // The pieces of a Roux 1x2x3 block, parameterized by the block's side face and
315
+ // the up face (which fixes down = opposite[up]). The block holds the side
316
+ // center, the side's three edges that do NOT touch the up face, and the side's
317
+ // two corners that DO touch the down face. The center always matches itself, so
318
+ // only edges/corners need checking.
319
+ function rouxBlockPieces(geo, sideFace, upFace) {
320
+ const downFace = geo.opposite[upFace];
321
+ const edges = geo
322
+ .edgesByFace(sideFace)
323
+ .filter((e) => !e.faces.includes(upFace));
324
+ const corners = geo
325
+ .cornersByFace(sideFace)
326
+ .filter((c) => c.faces.includes(downFace));
327
+ return { edges, corners };
328
+ }
255
329
 
256
- #rotateUw(clockwise = true) {
257
- if (clockwise) {
258
- this.#rotateY(true);
259
- this.#rotateD(true);
260
- } else {
261
- this.#rotateY(false);
262
- this.#rotateD(false);
263
- }
264
- }
330
+ // A Roux block is done when its three edges and two corners are all placed.
331
+ function rouxBlockDone(st, centers, geo, sideFace, upFace) {
332
+ const { edges, corners } = rouxBlockPieces(geo, sideFace, upFace);
333
+ if (edges.length !== 3 || corners.length !== 2) return false;
334
+ for (const e of edges)
335
+ if (!slotCorrect(st, centers, e.indices, geo.per)) return false;
336
+ for (const c of corners)
337
+ if (!slotCorrect(st, centers, c.indices, geo.per)) return false;
338
+ return true;
339
+ }
265
340
 
266
- /**
267
- * Rotates the wide (RIGHT two layers) clockwise or counterclockwise.
268
- */
269
- rotateRw(clockwise = true) {
270
- if (this.size === 2) return;
271
- if (clockwise) {
272
- this.#rotateRw(true);
273
- this.MOVES.push("Rw");
274
- } else {
275
- this.#rotateRw(false);
276
- this.MOVES.push("Rw'");
277
- }
278
- }
341
+ // Applies a permutation (out[i] = st[perm[i]]) to a flat sticker array.
342
+ function applyPerm(st, perm) {
343
+ const out = new Array(st.length);
344
+ for (let i = 0; i < st.length; i++) out[i] = st[perm[i]];
345
+ return out;
346
+ }
279
347
 
280
- #rotateRw(clockwise = true) {
281
- if (clockwise) {
282
- this.#rotateX(true);
283
- this.#rotateL(true);
284
- } else {
285
- this.#rotateX(false);
286
- this.#rotateL(false);
287
- }
348
+ // CMLL is done when the four corners touching the up face are solved, allowing
349
+ // the M slice to be unaligned: we accept the state if ANY of the four M-slice
350
+ // rotations makes those corners correct. M moves neither the corners nor the
351
+ // L/R blocks, only the U/F/D/B centers (and M-slice edges), so this captures
352
+ // exactly "last-layer corners solved, M not necessarily aligned yet".
353
+ function cmllDone(st, geo, upFace, mPerm) {
354
+ let cur = st;
355
+ const corners = geo.cornersByFace(upFace);
356
+ for (let m = 0; m < 4; m++) {
357
+ if (m > 0) cur = applyPerm(cur, mPerm);
358
+ const centers = centersOf(cur, geo);
359
+ if (corners.every((c) => slotCorrect(cur, centers, c.indices, geo.per)))
360
+ return true;
288
361
  }
362
+ return false;
363
+ }
289
364
 
290
- /**
291
- * Rotates the wide (LEFT two layers) clockwise or counterclockwise.
292
- */
293
- rotateLw(clockwise = true) {
294
- if (this.size === 2) return;
295
- if (clockwise) {
296
- this.#rotateLw(true);
297
- this.MOVES.push("Lw");
298
- } else {
299
- this.#rotateLw(false);
300
- this.MOVES.push("Lw'");
301
- }
302
- }
365
+ // Index of the move that COMPLETES a stage: the first move after which the
366
+ // condition holds, provided the stage is genuinely achieved by the end of the
367
+ // solve. Later moves are allowed to break it transiently (a turn mid-algorithm
368
+ // momentarily disturbs an already-finished cross/pair), which is why we take
369
+ // the first occurrence rather than requiring it to hold continuously.
370
+ function completionIndex(bools) {
371
+ const n = bools.length;
372
+ if (n === 0 || !bools[n - 1]) return null;
373
+ for (let i = 0; i < n; i++) if (bools[i]) return i;
374
+ return null;
375
+ }
303
376
 
304
- #rotateLw(clockwise = true) {
305
- if (clockwise) {
306
- // Lw equals x' R
307
- this.#rotateX(false);
308
- this.#rotateR(true);
309
- } else {
310
- this.#rotateX(true);
311
- this.#rotateR(false);
377
+ // Builds the milestone indices for one assumed cross color. Detection is
378
+ // cumulative: an F2L pair only counts while the cross is solved, and OLL only
379
+ // counts once the full F2L is solved. This rejects transient false positives.
380
+ function buildForCross(snapshots, geo, color) {
381
+ const n = snapshots.length;
382
+ const crossBools = snapshots.map((st) => crossDone(st, geo, color));
383
+ const crossIdx = completionIndex(crossBools);
384
+
385
+ // Per-slot completion, gated by the cross being solved at the same instant.
386
+ const slotSeries = new Map();
387
+ for (let i = 0; i < n; i++) {
388
+ const states = f2lSlotStates(snapshots[i], geo, color);
389
+ for (const [key, val] of Object.entries(states)) {
390
+ if (!slotSeries.has(key)) slotSeries.set(key, new Array(n).fill(false));
391
+ slotSeries.get(key)[i] = crossBools[i] && val;
312
392
  }
313
393
  }
314
-
315
- /**
316
- * Rotates the middle slice (M) parallel to L/R. Clockwise corresponds to Lw followed by L'.
317
- */
318
- rotateM(clockwise = true) {
319
- if (this.size === 2) return;
320
- if (clockwise) {
321
- this.#rotateM(true);
322
- this.MOVES.push("M");
323
- } else {
324
- this.#rotateM(false);
325
- this.MOVES.push("M'");
394
+ const f2lSlots = [...slotSeries.entries()]
395
+ .map(([slot, bools]) => ({ slot, idx: completionIndex(bools) }))
396
+ .filter((s) => s.idx != null)
397
+ .sort((a, b) => a.idx - b.idx);
398
+
399
+ // Full F2L solved = cross plus all four pairs solved at the same instant.
400
+ const f2lComplete = new Array(n).fill(false);
401
+ for (let i = 0; i < n; i++) {
402
+ if (!crossBools[i] || slotSeries.size < 4) continue;
403
+ let all = true;
404
+ for (const bools of slotSeries.values()) {
405
+ if (!bools[i]) {
406
+ all = false;
407
+ break;
408
+ }
326
409
  }
410
+ f2lComplete[i] = all;
327
411
  }
328
412
 
329
- #rotateM(clockwise = true) {
330
- if (clockwise) {
331
- this.#rotateLw(true);
332
- this.#rotateL(false);
333
- } else {
334
- this.#rotateLw(false);
335
- this.#rotateL(true);
336
- }
337
- }
413
+ // OLL is only meaningful once F2L is done (last layer oriented on top of it).
414
+ const ollIdx = completionIndex(
415
+ snapshots.map((st, i) => f2lComplete[i] && ollDone(st, geo, color))
416
+ );
417
+ const pllIdx = completionIndex(snapshots.map((st) => isSolvedFlat(st, geo.per)));
338
418
 
339
- /**
340
- * Rotates the (x) axis clockwise or counterclockwise.
341
- */
342
- rotateX(clockwise = true) {
343
- if (clockwise) {
344
- this.#rotateX(true);
345
- this.MOVES.push("x");
346
- } else {
347
- this.#rotateX(false);
348
- this.MOVES.push("x'");
349
- }
350
- }
419
+ return { crossIdx, f2lSlots, ollIdx, pllIdx };
420
+ }
351
421
 
352
- #rotateX(clockwise = true) {
353
- const tempFront = structuredClone(this.STATES.FRONT);
354
- const tempDown = structuredClone(this.STATES.DOWN);
355
- const tempUpper = structuredClone(this.STATES.UPPER);
356
- const tempBack = structuredClone(this.STATES.BACK);
357
- const tempLeft = structuredClone(this.STATES.LEFT);
358
- const tempRight = structuredClone(this.STATES.RIGHT);
359
-
360
- if (clockwise) {
361
- // Balance the rotation
362
- this.STATES.LEFT = this.#switchMatrix(tempLeft, false);
363
- this.STATES.RIGHT = this.#switchMatrix(tempRight, true);
364
-
365
- // Rotate mid X axis
366
- this.STATES.FRONT = [...tempDown];
367
- this.STATES.UPPER = [...tempFront];
368
-
369
- // Special permutation (BACK view elements)
370
- this.STATES.BACK = this.#specialFlip(tempUpper);
371
- this.STATES.DOWN = this.#specialFlip(tempBack);
372
- } else {
373
- this.STATES.LEFT = this.#switchMatrix(tempLeft, true);
374
- this.STATES.RIGHT = this.#switchMatrix(tempRight, false);
375
-
376
- this.STATES.FRONT = [...tempUpper];
377
- this.STATES.DOWN = [...tempFront];
378
-
379
- this.STATES.BACK = this.#specialFlip(tempDown);
380
- this.STATES.UPPER = this.#specialFlip(tempBack);
381
- }
382
- }
422
+ // Does this breakdown follow the CFOP order: cross -> 4x F2L -> OLL -> PLL?
423
+ function isCFOP(build) {
424
+ const { crossIdx, f2lSlots, ollIdx, pllIdx } = build;
425
+ if (crossIdx == null || pllIdx == null || ollIdx == null) return false;
426
+ if (f2lSlots.length !== 4) return false;
427
+ if (!f2lSlots.every((s) => s.idx >= crossIdx)) return false;
428
+ const lastF2L = f2lSlots[3].idx;
429
+ return ollIdx >= lastF2L && pllIdx >= ollIdx;
430
+ }
383
431
 
384
- /**
385
- * Rotates the (z) axis clockwise or counterclockwise.
386
- */
387
- rotateZ(clockwise = true) {
388
- if (clockwise) {
389
- this.#rotateZ(true);
390
- this.MOVES.push("z");
391
- } else {
392
- this.#rotateZ(false);
393
- this.MOVES.push("z'");
394
- }
432
+ // Builds the Roux milestone indices for one (sideFace, upFace) orientation.
433
+ // Detection is cumulative, mirroring buildForCross: the second block only counts
434
+ // while the first block holds, CMLL only once both blocks hold, and LSE is the
435
+ // fully solved cube. The first block is whichever of the two opposite side faces
436
+ // finishes its block earliest; the other side is the second block.
437
+ function buildForRoux(snapshots, geo, sideA, upFace, mPerm) {
438
+ snapshots.length;
439
+ const sideB = geo.opposite[sideA];
440
+
441
+ const centersAt = snapshots.map((st) => centersOf(st, geo));
442
+ const aDone = snapshots.map((st, i) =>
443
+ rouxBlockDone(st, centersAt[i], geo, sideA, upFace)
444
+ );
445
+ const bDone = snapshots.map((st, i) =>
446
+ rouxBlockDone(st, centersAt[i], geo, sideB, upFace)
447
+ );
448
+ const aIdx = completionIndex(aDone);
449
+ const bIdx = completionIndex(bDone);
450
+
451
+ // First block = the side that completes earliest; second block = the other.
452
+ let firstSide = sideA;
453
+ let secondSide = sideB;
454
+ let fbBools = aDone;
455
+ let sbBools = bDone;
456
+ if ((bIdx ?? Infinity) < (aIdx ?? Infinity)) {
457
+ firstSide = sideB;
458
+ secondSide = sideA;
459
+ fbBools = bDone;
460
+ sbBools = aDone;
395
461
  }
396
462
 
397
- #rotateZ(clockwise = true) {
398
- const tempUpper = structuredClone(this.STATES.UPPER);
399
- const tempRight = structuredClone(this.STATES.RIGHT);
400
- const tempDown = structuredClone(this.STATES.DOWN);
401
- const tempLeft = structuredClone(this.STATES.LEFT);
402
- const tempFront = structuredClone(this.STATES.FRONT);
403
- const tempBack = structuredClone(this.STATES.BACK);
404
-
405
- if (clockwise) {
406
- // Rotate faces on the rotation axis
407
- this.STATES.FRONT = this.#switchMatrix(tempFront, true);
408
- this.STATES.BACK = this.#switchMatrix(tempBack, false);
409
-
410
- // Cycle U -> R -> D -> L -> U with proper orientation
411
- this.STATES.RIGHT = this.#switchMatrix(tempUpper, true);
412
- this.STATES.DOWN = this.#switchMatrix(tempRight, true);
413
- this.STATES.LEFT = this.#switchMatrix(tempDown, true);
414
- this.STATES.UPPER = this.#switchMatrix(tempLeft, true);
415
- } else {
416
- // Counterclockwise
417
- this.STATES.FRONT = this.#switchMatrix(tempFront, false);
418
- this.STATES.BACK = this.#switchMatrix(tempBack, true);
419
-
420
- // Cycle U -> L -> D -> R -> U (inverse of clockwise), rotate CCW
421
- this.STATES.RIGHT = this.#switchMatrix(tempDown, false);
422
- this.STATES.DOWN = this.#switchMatrix(tempLeft, false);
423
- this.STATES.LEFT = this.#switchMatrix(tempUpper, false);
424
- this.STATES.UPPER = this.#switchMatrix(tempRight, false);
425
- }
426
- }
463
+ const fbIdx = completionIndex(fbBools);
464
+ // Second block gated by the first block holding at the same instant.
465
+ const secondBlockBools = snapshots.map((_, i) => fbBools[i] && sbBools[i]);
466
+ const sbIdx = completionIndex(secondBlockBools);
427
467
 
428
- /**
429
- * Rotates the (y) axis clockwise or counterclockwise.
430
- */
431
- rotateY(clockwise = true) {
432
- if (clockwise) {
433
- this.#rotateY(true);
434
- this.MOVES.push("y");
435
- } else {
436
- this.#rotateY(false);
437
- this.MOVES.push("y'");
438
- }
439
- }
468
+ const cmllIdx = completionIndex(
469
+ snapshots.map(
470
+ (st, i) => secondBlockBools[i] && cmllDone(st, geo, upFace, mPerm)
471
+ )
472
+ );
473
+ const lseIdx = completionIndex(snapshots.map((st) => isSolvedFlat(st, geo.per)));
440
474
 
441
- #rotateY(clockwise = true) {
442
- const tempFront = structuredClone(this.STATES.FRONT);
443
- const tempRight = structuredClone(this.STATES.RIGHT);
444
- const tempBack = structuredClone(this.STATES.BACK);
445
- const tempLeft = structuredClone(this.STATES.LEFT);
446
-
447
- if (clockwise) {
448
- this.STATES.UPPER = this.#switchMatrix(this.STATES.UPPER, true);
449
- this.STATES.DOWN = this.#switchMatrix(this.STATES.DOWN, false);
450
-
451
- this.STATES.FRONT = [...tempRight];
452
- this.STATES.RIGHT = [...tempBack];
453
- this.STATES.LEFT = [...tempFront];
454
- this.STATES.BACK = [...tempLeft];
455
- } else {
456
- this.STATES.UPPER = this.#switchMatrix(this.STATES.UPPER, false);
457
- this.STATES.DOWN = this.#switchMatrix(this.STATES.DOWN, true);
458
-
459
- this.STATES.FRONT = [...tempLeft];
460
- this.STATES.RIGHT = [...tempFront];
461
- this.STATES.LEFT = [...tempBack];
462
- this.STATES.BACK = [...tempRight];
463
- }
464
- }
475
+ return { firstSide, secondSide, upFace, fbIdx, sbIdx, cmllIdx, lseIdx };
476
+ }
465
477
 
466
- /**
467
- * Rotate the entire face in the direction set
468
- */
469
- #switchMatrix(matrix, clockwise = true) {
470
- const clone = structuredClone(matrix);
471
- const size = this.size;
472
-
473
- // Flatten the matrix
474
- let tempMatrix = [];
475
- for (let i = 0; i < size; i++) {
476
- tempMatrix = [...tempMatrix, ...clone[i]];
477
- }
478
+ // Does this breakdown follow the Roux order: 1st block -> 2nd block -> CMLL -> LSE?
479
+ function isRoux(build) {
480
+ const { fbIdx, sbIdx, cmllIdx, lseIdx } = build;
481
+ if (fbIdx == null || sbIdx == null || cmllIdx == null || lseIdx == null)
482
+ return false;
483
+ return fbIdx <= sbIdx && sbIdx <= cmllIdx && cmllIdx <= lseIdx;
484
+ }
478
485
 
479
- if (size === 2) {
480
- // For 2x2 cubes
481
- if (clockwise) {
482
- return [
483
- [tempMatrix[2], tempMatrix[0]],
484
- [tempMatrix[3], tempMatrix[1]]
485
- ];
486
- } else {
487
- return [
488
- [tempMatrix[1], tempMatrix[3]],
489
- [tempMatrix[0], tempMatrix[2]]
490
- ];
491
- }
492
- } else {
493
- // For 3x3 cubes (original logic)
494
- if (clockwise) {
495
- return [
496
- [tempMatrix[6], tempMatrix[3], tempMatrix[0]],
497
- [tempMatrix[7], tempMatrix[4], tempMatrix[1]],
498
- [tempMatrix[8], tempMatrix[5], tempMatrix[2]],
499
- ];
500
- } else {
501
- return [
502
- [tempMatrix[2], tempMatrix[5], tempMatrix[8]],
503
- [tempMatrix[1], tempMatrix[4], tempMatrix[7]],
504
- [tempMatrix[0], tempMatrix[3], tempMatrix[6]],
505
- ];
506
- }
507
- }
486
+ /**
487
+ * Analyzes a solution and returns the timing of each method milestone.
488
+ *
489
+ * @param {Array<{m: string, t: number}>} moves - Solution moves with cumulative
490
+ * timestamps. `m` is a move token; `t` is elapsed ms up to that move.
491
+ * @param {{size?: number}} [options] - Cube size (defaults to 3). Method staging
492
+ * is only computed for 3x3; other sizes report the solved (PLL) time only.
493
+ * @returns {object} Breakdown with `method` ("CFOP", "Roux" or "unknown"),
494
+ * `total`, `tps` and `allCrosses` (cross time per face color). For CFOP it
495
+ * carries `cross`, `f2l[]`, `oll`, `pll`; for Roux it carries `firstBlock`,
496
+ * `secondBlock`, `cmll`, `lse` (the other method's fields are null). Each
497
+ * block record also includes the `side` center color it was built on.
498
+ */
499
+ function analyzeSolution(moves, options = {}) {
500
+ const size = options.size === 2 ? 2 : 3;
501
+
502
+ // Keep both the original token (`m`, for display) and the engine-normalized
503
+ // token (`mm`, used for replay). Collect anything we cannot parse so the
504
+ // caller can see dropped moves instead of getting silently wrong timings.
505
+ const unsupported = [];
506
+ const seq = (Array.isArray(moves) ? moves : [])
507
+ .map((x) => {
508
+ const m = String(x?.m ?? "").trim();
509
+ const { token, base } = normalizeToken(m);
510
+ const supported = base != null && SUPPORTED_BASES.has(base);
511
+ if (m.length > 0 && !supported) unsupported.push(m);
512
+ return { m, mm: supported ? token : "", t: Number(x?.t) };
513
+ })
514
+ .filter((x) => x.m.length > 0);
515
+ const n = seq.length;
516
+
517
+ const simplifiedMoves = simplifyMoves(
518
+ (Array.isArray(moves) ? moves : []).filter((x) => x?.m)
519
+ );
520
+ const simplifiedCount = simplifiedMoves.length;
521
+
522
+ const empty = {
523
+ size,
524
+ method: "unknown",
525
+ solved: false,
526
+ total: n > 0 ? seq[n - 1].t : 0,
527
+ tps: 0,
528
+ moves: simplifiedMoves,
529
+ cross: null,
530
+ f2l: [],
531
+ oll: null,
532
+ pll: null,
533
+ firstBlock: null,
534
+ secondBlock: null,
535
+ cmll: null,
536
+ lse: null,
537
+ allCrosses: {},
538
+ unsupported,
539
+ };
540
+ if (n === 0) return empty;
541
+
542
+ // Reproduce the scramble (inverse of the solution), then replay forward,
543
+ // capturing a flat snapshot after every solution move. Unsupported tokens
544
+ // contribute no move (mm === "") so the replay simply skips them.
545
+ const engine = new CubeEngine("", { size });
546
+ const scramble = invertSequence(seq.map((x) => x.mm).filter(Boolean)).join(" ");
547
+ engine.applyMoves(scramble, { record: false });
548
+
549
+ const geo = buildGeometry(size);
550
+ const snapshots = new Array(n);
551
+ for (let i = 0; i < n; i++) {
552
+ if (seq[i].mm) engine.applyMoves(seq[i].mm, { record: false });
553
+ snapshots[i] = flattenState(engine.state());
508
554
  }
509
555
 
510
- #specialFlip(matrix) {
511
- return structuredClone(matrix)
512
- .reverse()
513
- .map((row) => [...row].reverse());
514
- }
556
+ const solved = isSolvedFlat(snapshots[n - 1], geo.per);
557
+ const pllIdxOnly = completionIndex(
558
+ snapshots.map((st) => isSolvedFlat(st, geo.per))
559
+ );
515
560
 
516
- /**
517
- * Logs the current state of the cube.
518
- */
519
- state() {
561
+ // Map a milestone index to a timed record, with duration since `prevAt`.
562
+ const milestone = (idx, prevAt) => {
563
+ if (idx == null) return { record: null, at: prevAt };
564
+ const at = seq[idx].t;
520
565
  return {
521
- ...this.STATES,
566
+ record: { at, duration: at - prevAt, moveIndex: idx, move: seq[idx].m },
567
+ at,
522
568
  };
523
- }
569
+ };
524
570
 
525
- /**
526
- * Indicates if the cube is solve or not in all layers.
527
- */
528
- isSolved() {
529
- const temp = {
530
- ...this.STATES,
571
+ // Non-3x3: only the solved (PLL) milestone is meaningful.
572
+ if (size !== 3) {
573
+ const pll = milestone(pllIdxOnly, 0);
574
+ const total = seq[n - 1].t;
575
+ return {
576
+ ...empty,
577
+ solved,
578
+ total,
579
+ tps: total > 0 ? simplifiedCount / (total / 1000) : 0,
580
+ pll: pll.record,
531
581
  };
582
+ }
532
583
 
533
- const layersSolved = Object.keys(temp).map((layer) => {
534
-
535
- let mixedMatrix = [];
536
- for (let i = 0; i < this.size; i++) {
537
- mixedMatrix = [...mixedMatrix, ...temp[layer][i]];
538
- }
539
-
540
- // For a 2x2 cube, there is no center; we use the first color as reference
541
- // For a 3x3 cube, we use the color of the center (position 4)
542
- const centerColor = this.size === 2 ? mixedMatrix[0] : mixedMatrix[4];
584
+ // Cross can complete on any of the six faces; record each, then pick the
585
+ // cross color that yields a valid CFOP staging (falling back to the earliest).
586
+ const finalCenters = centersOf(snapshots[n - 1], geo);
587
+ const colors = [...new Set(finalCenters)];
588
+
589
+ const allCrosses = {};
590
+ for (const color of colors) {
591
+ const idx = completionIndex(
592
+ snapshots.map((st) => crossDone(st, geo, color))
593
+ );
594
+ allCrosses[color] =
595
+ idx == null
596
+ ? null
597
+ : { at: seq[idx].t, moveIndex: idx, move: seq[idx].m };
598
+ }
543
599
 
544
- return mixedMatrix.every((currentColor) => currentColor === centerColor);
600
+ const ordered = colors
601
+ .map((color) => ({ color, build: buildForCross(snapshots, geo, color) }))
602
+ .sort((a, b) => {
603
+ const ai = a.build.crossIdx ?? Infinity;
604
+ const bi = b.build.crossIdx ?? Infinity;
605
+ return ai - bi;
545
606
  });
546
607
 
547
- return layersSolved.every((isLayerSolved) => isLayerSolved);
548
- }
549
-
550
- /**
551
- * Returns the history of all movements made.
552
- *
553
- * @param {boolean} asString - If true, returns the history as a string; otherwise, returns it as an array.
554
- * @returns {string|array} The history of movements as an array or string.
555
- */
556
- getMoves(asString = true) {
557
- return asString ? this.MOVES.join(" ") : this.MOVES;
608
+ const cfopChosen = ordered.find((c) => isCFOP(c.build)) ?? ordered[0];
609
+ const cfopValid = !!cfopChosen && isCFOP(cfopChosen.build);
610
+
611
+ // Stage the solve as Roux (1st block -> 2nd block -> CMLL -> LSE) on every
612
+ // orientation. Each candidate fixes a side face and a perpendicular up face;
613
+ // buildForRoux assigns first/second block by which side finishes earliest.
614
+ // Pick the valid candidate whose first block completes earliest.
615
+ const mPerm = getMovePermutations(size)["M"].cw;
616
+ const rouxCandidates = [];
617
+ for (let s = 0; s < 6; s++) {
618
+ for (const u of geo.neighbors[s]) {
619
+ rouxCandidates.push(buildForRoux(snapshots, geo, s, u, mPerm));
620
+ }
558
621
  }
559
-
560
- /**
561
- * Resets the cube to the solved state and clears the move history.
562
- */
563
- reset() {
564
- this.#initializeState();
565
- this.MOVES = [];
622
+ const rouxBuild =
623
+ rouxCandidates
624
+ .filter((b) => isRoux(b))
625
+ .sort((a, b) => a.fbIdx - b.fbIdx)[0] ?? null;
626
+
627
+ // A solved cube satisfies many orderings, so both stagings can be technically
628
+ // valid. Disambiguate by which method's FIRST milestone is genuinely reached
629
+ // early: a real CFOP cross is built up front, whereas on a Roux solve no cross
630
+ // completes until LSE; conversely a full 1x2x3 block only forms mid-CFOP. The
631
+ // structure that actually happened owns the earlier first milestone; ties go
632
+ // to CFOP.
633
+ let method = "unknown";
634
+ if (cfopValid && rouxBuild) {
635
+ method = cfopChosen.build.crossIdx <= rouxBuild.fbIdx ? "CFOP" : "Roux";
636
+ } else if (cfopValid) {
637
+ method = "CFOP";
638
+ } else if (rouxBuild) {
639
+ method = "Roux";
566
640
  }
567
-
568
- /**
569
- * Applies a sequence of moves provided as a string.
570
- * 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.
571
- * @param {string} sequence - e.g. "R U' F R2 D Dw Uw Rw Rw' Lw Lw2 M M' M2"
572
- * @param {object} options - { record: boolean } whether to record moves in history (default true)
573
- */
574
- applyMoves(sequence, options = { record: false }) {
575
- const record = options?.record !== false;
576
- this.#applyMovesFromString(sequence, record);
641
+ const total = seq[n - 1].t;
642
+ const base = {
643
+ size,
644
+ method,
645
+ solved,
646
+ total,
647
+ tps: total > 0 ? simplifiedCount / (total / 1000) : 0,
648
+ moves: simplifiedMoves,
649
+ cross: null,
650
+ f2l: [],
651
+ oll: null,
652
+ pll: null,
653
+ firstBlock: null,
654
+ secondBlock: null,
655
+ cmll: null,
656
+ lse: null,
657
+ allCrosses,
658
+ unsupported,
659
+ };
660
+
661
+ // Roux: report 1st block / 2nd block / CMLL / LSE, chaining durations.
662
+ if (method === "Roux") {
663
+ const fbM = milestone(rouxBuild.fbIdx, 0);
664
+ const sbM = milestone(rouxBuild.sbIdx, fbM.at);
665
+ const cmllM = milestone(rouxBuild.cmllIdx, sbM.at);
666
+ const lseM = milestone(rouxBuild.lseIdx, cmllM.at);
667
+ return {
668
+ ...base,
669
+ firstBlock: fbM.record
670
+ ? { side: finalCenters[rouxBuild.firstSide], ...fbM.record }
671
+ : null,
672
+ secondBlock: sbM.record
673
+ ? { side: finalCenters[rouxBuild.secondSide], ...sbM.record }
674
+ : null,
675
+ cmll: cmllM.record,
676
+ lse: lseM.record,
677
+ };
577
678
  }
578
679
 
579
- // Internal: parses and applies moves. If record=false, uses private methods to avoid logging.
580
- #applyMovesFromString(sequence, record = true) {
581
- if (typeof sequence !== "string") return;
582
- const tokens = sequence
583
- .split(/\s+/)
584
- .map((t) => t.trim())
585
- .filter((t) => t.length > 0);
586
-
587
- for (const token of tokens) {
588
- const base = token[0];
589
- const rest = token.slice(1);
590
- const isDouble = rest.includes("2");
591
- const isPrime = rest.includes("'");
592
-
593
- const exec = (fnClockwise, fnCounter) => {
594
- if (isDouble) {
595
- fnClockwise();
596
- fnClockwise();
597
- } else {
598
- if (isPrime) {
599
- fnCounter();
600
- } else {
601
- fnClockwise();
602
- }
603
- }
604
- };
605
-
606
- switch (base) {
607
- case 'U':
608
- {
609
- const isWide = /w/i.test(rest);
610
- if (isWide) {
611
- exec(
612
- () => (record ? this.rotateUw(true) : this.#rotateUw(true)),
613
- () => (record ? this.rotateUw(false) : this.#rotateUw(false))
614
- );
615
- } else {
616
- exec(
617
- () => (record ? this.rotateU(true) : this.#rotateU(true)),
618
- () => (record ? this.rotateU(false) : this.#rotateU(false))
619
- );
620
- }
621
- }
622
- break;
623
- case 'D':
624
- {
625
- const isWide = /w/i.test(rest);
626
- if (isWide) {
627
- exec(
628
- () => (record ? this.rotateDw(true) : this.#rotateDw(true)),
629
- () => (record ? this.rotateDw(false) : this.#rotateDw(false))
630
- );
631
- } else {
632
- exec(
633
- () => (record ? this.rotateD(true) : this.#rotateD(true)),
634
- () => (record ? this.rotateD(false) : this.#rotateD(false))
635
- );
636
- }
637
- }
638
- break;
639
- case 'L':
640
- {
641
- const isWide = /w/i.test(rest);
642
- if (isWide) {
643
- exec(
644
- () => (record ? this.rotateLw(true) : this.#rotateLw(true)),
645
- () => (record ? this.rotateLw(false) : this.#rotateLw(false))
646
- );
647
- } else {
648
- exec(
649
- () => (record ? this.rotateL(true) : this.#rotateL(true)),
650
- () => (record ? this.rotateL(false) : this.#rotateL(false))
651
- );
652
- }
653
- }
654
- break;
655
- case 'R':
656
- {
657
- const isWide = /w/i.test(rest);
658
- if (isWide) {
659
- exec(
660
- () => (record ? this.rotateRw(true) : this.#rotateRw(true)),
661
- () => (record ? this.rotateRw(false) : this.#rotateRw(false))
662
- );
663
- } else {
664
- exec(
665
- () => (record ? this.rotateR(true) : this.#rotateR(true)),
666
- () => (record ? this.rotateR(false) : this.#rotateR(false))
667
- );
668
- }
669
- }
670
- break;
671
- case 'F':
672
- exec(
673
- () => (record ? this.rotateF(true) : this.#rotateF(true)),
674
- () => (record ? this.rotateF(false) : this.#rotateF(false))
675
- );
676
- break;
677
- case 'B':
678
- exec(
679
- () => (record ? this.rotateB(true) : this.#rotateB(true)),
680
- () => (record ? this.rotateB(false) : this.#rotateB(false))
681
- );
682
- break;
683
- case 'x':
684
- exec(
685
- () => (record ? this.rotateX(true) : this.#rotateX(true)),
686
- () => (record ? this.rotateX(false) : this.#rotateX(false))
687
- );
688
- break;
689
- case 'y':
690
- exec(
691
- () => (record ? this.rotateY(true) : this.#rotateY(true)),
692
- () => (record ? this.rotateY(false) : this.#rotateY(false))
693
- );
694
- break;
695
- case 'z':
696
- exec(
697
- () => (record ? this.rotateZ(true) : this.#rotateZ(true)),
698
- () => (record ? this.rotateZ(false) : this.#rotateZ(false))
699
- );
700
- break;
701
- case 'M':
702
- exec(
703
- () => (record ? this.rotateM(true) : this.#rotateM(true)),
704
- () => (record ? this.rotateM(false) : this.#rotateM(false))
705
- );
706
- break;
707
- }
680
+ // CFOP (or unknown): report cross / F2L / OLL / PLL from the earliest cross.
681
+ const { color: crossColor, build } = cfopChosen;
682
+ const crossM = milestone(build.crossIdx, 0);
683
+ const cross = crossM.record
684
+ ? { color: crossColor, ...crossM.record }
685
+ : null;
686
+
687
+ let prevAt = crossM.at;
688
+ const f2l = [];
689
+ for (const slot of build.f2lSlots) {
690
+ const m = milestone(slot.idx, prevAt);
691
+ if (m.record) {
692
+ f2l.push({ slot: slot.slot, ...m.record });
693
+ prevAt = m.at;
708
694
  }
709
695
  }
710
- }
711
696
 
712
- const COLOR = {
713
- W: ["W", "W", "W", "W", "W", "W", "W", "W", "W"],
714
- G: ["G", "G", "G", "G", "G", "G", "G", "G", "G"],
715
- R: ["R", "R", "R", "R", "R", "R", "R", "R", "R"],
716
- B: ["B", "B", "B", "B", "B", "B", "B", "B", "B"],
717
- O: ["O", "O", "O", "O", "O", "O", "O", "O", "O"],
718
- Y: ["Y", "Y", "Y", "Y", "Y", "Y", "Y", "Y", "Y"],
697
+ const ollM = milestone(build.ollIdx, prevAt);
698
+ prevAt = ollM.at;
699
+ const pllM = milestone(build.pllIdx, prevAt);
700
+
701
+ return {
702
+ ...base,
703
+ cross,
704
+ f2l,
705
+ oll: ollM.record,
706
+ pll: pllM.record,
707
+ };
708
+ }
709
+
710
+ // Face order used across the flat sticker representation.
711
+ // Index: 0=UPPER 1=LEFT 2=FRONT 3=RIGHT 4=BACK 5=DOWN
712
+ const FACE_NAMES = ["UPPER", "LEFT", "FRONT", "RIGHT", "BACK", "DOWN"];
713
+ const FACE_COLORS = ["W", "O", "G", "R", "B", "Y"];
714
+
715
+ // Moves that only affect inner/double layers and therefore are no-ops on a 2x2.
716
+ const NOOP_SIZE2 = new Set(["Uw", "Dw", "Rw", "Lw", "Fw", "M", "E", "S"]);
717
+
718
+ // Maps each move key to the oracle method that performs it.
719
+ const MOVE_FNS = {
720
+ U: "rotateU",
721
+ D: "rotateD",
722
+ L: "rotateL",
723
+ R: "rotateR",
724
+ F: "rotateF",
725
+ B: "rotateB",
726
+ x: "rotateX",
727
+ y: "rotateY",
728
+ z: "rotateZ",
729
+ M: "rotateM",
730
+ E: "rotateE",
731
+ S: "rotateS",
732
+ Uw: "rotateUw",
733
+ Dw: "rotateDw",
734
+ Rw: "rotateRw",
735
+ Lw: "rotateLw",
736
+ Fw: "rotateFw",
737
+ };
738
+
739
+ // Permutation tables are derived once per cube size and shared across instances.
740
+ const PERM_CACHE = new Map();
741
+
742
+ /**
743
+ * Reference (matrix-based) cube used only to derive permutation tables.
744
+ *
745
+ * It reproduces the exact rotation algorithm the engine has always used, but
746
+ * operates on integer sticker tags (the flat index each sticker starts at).
747
+ * Applying a move and flattening the result yields a permutation array `perm`
748
+ * such that `newState[i] = oldState[perm[i]]`. This runs a handful of times per
749
+ * size at module load and is then cached, so the runtime hot path never touches
750
+ * matrices, structuredClone, or move composition.
751
+ */
752
+ class _OracleCube {
753
+ constructor(size) {
754
+ this.size = size;
755
+ this.STATES = {};
756
+ for (let f = 0; f < FACE_NAMES.length; f++) {
757
+ const face = [];
758
+ for (let r = 0; r < size; r++) {
759
+ const row = [];
760
+ for (let c = 0; c < size; c++) {
761
+ row.push(f * size * size + r * size + c);
762
+ }
763
+ face.push(row);
764
+ }
765
+ this.STATES[FACE_NAMES[f]] = face;
766
+ }
767
+ }
768
+
769
+ // Concatenate every face row-major into a single flat array of tags.
770
+ flatten() {
771
+ const out = [];
772
+ for (const name of FACE_NAMES) {
773
+ const face = this.STATES[name];
774
+ for (let r = 0; r < this.size; r++) {
775
+ for (let c = 0; c < this.size; c++) {
776
+ out.push(face[r][c]);
777
+ }
778
+ }
779
+ }
780
+ return out;
781
+ }
782
+
783
+ switchMatrix(matrix, clockwise = true) {
784
+ const clone = structuredClone(matrix);
785
+ const size = this.size;
786
+
787
+ let tempMatrix = [];
788
+ for (let i = 0; i < size; i++) {
789
+ tempMatrix = [...tempMatrix, ...clone[i]];
790
+ }
791
+
792
+ if (size === 2) {
793
+ if (clockwise) {
794
+ return [
795
+ [tempMatrix[2], tempMatrix[0]],
796
+ [tempMatrix[3], tempMatrix[1]],
797
+ ];
798
+ } else {
799
+ return [
800
+ [tempMatrix[1], tempMatrix[3]],
801
+ [tempMatrix[0], tempMatrix[2]],
802
+ ];
803
+ }
804
+ } else {
805
+ if (clockwise) {
806
+ return [
807
+ [tempMatrix[6], tempMatrix[3], tempMatrix[0]],
808
+ [tempMatrix[7], tempMatrix[4], tempMatrix[1]],
809
+ [tempMatrix[8], tempMatrix[5], tempMatrix[2]],
810
+ ];
811
+ } else {
812
+ return [
813
+ [tempMatrix[2], tempMatrix[5], tempMatrix[8]],
814
+ [tempMatrix[1], tempMatrix[4], tempMatrix[7]],
815
+ [tempMatrix[0], tempMatrix[3], tempMatrix[6]],
816
+ ];
817
+ }
818
+ }
819
+ }
820
+
821
+ specialFlip(matrix) {
822
+ return structuredClone(matrix)
823
+ .reverse()
824
+ .map((row) => [...row].reverse());
825
+ }
826
+
827
+ rotateU(clockwise = true) {
828
+ if (clockwise) {
829
+ this.STATES.UPPER = this.switchMatrix(this.STATES.UPPER, true);
830
+
831
+ const tempFront = [...this.STATES.FRONT[0]];
832
+ const tempRight = [...this.STATES.RIGHT[0]];
833
+ const tempLeft = [...this.STATES.LEFT[0]];
834
+ const tempBack = [...this.STATES.BACK[0]];
835
+
836
+ this.STATES.FRONT[0] = [...tempRight];
837
+ this.STATES.LEFT[0] = [...tempFront];
838
+ this.STATES.BACK[0] = [...tempLeft];
839
+ this.STATES.RIGHT[0] = [...tempBack];
840
+ } else {
841
+ this.STATES.UPPER = this.switchMatrix(this.STATES.UPPER, false);
842
+
843
+ const tempFront = [...this.STATES.FRONT[0]];
844
+ const tempRight = [...this.STATES.RIGHT[0]];
845
+ const tempLeft = [...this.STATES.LEFT[0]];
846
+ const tempBack = [...this.STATES.BACK[0]];
847
+
848
+ this.STATES.FRONT[0] = [...tempLeft];
849
+ this.STATES.LEFT[0] = [...tempBack];
850
+ this.STATES.BACK[0] = [...tempRight];
851
+ this.STATES.RIGHT[0] = [...tempFront];
852
+ }
853
+ }
854
+
855
+ rotateF(clockwise = true) {
856
+ if (clockwise) {
857
+ this.rotateX(true);
858
+ this.rotateU(true);
859
+ this.rotateX(false);
860
+ } else {
861
+ this.rotateX(true);
862
+ this.rotateU(false);
863
+ this.rotateX(false);
864
+ }
865
+ }
866
+
867
+ rotateB(clockwise = true) {
868
+ this.rotateY(true);
869
+ this.rotateY(true);
870
+ if (clockwise) {
871
+ this.rotateF(true);
872
+ } else {
873
+ this.rotateF(false);
874
+ }
875
+ this.rotateY(false);
876
+ this.rotateY(false);
877
+ }
878
+
879
+ rotateR(clockwise = true) {
880
+ if (clockwise) {
881
+ this.rotateY(true);
882
+ this.rotateX(true);
883
+ this.rotateU(true);
884
+ this.rotateX(false);
885
+ this.rotateY(false);
886
+ } else {
887
+ this.rotateY(true);
888
+ this.rotateX(true);
889
+ this.rotateU(false);
890
+ this.rotateX(false);
891
+ this.rotateY(false);
892
+ }
893
+ }
894
+
895
+ rotateL(clockwise = true) {
896
+ if (clockwise) {
897
+ this.rotateY(false);
898
+ this.rotateX(true);
899
+ this.rotateU(true);
900
+ this.rotateX(false);
901
+ this.rotateY(true);
902
+ } else {
903
+ this.rotateY(false);
904
+ this.rotateX(true);
905
+ this.rotateU(false);
906
+ this.rotateX(false);
907
+ this.rotateY(true);
908
+ }
909
+ }
910
+
911
+ rotateD(clockwise = true) {
912
+ if (clockwise) {
913
+ this.rotateX(true);
914
+ this.rotateF(true);
915
+ this.rotateX(false);
916
+ } else {
917
+ this.rotateX(true);
918
+ this.rotateF(false);
919
+ this.rotateX(false);
920
+ }
921
+ }
922
+
923
+ rotateDw(clockwise = true) {
924
+ if (this.size === 2) return;
925
+ if (clockwise) {
926
+ this.rotateY(false);
927
+ this.rotateU(true);
928
+ } else {
929
+ this.rotateY(true);
930
+ this.rotateU(false);
931
+ }
932
+ }
933
+
934
+ rotateUw(clockwise = true) {
935
+ if (this.size === 2) return;
936
+ if (clockwise) {
937
+ this.rotateY(true);
938
+ this.rotateD(true);
939
+ } else {
940
+ this.rotateY(false);
941
+ this.rotateD(false);
942
+ }
943
+ }
944
+
945
+ rotateRw(clockwise = true) {
946
+ if (this.size === 2) return;
947
+ if (clockwise) {
948
+ this.rotateX(true);
949
+ this.rotateL(true);
950
+ } else {
951
+ this.rotateX(false);
952
+ this.rotateL(false);
953
+ }
954
+ }
955
+
956
+ rotateLw(clockwise = true) {
957
+ if (this.size === 2) return;
958
+ if (clockwise) {
959
+ this.rotateX(false);
960
+ this.rotateR(true);
961
+ } else {
962
+ this.rotateX(true);
963
+ this.rotateR(false);
964
+ }
965
+ }
966
+
967
+ rotateM(clockwise = true) {
968
+ if (this.size === 2) return;
969
+ if (clockwise) {
970
+ this.rotateLw(true);
971
+ this.rotateL(false);
972
+ } else {
973
+ this.rotateLw(false);
974
+ this.rotateL(true);
975
+ }
976
+ }
977
+
978
+ rotateE(clockwise = true) {
979
+ if (this.size === 2) return;
980
+ if (clockwise) {
981
+ this.rotateDw(true);
982
+ this.rotateD(false);
983
+ } else {
984
+ this.rotateDw(false);
985
+ this.rotateD(true);
986
+ }
987
+ }
988
+
989
+ rotateFw(clockwise = true) {
990
+ if (this.size === 2) return;
991
+ if (clockwise) {
992
+ this.rotateZ(true);
993
+ this.rotateB(true);
994
+ } else {
995
+ this.rotateZ(false);
996
+ this.rotateB(false);
997
+ }
998
+ }
999
+
1000
+ rotateS(clockwise = true) {
1001
+ if (this.size === 2) return;
1002
+ if (clockwise) {
1003
+ this.rotateFw(true);
1004
+ this.rotateF(false);
1005
+ } else {
1006
+ this.rotateFw(false);
1007
+ this.rotateF(true);
1008
+ }
1009
+ }
1010
+
1011
+ rotateX(clockwise = true) {
1012
+ const tempFront = structuredClone(this.STATES.FRONT);
1013
+ const tempDown = structuredClone(this.STATES.DOWN);
1014
+ const tempUpper = structuredClone(this.STATES.UPPER);
1015
+ const tempBack = structuredClone(this.STATES.BACK);
1016
+ const tempLeft = structuredClone(this.STATES.LEFT);
1017
+ const tempRight = structuredClone(this.STATES.RIGHT);
1018
+
1019
+ if (clockwise) {
1020
+ this.STATES.LEFT = this.switchMatrix(tempLeft, false);
1021
+ this.STATES.RIGHT = this.switchMatrix(tempRight, true);
1022
+
1023
+ this.STATES.FRONT = [...tempDown];
1024
+ this.STATES.UPPER = [...tempFront];
1025
+
1026
+ this.STATES.BACK = this.specialFlip(tempUpper);
1027
+ this.STATES.DOWN = this.specialFlip(tempBack);
1028
+ } else {
1029
+ this.STATES.LEFT = this.switchMatrix(tempLeft, true);
1030
+ this.STATES.RIGHT = this.switchMatrix(tempRight, false);
1031
+
1032
+ this.STATES.FRONT = [...tempUpper];
1033
+ this.STATES.DOWN = [...tempFront];
1034
+
1035
+ this.STATES.BACK = this.specialFlip(tempDown);
1036
+ this.STATES.UPPER = this.specialFlip(tempBack);
1037
+ }
1038
+ }
1039
+
1040
+ rotateZ(clockwise = true) {
1041
+ const tempUpper = structuredClone(this.STATES.UPPER);
1042
+ const tempRight = structuredClone(this.STATES.RIGHT);
1043
+ const tempDown = structuredClone(this.STATES.DOWN);
1044
+ const tempLeft = structuredClone(this.STATES.LEFT);
1045
+ const tempFront = structuredClone(this.STATES.FRONT);
1046
+ const tempBack = structuredClone(this.STATES.BACK);
1047
+
1048
+ if (clockwise) {
1049
+ this.STATES.FRONT = this.switchMatrix(tempFront, true);
1050
+ this.STATES.BACK = this.switchMatrix(tempBack, false);
1051
+
1052
+ this.STATES.RIGHT = this.switchMatrix(tempUpper, true);
1053
+ this.STATES.DOWN = this.switchMatrix(tempRight, true);
1054
+ this.STATES.LEFT = this.switchMatrix(tempDown, true);
1055
+ this.STATES.UPPER = this.switchMatrix(tempLeft, true);
1056
+ } else {
1057
+ this.STATES.FRONT = this.switchMatrix(tempFront, false);
1058
+ this.STATES.BACK = this.switchMatrix(tempBack, true);
1059
+
1060
+ this.STATES.RIGHT = this.switchMatrix(tempDown, false);
1061
+ this.STATES.DOWN = this.switchMatrix(tempLeft, false);
1062
+ this.STATES.LEFT = this.switchMatrix(tempUpper, false);
1063
+ this.STATES.UPPER = this.switchMatrix(tempRight, false);
1064
+ }
1065
+ }
1066
+
1067
+ rotateY(clockwise = true) {
1068
+ const tempFront = structuredClone(this.STATES.FRONT);
1069
+ const tempRight = structuredClone(this.STATES.RIGHT);
1070
+ const tempBack = structuredClone(this.STATES.BACK);
1071
+ const tempLeft = structuredClone(this.STATES.LEFT);
1072
+
1073
+ if (clockwise) {
1074
+ this.STATES.UPPER = this.switchMatrix(this.STATES.UPPER, true);
1075
+ this.STATES.DOWN = this.switchMatrix(this.STATES.DOWN, false);
1076
+
1077
+ this.STATES.FRONT = [...tempRight];
1078
+ this.STATES.RIGHT = [...tempBack];
1079
+ this.STATES.LEFT = [...tempFront];
1080
+ this.STATES.BACK = [...tempLeft];
1081
+ } else {
1082
+ this.STATES.UPPER = this.switchMatrix(this.STATES.UPPER, false);
1083
+ this.STATES.DOWN = this.switchMatrix(this.STATES.DOWN, true);
1084
+
1085
+ this.STATES.FRONT = [...tempLeft];
1086
+ this.STATES.RIGHT = [...tempFront];
1087
+ this.STATES.LEFT = [...tempBack];
1088
+ this.STATES.BACK = [...tempRight];
1089
+ }
1090
+ }
1091
+ }
1092
+
1093
+ // Build a single move's permutation by applying it to a tagged oracle cube.
1094
+ function buildPerm(size, fnName, clockwise) {
1095
+ const oracle = new _OracleCube(size);
1096
+ oracle[fnName](clockwise);
1097
+ return oracle.flatten();
1098
+ }
1099
+
1100
+ // Build (and cache) the full clockwise/counterclockwise permutation set for a size.
1101
+ function getPerms(size) {
1102
+ if (PERM_CACHE.has(size)) return PERM_CACHE.get(size);
1103
+ const perms = {};
1104
+ for (const key of Object.keys(MOVE_FNS)) {
1105
+ perms[key] = {
1106
+ cw: buildPerm(size, MOVE_FNS[key], true),
1107
+ ccw: buildPerm(size, MOVE_FNS[key], false),
1108
+ };
1109
+ }
1110
+ PERM_CACHE.set(size, perms);
1111
+ return perms;
1112
+ }
1113
+
1114
+ /**
1115
+ * Exposes the (cached) permutation tables for a given cube size.
1116
+ *
1117
+ * Each entry maps a move key to `{ cw, ccw }` permutation arrays such that
1118
+ * `newState[i] = oldState[perm[i]]`. This is primarily an advanced/introspection
1119
+ * helper: the solution analyzer uses it to derive the cube's sticker adjacency
1120
+ * (which stickers form each edge/corner) without hardcoding any geometry.
1121
+ *
1122
+ * @param {number} size - 2 or 3 (defaults to 3).
1123
+ * @returns {Object<string, {cw: number[], ccw: number[]}>}
1124
+ */
1125
+ function getMovePermutations(size = 3) {
1126
+ const allowedSizes = [2, 3];
1127
+ return getPerms(allowedSizes.includes(size) ? size : 3);
1128
+ }
1129
+
1130
+ class CubeEngine {
1131
+ MOVES = [];
1132
+ size = 3;
1133
+ #stickers = [];
1134
+ #perms = null;
1135
+
1136
+ constructor(initialScramble = "", options = { size: 3 }) {
1137
+ const allowedSizes = [2, 3];
1138
+ this.size = allowedSizes.includes(options.size) ? options.size : 3;
1139
+ this.#perms = getPerms(this.size);
1140
+
1141
+ this.#initializeState();
1142
+
1143
+ // If an initial scramble string is provided, apply it without recording moves
1144
+ if (typeof initialScramble === "string" && initialScramble.trim().length > 0) {
1145
+ this.#applyMovesFromString(initialScramble, false);
1146
+ this.MOVES = [];
1147
+ }
1148
+ }
1149
+
1150
+ #initializeState() {
1151
+ const per = this.size * this.size;
1152
+ const stickers = new Array(FACE_COLORS.length * per);
1153
+ for (let f = 0; f < FACE_COLORS.length; f++) {
1154
+ const color = FACE_COLORS[f];
1155
+ const base = f * per;
1156
+ for (let i = 0; i < per; i++) {
1157
+ stickers[base + i] = color;
1158
+ }
1159
+ }
1160
+ this.#stickers = stickers;
1161
+ }
1162
+
1163
+ // Apply a precomputed permutation: newState[i] = oldState[perm[i]].
1164
+ #applyPerm(perm) {
1165
+ const current = this.#stickers;
1166
+ const next = new Array(current.length);
1167
+ for (let i = 0; i < current.length; i++) {
1168
+ next[i] = current[perm[i]];
1169
+ }
1170
+ this.#stickers = next;
1171
+ }
1172
+
1173
+ // Core move dispatch. dir is "cw" or "ccw"; record controls history logging.
1174
+ #apply(key, dir, record) {
1175
+ if (this.size === 2 && NOOP_SIZE2.has(key)) return;
1176
+ this.#applyPerm(this.#perms[key][dir]);
1177
+ if (record) this.MOVES.push(dir === "ccw" ? key + "'" : key);
1178
+ }
1179
+
1180
+ // Build a single face matrix from the flat sticker array.
1181
+ #faceMatrix(faceIndex) {
1182
+ const size = this.size;
1183
+ const base = faceIndex * size * size;
1184
+ const matrix = [];
1185
+ for (let r = 0; r < size; r++) {
1186
+ const row = [];
1187
+ for (let c = 0; c < size; c++) {
1188
+ row.push(this.#stickers[base + r * size + c]);
1189
+ }
1190
+ matrix.push(row);
1191
+ }
1192
+ return matrix;
1193
+ }
1194
+
1195
+ /**
1196
+ * Rotates the (UPPER) layer clockwise or counterclockwise.
1197
+ */
1198
+ rotateU(clockwise = true) {
1199
+ this.#apply("U", clockwise ? "cw" : "ccw", true);
1200
+ }
1201
+
1202
+ /**
1203
+ * Rotates the (FRONT) layer clockwise or counterclockwise.
1204
+ */
1205
+ rotateF(clockwise = true) {
1206
+ this.#apply("F", clockwise ? "cw" : "ccw", true);
1207
+ }
1208
+
1209
+ /**
1210
+ * Rotates the (BACK) layer clockwise or counterclockwise.
1211
+ */
1212
+ rotateB(clockwise = true) {
1213
+ this.#apply("B", clockwise ? "cw" : "ccw", true);
1214
+ }
1215
+
1216
+ /**
1217
+ * Rotates the (RIGHT) layer clockwise or counterclockwise.
1218
+ */
1219
+ rotateR(clockwise = true) {
1220
+ this.#apply("R", clockwise ? "cw" : "ccw", true);
1221
+ }
1222
+
1223
+ /**
1224
+ * Rotates the (LEFT) layer clockwise or counterclockwise.
1225
+ */
1226
+ rotateL(clockwise = true) {
1227
+ this.#apply("L", clockwise ? "cw" : "ccw", true);
1228
+ }
1229
+
1230
+ /**
1231
+ * Rotates the (DOWN) layer clockwise or counterclockwise.
1232
+ */
1233
+ rotateD(clockwise = true) {
1234
+ this.#apply("D", clockwise ? "cw" : "ccw", true);
1235
+ }
1236
+
1237
+ /**
1238
+ * Rotates the wide (DOWN two layers) clockwise or counterclockwise.
1239
+ */
1240
+ rotateDw(clockwise = true) {
1241
+ this.#apply("Dw", clockwise ? "cw" : "ccw", true);
1242
+ }
1243
+
1244
+ /**
1245
+ * Rotates the wide (UPPER two layers) clockwise or counterclockwise.
1246
+ */
1247
+ rotateUw(clockwise = true) {
1248
+ this.#apply("Uw", clockwise ? "cw" : "ccw", true);
1249
+ }
1250
+
1251
+ /**
1252
+ * Rotates the wide (RIGHT two layers) clockwise or counterclockwise.
1253
+ */
1254
+ rotateRw(clockwise = true) {
1255
+ this.#apply("Rw", clockwise ? "cw" : "ccw", true);
1256
+ }
1257
+
1258
+ /**
1259
+ * Rotates the wide (LEFT two layers) clockwise or counterclockwise.
1260
+ */
1261
+ rotateLw(clockwise = true) {
1262
+ this.#apply("Lw", clockwise ? "cw" : "ccw", true);
1263
+ }
1264
+
1265
+ /**
1266
+ * Rotates the middle slice (M) parallel to L/R. Clockwise corresponds to Lw followed by L'.
1267
+ */
1268
+ rotateM(clockwise = true) {
1269
+ this.#apply("M", clockwise ? "cw" : "ccw", true);
1270
+ }
1271
+
1272
+ /**
1273
+ * Rotates the equatorial slice (E) parallel to U/D. Clockwise follows the D direction (E = Dw D').
1274
+ */
1275
+ rotateE(clockwise = true) {
1276
+ this.#apply("E", clockwise ? "cw" : "ccw", true);
1277
+ }
1278
+
1279
+ /**
1280
+ * Rotates the wide (FRONT two layers) clockwise or counterclockwise. Equivalent to z B.
1281
+ */
1282
+ rotateFw(clockwise = true) {
1283
+ this.#apply("Fw", clockwise ? "cw" : "ccw", true);
1284
+ }
1285
+
1286
+ /**
1287
+ * Rotates the standing slice (S) parallel to F/B. Clockwise follows the F direction (S = Fw F').
1288
+ */
1289
+ rotateS(clockwise = true) {
1290
+ this.#apply("S", clockwise ? "cw" : "ccw", true);
1291
+ }
1292
+
1293
+ /**
1294
+ * Rotates the (x) axis clockwise or counterclockwise.
1295
+ */
1296
+ rotateX(clockwise = true) {
1297
+ this.#apply("x", clockwise ? "cw" : "ccw", true);
1298
+ }
1299
+
1300
+ /**
1301
+ * Rotates the (z) axis clockwise or counterclockwise.
1302
+ */
1303
+ rotateZ(clockwise = true) {
1304
+ this.#apply("z", clockwise ? "cw" : "ccw", true);
1305
+ }
1306
+
1307
+ /**
1308
+ * Rotates the (y) axis clockwise or counterclockwise.
1309
+ */
1310
+ rotateY(clockwise = true) {
1311
+ this.#apply("y", clockwise ? "cw" : "ccw", true);
1312
+ }
1313
+
1314
+ /**
1315
+ * Logs the current state of the cube.
1316
+ */
1317
+ state() {
1318
+ return {
1319
+ UPPER: this.#faceMatrix(0),
1320
+ LEFT: this.#faceMatrix(1),
1321
+ FRONT: this.#faceMatrix(2),
1322
+ RIGHT: this.#faceMatrix(3),
1323
+ BACK: this.#faceMatrix(4),
1324
+ DOWN: this.#faceMatrix(5),
1325
+ };
1326
+ }
1327
+
1328
+ /**
1329
+ * Indicates if the cube is solve or not in all layers.
1330
+ */
1331
+ isSolved() {
1332
+ const per = this.size * this.size;
1333
+ // 2x2 has no center, so use the first sticker; 3x3 uses the center (index 4).
1334
+ const centerOffset = this.size === 2 ? 0 : 4;
1335
+ for (let f = 0; f < FACE_COLORS.length; f++) {
1336
+ const base = f * per;
1337
+ const centerColor = this.#stickers[base + centerOffset];
1338
+ for (let i = 0; i < per; i++) {
1339
+ if (this.#stickers[base + i] !== centerColor) return false;
1340
+ }
1341
+ }
1342
+ return true;
1343
+ }
1344
+
1345
+ /**
1346
+ * Returns the history of all movements made.
1347
+ *
1348
+ * @param {boolean} asString - If true, returns the history as a string; otherwise, returns it as an array.
1349
+ * @returns {string|array} The history of movements as an array or string.
1350
+ */
1351
+ getMoves(asString = true) {
1352
+ return asString ? this.MOVES.join(" ") : this.MOVES;
1353
+ }
1354
+
1355
+ /**
1356
+ * Resets the cube to the solved state and clears the move history.
1357
+ */
1358
+ reset() {
1359
+ this.#initializeState();
1360
+ this.MOVES = [];
1361
+ }
1362
+
1363
+ /**
1364
+ * Applies a sequence of moves provided as a string.
1365
+ * 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.
1366
+ * @param {string} sequence - e.g. "R U' F R2 D Dw Uw Rw Rw' Lw Lw2 M M' M2 E E' S S2 Fw"
1367
+ * @param {object} options - { record: boolean } whether to record moves in history (default true)
1368
+ */
1369
+ applyMoves(sequence, options = { record: false }) {
1370
+ const record = options?.record !== false;
1371
+ this.#applyMovesFromString(sequence, record);
1372
+ }
1373
+
1374
+ // Internal: parses and applies moves, optionally recording them in history.
1375
+ #applyMovesFromString(sequence, record = true) {
1376
+ if (typeof sequence !== "string") return;
1377
+ const tokens = sequence
1378
+ .split(/\s+/)
1379
+ .map((t) => t.trim())
1380
+ .filter((t) => t.length > 0);
1381
+
1382
+ for (const token of tokens) {
1383
+ const base = token[0];
1384
+ const rest = token.slice(1);
1385
+ const isDouble = rest.includes("2");
1386
+ const isPrime = rest.includes("'");
1387
+ const isWide = /w/i.test(rest);
1388
+
1389
+ let key;
1390
+ switch (base) {
1391
+ case "U":
1392
+ key = isWide ? "Uw" : "U";
1393
+ break;
1394
+ case "D":
1395
+ key = isWide ? "Dw" : "D";
1396
+ break;
1397
+ case "L":
1398
+ key = isWide ? "Lw" : "L";
1399
+ break;
1400
+ case "R":
1401
+ key = isWide ? "Rw" : "R";
1402
+ break;
1403
+ case "F":
1404
+ key = isWide ? "Fw" : "F";
1405
+ break;
1406
+ case "B":
1407
+ key = "B";
1408
+ break;
1409
+ case "x":
1410
+ key = "x";
1411
+ break;
1412
+ case "y":
1413
+ key = "y";
1414
+ break;
1415
+ case "z":
1416
+ key = "z";
1417
+ break;
1418
+ case "M":
1419
+ key = "M";
1420
+ break;
1421
+ case "E":
1422
+ key = "E";
1423
+ break;
1424
+ case "S":
1425
+ key = "S";
1426
+ break;
1427
+ default:
1428
+ // Unsupported token. Ignore silently for now.
1429
+ continue;
1430
+ }
1431
+
1432
+ if (isDouble) {
1433
+ this.#apply(key, "cw", record);
1434
+ this.#apply(key, "cw", record);
1435
+ } else if (isPrime) {
1436
+ this.#apply(key, "ccw", record);
1437
+ } else {
1438
+ this.#apply(key, "cw", record);
1439
+ }
1440
+ }
1441
+ }
1442
+ }
1443
+
1444
+ const COLOR = {
1445
+ W: ["W", "W", "W", "W", "W", "W", "W", "W", "W"],
1446
+ G: ["G", "G", "G", "G", "G", "G", "G", "G", "G"],
1447
+ R: ["R", "R", "R", "R", "R", "R", "R", "R", "R"],
1448
+ B: ["B", "B", "B", "B", "B", "B", "B", "B", "B"],
1449
+ O: ["O", "O", "O", "O", "O", "O", "O", "O", "O"],
1450
+ Y: ["Y", "Y", "Y", "Y", "Y", "Y", "Y", "Y", "Y"],
719
1451
  };
720
1452
 
721
- export { COLOR, CubeEngine };
1453
+ export { COLOR, CubeEngine, analyzeSolution, getMovePermutations, invertSequence, simplifyMoves };