squanlib 1.0.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/LICENSE +674 -0
- package/README.md +2 -0
- package/package.json +30 -0
- package/squanlib.js +1048 -0
package/squanlib.js
ADDED
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Unified Squan toolkit
|
|
3
|
+
// =============================================================================
|
|
4
|
+
|
|
5
|
+
export default class SquanLib {
|
|
6
|
+
|
|
7
|
+
// =========================================================================
|
|
8
|
+
// SECTION 1 — DATA TABLES
|
|
9
|
+
// =========================================================================
|
|
10
|
+
|
|
11
|
+
// -------------------------------------------------------------------------
|
|
12
|
+
// karnToWCA
|
|
13
|
+
// Keys are padded with spaces on both sides so a simple global replace on a
|
|
14
|
+
// space-delimited string cannot match partial tokens.
|
|
15
|
+
// -------------------------------------------------------------------------
|
|
16
|
+
static karnToWCA = {
|
|
17
|
+
"U4": "U U' U U'", "U4'": "U' U U' U",
|
|
18
|
+
"D4": "D D' D D'", "D4'": "D' D D' D",
|
|
19
|
+
"u4": "u u' u u'", "u4'": "u' u u' u",
|
|
20
|
+
"d4": "d d' d d'", "d4'": "d' d d' d",
|
|
21
|
+
|
|
22
|
+
"U3": "U U' U", "U3'": "U' U U'",
|
|
23
|
+
"D3": "D D' D", "D3'": "D' D D'",
|
|
24
|
+
"u3": "u u' u", "u3'": "u' u u'",
|
|
25
|
+
"d3": "d d' d", "d3'": "d' d d'",
|
|
26
|
+
"F3": "F F' F", "F3'": "F' F F'",
|
|
27
|
+
"f3": "f f' f", "f3'": "f' f f'",
|
|
28
|
+
|
|
29
|
+
"W": "U U'", "W'": "U' U",
|
|
30
|
+
"B": "D D'", "B'": "D' D",
|
|
31
|
+
"w": "u u'", "w'": "u' u",
|
|
32
|
+
"b": "d d'", "b'": "d' d",
|
|
33
|
+
"F2": "F F'", "F2'": "F' F",
|
|
34
|
+
"f2": "f f'", "f2'": "f' f",
|
|
35
|
+
"UU": "U U", "UU'": "U' U'",
|
|
36
|
+
"DD": "D D", "DD'": "D' D'",
|
|
37
|
+
"T2": "T T'", "T2'": "T' T",
|
|
38
|
+
"t2": "t t'", "t2'": "t' t",
|
|
39
|
+
"E2": "E E'", "E2'": "E' E",
|
|
40
|
+
"ɇ": "U D", "ɇ'": "U' D'",
|
|
41
|
+
"Ɇ": "U D'", "Ɇ'": "U' D",
|
|
42
|
+
|
|
43
|
+
"U2": "6,0", "U2'": "6,0",
|
|
44
|
+
"D2": "0,6",
|
|
45
|
+
"U2D": "6,3", "U2D'": "6,-3",
|
|
46
|
+
"U2'D": "6,3", "U2'D'": "6,-3",
|
|
47
|
+
"U2D2": "6,6",
|
|
48
|
+
"UD2": "3,6", "U'D2": "-3,6",
|
|
49
|
+
|
|
50
|
+
"U": "3,0", "U'": "-3,0",
|
|
51
|
+
"D": "0,3", "D'": "0,-3",
|
|
52
|
+
"E": "3,-3", "E'": "-3,3",
|
|
53
|
+
"e": "3,3", "e'": "-3,-3",
|
|
54
|
+
"u": "2,-1", "u'": "-2,1",
|
|
55
|
+
"d": "-1,2", "d'": "1,-2",
|
|
56
|
+
"F": "4,1", "F'": "-4,-1",
|
|
57
|
+
"f": "1,4", "f'": "-1,-4",
|
|
58
|
+
"T": "2,-4", "T'": "-2,4",
|
|
59
|
+
"t": "4,-2", "t'": "-4,2",
|
|
60
|
+
"m": "2,2", "m'": "-2,-2",
|
|
61
|
+
"M": "1,1", "M'": "-1,-1",
|
|
62
|
+
"u2": "5,-1", "u2'": "-5,1",
|
|
63
|
+
"d2": "-1,5", "d2'": "1,-5",
|
|
64
|
+
"K": "5,2", "K'": "-5,-2",
|
|
65
|
+
"k": "2,5", "k'": "-2,-5",
|
|
66
|
+
"A": "1,0", "A'": "-1,0",
|
|
67
|
+
"G": "5,-4", "G'": "-5,4",
|
|
68
|
+
"g": "4,-5", "g'": "-4,5",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// -------------------------------------------------------------------------
|
|
72
|
+
// shorthandToKarn
|
|
73
|
+
// "move10" means top misalign, "move1-1" means double misalign, etc.
|
|
74
|
+
// Some shorthands are alignment-independent (bjj, fjj, nn, …).
|
|
75
|
+
// -------------------------------------------------------------------------
|
|
76
|
+
static shorthandToKarn = {
|
|
77
|
+
// ── alignment-independent ─────────────────────────────────────────────
|
|
78
|
+
"bjj": "U' e D'", "fjj": "U e' D",
|
|
79
|
+
"e2bjj": "U' e' U'", "e2fjj": "U e U",
|
|
80
|
+
"nn": "E E'",
|
|
81
|
+
"jn": "D4'", "nj": "U4",
|
|
82
|
+
"jj": "U e' D", "bjj+e2": "U' e' U'",
|
|
83
|
+
"-nn": "E' E",
|
|
84
|
+
"-jn": "D4", "-nj": "D4'",
|
|
85
|
+
// ── alignment-dependent ───────────────────────────────────────────────
|
|
86
|
+
"bpj10": "d m' U", "bpj0-1": "u' m D'",
|
|
87
|
+
"fpj10": "u m' D", "fpj0-1": "d' m U'",
|
|
88
|
+
"aa10": "u m' u T'", "aa0-1": "U m' U t'",
|
|
89
|
+
"fadj10": "D M' d'", "dadj10": "D M' d'",
|
|
90
|
+
"fadj0-1": "U' M u", "u'adj0-1": "U' M u",
|
|
91
|
+
"badj10": "U M' u'", "uadj10": "U M' u'",
|
|
92
|
+
"badj0-1": "D' M d", "d'adj0-1": "D' M d",
|
|
93
|
+
"bb10": "T u' e U'", "bb0-1": "t d e' D",
|
|
94
|
+
"fdd10": "D e' d t", "fdd0-1": "U' e u' T",
|
|
95
|
+
"bdd10": "U e' u T'", "bdd0-1": "D' e d' t'",
|
|
96
|
+
"ff10": "d m' d M E", "ff0-1": "u' m U' M T",
|
|
97
|
+
"fv10": "d4", "fv0-1": "d4'",
|
|
98
|
+
"vf10": "u4", "vf0-1": "u4'",
|
|
99
|
+
"y2fv10": "u d' u -5,4",
|
|
100
|
+
"jf10": "w D' u T'", "jf0-1": "w' D u' T",
|
|
101
|
+
"fj10": "b U' d t", "fj0-1": "b' U d' t'",
|
|
102
|
+
"jr00": "e' w e", "jr10": "e' b e",
|
|
103
|
+
"jr0-1": "e' w' e", "jr1-1": "e' b' e",
|
|
104
|
+
"rj00": "e b' e'", "rj10": "e w e'",
|
|
105
|
+
"rj0-1": "e b' e'", "rj1-1": "e w e'",
|
|
106
|
+
"jv10": "b D d d2'", "jv0-1": "b' D' d' d2",
|
|
107
|
+
"vj10": "w U u u2'", "vj0-1": "w' U' u' u2",
|
|
108
|
+
"kk10": "u m' U E'", "kk0-1": "U m' u E'",
|
|
109
|
+
"opp10": "u2 u2'", "opp0-1": "u2' u2",
|
|
110
|
+
"pn10": "T T'", "pn0-1": "t t'",
|
|
111
|
+
"px10": "f' d3' f'", "px0-1": "f d3 f",
|
|
112
|
+
"xp10": "F' u3' F'", "xp0-1": "F u3 F",
|
|
113
|
+
"tt10": "d m' F' u2'",
|
|
114
|
+
"fss10": "u M D' E'", "fss0-1": "D' M u E'",
|
|
115
|
+
"bss10": "D M' u' E", "bss0-1": "U' M d E",
|
|
116
|
+
"vv10": "u M u m' E'",
|
|
117
|
+
"zz10": "u M t' M D'", "zz0-1": "D' M t' M u",
|
|
118
|
+
// random things
|
|
119
|
+
"30adj10": "U M' u'", "-30adj0-1": "U' M u",
|
|
120
|
+
"03adj10": "D M' d'",
|
|
121
|
+
"obopp00": "1,0/M' F M' F M'/0,1",
|
|
122
|
+
"oaopp1-1": "0,1/M' u' M' u' M'/0,1",
|
|
123
|
+
"but00": "", "also00": "", "done!00": "0,0",
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
static alignmentIndependent = new Set([
|
|
127
|
+
'bjj', 'fjj', 'nn', 'jn', 'nj', 'e2bjj', 'e2fjj',
|
|
128
|
+
'jj', 'bjj+e2', '-nn', '-jn', '-nj',
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
// -------------------------------------------------------------------------
|
|
132
|
+
// wcaToBaseKarn — maps single WCA move → base karn.
|
|
133
|
+
// -------------------------------------------------------------------------
|
|
134
|
+
static wcaToBaseKarn = {
|
|
135
|
+
// ── compound numeric → single karn ────────────────────────────────────
|
|
136
|
+
"6,0": "U2",
|
|
137
|
+
"6,3": "U2D", "6,-3": "U2D'", "6,6": "U2D2",
|
|
138
|
+
"0,6": "D2",
|
|
139
|
+
"3,6": "UD2", "-3,6": "U'D2",
|
|
140
|
+
// ── single numeric → single karn ──────────────────────────────────────
|
|
141
|
+
"3,0": "U", "-3,0": "U'",
|
|
142
|
+
"0,3": "D", "0,-3": "D'",
|
|
143
|
+
"3,-3": "E", "-3,3": "E'",
|
|
144
|
+
"3,3": "e", "-3,-3": "e'",
|
|
145
|
+
"2,-1": "u", "-2,1": "u'",
|
|
146
|
+
"-1,2": "d", "1,-2": "d'",
|
|
147
|
+
"4,1": "F", "-4,-1": "F'",
|
|
148
|
+
"1,4": "f", "-1,-4": "f'",
|
|
149
|
+
"2,-4": "T", "-2,4": "T'",
|
|
150
|
+
"4,-2": "t", "-4,2": "t'",
|
|
151
|
+
"2,2": "m", "-2,-2": "m'",
|
|
152
|
+
"1,1": "M", "-1,-1": "M'",
|
|
153
|
+
"5,-1": "u2", "-5,1": "u2'",
|
|
154
|
+
"-1,5": "d2", "1,-5": "d2'",
|
|
155
|
+
"5,2": "K", "-5,-2": "K'",
|
|
156
|
+
"2,5": "k", "-2,-5": "k'",
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// -------------------------------------------------------------------------
|
|
160
|
+
// baseKarnToHighKarn — longest first, base karn → high karn
|
|
161
|
+
// -------------------------------------------------------------------------
|
|
162
|
+
static baseKarnToHighKarn = {
|
|
163
|
+
"U U' U U'": "U4", "U' U U' U": "U4'",
|
|
164
|
+
"D D' D D'": "D4", "D' D D' D": "D4'",
|
|
165
|
+
"u u' u u'": "u4", "u' u u' u": "u4'",
|
|
166
|
+
"d d' d d'": "d4", "d' d d' d": "d4'",
|
|
167
|
+
|
|
168
|
+
"U U' U": "U3", "U' U U'": "U3'",
|
|
169
|
+
"D D' D": "D3", "D' D D'": "D3'",
|
|
170
|
+
"u u' u": "u3", "u' u u'": "u3'",
|
|
171
|
+
"d d' d": "d3", "d' d d'": "d3'",
|
|
172
|
+
"F F' F": "F3", "F' F F'": "F3'",
|
|
173
|
+
"f f' f": "f3", "f' f f'": "f3'",
|
|
174
|
+
|
|
175
|
+
"U U'": "W", "U' U": "W'",
|
|
176
|
+
"D D'": "B", "D' D": "B'",
|
|
177
|
+
"u u'": "w", "u' u": "w'",
|
|
178
|
+
"d d'": "b", "d' d": "b'",
|
|
179
|
+
"F F'": "F2", "F' F": "F2'",
|
|
180
|
+
"f f'": "f2", "f' f": "f2'",
|
|
181
|
+
"U U": "UU", "U' U'": "UU'",
|
|
182
|
+
"D D": "DD", "D' D'": "DD'",
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A_MOVES — legal moves available for top misalign
|
|
187
|
+
* a_MOVES — legal moves available for bottom misalign
|
|
188
|
+
*/
|
|
189
|
+
static A_MOVES = [
|
|
190
|
+
[3, 0], [-3, 0], [0, 3], [0, -3], [3, 3],
|
|
191
|
+
[2, -1], [-1, 2], [-4, -1], [-1, -4], [2, -4], [2, 2], [-1, -1], [5, -1],
|
|
192
|
+
];
|
|
193
|
+
static a_MOVES = [
|
|
194
|
+
[3, 0], [-3, 0], [0, 3], [0, -3], [3, 3],
|
|
195
|
+
[-2, 1], [1, -2], [4, 1], [1, 4], [-2, 4], [-2, -2], [1, 1], [-5, 1],
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* OPTIM — table for optimizable moves
|
|
200
|
+
*/
|
|
201
|
+
static OPTIM = {
|
|
202
|
+
// special case
|
|
203
|
+
"/0,0/": "",
|
|
204
|
+
"/3,3/3,3/": "-3,-3/-3,-3",
|
|
205
|
+
"/-3,-3/-3,-3/": "3,3/3,3",
|
|
206
|
+
"/2,2/-2,-2/": "2,2/-2,-2",
|
|
207
|
+
"/-2,-2/2,2/": "-2,-2/2,2",
|
|
208
|
+
"/1,1/-1,-1/": "1,1/-1,-1",
|
|
209
|
+
"/-1,-1/1,1/": "-1,-1/1,1",
|
|
210
|
+
"/2,-4/-2,4/2,-4/": "2,-4/-2,4/2,-4",
|
|
211
|
+
"/-2,4/2,-4/-2,4/": "-2,4/2,-4/-2,4",
|
|
212
|
+
"/5,-1/-5,1/5,-1/": "5,-1/-5,1/5,-1",
|
|
213
|
+
"/-5,1/5,-1/-5,1/": "-5,1/5,-1/-5,1",
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* CLOSEST_MAP — maps a -5~6 turn to the its closest 3n move
|
|
218
|
+
*/
|
|
219
|
+
static CLOSEST_MAP = new Map([
|
|
220
|
+
[-5, -6], [-4, -3], [-3, -3], [-2, -3], [-1, 0], [0, 0],
|
|
221
|
+
[1, 0], [2, 3], [3, 3], [4, 3], [5, 6], [6, 6],
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* MOVE_VALUES — lookup table for the ergonomic rating of each individual move.
|
|
226
|
+
* A = 10, a = 0-1
|
|
227
|
+
* / = upslice, \ = downslice
|
|
228
|
+
*/
|
|
229
|
+
static MOVE_VALUES = new Map([
|
|
230
|
+
// aligned top, upslice
|
|
231
|
+
['A/0,3', 16], ['A/0,6', 1], ['A/0,-3', 18],
|
|
232
|
+
['A/3,0', 16], ['A/3,3', 12], ['A/3,6', 0], ['A/3,-3', 13],
|
|
233
|
+
['A/6,0', 12], ['A/6,3', 11], ['A/6,6', 2], ['A/6,-3', 12],
|
|
234
|
+
['A/-3,0', 9], ['A/-3,3', 13], ['A/-3,6', 4], ['A/-3,-3', 12],
|
|
235
|
+
// aligned top, downslice
|
|
236
|
+
['A\\0,3', 17], ['A\\0,6', 1], ['A\\0,-3', 8],
|
|
237
|
+
['A\\3,0', 6], ['A\\3,3', 14], ['A\\3,6', 1], ['A\\3,-3', 12],
|
|
238
|
+
['A\\6,0', 14], ['A\\6,3', 11], ['A\\6,6', 5], ['A\\6,-3', 8],
|
|
239
|
+
['A\\-3,0', 11], ['A\\-3,3', 14], ['A\\-3,6', 6], ['A\\-3,-3', 9],
|
|
240
|
+
// unaligned top, upslice
|
|
241
|
+
['a/0,3', 5], ['a/0,6', 5], ['a/0,-3', 12],
|
|
242
|
+
['a/3,0', 17], ['a/3,3', 10], ['a/3,6', 5], ['a/3,-3', 7],
|
|
243
|
+
['a/6,0', 4], ['a/6,3', 2], ['a/6,6', 0], ['a/6,-3', 3],
|
|
244
|
+
['a/-3,0', 18], ['a/-3,3', 12], ['a/-3,6', 7], ['a/-3,-3', 11],
|
|
245
|
+
// unaligned top, downslice
|
|
246
|
+
['a\\0,3', 5], ['a\\0,6', 5], ['a\\0,-3', 5],
|
|
247
|
+
['a\\3,0', 16], ['a\\3,3', 11], ['a\\3,6', 4], ['a\\3,-3', 6],
|
|
248
|
+
['a\\6,0', 4], ['a\\6,3', 2], ['a\\6,6', 0], ['a\\6,-3', 1],
|
|
249
|
+
['a\\-3,0', 15], ['a\\-3,3', 10], ['a\\-3,6', 2], ['a\\-3,-3', 5],
|
|
250
|
+
// fractional (non-multiple-of-3) moves — alignment prefix omitted
|
|
251
|
+
['/1,-2', 4], ['\\1,-2', 17], ['/-1,2', 15], ['\\-1,2', 14],
|
|
252
|
+
['/1,-5', 3], ['\\1,-5', 1], ['/-1,5', 8], ['\\-1,5', 3],
|
|
253
|
+
['/1,4', 7], ['\\1,4', 14], ['/-1,-4', 12], ['\\-1,-4', 9],
|
|
254
|
+
['/1,1', 11], ['\\1,1', 20], ['/-1,-1', 20], ['\\-1,-1', 10],
|
|
255
|
+
['/2,-1', 20], ['\\2,-1', 12], ['/-2,1', 14], ['\\-2,1', 18],
|
|
256
|
+
['/2,2', 12], ['\\2,2', 13], ['/-2,-2', 14], ['\\-2,-2', 8],
|
|
257
|
+
['/2,5', 5], ['\\2,5', 3], ['/-2,-5', 4], ['\\-2,-5', 3],
|
|
258
|
+
['/2,-4', 14], ['\\2,-4', 6], ['/-2,4', 13], ['\\-2,4', 13],
|
|
259
|
+
['/4,4', 5], ['\\4,4', 12], ['/-4,-4', 12], ['\\-4,-4', 4],
|
|
260
|
+
['/4,1', 6], ['\\4,1', 13], ['/-4,-1', 16], ['\\-4,-1', 6],
|
|
261
|
+
['/4,-2', 12], ['\\4,-2', 9], ['/-4,2', 16], ['\\-4,2', 13],
|
|
262
|
+
['/4,-5', 2], ['\\4,-5', 5], ['/-4,5', 13], ['\\-4,5', 3],
|
|
263
|
+
['/5,5', 1], ['\\5,5', 4], ['/-5,-5', 2], ['\\-5,-5', 0],
|
|
264
|
+
['/5,2', 6], ['\\5,2', 10], ['/-5,-2', 12], ['\\-5,-2', 13],
|
|
265
|
+
['/5,-1', 11], ['\\5,-1', 7], ['/-5,1', 14], ['\\-5,1', 15],
|
|
266
|
+
['/5,-4', 2], ['\\5,-4', 2], ['/-5,4', 12], ['\\-5,4', 14],
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @param {object} [tempReplacements] — initial manual unkarnifications.
|
|
271
|
+
*/
|
|
272
|
+
constructor(tempReplacements = { "meow :3": "meow :3" }) {
|
|
273
|
+
// place to put manual unkarnifications
|
|
274
|
+
this.tempReplacements = { ...tempReplacements };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* setTempReplacements — replace the entire tempReplacements map.
|
|
279
|
+
*
|
|
280
|
+
* @param {Object<string,string>} replacements — the new key→value pairs
|
|
281
|
+
* @returns {this}
|
|
282
|
+
*/
|
|
283
|
+
setTempReplacements(replacements) {
|
|
284
|
+
this.tempReplacements = { ...replacements };
|
|
285
|
+
return this;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* addTempReplacements — merge key→value pairs into tempReplacements.
|
|
290
|
+
*
|
|
291
|
+
* @param {Object<string,string>} replacements — pairs to add (overwrites collisions)
|
|
292
|
+
* @returns {this}
|
|
293
|
+
*/
|
|
294
|
+
addTempReplacements(replacements) {
|
|
295
|
+
Object.assign(this.tempReplacements, replacements);
|
|
296
|
+
return this;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
// =========================================================================
|
|
301
|
+
// SECTION 2 — CORE UTILITIES
|
|
302
|
+
// =========================================================================
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* dictReplace — repeatedly applies every key→value substitution in `dict`
|
|
306
|
+
* to `str` until the string stabilizes.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} str — the string to be replaced
|
|
309
|
+
* @param {object} dict — the dictionary
|
|
310
|
+
* @returns {string} — the fully replaced string
|
|
311
|
+
*/
|
|
312
|
+
dictReplace(str, dict) {
|
|
313
|
+
const pattern = new RegExp(
|
|
314
|
+
Object.keys(dict).map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'),
|
|
315
|
+
'g'
|
|
316
|
+
);
|
|
317
|
+
let prev;
|
|
318
|
+
do { prev = str; str = str.replace(pattern, m => dict[m]); } while (str !== prev);
|
|
319
|
+
return str;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* addCommas — e.g. "2-1" → "2,-1"
|
|
324
|
+
*
|
|
325
|
+
* length 1 → "N,0"
|
|
326
|
+
* length 2 → starts with '-'? "-N,0" : "A,B"
|
|
327
|
+
* length 3 → starts with '-'? "-A,B" : "A,BC" (where BC is the second part)
|
|
328
|
+
* length 4 → "AB,CD"
|
|
329
|
+
* anything else that is not all-digits/minus → pass through unchanged
|
|
330
|
+
*
|
|
331
|
+
* @param {string} alg — the scramble, single space separated. can have commas already.
|
|
332
|
+
* @returns {string} — the scramble, with commas added
|
|
333
|
+
*/
|
|
334
|
+
addCommas(alg) {
|
|
335
|
+
return alg.split(' ').map(move => {
|
|
336
|
+
if (!move || isNaN(Number(move.replaceAll('-', ''))) || move.includes(","))
|
|
337
|
+
return move;
|
|
338
|
+
switch (move.length) {
|
|
339
|
+
case 1: return move + ',0';
|
|
340
|
+
case 2: return move.charAt(0) === '-' ? move + ',0'
|
|
341
|
+
: move[0] + ',' + move[1];
|
|
342
|
+
case 3: return move.charAt(0) === '-' ? move.slice(0, 2) + ',' + move[2]
|
|
343
|
+
: move[0] + ',' + move.slice(1);
|
|
344
|
+
case 4: return move.slice(0, 2) + ',' + move.slice(2);
|
|
345
|
+
default: throw new Error(`"${move}" is not a valid karn numeric move`);
|
|
346
|
+
}
|
|
347
|
+
}).join(' ');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* isKarn — returns true if the string uses any letters
|
|
352
|
+
*
|
|
353
|
+
* @param {string} str — the alg
|
|
354
|
+
* @returns {boolean} — whether the alg contains letters
|
|
355
|
+
*/
|
|
356
|
+
isKarn(str) {
|
|
357
|
+
return /[a-zA-Z]/.test(str);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* getAlignment — turns topA and bottomA into a starting move
|
|
362
|
+
*
|
|
363
|
+
* @param {boolean} topA — top misalign?
|
|
364
|
+
* @param {boolean} bottomA — bottom misalign?
|
|
365
|
+
* @returns {string} — e.g. "10", "1-1"
|
|
366
|
+
*/
|
|
367
|
+
getAlignment(topA, bottomA) {
|
|
368
|
+
return (topA ? '1' : '0') + (bottomA ? '-1' : '0');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
// =========================================================================
|
|
373
|
+
// SECTION 3 — UNKARNIFY PIPELINE
|
|
374
|
+
// =========================================================================
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* unkarnifyHelp — does the actual unkarnifying
|
|
378
|
+
*
|
|
379
|
+
* @param {string} alg — the alg
|
|
380
|
+
* @returns {string} — surface-level unkarnified alg
|
|
381
|
+
*/
|
|
382
|
+
unkarnifyHelp(alg) {
|
|
383
|
+
// trim and replace random ass characters
|
|
384
|
+
alg = alg.trim().replaceAll(/[()]/g, "");
|
|
385
|
+
// " / " → "/"
|
|
386
|
+
alg = alg.replaceAll(/ ([\/\\\|]) /g, "$1")
|
|
387
|
+
if (/[\/\\\|]{2,}/.test(alg)) throw new Error("unkarnifyHelp: Two slices in a row.");
|
|
388
|
+
|
|
389
|
+
if (!this.isKarn(alg)) return alg; // not karn at all
|
|
390
|
+
|
|
391
|
+
// these can be "", if the alg starts/ends with a slice
|
|
392
|
+
let firstMove, lastMove;
|
|
393
|
+
if (!/[/\\| ]/.test(alg)) firstMove = lastMove = alg; // only one move
|
|
394
|
+
else {
|
|
395
|
+
firstMove = alg.match(/^([^/\\| ]*)[/\\| ]/)?.[1];
|
|
396
|
+
lastMove = alg.match(/[/\\| ]([^/\\| ]*)$/)?.[1];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// only tests if it literally starts with a slice
|
|
400
|
+
let startsSlice = ["/", "\\", "|"].includes(alg.charAt(0));
|
|
401
|
+
// grab the literal starting slice, or just use a /
|
|
402
|
+
let startingSlice = startsSlice ? alg.charAt(0) :
|
|
403
|
+
firstMove in SquanLib.karnToWCA ? "/" : "";
|
|
404
|
+
// same
|
|
405
|
+
let endingSlice = "/" === alg.at(-1) ? "/" :
|
|
406
|
+
lastMove in SquanLib.karnToWCA ? "/" : "";
|
|
407
|
+
|
|
408
|
+
// replace all possible slices with spaces now that we have slice start
|
|
409
|
+
alg = alg.replaceAll(/[/\\| ]+/g, ' ');
|
|
410
|
+
alg = this.addCommas(alg);
|
|
411
|
+
// now go through scramble move by move
|
|
412
|
+
let s = alg.split(" ").filter(Boolean);
|
|
413
|
+
for (let i = 0; i < s.length; i++)
|
|
414
|
+
if (s[i] in SquanLib.karnToWCA) s[i] = SquanLib.karnToWCA[s[i]].split(" ");
|
|
415
|
+
|
|
416
|
+
// high karns gone. now flatten
|
|
417
|
+
s = s.flat();
|
|
418
|
+
for (let i = 0; i < s.length; i++)
|
|
419
|
+
if (s[i] in SquanLib.karnToWCA) s[i] = SquanLib.karnToWCA[s[i]];
|
|
420
|
+
|
|
421
|
+
alg = startingSlice + s.join("/") + endingSlice;
|
|
422
|
+
// sanity replacements
|
|
423
|
+
alg = alg.replaceAll(/ +/g, "")
|
|
424
|
+
if (/[\/\\\|]{2,}/.test(alg)) throw new Error("unkarnifyHelp: Two slices in a row post-replacements.");
|
|
425
|
+
|
|
426
|
+
return alg;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* unkarnify — master karn → WCA
|
|
431
|
+
* basically unkarnifyHelp + replaceShorthand with bling blings
|
|
432
|
+
*
|
|
433
|
+
* @param {string} alg — the alg to be unkarnified
|
|
434
|
+
* @returns {string} — unkarnified alg, duh
|
|
435
|
+
*/
|
|
436
|
+
unkarnify(alg) {
|
|
437
|
+
// overrides
|
|
438
|
+
if (alg in this.tempReplacements) return this.tempReplacements[alg];
|
|
439
|
+
|
|
440
|
+
// p scrambles
|
|
441
|
+
let isPScramble = /^p[ /\\|]/.test(alg);
|
|
442
|
+
let startingSlice;
|
|
443
|
+
if (isPScramble) {
|
|
444
|
+
startingSlice = alg.charAt(1) === " " ? "/" : alg.charAt(1);
|
|
445
|
+
alg = alg.slice(2, -3);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// legacy character substitutions
|
|
449
|
+
alg = alg
|
|
450
|
+
.replaceAll('&', '-1')
|
|
451
|
+
.replaceAll('^', '-2')
|
|
452
|
+
.replaceAll('9', '-3')
|
|
453
|
+
.replaceAll('8', '-4')
|
|
454
|
+
.replaceAll('7', '-5');
|
|
455
|
+
|
|
456
|
+
// expand move groups, e.g. "(U U')3" → "U U' U U' U U'"
|
|
457
|
+
for (const group of alg.matchAll(/(\(.*?\))(\d+)/g)) {
|
|
458
|
+
const inner = group[1].replaceAll(/[()]/g, '');
|
|
459
|
+
const count = parseInt(group[2], 10);
|
|
460
|
+
alg = alg.replace(group[0], Array(count).fill(inner).join(' '));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// the core defer
|
|
464
|
+
let final = this.replaceShorthands(this.unkarnifyHelp(alg));
|
|
465
|
+
|
|
466
|
+
// handle p scramble
|
|
467
|
+
if (isPScramble) {
|
|
468
|
+
if (["/", "\\", "|"].includes(final.charAt(0))) final = final.slice(1);
|
|
469
|
+
final = 'p' + startingSlice + final + "/p'";
|
|
470
|
+
}
|
|
471
|
+
final = final.replaceAll(/\/+/g, '/');
|
|
472
|
+
|
|
473
|
+
return final;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* replaceShorthands — replace shorthands (bjj, fv, kk, …) in an alg,
|
|
478
|
+
* tracking alignment state to choose the correct shorthand.
|
|
479
|
+
*
|
|
480
|
+
* @param {string} alg — the alg
|
|
481
|
+
* @returns {string} — the alg with shorthands replaced... guys jsdoc is sometimes dumb
|
|
482
|
+
*/
|
|
483
|
+
replaceShorthands(alg) {
|
|
484
|
+
const moves = alg.split(/[\/\\\|]/);
|
|
485
|
+
|
|
486
|
+
// early out: no shorthands
|
|
487
|
+
const allKnown = moves.every(m =>
|
|
488
|
+
!m || !this.isKarn(m) || (' ' + m + ' ' in SquanLib.karnToWCA)
|
|
489
|
+
);
|
|
490
|
+
if (allKnown) return this.unkarnifyHelp(alg);
|
|
491
|
+
|
|
492
|
+
let topA = false, bottomA = false;
|
|
493
|
+
|
|
494
|
+
for (const move of moves) {
|
|
495
|
+
if (!move) continue;
|
|
496
|
+
|
|
497
|
+
if (move.includes(',')) {
|
|
498
|
+
// Numeric turn — update alignment tracker.
|
|
499
|
+
const [u, d] = move.split(',');
|
|
500
|
+
if (parseInt(u, 10) % 3 !== 0) topA = !topA;
|
|
501
|
+
if (parseInt(d, 10) % 3 !== 0) bottomA = !bottomA;
|
|
502
|
+
} else {
|
|
503
|
+
// shorthand
|
|
504
|
+
const key = SquanLib.alignmentIndependent.has(move.toLowerCase())
|
|
505
|
+
? move.toLowerCase()
|
|
506
|
+
: move.toLowerCase() + this.getAlignment(topA, bottomA);
|
|
507
|
+
|
|
508
|
+
const replacement = SquanLib.shorthandToKarn[key];
|
|
509
|
+
if (replacement === undefined)
|
|
510
|
+
throw new Error(`replaceShorthands: "${move}" with alignment ${this.getAlignment(topA, bottomA)} is not defined.`);
|
|
511
|
+
|
|
512
|
+
alg = alg.replace(move, replacement);
|
|
513
|
+
|
|
514
|
+
// Update alignment based on what the replacement expands to.
|
|
515
|
+
for (const sub of this.unkarnifyHelp(replacement).split('/')) {
|
|
516
|
+
if (!sub) continue;
|
|
517
|
+
const [u, d] = sub.split(',');
|
|
518
|
+
if (parseInt(u, 10) % 3 !== 0) topA = !topA;
|
|
519
|
+
if (parseInt(d, 10) % 3 !== 0) bottomA = !bottomA;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// unkarnify the shorthands that were replaced into the alg
|
|
525
|
+
return this.unkarnifyHelp(alg);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
// =========================================================================
|
|
530
|
+
// SECTION 4 — SCRAMBLE / ALG UTILITIES
|
|
531
|
+
// =========================================================================
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* parseScramble — tokenizes a WCA squan scramble
|
|
535
|
+
*
|
|
536
|
+
* @param {string} alg the alg
|
|
537
|
+
* @returns {{ type: string, top?: number, bottom?: number }[]} after parsing
|
|
538
|
+
*/
|
|
539
|
+
parseScramble(alg) {
|
|
540
|
+
const moves = [];
|
|
541
|
+
const parts = alg.replace(/[\/\\\|]/g, ' / ').trim().split(/\s+/).filter(Boolean);
|
|
542
|
+
for (let part of parts) {
|
|
543
|
+
if (part.startsWith("p"))
|
|
544
|
+
moves.push({ type: 'turn', top: 0, bottom: 0 });
|
|
545
|
+
else if (part === '/') {
|
|
546
|
+
moves.push({ type: 'twist' });
|
|
547
|
+
} else {
|
|
548
|
+
part = this.addCommas(part.replace(/[()]/g, ''));
|
|
549
|
+
if (!part.includes(",")) throw new Error(`parseScramble: move: ${part} is weird.`)
|
|
550
|
+
const [top, bottom] = part.split(',').map(n => parseInt(n.trim(), 10));
|
|
551
|
+
if (!isNaN(top) && !isNaN(bottom))
|
|
552
|
+
moves.push({ type: 'turn', top, bottom });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return moves;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* twist — does a slice on a hex string
|
|
560
|
+
*
|
|
561
|
+
* @param {string} tlHex top layer hex, from UFL clockwise
|
|
562
|
+
* @param {string} blHex bottom layer hex, from DF clockwise
|
|
563
|
+
*/
|
|
564
|
+
twist(tlHex, blHex) {
|
|
565
|
+
return {
|
|
566
|
+
tlHex: tlHex.slice(0, 6) + blHex.slice(0, 6),
|
|
567
|
+
blHex: tlHex.slice(6) + blHex.slice(6),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* cycleLeft — rotate a hex string left by `places` positions (mod 12).
|
|
573
|
+
*
|
|
574
|
+
* @param {string} hex 12-char hex
|
|
575
|
+
* @param {number} places how much to shift left by
|
|
576
|
+
* @returns {string} the shifted string
|
|
577
|
+
*/
|
|
578
|
+
cycleLeft(hex, places) {
|
|
579
|
+
const n = ((places % 12) + 12) % 12;
|
|
580
|
+
return hex.slice(n) + hex.slice(0, n);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* algToHex — get the hex state that the alg generates
|
|
585
|
+
*
|
|
586
|
+
* @param {string} alg an alg. karn is accepted.
|
|
587
|
+
* @returns {tlHex: string, blHex: string} the hex
|
|
588
|
+
*/
|
|
589
|
+
algToHex(alg) {
|
|
590
|
+
let tlHex = '011233455677';
|
|
591
|
+
let blHex = '998bbaddcffe';
|
|
592
|
+
for (const move of this.parseScramble(this.unkarnify(alg))) {
|
|
593
|
+
if (move.type === 'twist') {
|
|
594
|
+
({ tlHex, blHex } = this.twist(tlHex, blHex));
|
|
595
|
+
} else {
|
|
596
|
+
tlHex = this.cycleLeft(tlHex, move.top);
|
|
597
|
+
blHex = this.cycleLeft(blHex, move.bottom);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return { tlHex, blHex };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* invertScramble — reverses a scramble
|
|
605
|
+
*
|
|
606
|
+
* @param {string} alg the alg. karn is accepted.
|
|
607
|
+
* @returns {string} the reversed alg
|
|
608
|
+
*/
|
|
609
|
+
invertScramble(alg) {
|
|
610
|
+
if (!alg) return alg;
|
|
611
|
+
return this.unkarnify(alg).trim().split('/').reverse().map(part => {
|
|
612
|
+
part = part.trim();
|
|
613
|
+
const src = part.includes('(')
|
|
614
|
+
? part.match(/\(([^)]+)\)/)?.[1]
|
|
615
|
+
: part.includes(',') ? part : null;
|
|
616
|
+
if (!src) return part;
|
|
617
|
+
const inverted = src.split(',').map(v => {
|
|
618
|
+
const n = parseInt(v.trim(), 10);
|
|
619
|
+
return isNaN(n) ? v.trim() : String(-n);
|
|
620
|
+
}).join(',');
|
|
621
|
+
return part.includes('(') ? `(${inverted})` : inverted;
|
|
622
|
+
}).join('/');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* isPBL — check if a hex is PBL
|
|
627
|
+
*
|
|
628
|
+
* @param {string} hex
|
|
629
|
+
* @returns {boolean} whether it's a PBL
|
|
630
|
+
*/
|
|
631
|
+
isPBL(hex) {
|
|
632
|
+
let { tlHex, blHex } = hex;
|
|
633
|
+
let tA = [...tlHex]; let bA = [...blHex];
|
|
634
|
+
if (tA[1] !== tA[2] ||
|
|
635
|
+
tA[4] !== tA[5] ||
|
|
636
|
+
tA[7] !== tA[8] ||
|
|
637
|
+
tA[10] !== tA[11]
|
|
638
|
+
) return false;
|
|
639
|
+
if (parseInt(tA[0], 10) % 2 !== 0 ||
|
|
640
|
+
parseInt(tA[1], 10) % 2 !== 1 ||
|
|
641
|
+
parseInt(tA[3], 10) % 2 !== 0 ||
|
|
642
|
+
parseInt(tA[4], 10) % 2 !== 1 ||
|
|
643
|
+
parseInt(tA[6], 10) % 2 !== 0 ||
|
|
644
|
+
parseInt(tA[7], 10) % 2 !== 1 ||
|
|
645
|
+
parseInt(tA[9], 10) % 2 !== 0 ||
|
|
646
|
+
parseInt(tA[10], 10) % 2 !== 1
|
|
647
|
+
) return false;
|
|
648
|
+
if (!tA.includes('0') ||
|
|
649
|
+
!tA.includes('1') ||
|
|
650
|
+
!tA.includes('2') ||
|
|
651
|
+
!tA.includes('3') ||
|
|
652
|
+
!tA.includes('4') ||
|
|
653
|
+
!tA.includes('5') ||
|
|
654
|
+
!tA.includes('6') ||
|
|
655
|
+
!tA.includes('7')
|
|
656
|
+
) return false;
|
|
657
|
+
if (bA[0] !== bA[1] ||
|
|
658
|
+
bA[3] !== bA[4] ||
|
|
659
|
+
bA[6] !== bA[7] ||
|
|
660
|
+
bA[9] !== bA[10]
|
|
661
|
+
) return false;
|
|
662
|
+
if (parseInt(bA[0], 16) % 2 !== 1 ||
|
|
663
|
+
parseInt(bA[2], 16) % 2 !== 0 ||
|
|
664
|
+
parseInt(bA[3], 16) % 2 !== 1 ||
|
|
665
|
+
parseInt(bA[5], 16) % 2 !== 0 ||
|
|
666
|
+
parseInt(bA[6], 16) % 2 !== 1 ||
|
|
667
|
+
parseInt(bA[8], 16) % 2 !== 0 ||
|
|
668
|
+
parseInt(bA[9], 16) % 2 !== 1 ||
|
|
669
|
+
parseInt(bA[11], 16) % 2 !== 0
|
|
670
|
+
) return false;
|
|
671
|
+
if (!bA.includes('8') ||
|
|
672
|
+
!bA.includes('9') ||
|
|
673
|
+
!bA.includes('a') ||
|
|
674
|
+
!bA.includes('b') ||
|
|
675
|
+
!bA.includes('c') ||
|
|
676
|
+
!bA.includes('d') ||
|
|
677
|
+
!bA.includes('e') ||
|
|
678
|
+
!bA.includes('f')
|
|
679
|
+
) return false;
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
// =========================================================================
|
|
685
|
+
// SECTION 5 — KARNIFY (WCA → karn)
|
|
686
|
+
// =========================================================================
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* karnify — converts WCA to karn.
|
|
690
|
+
*
|
|
691
|
+
* 1. assert that no two slices are next to each other and it's fully numeric
|
|
692
|
+
* 2. compute any startingSlice and endingSlice
|
|
693
|
+
* 3. normalize slice symbols
|
|
694
|
+
* 4. go through move by move for base karns and only karnify first/last non-zero move
|
|
695
|
+
* if they have slices around.
|
|
696
|
+
* 5. join back into an alg while reattaching leading/trailing slices,
|
|
697
|
+
* and dictReplace for high karns
|
|
698
|
+
*
|
|
699
|
+
* @param {string} alg — WCA format
|
|
700
|
+
* @returns {string} — karn
|
|
701
|
+
*/
|
|
702
|
+
karnify(alg) {
|
|
703
|
+
alg = alg.trim();
|
|
704
|
+
if (/[\/\\\|]{2,}/.test(alg)) throw new Error("karnify: Two slices in a row.");
|
|
705
|
+
if (this.isKarn(alg)) throw new Error("karnify: Alg has letters. Try unkarnifying first.")
|
|
706
|
+
let startsSlice = ["/", "\\", "|"].includes(alg.charAt(0));
|
|
707
|
+
let startingSlice = startsSlice ? alg.charAt(0) : "";
|
|
708
|
+
let endsSlice = "/" === alg.at(-1);
|
|
709
|
+
let endingSlice = endsSlice ? "/" : "";
|
|
710
|
+
|
|
711
|
+
// replace all possible slices with spaces
|
|
712
|
+
alg = alg.replaceAll(/[/\\| ]+/g, ' ');
|
|
713
|
+
|
|
714
|
+
// now go through scramble move by move to apply base karn
|
|
715
|
+
let s = alg.split(" ").filter(Boolean);
|
|
716
|
+
for (let i = 0; i < s.length; i++) {
|
|
717
|
+
if (i === 0 && !startsSlice) continue;
|
|
718
|
+
if (i === s.length - 1 && !endsSlice) break;
|
|
719
|
+
// good to replace
|
|
720
|
+
s[i] = SquanLib.wcaToBaseKarn[s[i]] ? SquanLib.wcaToBaseKarn[s[i]] : s[i].replace(",", "");
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
alg = startingSlice + s.join(" ") + endingSlice;
|
|
724
|
+
alg = this.dictReplace(alg, SquanLib.baseKarnToHighKarn);
|
|
725
|
+
return alg;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
// =========================================================================
|
|
730
|
+
// SECTION 6 — MOVE MATH & SEQUENCE OPTIMIZER
|
|
731
|
+
// =========================================================================
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* legalMove — normalizes a raw turn value into the canonical squan range [−5, 6].
|
|
735
|
+
*
|
|
736
|
+
* @param {number} m — raw turn value, e.g. −7 or 9
|
|
737
|
+
* @returns {number} — normalized value in [−5, 6]
|
|
738
|
+
*/
|
|
739
|
+
legalMove(m) {
|
|
740
|
+
m = m % 12; // get a range from -11 to 11
|
|
741
|
+
if (m < -5) return m + 12; // send -11 to -6 up
|
|
742
|
+
if (m > 6) return m - 12; // send 7 to 11 down
|
|
743
|
+
return m;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* addMoves — adds two move strings component-wise and legalizes each result.
|
|
748
|
+
*
|
|
749
|
+
* Tolerates "A"/"a" alignment markers. When one operand is an alignment
|
|
750
|
+
* marker and the other is a move, the marker is flipped iff the top
|
|
751
|
+
* component of the numeric move changes alignment (not a multiple of 3).
|
|
752
|
+
* The above assumes that the move is in-CS.
|
|
753
|
+
* Both operands cannot simultaneously be alignment markers.
|
|
754
|
+
*
|
|
755
|
+
* @param {string} move1 — "top,bot" string, OR "A"/"a" alignment marker
|
|
756
|
+
* @param {string} move2 — "top,bot" string, OR "A"/"a" alignment marker
|
|
757
|
+
* @returns {string}
|
|
758
|
+
*
|
|
759
|
+
* @example
|
|
760
|
+
* addMoves("3,0", "-3,0") // → "0,0"
|
|
761
|
+
* addMoves("2,-1", "1,-2") // → "3,-3"
|
|
762
|
+
* addMoves("5,-1", "3,0") // → "-4,-1"
|
|
763
|
+
* addMoves("A", "2,-1") // → "a" (2 % 3 !== 0 → flip)
|
|
764
|
+
* addMoves("A", "3,0") // → "A" (3 % 3 === 0 → no flip)
|
|
765
|
+
*/
|
|
766
|
+
addMoves(move1, move2) {
|
|
767
|
+
if (!move1 && !move2) throw new Error("addMoves: both moves are empty.");
|
|
768
|
+
else if (!move1) return move2;
|
|
769
|
+
else if (!move2) return move1;
|
|
770
|
+
const flip = { A: 'a', a: 'A' };
|
|
771
|
+
if (move1 in flip && move2 in flip)
|
|
772
|
+
throw new Error("addMoves: both moves cannot be alignment markers.");
|
|
773
|
+
if (move1 in flip) {
|
|
774
|
+
const top = parseInt(move2.split(',')[0], 10);
|
|
775
|
+
return this.changesAlignment(top) ? flip[move1] : move1;
|
|
776
|
+
} else if (move2 in flip) {
|
|
777
|
+
const top = parseInt(move1.split(',')[0], 10);
|
|
778
|
+
return this.changesAlignment(top) ? flip[move2] : move2;
|
|
779
|
+
}
|
|
780
|
+
const [u1, d1] = move1.split(',').map(Number);
|
|
781
|
+
const [u2, d2] = move2.split(',').map(Number);
|
|
782
|
+
return `${this.legalMove(u1 + u2)},${this.legalMove(d1 + d2)}`;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* changesAlignment — check if performing this turn value changes the
|
|
787
|
+
* alignment state (i.e. the turn is not a multiple of 3). Assumes everything
|
|
788
|
+
* is in CS.
|
|
789
|
+
*
|
|
790
|
+
* @param {number} m — top-layer turn value,
|
|
791
|
+
* @returns {boolean}
|
|
792
|
+
*/
|
|
793
|
+
changesAlignment(m) {
|
|
794
|
+
return m % 3 !== 0;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* optimize — replaces known optimizable moves (the OPTIM table) in a WCA alg
|
|
799
|
+
*
|
|
800
|
+
* 1. if no more optimization can be done, exit.
|
|
801
|
+
* 2. split on "/" into an array
|
|
802
|
+
* 3. walk to the next slice
|
|
803
|
+
* 4. at each slice, check if any OPTIM key match the alg starting at that position
|
|
804
|
+
* 5. if match:
|
|
805
|
+
* - "/0,0/": merge the two surrounding moves
|
|
806
|
+
* - generally: merge the first replacement move into the preceding move, merge
|
|
807
|
+
* the last into the succeeding move, and replace the inners
|
|
808
|
+
* 6. restart the outer loop after any change
|
|
809
|
+
*
|
|
810
|
+
* @param {string} alg — slash-separated WCA alg, e.g. "A/-3,0/3,3/3,3/a"
|
|
811
|
+
* @returns {string} — optimized alg
|
|
812
|
+
*/
|
|
813
|
+
optimize(alg) {
|
|
814
|
+
const optimKeys = Object.keys(SquanLib.OPTIM);
|
|
815
|
+
while (this.dictReplace(alg, SquanLib.OPTIM) !== alg) {
|
|
816
|
+
const moves = alg.split('/').map(m => m.trim());
|
|
817
|
+
let atSlice = 0;
|
|
818
|
+
let cycleCompleted = false;
|
|
819
|
+
for (let i = 0; i < alg.length; i++) {
|
|
820
|
+
if (cycleCompleted) break;
|
|
821
|
+
if (alg[i] !== '/') continue;
|
|
822
|
+
// only stop when scramble[i] is a slice
|
|
823
|
+
atSlice++;
|
|
824
|
+
for (const optimable of optimKeys) {
|
|
825
|
+
// if the OPTIM key is longer than what's left of scramble
|
|
826
|
+
if (alg.length - 1 - i < optimable.length) continue;
|
|
827
|
+
// if it doesn't match
|
|
828
|
+
if (alg.slice(i, i + optimable.length) !== optimable) continue;
|
|
829
|
+
|
|
830
|
+
// match!!
|
|
831
|
+
if (optimable === '/0,0/') {
|
|
832
|
+
// special case: merge surrounding moves
|
|
833
|
+
moves[atSlice - 1] = this.addMoves(moves[atSlice - 1], moves[atSlice + 1]);
|
|
834
|
+
moves.splice(atSlice, 2);
|
|
835
|
+
} else {
|
|
836
|
+
const optimableLen = optimable.split('/').length;
|
|
837
|
+
const optimTo = SquanLib.OPTIM[optimable].split('/');
|
|
838
|
+
// 2 represents the leading and trailing slash of optimable (forced)
|
|
839
|
+
const delSliceNum = optimableLen - 2;
|
|
840
|
+
// merge moves, even when empty (handled by addMoves)
|
|
841
|
+
moves[atSlice - 1] = this.addMoves(moves[atSlice - 1], optimTo.shift());
|
|
842
|
+
moves[atSlice + optimableLen - 2] = this.addMoves(
|
|
843
|
+
moves[atSlice + optimableLen - 2],
|
|
844
|
+
optimTo.pop()
|
|
845
|
+
);
|
|
846
|
+
moves.splice(atSlice, delSliceNum, ...optimTo);
|
|
847
|
+
}
|
|
848
|
+
alg = moves.join('/');
|
|
849
|
+
cycleCompleted = true;
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return alg;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
// =========================================================================
|
|
859
|
+
// SECTION 7 — ERGONOMICS RATING
|
|
860
|
+
// =========================================================================
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* getMoveValue — look up the ergonomic cost of a single move.
|
|
864
|
+
* @param {boolean} startA — is it top misalign right now?
|
|
865
|
+
* @param {boolean} upslice — is this an upslice?
|
|
866
|
+
* @param {string} move — e.g. "3,0"
|
|
867
|
+
* @returns {number}
|
|
868
|
+
*/
|
|
869
|
+
getMoveValue(startA, upslice, move) {
|
|
870
|
+
let comma = move.indexOf(',');
|
|
871
|
+
if (comma === -1) {
|
|
872
|
+
comma = this.addCommas(move).indexOf(',');
|
|
873
|
+
if (comma === -1) throw new Error(`getMoveValue: move: ${move} is weird`);
|
|
874
|
+
}
|
|
875
|
+
const topMove = parseInt(move.slice(0, comma), 10);
|
|
876
|
+
const slashCh = upslice ? '/' : '\\';
|
|
877
|
+
let key;
|
|
878
|
+
if (topMove % 3 === 0) {
|
|
879
|
+
key = (startA ? 'A' : 'a') + slashCh + move;
|
|
880
|
+
} else {
|
|
881
|
+
key = slashCh + move;
|
|
882
|
+
}
|
|
883
|
+
return SquanLib.MOVE_VALUES.get(key) ?? 5; // if unknown, just say 5
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* getOverwork — calculates how much overwork is present in an alg, along with bonus.
|
|
888
|
+
* @param {string[]} moves — array of "top,bot" strings (interior moves only)
|
|
889
|
+
* @returns {{ movement: number, bonus: number }}
|
|
890
|
+
*/
|
|
891
|
+
getOverwork(moves) {
|
|
892
|
+
const tops = [], bots = [];
|
|
893
|
+
for (const m of moves) {
|
|
894
|
+
let c = m.indexOf(',');
|
|
895
|
+
if (c === -1) {
|
|
896
|
+
const m2 = this.addCommas(m);
|
|
897
|
+
c = m2.indexOf(",");
|
|
898
|
+
if (c === -1) throw new Error(`getOverwork: in moves, m: ${m} is weird.`);
|
|
899
|
+
}
|
|
900
|
+
tops.push(parseInt(m.slice(0, c), 10) || 0);
|
|
901
|
+
bots.push(parseInt(m.slice(c + 1), 10) || 0);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
let movement = 0, bonus = 0;
|
|
905
|
+
|
|
906
|
+
// penalize streaks of lefty turns for both layers
|
|
907
|
+
// U layer
|
|
908
|
+
let streak = 0, closestMov = 0, buffer = 0;
|
|
909
|
+
for (const t of tops) {
|
|
910
|
+
const isLeft = (t === 6 || t < 0);
|
|
911
|
+
if (isLeft) {
|
|
912
|
+
streak++;
|
|
913
|
+
closestMov += Math.abs(SquanLib.CLOSEST_MAP.get(t) ?? 0);
|
|
914
|
+
buffer += Math.abs(t);
|
|
915
|
+
// has streak and nontrivial move → penalize
|
|
916
|
+
if (streak > 1 && closestMov > 3) { movement += buffer; buffer = 0; }
|
|
917
|
+
} else { streak = 0; closestMov = 0; buffer = 0; } // reset streak
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// D layer
|
|
921
|
+
streak = 0; closestMov = 0; buffer = 0;
|
|
922
|
+
for (const b of bots) {
|
|
923
|
+
const isLeft = b > 0;
|
|
924
|
+
if (isLeft) {
|
|
925
|
+
streak++;
|
|
926
|
+
closestMov += Math.abs(SquanLib.CLOSEST_MAP.get(b) ?? 0);
|
|
927
|
+
buffer += Math.abs(b);
|
|
928
|
+
if (streak > 1 && closestMov > 3) { movement += buffer; buffer = 0; }
|
|
929
|
+
} else { streak = 0; closestMov = 0; buffer = 0; }
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Bonus: count consecutive pairs that cancel.
|
|
933
|
+
for (let i = 0; i + 1 < tops.length; i++) {
|
|
934
|
+
if (tops[i] + tops[i + 1] === 0) bonus++;
|
|
935
|
+
if (bots[i] + bots[i + 1] === 0) bonus++;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
return { movement, bonus };
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* rateAlg — ergonomic rating for a single in CS squan alg
|
|
943
|
+
*
|
|
944
|
+
* @param {string} algRaw — raw alg (karn or WCA)
|
|
945
|
+
* @param {boolean} initialTopA — if the alg starts top misalign
|
|
946
|
+
* @param {object} [weights] — override default weight constants
|
|
947
|
+
* @returns {{ score: number, sliceStart: string }}
|
|
948
|
+
* sliceStart: '/' (prefer upslice), '\' (prefer downslice), or ' ' (no preference)
|
|
949
|
+
*/
|
|
950
|
+
rateAlg(algRaw, initialTopA = false, weights = {}) {
|
|
951
|
+
const W1 = weights.W1 ?? 34; // per-move ergonomic rating
|
|
952
|
+
const W2 = weights.W2 ?? 100; // slice-count penalty
|
|
953
|
+
const W3 = weights.W3 ?? 38; // overwork penalty
|
|
954
|
+
const W4 = weights.W4 ?? 500; // constant term
|
|
955
|
+
const W5 = weights.W5 ?? 10; // bonus
|
|
956
|
+
|
|
957
|
+
// strip brackets e.g. "[7|14]"
|
|
958
|
+
let a = algRaw.replace(/\[.*$/, '').trim();
|
|
959
|
+
|
|
960
|
+
// unkarnify if needed
|
|
961
|
+
const numeric = this.isKarn(a) ? this.unkarnify(a) : a.replaceAll(' ', '');
|
|
962
|
+
|
|
963
|
+
const rawParts = numeric.split('/');
|
|
964
|
+
// keep leading slice for up/downslice, drop trailing slice
|
|
965
|
+
const r = rawParts.filter((pt, i) => i === 0 || pt.trim() !== '').map(p => p.trim());
|
|
966
|
+
const sliceCount = r.length - 1;
|
|
967
|
+
if (sliceCount <= 0) return { score: W4, sliceStart: ' ' };
|
|
968
|
+
|
|
969
|
+
let ergoUp = 0, ergoDown = 0;
|
|
970
|
+
let isTopA = false, oddSlice = true;
|
|
971
|
+
|
|
972
|
+
for (let i = 0; i < r.length - 1; i++) {
|
|
973
|
+
let c = r[i].indexOf(',');
|
|
974
|
+
if (c === -1) {
|
|
975
|
+
const m = this.addCommas(r[i]);
|
|
976
|
+
c = m.indexOf(',');
|
|
977
|
+
if (c === -1)
|
|
978
|
+
throw new Error(`rateAlg:\nalg: ${algRaw}\nmove: ${r[i]}\nis weird.`)
|
|
979
|
+
}
|
|
980
|
+
const t = parseInt(r[i].slice(0, c), 10);
|
|
981
|
+
if (isNaN(t)) throw new Error(`rateAlg:\nalg: ${algRaw}\nmove: ${r[i]}\nis weird.`)
|
|
982
|
+
|
|
983
|
+
if (i === 0) {
|
|
984
|
+
// 1st move: use to determine initial alignment
|
|
985
|
+
isTopA = initialTopA !== (t % 3 !== 0);
|
|
986
|
+
oddSlice = true;
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
ergoUp += this.getMoveValue(isTopA, oddSlice, r[i]);
|
|
991
|
+
ergoDown += this.getMoveValue(isTopA, !oddSlice, r[i]);
|
|
992
|
+
isTopA = isTopA !== (t % 3 !== 0);
|
|
993
|
+
oddSlice = !oddSlice;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const phase1 = W1 * Math.max(ergoUp, ergoDown) / sliceCount; // average move rating
|
|
997
|
+
|
|
998
|
+
let sliceStart = '|';
|
|
999
|
+
if (Math.abs(ergoUp - ergoDown) / sliceCount > 5)
|
|
1000
|
+
sliceStart = ergoUp > ergoDown ? '/' : '\\';
|
|
1001
|
+
|
|
1002
|
+
const phase2 = W2 * sliceCount;
|
|
1003
|
+
const interior = r.slice(1, -1);
|
|
1004
|
+
const { movement, bonus } = this.getOverwork(interior);
|
|
1005
|
+
const phase3 = W3 * movement / sliceCount;
|
|
1006
|
+
const phase4 = bonus * W5 / sliceCount;
|
|
1007
|
+
|
|
1008
|
+
return { score: phase1 - phase2 - phase3 + phase4 + W4, sliceStart };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* rateAndSort — rate a list of algs and return them sorted by ergonomics high to low
|
|
1013
|
+
*
|
|
1014
|
+
* @param {string[]} algLines — raw alg strings
|
|
1015
|
+
* @param {string} [posHex=''] — position hex (used to determine initialTopA)
|
|
1016
|
+
* @returns {{ alg: string, score: number }[]}
|
|
1017
|
+
*/
|
|
1018
|
+
rateAndSort(algLines, posHex = '') {
|
|
1019
|
+
// Determine initial top-layer alignment from position hex.
|
|
1020
|
+
let initialTopA = false;
|
|
1021
|
+
if (posHex) {
|
|
1022
|
+
const ch = posHex[0];
|
|
1023
|
+
initialTopA = /[A-HU-W]/i.test(ch);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return algLines.map(line => {
|
|
1027
|
+
const bracketPos = line.indexOf('[');
|
|
1028
|
+
const algOnly = bracketPos > 0 ? line.slice(0, bracketPos).trim() : line.trim();
|
|
1029
|
+
let result = { alg: line, score: 500 };
|
|
1030
|
+
let rated = false;
|
|
1031
|
+
let sliceStart = ' ';
|
|
1032
|
+
|
|
1033
|
+
// Pre-unkarnify so rateAlg always receives a numeric string.
|
|
1034
|
+
const numericAlg = this.isKarn(algOnly) ? this.unkarnify(algOnly) : algOnly;
|
|
1035
|
+
({ score: result.score, sliceStart } = this.rateAlg(numericAlg, initialTopA));
|
|
1036
|
+
rated = true;
|
|
1037
|
+
|
|
1038
|
+
if (rated && ["/", "\\", "|"].includes(sliceStart)) {
|
|
1039
|
+
// Replace the first '/' in the alg-only portion of the display line.
|
|
1040
|
+
const slashPos = line.indexOf('/');
|
|
1041
|
+
if (slashPos >= 0)
|
|
1042
|
+
result.alg = line.slice(0, slashPos) + sliceStart + line.slice(slashPos + 1);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
return result;
|
|
1046
|
+
}).sort((a, b) => b.score - a.score);
|
|
1047
|
+
}
|
|
1048
|
+
}
|