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.
Files changed (4) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +2 -0
  3. package/package.json +30 -0
  4. 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
+ }