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/.claude/settings.local.json +9 -0
- package/.idea/AICommit.xml +6 -0
- package/.idea/awsToolkit.xml +11 -0
- package/.idea/cube-state-engine.iml +8 -0
- package/.idea/inspectionProfiles/Project_Default.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/dist/index.d.mts +1388 -656
- package/dist/index.d.ts +1388 -656
- package/dist/index.js +961 -462
- package/dist/index.mjs +954 -461
- package/package.json +2 -2
- package/.idea/workspace.xml +0 -304
package/dist/index.d.mts
CHANGED
|
@@ -1,721 +1,1453 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
442
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
566
|
+
record: { at, duration: at - prevAt, moveIndex: idx, move: seq[idx].m },
|
|
567
|
+
at,
|
|
522
568
|
};
|
|
523
|
-
}
|
|
569
|
+
};
|
|
524
570
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
...
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
//
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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 };
|