squanlib 1.0.0 → 1.1.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/squanlib.js CHANGED
@@ -5,7 +5,7 @@
5
5
  export default class SquanLib {
6
6
 
7
7
  // =========================================================================
8
- // SECTION 1 DATA TABLES
8
+ // SECTION 1: DATA TABLES
9
9
  // =========================================================================
10
10
 
11
11
  // -------------------------------------------------------------------------
@@ -129,7 +129,7 @@ export default class SquanLib {
129
129
  ]);
130
130
 
131
131
  // -------------------------------------------------------------------------
132
- // wcaToBaseKarn maps single WCA move → base karn.
132
+ // wcaToBaseKarn: maps single WCA move → base karn.
133
133
  // -------------------------------------------------------------------------
134
134
  static wcaToBaseKarn = {
135
135
  // ── compound numeric → single karn ────────────────────────────────────
@@ -157,7 +157,7 @@ export default class SquanLib {
157
157
  };
158
158
 
159
159
  // -------------------------------------------------------------------------
160
- // baseKarnToHighKarn longest first, base karn → high karn
160
+ // baseKarnToHighKarn: longest first, base karn → high karn
161
161
  // -------------------------------------------------------------------------
162
162
  static baseKarnToHighKarn = {
163
163
  "U U' U U'": "U4", "U' U U' U": "U4'",
@@ -183,8 +183,8 @@ export default class SquanLib {
183
183
  };
184
184
 
185
185
  /**
186
- * A_MOVES legal moves available for top misalign
187
- * a_MOVES legal moves available for bottom misalign
186
+ * A_MOVES: legal moves available for top misalign
187
+ * a_MOVES: legal moves available for bottom misalign
188
188
  */
189
189
  static A_MOVES = [
190
190
  [3, 0], [-3, 0], [0, 3], [0, -3], [3, 3],
@@ -196,7 +196,7 @@ export default class SquanLib {
196
196
  ];
197
197
 
198
198
  /**
199
- * OPTIM table for optimizable moves
199
+ * OPTIM: table for optimizable moves
200
200
  */
201
201
  static OPTIM = {
202
202
  // special case
@@ -214,7 +214,7 @@ export default class SquanLib {
214
214
  };
215
215
 
216
216
  /**
217
- * CLOSEST_MAP maps a -5~6 turn to the its closest 3n move
217
+ * CLOSEST_MAP: maps a -5~6 turn to the its closest 3n move
218
218
  */
219
219
  static CLOSEST_MAP = new Map([
220
220
  [-5, -6], [-4, -3], [-3, -3], [-2, -3], [-1, 0], [0, 0],
@@ -222,7 +222,7 @@ export default class SquanLib {
222
222
  ]);
223
223
 
224
224
  /**
225
- * MOVE_VALUES lookup table for the ergonomic rating of each individual move.
225
+ * MOVE_VALUES: lookup table for the ergonomic rating of each individual move.
226
226
  * A = 10, a = 0-1
227
227
  * / = upslice, \ = downslice
228
228
  */
@@ -247,7 +247,7 @@ export default class SquanLib {
247
247
  ['a\\3,0', 16], ['a\\3,3', 11], ['a\\3,6', 4], ['a\\3,-3', 6],
248
248
  ['a\\6,0', 4], ['a\\6,3', 2], ['a\\6,6', 0], ['a\\6,-3', 1],
249
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
250
+ // fractional (non-multiple-of-3) moves: alignment prefix omitted
251
251
  ['/1,-2', 4], ['\\1,-2', 17], ['/-1,2', 15], ['\\-1,2', 14],
252
252
  ['/1,-5', 3], ['\\1,-5', 1], ['/-1,5', 8], ['\\-1,5', 3],
253
253
  ['/1,4', 7], ['\\1,4', 14], ['/-1,-4', 12], ['\\-1,-4', 9],
@@ -267,7 +267,266 @@ export default class SquanLib {
267
267
  ]);
268
268
 
269
269
  /**
270
- * @param {object} [tempReplacements] initial manual unkarnifications.
270
+ * GOOD_FINISHES: moves that are acceptable as the last move
271
+ */
272
+ static GOOD_FINISHES = new Set([
273
+ "11", "-1-1", "22", "-2-2", "2-1", "-21", "1-2", "-12",
274
+ "30", "-30", "03", "0-3", "33", "3-3", "-3-3", "-33",
275
+ "41", "-4-1", "14", "-1-4", "2-4", "-24", "4-2", "-42",
276
+ "5-1", "-51", "-45", "-54", "63",
277
+ ]);
278
+
279
+ /**
280
+ * OBLToEnglish: maps CSP-style hex into OBL names
281
+ * (format is 24-character string, both corner first, STARTING FROM TOP RIGHT OF SLICE
282
+ * meaning: 0-1 is the solved position. **the order is like CSP tracing.**)
283
+ */
284
+ static OBLToEnglish = {
285
+ 'BBbBBbBBbBBb': 'solved',
286
+ 'BBwWWwWWwWWw': '1c',
287
+ 'BBwBBwWWwWWw': 'cadj',
288
+ 'BBwWWwBBwWWw': 'copp',
289
+ 'BBwBBwBBwWWw': '3c',
290
+ 'BBwBBwBBwBBw': '4e',
291
+ 'WWbWWbWWbWWw': '3e',
292
+ 'WWbWWwWWbWWw': 'line',
293
+ 'WWbWWbWWwWWw': 'L',
294
+ 'WWbWWwWWwWWw': '1e',
295
+ 'WWbBBwWWwWWw': 'left pair', 'BBbWWwWWwWWw': 'right pair',
296
+ 'BBwWWwWWbWWw': 'left arrow', 'BBwWWbWWwWWw': 'right arrow',
297
+ 'WWbBBbWWwWWw': 'gem',
298
+ 'WWwWWbWWbBBw': 'left knight', 'BBbWWbWWwWWw': 'right knight',
299
+ 'WWwWWbWWwBBb': 'left axe', 'BBwWWbWWwWWb': 'right axe',
300
+ 'BBwWWbWWbWWw': 'squid',
301
+ 'WWwWWbBBbWWb': 'left thumb', 'WWbBBbWWwWWb': 'right thumb',
302
+ 'WWwBBbWWbWWb': 'left bunny', 'WWbWWbBBwWWb': 'right bunny',
303
+ 'BBbBBwWWwWWw': 'shell',
304
+ 'BBwWWwWWbBBw': 'left bird', 'BBwBBbWWwWWw': 'right bird',
305
+ 'BBwWWbWWwBBw': 'hazard',
306
+ 'BBbBBbWWwWWw': 'left kite', 'WWwWWbBBbBBw': 'right kite',
307
+ 'BBwBBwWWbWWb': 'left cut', 'BBwBBbWWbWWw': 'right cut',
308
+ 'BBbBBwWWbWWw': 'black T', 'WWwWWbBBwBBb': 'white T',
309
+ 'WWbBBwWWbBBw': 'left N', 'WWwBBbWWwBBb': 'right N',
310
+ 'WWbBBbWWwBBw': 'black tie', 'BBwWWwBBbWWb': 'white tie',
311
+ 'BBbWWwBBwWWw': 'left yoshi', 'WWwBBwWWbBBw': 'right yoshi'
312
+ };
313
+
314
+ static OBLToState = Object.fromEntries(
315
+ Object.entries(this.OBLToEnglish).map(([key, value]) => [value, key])
316
+ );
317
+
318
+ /**
319
+ * NAMING: matt's OBL naming
320
+ */
321
+ static NAMING = {
322
+ "solved": "O", "1c": "D", "cadj": "J", "copp": "V", "3c": "M", "4e": "Q",
323
+ "3e": "W", "line": "F", "L": "L", "1e": "E", "left pair": "Pw", "right pair": "Pc",
324
+ "left arrow": "Aw", "right arrow": "Ac", "gem": "G", "left knight": "Hw",
325
+ "right knight": "Hc", "left axe": "Xc", "right axe": "Xw", "squid": "S",
326
+ "left thumb": "THw", "right thumb": "THc", "left bunny": "Uc", "right bunny": "Uw",
327
+ "shell": "SH", "left bird": "Bc", "right bird": "Bw", "hazard": "Z",
328
+ "left kite": "Kc", "right kite": "Kw", "left cut": "Cw", "right cut": "Cc",
329
+ "black T": "Tu", "white T": "Td", "left N": "Nw", "right N": "Nc",
330
+ "black tie": "Iu", "white tie": "Id", "left yoshi": "Yc", "right yoshi": "Yw"
331
+ };
332
+
333
+ /**
334
+ * OBL_ANGLES: the starting angle, logged in PATTERNS
335
+ */
336
+ static OBL_ANGLES = {
337
+ "solved": "-", "1c": "UR", "cadj": "R",
338
+ "copp": "/", "3c": "DR", "4e": "-",
339
+ "3e": "D", "line": "—", "L": "DR",
340
+ "1e": "R", "left pair": "DR", "right pair": "UR",
341
+ "left arrow": "UR", "right arrow": "UR",
342
+ "gem": "DR", "left knight": "L", "right knight": "R",
343
+ "left axe": "L", "right axe": "R", "squid": "UR",
344
+ "left thumb": "DL", "right thumb": "DR",
345
+ "left bunny": "DR", "right bunny": "DL",
346
+ "shell": "R", "left bird": "R", "right bird": "U",
347
+ "hazard": "U", "left kite": "R", "right kite": "L",
348
+ "left cut": "R", "right cut": "R",
349
+ "black T": "R", "white T": "R",
350
+ "left N": "\\", "right N": "\\",
351
+ "black tie": "DR", "white tie": "DR",
352
+ "left yoshi": "UL", "right yoshi": "UR"
353
+ };
354
+
355
+ static nextAngle = {
356
+ // Single character angles
357
+ "-": "-", "/": "\\", "\\": "/", "—": "|", "|": "—",
358
+ "U": "R", "R": "D", "D": "L", "L": "U",
359
+ // Double character angles
360
+ "UR": "DR", "DR": "DL", "DL": "UL", "UL": "UR"
361
+ };
362
+
363
+ static get HALF_L() { return 6; }
364
+ static get LAYERL() { return 12; }
365
+ static get THREE_FOUR_L() { return 18; }
366
+ static get CUBEL() { return 24; }
367
+ static get SOLVED() { return "bBBbBBbBBbBBwWWwWWwWWwWW"; }
368
+
369
+ /**
370
+ * POSSIBLE_OBL: every OBL case as [specifier, U, D].
371
+ */
372
+ static POSSIBLE_OBL = [
373
+ ['', 'solved', 'solved'],
374
+ ['', '1c', '1c'], ['', 'cadj', 'cadj'], ['', 'cadj', 'copp'], ['', 'copp', 'copp'],
375
+ ['', '3c', '3c'], ['', '4e', '4e'], ['', '3e', '3e'], ['', 'line', 'line'],
376
+ ['', 'L', 'line'], ['', 'L', 'L'], ['', '1e', '1e'],
377
+ ['good', 'pair', 'pair'], ['bad', 'pair', 'pair'],
378
+ ['good', 'arrow', 'pair'], ['bad', 'arrow', 'pair'],
379
+ ['good', 'arrow', 'arrow'], ['bad', 'arrow', 'arrow'],
380
+ ['', 'gem', 'gem'], ['', 'gem', 'knight'], ['', 'gem', 'axe'], ['', 'gem', 'squid'],
381
+ ['good', 'knight', 'knight'], ['bad', 'knight', 'knight'],
382
+ ['good', 'knight', 'axe'], ['bad', 'knight', 'axe'],
383
+ ['same', 'axe', 'axe'], ['diff', 'axe', 'axe'],
384
+ ['', 'squid', 'knight'], ['', 'squid', 'axe'], ['', 'squid', 'squid'],
385
+ ['good', 'thumb', 'thumb'], ['bad', 'thumb', 'thumb'],
386
+ ['good', 'thumb', 'bunny'], ['bad', 'thumb', 'bunny'],
387
+ ['good', 'bunny', 'bunny'], ['bad', 'bunny', 'bunny'],
388
+ ['', 'shell', 'shell'], ['', 'shell', 'bird'], ['', 'shell', 'hazard'],
389
+ ['', 'yoshi', 'shell'],
390
+ ['good', 'bird', 'bird'], ['bad', 'bird', 'bird'],
391
+ ['', 'bird', 'hazard'], ['', 'hazard', 'hazard'],
392
+ ['good', 'yoshi', 'bird'], ['bad', 'yoshi', 'bird'],
393
+ ['', 'yoshi', 'hazard'], ['same', 'yoshi', 'yoshi'], ['diff', 'yoshi', 'yoshi'],
394
+ ['good', 'kite', 'kite'], ['bad', 'kite', 'kite'],
395
+ ['good', 'kite', 'cut'], ['bad', 'kite', 'cut'],
396
+ ['', 'kite', 'T'], ['good', 'kite', 'N'], ['bad', 'kite', 'N'], ['', 'kite', 'tie'],
397
+ ['', 'cut', 'T'], ['good', 'cut', 'N'], ['bad', 'cut', 'N'], ['', 'cut', 'tie'],
398
+ ['good', 'cut', 'cut'], ['bad', 'cut', 'cut'],
399
+ ['good', 'T', 'T'], ['bad', 'T', 'T'], ['', 'T', 'N'],
400
+ ['good', 'T', 'tie'], ['bad', 'T', 'tie'],
401
+ ['good', 'N', 'N'], ['bad', 'N', 'N'], ['', 'tie', 'N'],
402
+ ['good', 'tie', 'tie'], ['bad', 'tie', 'tie']
403
+ ];
404
+
405
+ /**
406
+ * OBL_LEN: optimal slicecount of each OBL
407
+ */
408
+ static OBL_LEN = {
409
+ "solved/solved": 0, "1c/1c": 5, "cadj/cadj": 4, "cadj/copp": 5, "copp/copp": 2,
410
+ "3c/3c": 5, "4e/4e": 4, "3e/3e": 5, "line/line": 2, "L/line": 5, "L/L": 4, "1e/1e": 5,
411
+ "good pair/pair": 2, "bad pair/pair": 4, "good arrow/pair": 3, "bad arrow/pair": 4,
412
+ "good arrow/arrow": 3, "bad arrow/arrow": 4, "gem/gem": 4, "gem/knight": 4,
413
+ "gem/axe": 3, "gem/squid": 4, "good knight/knight": 4, "bad knight/knight": 5,
414
+ "good knight/axe": 3, "bad knight/axe": 4, "same axe/axe": 5, "diff axe/axe": 5,
415
+ "squid/knight": 4, "squid/axe": 4, "squid/squid": 5, "good thumb/thumb": 2,
416
+ "bad thumb/thumb": 5, "good thumb/bunny": 4, "bad thumb/bunny": 4,
417
+ "good bunny/bunny": 3, "bad bunny/bunny": 5, "shell/shell": 4, "shell/bird": 4,
418
+ "shell/hazard": 4, "yoshi/shell": 3, "good bird/bird": 4, "bad bird/bird": 5,
419
+ "bird/hazard": 4, "hazard/hazard": 5, "good yoshi/bird": 3, "bad yoshi/bird": 4,
420
+ "yoshi/hazard": 4, "same yoshi/yoshi": 5, "diff yoshi/yoshi": 5,
421
+ "good kite/kite": 1, "bad kite/kite": 5, "good kite/cut": 3, "bad kite/cut": 6,
422
+ "kite/T": 4, "good kite/N": 3, "bad kite/N": 4, "kite/tie": 4, "cut/T": 4,
423
+ "good cut/N": 4, "bad cut/N": 5, "cut/tie": 4, "good cut/cut": 3, "bad cut/cut": 6,
424
+ "good T/T": 3, "bad T/T": 4, "T/N": 5, "good T/tie": 3, "bad T/tie": 4,
425
+ "good N/N": 2, "bad N/N": 4, "tie/N": 5, "good tie/tie": 3, "bad tie/tie": 4
426
+ };
427
+
428
+ /**
429
+ * OBL_TRANSLATION: map nonspecific OBL to specific OBLs without layer flips
430
+ */
431
+ static OBL_TRANSLATION = {
432
+ 'solved/solved': ['solved/solved'],
433
+ '1c/1c': ['1c/1c'],
434
+ 'cadj/cadj': ['cadj/cadj'],
435
+ 'cadj/copp': ['cadj/copp'],
436
+ 'copp/copp': ['copp/copp'],
437
+ '3c/3c': ['3c/3c'],
438
+ '4e/4e': ['4e/4e'],
439
+ '3e/3e': ['3e/3e'],
440
+ 'line/line': ['line/line'],
441
+ 'L/line': ['L/line'],
442
+ 'L/L': ['L/L'],
443
+ '1e/1e': ['1e/1e'],
444
+ 'good pair/pair': ['left pair/left pair', 'right pair/right pair'],
445
+ 'bad pair/pair': ['left pair/right pair'],
446
+ 'good arrow/pair': ['left arrow/right pair', 'right arrow/left pair'],
447
+ 'bad arrow/pair': ['left arrow/left pair', 'right arrow/right pair'],
448
+ 'good arrow/arrow': ['left arrow/left arrow', 'right arrow/right arrow'],
449
+ 'bad arrow/arrow': ['left arrow/right arrow'],
450
+ 'gem/gem': ['gem/gem'],
451
+ 'gem/knight': ['gem/left knight', 'gem/right knight'],
452
+ 'gem/axe': ['gem/left axe', 'gem/right axe'],
453
+ 'gem/squid': ['gem/squid'],
454
+ 'good knight/knight': ['left knight/right knight'],
455
+ 'bad knight/knight': ['left knight/left knight', 'right knight/right knight'],
456
+ 'good knight/axe': ['left knight/left axe', 'right knight/right axe'],
457
+ 'bad knight/axe': ['left knight/right axe', 'right knight/left axe'],
458
+ 'same axe/axe': ['left axe/left axe', 'right axe/right axe'],
459
+ 'diff axe/axe': ['left axe/right axe'],
460
+ 'squid/knight': ['squid/left knight', 'squid/right knight'],
461
+ 'squid/axe': ['squid/left axe', 'squid/right axe'],
462
+ 'squid/squid': ['squid/squid'],
463
+ 'good thumb/thumb': ['left thumb/left thumb', 'right thumb/right thumb'],
464
+ 'bad thumb/thumb': ['left thumb/right thumb'],
465
+ 'good thumb/bunny': ['left thumb/right bunny', 'right thumb/left bunny'],
466
+ 'bad thumb/bunny': ['left thumb/left bunny', 'right thumb/right bunny'],
467
+ 'good bunny/bunny': ['left bunny/left bunny', 'right bunny/right bunny'],
468
+ 'bad bunny/bunny': ['left bunny/right bunny'],
469
+ 'shell/shell': ['shell/shell'],
470
+ 'shell/bird': ['shell/left bird', 'shell/right bird'],
471
+ 'shell/hazard': ['shell/hazard'],
472
+ 'yoshi/shell': ['left yoshi/shell', 'right yoshi/shell'],
473
+ 'good bird/bird': ['left bird/right bird'],
474
+ 'bad bird/bird': ['left bird/left bird', 'right bird/right bird'],
475
+ 'bird/hazard': ['left bird/hazard', 'right bird/hazard'],
476
+ 'hazard/hazard': ['hazard/hazard'],
477
+ 'good yoshi/bird': ['left yoshi/left bird', 'right yoshi/right bird'],
478
+ 'bad yoshi/bird': ['left yoshi/right bird', 'right yoshi/left bird'],
479
+ 'yoshi/hazard': ['left yoshi/hazard', 'right yoshi/hazard'],
480
+ 'same yoshi/yoshi': ['left yoshi/left yoshi', 'right yoshi/right yoshi'],
481
+ 'diff yoshi/yoshi': ['left yoshi/right yoshi'],
482
+ 'good kite/kite': ['left kite/left kite', 'right kite/right kite'],
483
+ 'bad kite/kite': ['left kite/right kite'],
484
+ 'good kite/cut': ['left kite/left cut', 'right kite/right cut'],
485
+ 'bad kite/cut': ['left kite/right cut', 'right kite/left cut'],
486
+ 'kite/T': ['left kite/black T', 'left kite/white T', 'right kite/black T', 'right kite/white T'],
487
+ 'good kite/N': ['left kite/right N', 'right kite/left N'],
488
+ 'bad kite/N': ['left kite/left N', 'right kite/right N'],
489
+ 'kite/tie': ['left kite/black tie', 'left kite/white tie', 'right kite/black tie', 'right kite/white tie'],
490
+ 'cut/T': ['left cut/black T', 'left cut/white T', 'right cut/black T', 'right cut/white T'],
491
+ 'good cut/N': ['left cut/left N', 'right cut/right N'],
492
+ 'bad cut/N': ['left cut/right N', 'right cut/left N'],
493
+ 'cut/tie': ['left cut/black tie', 'left cut/white tie', 'right cut/black tie', 'right cut/white tie'],
494
+ 'good cut/cut': ['left cut/left cut', 'right cut/right cut'],
495
+ 'bad cut/cut': ['left cut/right cut'],
496
+ 'good T/T': ['black T/black T', 'white T/white T'],
497
+ 'bad T/T': ['black T/white T'],
498
+ 'T/N': ['black T/left N', 'black T/right N', 'white T/left N', 'white T/right N'],
499
+ 'good T/tie': ['black T/black tie', 'white T/white tie'],
500
+ 'bad T/tie': ['black T/white tie', 'white T/black tie'],
501
+ 'good N/N': ['left N/left N', 'right N/right N'],
502
+ 'bad N/N': ['left N/right N'],
503
+ 'tie/N': ['black tie/left N', 'black tie/right N', 'white tie/left N', 'white tie/right N'],
504
+ 'good tie/tie': ['black tie/black tie', 'white tie/white tie'],
505
+ 'bad tie/tie': ['black tie/white tie']
506
+ };
507
+
508
+ /**
509
+ * CORNERS: possible OBLP corner memo
510
+ */
511
+ static CORNERS = [[''], ['1', '3', '5', '7'], ['13', '15', '17', '35', '37', '57'], ['135', '137', '157', '357'], ['1357']];
512
+
513
+ /**
514
+ * EDGES: possible OBLP edge memo
515
+ */
516
+ static EDGES = [[''], ['2', '4', '6', '8'], ['24', '26', '28', '46', '48', '68'], ['246', '248', '268', '468'], ['2468']];
517
+
518
+ /**
519
+ * TOTAL_CORNERS: flat list of CORNERS
520
+ */
521
+ static TOTAL_CORNERS = ['', '1', '3', '5', '7', '13', '15', '17', '35', '37', '57', '135', '137', '157', '357', '1357'];
522
+
523
+ /**
524
+ * TOTAL_EDGES: flat list of EDGES
525
+ */
526
+ static TOTAL_EDGES = ['', '2', '4', '6', '8', '24', '26', '28', '46', '48', '68', '246', '248', '268', '468', '2468'];
527
+
528
+ /**
529
+ * @param {object} [tempReplacements]: initial manual unkarnifications.
271
530
  */
272
531
  constructor(tempReplacements = { "meow :3": "meow :3" }) {
273
532
  // place to put manual unkarnifications
@@ -275,9 +534,9 @@ export default class SquanLib {
275
534
  }
276
535
 
277
536
  /**
278
- * setTempReplacements replace the entire tempReplacements map.
537
+ * setTempReplacements: replace the entire tempReplacements map.
279
538
  *
280
- * @param {Object<string,string>} replacements the new key→value pairs
539
+ * @param {Object<string,string>} replacements the new key→value pairs
281
540
  * @returns {this}
282
541
  */
283
542
  setTempReplacements(replacements) {
@@ -286,9 +545,9 @@ export default class SquanLib {
286
545
  }
287
546
 
288
547
  /**
289
- * addTempReplacements merge key→value pairs into tempReplacements.
548
+ * addTempReplacements: merge key→value pairs into tempReplacements.
290
549
  *
291
- * @param {Object<string,string>} replacements pairs to add (overwrites collisions)
550
+ * @param {Object<string,string>} replacements: pairs to add (overwrites collisions)
292
551
  * @returns {this}
293
552
  */
294
553
  addTempReplacements(replacements) {
@@ -298,16 +557,16 @@ export default class SquanLib {
298
557
 
299
558
 
300
559
  // =========================================================================
301
- // SECTION 2 CORE UTILITIES
560
+ // SECTION 2: CORE UTILITIES
302
561
  // =========================================================================
303
562
 
304
563
  /**
305
- * dictReplace repeatedly applies every key→value substitution in `dict`
564
+ * dictReplace: repeatedly applies every key→value substitution in `dict`
306
565
  * to `str` until the string stabilizes.
307
566
  *
308
- * @param {string} str the string to be replaced
309
- * @param {object} dict the dictionary
310
- * @returns {string} the fully replaced string
567
+ * @param {string} str the string to be replaced
568
+ * @param {object} dict the dictionary
569
+ * @returns {string} the fully replaced string
311
570
  */
312
571
  dictReplace(str, dict) {
313
572
  const pattern = new RegExp(
@@ -320,7 +579,7 @@ export default class SquanLib {
320
579
  }
321
580
 
322
581
  /**
323
- * addCommas e.g. "2-1" → "2,-1"
582
+ * addCommas: e.g. "2-1" → "2,-1"
324
583
  *
325
584
  * length 1 → "N,0"
326
585
  * length 2 → starts with '-'? "-N,0" : "A,B"
@@ -328,11 +587,11 @@ export default class SquanLib {
328
587
  * length 4 → "AB,CD"
329
588
  * anything else that is not all-digits/minus → pass through unchanged
330
589
  *
331
- * @param {string} alg the scramble, single space separated. can have commas already.
332
- * @returns {string} the scramble, with commas added
590
+ * @param {string} alg the scramble, any separator (no additional spaces). can have commas already.
591
+ * @returns {string} the scramble, with commas added
333
592
  */
334
593
  addCommas(alg) {
335
- return alg.split(' ').map(move => {
594
+ return alg.split(/[/\\| ]/).map(move => {
336
595
  if (!move || isNaN(Number(move.replaceAll('-', ''))) || move.includes(","))
337
596
  return move;
338
597
  switch (move.length) {
@@ -348,36 +607,50 @@ export default class SquanLib {
348
607
  }
349
608
 
350
609
  /**
351
- * isKarn returns true if the string uses any letters
610
+ * isKarn: returns true if the string uses any letters
352
611
  *
353
- * @param {string} str the alg
354
- * @returns {boolean} whether the alg contains letters
612
+ * @param {string} str the alg
613
+ * @returns {boolean} whether the alg contains letters
355
614
  */
356
615
  isKarn(str) {
357
616
  return /[a-zA-Z]/.test(str);
358
617
  }
359
618
 
360
619
  /**
361
- * getAlignment turns topA and bottomA into a starting move
620
+ * getAlignmentMove: turns topA and bottomA into a starting move
362
621
  *
363
- * @param {boolean} topA top misalign?
364
- * @param {boolean} bottomA bottom misalign?
365
- * @returns {string} e.g. "10", "1-1"
622
+ * @param {boolean} topA top misalign?
623
+ * @param {boolean} bottomA bottom misalign?
624
+ * @returns {string} e.g. "10", "1-1"
366
625
  */
367
- getAlignment(topA, bottomA) {
626
+ getAlignmentMove(topA, bottomA) {
368
627
  return (topA ? '1' : '0') + (bottomA ? '-1' : '0');
369
628
  }
370
629
 
630
+ /**
631
+ * getAlignment: turns a starting/ending move into topA and bottomA
632
+ *
633
+ * @param {string} m the move
634
+ * @returns {topA: top misalign?, bottomA: bottom misalign?}}
635
+ */
636
+ getAlignment(m) {
637
+ if (!m) return { topA: false, bottomA: false } // this is just a 00 move
638
+ m = this.addCommas(m);
639
+ if (!m.includes(",")) throw new Error("getAlignment: move is weird: " + m);
640
+ const [u, d] = m.split(",");
641
+ return { topA: u !== "0", bottomA: d !== "0" }
642
+ }
643
+
371
644
 
372
645
  // =========================================================================
373
- // SECTION 3 UNKARNIFY PIPELINE
646
+ // SECTION 3: UNKARNIFY PIPELINE
374
647
  // =========================================================================
375
648
 
376
649
  /**
377
- * unkarnifyHelp does the actual unkarnifying
650
+ * unkarnifyHelp: does the actual unkarnifying
378
651
  *
379
- * @param {string} alg the alg
380
- * @returns {string} surface-level unkarnified alg
652
+ * @param {string} alg the alg
653
+ * @returns {string} surface-level unkarnified alg
381
654
  */
382
655
  unkarnifyHelp(alg) {
383
656
  // trim and replace random ass characters
@@ -427,11 +700,11 @@ export default class SquanLib {
427
700
  }
428
701
 
429
702
  /**
430
- * unkarnify master karn → WCA
703
+ * unkarnify: master karn → WCA
431
704
  * basically unkarnifyHelp + replaceShorthand with bling blings
432
705
  *
433
- * @param {string} alg the alg to be unkarnified
434
- * @returns {string} unkarnified alg, duh
706
+ * @param {string} alg the alg to be unkarnified
707
+ * @returns {string} unkarnified alg, duh
435
708
  */
436
709
  unkarnify(alg) {
437
710
  // overrides
@@ -474,11 +747,11 @@ export default class SquanLib {
474
747
  }
475
748
 
476
749
  /**
477
- * replaceShorthands replace shorthands (bjj, fv, kk, …) in an alg,
750
+ * replaceShorthands: replace shorthands (bjj, fv, kk, …) in an alg,
478
751
  * tracking alignment state to choose the correct shorthand.
479
752
  *
480
- * @param {string} alg the alg
481
- * @returns {string} the alg with shorthands replaced... guys jsdoc is sometimes dumb
753
+ * @param {string} alg the alg
754
+ * @returns {string} the alg with shorthands replaced... guys jsdoc is sometimes dumb
482
755
  */
483
756
  replaceShorthands(alg) {
484
757
  const moves = alg.split(/[\/\\\|]/);
@@ -495,7 +768,7 @@ export default class SquanLib {
495
768
  if (!move) continue;
496
769
 
497
770
  if (move.includes(',')) {
498
- // Numeric turn update alignment tracker.
771
+ // Numeric turn: update alignment tracker.
499
772
  const [u, d] = move.split(',');
500
773
  if (parseInt(u, 10) % 3 !== 0) topA = !topA;
501
774
  if (parseInt(d, 10) % 3 !== 0) bottomA = !bottomA;
@@ -503,11 +776,11 @@ export default class SquanLib {
503
776
  // shorthand
504
777
  const key = SquanLib.alignmentIndependent.has(move.toLowerCase())
505
778
  ? move.toLowerCase()
506
- : move.toLowerCase() + this.getAlignment(topA, bottomA);
779
+ : move.toLowerCase() + this.getAlignmentMove(topA, bottomA);
507
780
 
508
781
  const replacement = SquanLib.shorthandToKarn[key];
509
782
  if (replacement === undefined)
510
- throw new Error(`replaceShorthands: "${move}" with alignment ${this.getAlignment(topA, bottomA)} is not defined.`);
783
+ throw new Error(`replaceShorthands: "${move}" with alignment ${this.getAlignmentMove(topA, bottomA)} is not defined.`);
511
784
 
512
785
  alg = alg.replace(move, replacement);
513
786
 
@@ -527,11 +800,11 @@ export default class SquanLib {
527
800
 
528
801
 
529
802
  // =========================================================================
530
- // SECTION 4 SCRAMBLE / ALG UTILITIES
803
+ // SECTION 4: SCRAMBLE / ALG UTILITIES
531
804
  // =========================================================================
532
805
 
533
806
  /**
534
- * parseScramble tokenizes a WCA squan scramble
807
+ * parseScramble: tokenizes a WCA squan scramble
535
808
  *
536
809
  * @param {string} alg the alg
537
810
  * @returns {{ type: string, top?: number, bottom?: number }[]} after parsing
@@ -556,7 +829,7 @@ export default class SquanLib {
556
829
  }
557
830
 
558
831
  /**
559
- * twist does a slice on a hex string
832
+ * twist: does a slice on a hex string
560
833
  *
561
834
  * @param {string} tlHex top layer hex, from UFL clockwise
562
835
  * @param {string} blHex bottom layer hex, from DF clockwise
@@ -569,19 +842,30 @@ export default class SquanLib {
569
842
  }
570
843
 
571
844
  /**
572
- * cycleLeft rotate a hex string left by `places` positions (mod 12).
845
+ * doSlice: does a slice on a CSP-style OBL cube. throws error if unsliceable.
573
846
  *
574
- * @param {string} hex 12-char hex
575
- * @param {number} places how much to shift left by
576
- * @returns {string} the shifted string
847
+ * @param {string} cube the CSP-style "BbWw" cube
848
+ * @returns {string} the cube post-slice, if sliceable
577
849
  */
578
- cycleLeft(hex, places) {
579
- const n = ((places % 12) + 12) % 12;
580
- return hex.slice(n) + hex.slice(0, n);
850
+ doSlice(cube) {
851
+ const [UR, UL, DR, DL] = [
852
+ cube.slice(0, SquanLib.HALF_L),
853
+ cube.slice(SquanLib.HALF_L, SquanLib.LAYERL),
854
+ cube.slice(SquanLib.LAYERL, SquanLib.THREE_FOUR_L),
855
+ cube.slice(SquanLib.THREE_FOUR_L, SquanLib.CUBEL)
856
+ ];
857
+ const isUP = (char) => char === char.toUpperCase();
858
+ const canSlice = (halfLayer) => (
859
+ !isUP(halfLayer.at(0)) || isUP(halfLayer.at(1)) &&
860
+ !isUP(halfLayer.at(-1)) || isUP(halfLayer.at(-2))
861
+ );
862
+ if (!([UR, UL, DR, DL].map(canSlice).every(Boolean)))
863
+ throw new Error("doSlice: unsliceable position encountered");
864
+ return DR + UL + UR + DL;
581
865
  }
582
866
 
583
867
  /**
584
- * algToHex get the hex state that the alg generates
868
+ * algToHex: get the hex state that the alg generates
585
869
  *
586
870
  * @param {string} alg an alg. karn is accepted.
587
871
  * @returns {tlHex: string, blHex: string} the hex
@@ -593,15 +877,48 @@ export default class SquanLib {
593
877
  if (move.type === 'twist') {
594
878
  ({ tlHex, blHex } = this.twist(tlHex, blHex));
595
879
  } else {
596
- tlHex = this.cycleLeft(tlHex, move.top);
597
- blHex = this.cycleLeft(blHex, move.bottom);
880
+ tlHex = this.shift(tlHex, -move.top);
881
+ blHex = this.shift(blHex, -move.bottom);
598
882
  }
599
883
  }
600
884
  return { tlHex, blHex };
601
885
  }
602
886
 
603
887
  /**
604
- * invertScramble reverses a scramble
888
+ * doMoves: does moves on a cube
889
+ *
890
+ * @param {string} ms the moves. karn accepted.
891
+ * @param {string} s the starting CSP-styled cube. leave to use the solved OBL state
892
+ * @returns {string} the cube post-moves
893
+ */
894
+ doMoves(ms, s) {
895
+ if (s === undefined) s = SquanLib.SOLVED;
896
+ for (const tok of this.unkarnify(ms).split('/')) {
897
+ const m = tok.trim();
898
+ if (m !== '') {
899
+ const [u, d] = m.split(',').map(Number);
900
+ s = this.moveCube(s, u, d);
901
+ }
902
+ s = this.doSlice(s);
903
+ }
904
+ return this.doSlice(s);
905
+ }
906
+
907
+ /**
908
+ * moveCube: does a move on a CSP-style cube
909
+ *
910
+ * @param {string} cube the CSP-style cube
911
+ * @param {number} u the U move
912
+ * @param {number} d the D move
913
+ * @returns {string} the cube post-move
914
+ */
915
+ moveCube(cube, u, d) {
916
+ return this.shift(cube.slice(0, SquanLib.LAYERL), u) +
917
+ this.shift(cube.slice(SquanLib.LAYERL), d);
918
+ }
919
+
920
+ /**
921
+ * invertScramble: reverses a scramble
605
922
  *
606
923
  * @param {string} alg the alg. karn is accepted.
607
924
  * @returns {string} the reversed alg
@@ -623,7 +940,7 @@ export default class SquanLib {
623
940
  }
624
941
 
625
942
  /**
626
- * isPBL check if a hex is PBL
943
+ * isPBL: check if a hex is PBL
627
944
  *
628
945
  * @param {string} hex
629
946
  * @returns {boolean} whether it's a PBL
@@ -682,11 +999,11 @@ export default class SquanLib {
682
999
 
683
1000
 
684
1001
  // =========================================================================
685
- // SECTION 5 KARNIFY (WCA → karn)
1002
+ // SECTION 5: KARNIFY (WCA → karn)
686
1003
  // =========================================================================
687
1004
 
688
1005
  /**
689
- * karnify converts WCA to karn.
1006
+ * karnify: converts WCA to karn.
690
1007
  *
691
1008
  * 1. assert that no two slices are next to each other and it's fully numeric
692
1009
  * 2. compute any startingSlice and endingSlice
@@ -696,8 +1013,8 @@ export default class SquanLib {
696
1013
  * 5. join back into an alg while reattaching leading/trailing slices,
697
1014
  * and dictReplace for high karns
698
1015
  *
699
- * @param {string} alg WCA format
700
- * @returns {string} karn
1016
+ * @param {string} alg WCA format
1017
+ * @returns {string} karn
701
1018
  */
702
1019
  karnify(alg) {
703
1020
  alg = alg.trim();
@@ -727,14 +1044,14 @@ export default class SquanLib {
727
1044
 
728
1045
 
729
1046
  // =========================================================================
730
- // SECTION 6 MOVE MATH & SEQUENCE OPTIMIZER
1047
+ // SECTION 6: MOVE MATH & SEQUENCE OPTIMIZER
731
1048
  // =========================================================================
732
1049
 
733
1050
  /**
734
- * legalMove normalizes a raw turn value into the canonical squan range [−5, 6].
1051
+ * legalMove: normalizes a raw turn value into the canonical squan range [−5, 6].
735
1052
  *
736
- * @param {number} m raw turn value, e.g. −7 or 9
737
- * @returns {number} normalized value in [−5, 6]
1053
+ * @param {number} m raw turn value, e.g. −7 or 9
1054
+ * @returns {number} normalized value in [−5, 6]
738
1055
  */
739
1056
  legalMove(m) {
740
1057
  m = m % 12; // get a range from -11 to 11
@@ -744,7 +1061,7 @@ export default class SquanLib {
744
1061
  }
745
1062
 
746
1063
  /**
747
- * addMoves adds two move strings component-wise and legalizes each result.
1064
+ * addMoves: adds two move strings component-wise and legalizes each result.
748
1065
  *
749
1066
  * Tolerates "A"/"a" alignment markers. When one operand is an alignment
750
1067
  * marker and the other is a move, the marker is flipped iff the top
@@ -752,8 +1069,8 @@ export default class SquanLib {
752
1069
  * The above assumes that the move is in-CS.
753
1070
  * Both operands cannot simultaneously be alignment markers.
754
1071
  *
755
- * @param {string} move1 "top,bot" string, OR "A"/"a" alignment marker
756
- * @param {string} move2 "top,bot" string, OR "A"/"a" alignment marker
1072
+ * @param {string} move1 "top,bot" string, OR "A"/"a" alignment marker
1073
+ * @param {string} move2 "top,bot" string, OR "A"/"a" alignment marker
757
1074
  * @returns {string}
758
1075
  *
759
1076
  * @example
@@ -783,11 +1100,11 @@ export default class SquanLib {
783
1100
  }
784
1101
 
785
1102
  /**
786
- * changesAlignment check if performing this turn value changes the
1103
+ * changesAlignment: check if performing this turn value changes the
787
1104
  * alignment state (i.e. the turn is not a multiple of 3). Assumes everything
788
1105
  * is in CS.
789
1106
  *
790
- * @param {number} m top-layer turn value,
1107
+ * @param {number} m top-layer turn value,
791
1108
  * @returns {boolean}
792
1109
  */
793
1110
  changesAlignment(m) {
@@ -795,7 +1112,7 @@ export default class SquanLib {
795
1112
  }
796
1113
 
797
1114
  /**
798
- * optimize replaces known optimizable moves (the OPTIM table) in a WCA alg
1115
+ * optimize: replaces known optimizable moves (the OPTIM table) in a WCA alg
799
1116
  *
800
1117
  * 1. if no more optimization can be done, exit.
801
1118
  * 2. split on "/" into an array
@@ -807,8 +1124,8 @@ export default class SquanLib {
807
1124
  * the last into the succeeding move, and replace the inners
808
1125
  * 6. restart the outer loop after any change
809
1126
  *
810
- * @param {string} alg slash-separated WCA alg, e.g. "A/-3,0/3,3/3,3/a"
811
- * @returns {string} optimized alg
1127
+ * @param {string} alg slash-separated WCA alg, e.g. "A/-3,0/3,3/3,3/a"
1128
+ * @returns {string} optimized alg
812
1129
  */
813
1130
  optimize(alg) {
814
1131
  const optimKeys = Object.keys(SquanLib.OPTIM);
@@ -856,14 +1173,14 @@ export default class SquanLib {
856
1173
 
857
1174
 
858
1175
  // =========================================================================
859
- // SECTION 7 ERGONOMICS RATING
1176
+ // SECTION 7: ERGONOMICS RATING
860
1177
  // =========================================================================
861
1178
 
862
1179
  /**
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"
1180
+ * getMoveValue: look up the ergonomic cost of a single move.
1181
+ * @param {boolean} startA is it top misalign right now?
1182
+ * @param {boolean} upslice is this an upslice?
1183
+ * @param {string} move e.g. "3,0"
867
1184
  * @returns {number}
868
1185
  */
869
1186
  getMoveValue(startA, upslice, move) {
@@ -884,8 +1201,8 @@ export default class SquanLib {
884
1201
  }
885
1202
 
886
1203
  /**
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)
1204
+ * getOverwork: calculates how much overwork is present in an alg, along with bonus.
1205
+ * @param {string[]} moves array of "top,bot" strings (interior moves only)
889
1206
  * @returns {{ movement: number, bonus: number }}
890
1207
  */
891
1208
  getOverwork(moves) {
@@ -910,7 +1227,9 @@ export default class SquanLib {
910
1227
  const isLeft = (t === 6 || t < 0);
911
1228
  if (isLeft) {
912
1229
  streak++;
913
- closestMov += Math.abs(SquanLib.CLOSEST_MAP.get(t) ?? 0);
1230
+ if (!SquanLib.CLOSEST_MAP.has(t))
1231
+ throw new Error("getOverwork: top move is weird: " + t)
1232
+ closestMov += Math.abs(SquanLib.CLOSEST_MAP.get(t));
914
1233
  buffer += Math.abs(t);
915
1234
  // has streak and nontrivial move → penalize
916
1235
  if (streak > 1 && closestMov > 3) { movement += buffer; buffer = 0; }
@@ -923,7 +1242,9 @@ export default class SquanLib {
923
1242
  const isLeft = b > 0;
924
1243
  if (isLeft) {
925
1244
  streak++;
926
- closestMov += Math.abs(SquanLib.CLOSEST_MAP.get(b) ?? 0);
1245
+ if (!SquanLib.CLOSEST_MAP.has(b))
1246
+ throw new Error("getOverwork: top move is weird: " + b)
1247
+ closestMov += Math.abs(SquanLib.CLOSEST_MAP.get(b));
927
1248
  buffer += Math.abs(b);
928
1249
  if (streak > 1 && closestMov > 3) { movement += buffer; buffer = 0; }
929
1250
  } else { streak = 0; closestMov = 0; buffer = 0; }
@@ -939,11 +1260,11 @@ export default class SquanLib {
939
1260
  }
940
1261
 
941
1262
  /**
942
- * rateAlg ergonomic rating for a single in CS squan alg
1263
+ * rateAlg: ergonomic rating for a single in CS squan alg
943
1264
  *
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
1265
+ * @param {string} algRaw raw alg (karn or WCA)
1266
+ * @param {boolean} initialTopA if the alg starts top misalign
1267
+ * @param {object} [weights] override default weight constants
947
1268
  * @returns {{ score: number, sliceStart: string }}
948
1269
  * sliceStart: '/' (prefer upslice), '\' (prefer downslice), or ' ' (no preference)
949
1270
  */
@@ -1009,10 +1330,10 @@ export default class SquanLib {
1009
1330
  }
1010
1331
 
1011
1332
  /**
1012
- * rateAndSort rate a list of algs and return them sorted by ergonomics high to low
1333
+ * rateAndSort: rate a list of algs and return them sorted by ergonomics high to low
1013
1334
  *
1014
- * @param {string[]} algLines raw alg strings
1015
- * @param {string} [posHex=''] position hex (used to determine initialTopA)
1335
+ * @param {string[]} algLines raw alg strings
1336
+ * @param {string} [posHex=''] position hex (used to determine initialTopA)
1016
1337
  * @returns {{ alg: string, score: number }[]}
1017
1338
  */
1018
1339
  rateAndSort(algLines, posHex = '') {
@@ -1045,4 +1366,429 @@ export default class SquanLib {
1045
1366
  return result;
1046
1367
  }).sort((a, b) => b.score - a.score);
1047
1368
  }
1369
+
1370
+ // =========================================================================
1371
+ // SECTION 8: ALG TRANSFORM
1372
+ // =========================================================================
1373
+
1374
+ /**
1375
+ * sepIndex: helper to identify where the D move starts in a move without comma
1376
+ *
1377
+ * @param {string} a a move without commas, e.g. "3-3"
1378
+ * @returns {number} the 0-based index of the start of the D move
1379
+ * @example "3-3" → 1
1380
+ */
1381
+ sepIndex(a) {
1382
+ let inx = 0;
1383
+ for (const ch of a) { inx++; if (/\d/.test(ch)) break; }
1384
+ return inx;
1385
+ }
1386
+
1387
+ /**
1388
+ * compl: get the complement of a move
1389
+ *
1390
+ * @param {string} a a move without commas, e.g. "-12"
1391
+ * @returns {string} the complement move
1392
+ * @example "-12" → "5-4"
1393
+ */
1394
+ compl(a) {
1395
+ if (!a) return a;
1396
+ const inx = this.sepIndex(a);
1397
+ return String(this.legalMove(6 + parseInt(a.slice(0, inx), 10))) +
1398
+ String(this.legalMove(6 + parseInt(a.slice(inx), 10)));
1399
+ }
1400
+
1401
+ /**
1402
+ * lf: get the layer flip of a move
1403
+ *
1404
+ * @param {string} a a move without commas, e.g. "-12"
1405
+ * @returns {string} the layer flip move
1406
+ * @example "-12" → "2-1"
1407
+ */
1408
+ lf(a) {
1409
+ if (!a) return a;
1410
+ const inx = this.sepIndex(a);
1411
+ return a.slice(inx) + a.slice(0, inx);
1412
+ }
1413
+
1414
+ /**
1415
+ * compact: parse any input format into space-separated no-comma compact
1416
+ * segments, e.g. "30 -33 30". uses full unkarnify for karn input.
1417
+ *
1418
+ * @param {string} algIn the inputted alg. any input format
1419
+ * @returns {string} the compact format
1420
+ */
1421
+ compact(algIn) {
1422
+ let alg = algIn
1423
+ .replace(/\[.*?\]/g, "")
1424
+ .replace(/[()]/g, "")
1425
+ .replaceAll(" ", "").trim();
1426
+ if (this.isKarn(alg)) {
1427
+ const numeric = this.unkarnify(alg);
1428
+ return numeric.split("/").filter(Boolean).map(m => {
1429
+ if (!m.includes(","))
1430
+ throw new Error(
1431
+ `algToInternal: m doesn't have commas post karnifying: ${m}`
1432
+ )
1433
+ const [u, d] = m.split(",");
1434
+ return String(this.legalMove(parseInt(u, 10))) +
1435
+ String(this.legalMove(parseInt(d, 10)));
1436
+ }).join(" ");
1437
+ }
1438
+ alg = alg.trim();
1439
+ if (alg.includes("/")) {
1440
+ return alg.split("/").filter(Boolean).map(m => {
1441
+ if (!m.includes(",")) m = this.addCommas(m);
1442
+ if (!m.includes(","))
1443
+ throw new Error(
1444
+ `algToInternal: m doesn't have commas post addComma: ${m}`
1445
+ )
1446
+ const [u, d] = m.split(",");
1447
+ return String(this.legalMove(parseInt(u, 10))) +
1448
+ String(this.legalMove(parseInt(d, 10)));
1449
+ }).join(" ");
1450
+ }
1451
+ return alg.split(" ").filter(p => p).map(m => {
1452
+ const p = m.includes(",")
1453
+ ? m.split(",")
1454
+ : [m.slice(0, this.sepIndex(m)), m.slice(this.sepIndex(m))];
1455
+ return String(this.legalMove(parseInt(p[0], 10))) +
1456
+ String(this.legalMove(parseInt(p[1], 10)));
1457
+ }).join(" ");
1458
+ }
1459
+
1460
+ /**
1461
+ * countY2Positions: count the number of possible y2 positions
1462
+ *
1463
+ * @param {string} algIn the alg
1464
+ * @returns {number} how many positions the alg can y2 at
1465
+ */
1466
+ countY2Positions(algIn) {
1467
+ const segs = this.compact(algIn).split(" ").filter(p => p);
1468
+ return Math.max(0, segs.length - 3);
1469
+ }
1470
+
1471
+ /**
1472
+ * applyY2s: y2 an alg at a list of specified slices, and give comments
1473
+ *
1474
+ * @param {string} algIn the alg
1475
+ * @param {number[]} lfLst the list of slice positions to y2 at
1476
+ * @param {boolean} k whether to output as karn, leave to use input format
1477
+ * @returns {string} the y2'ed alg, plus comments
1478
+ */
1479
+ applyY2s(algIn, lfLst, k = null) {
1480
+ let alg = algIn.replaceAll(/\[.*?\]/g, "").trim();
1481
+ if (!lfLst) lfLst = [];
1482
+
1483
+ const ki = this.isKarn(alg);
1484
+ const kOut = k === null ? ki : k;
1485
+
1486
+ alg = this.unkarnify(algIn);
1487
+ let alst = alg.split("/");
1488
+
1489
+ // save for alignment-changes check
1490
+ const firstMove = alst[0];
1491
+ const lastMove = alst[alst.length - 1];
1492
+
1493
+ // Apply explicit y2s to interior moves
1494
+ let lfing = false, facingD = false;
1495
+ for (let i = 1; i <= alst.length - 3; i++) {
1496
+ let m = alst[i];
1497
+ m = facingD ? this.lf(m) : m;
1498
+ if (lfLst.includes(i)) { m = this.compl(m); lfing = !lfing; }
1499
+ facingD = lfing ? !facingD : facingD;
1500
+ alst[i] = m;
1501
+ }
1502
+
1503
+ // fix last interior move
1504
+ const lastIntIdx = alst.length - 2;
1505
+ if (facingD) alst[lastIntIdx] = this.lf(alst[lastIntIdx]);
1506
+ if (lfing !== facingD) alst[lastIntIdx] = this.compl(alst[lastIntIdx]);
1507
+ const lastIntMove = alst[lastIntIdx];
1508
+
1509
+ // add commas to all moves
1510
+ for (let i = 0; i < alst.length; i++) alst[i] = this.addCommas(alst[i]);
1511
+ alg = alst.join('/');
1512
+
1513
+ // build comment
1514
+ let comment = "";
1515
+ if (lastIntMove && !SquanLib.GOOD_FINISHES.has(lastIntMove))
1516
+ comment += ' (bad finish)';
1517
+ const { topA: topAstart, bottomA: bottomAstart } = this.getAlignment(firstMove);
1518
+ const { topA: topAend, bottomA: bottomAend } = this.getAlignment(lastMove);
1519
+ if (topAstart !== bottomAstart &&
1520
+ topAstart !== topAend &&
1521
+ topAend !== bottomAend)
1522
+ comment += ' (alignment changes in CS)';
1523
+
1524
+ if (kOut) {
1525
+ alg = this.karnify(alg);
1526
+ }
1527
+
1528
+ return alg + comment;
1529
+ }
1530
+
1531
+ // =========================================================================
1532
+ // SECTION 9: OBL and OBLP Utilities
1533
+ // =========================================================================
1534
+
1535
+ /**
1536
+ * layerFlip: give the layer flipped state of a CSP-style OBL state
1537
+ *
1538
+ * @param {string} state an OBL state of however long
1539
+ * @returns {string} the layer flip of that
1540
+ */
1541
+ layerFlip(state) {
1542
+ const layerFlipMap = { 'b': 'w', 'B': 'W', 'w': 'b', 'W': 'B' };
1543
+ return [...state].map(c => {
1544
+ if (c in layerFlipMap) return layerFlipMap[c];
1545
+ throw new Error("layerFlip: unrecognized character: " + c)
1546
+ }).join('');
1547
+ }
1548
+
1549
+ /**
1550
+ * shift: basically does a move on a layer
1551
+ *
1552
+ * @param {string} a CSP-style (layer) state
1553
+ * @param {number} amount the move. literally. cw = positive
1554
+ * @returns {string} the state after the move
1555
+ */
1556
+ shift(a, amount) {
1557
+ amount = ((-amount % a.length) + a.length) % a.length;
1558
+ return a.slice(amount) + a.slice(0, amount);
1559
+ }
1560
+
1561
+ /**
1562
+ * oblName: turns a possibleOBL array into a string
1563
+ *
1564
+ * @param {string[]} obl a possibleOBL-style array
1565
+ * @returns {string} the actual OBL name
1566
+ */
1567
+ oblName(obl) {
1568
+ return obl[0] ? `${obl[0]} ${obl[1]}/${obl[2]}` : `${obl[1]}/${obl[2]}`;
1569
+ }
1570
+
1571
+ /**
1572
+ * layerFlipName: layer flips an OBL name
1573
+ *
1574
+ * @param {string} obl an unspecific OBL name
1575
+ * @returns {string} the layer flipped version
1576
+ * @example "good bunny/thumb" → "good thumb/bunny"
1577
+ */
1578
+ layerFlipName(obl) {
1579
+ obl = obl.replace('/', ' ');
1580
+ const parts = obl.split(' ');
1581
+ if (parts.length === 2) return parts[1] + '/' + parts[0];
1582
+ return parts[0] + ' ' + parts[2] + '/' + parts[1];
1583
+ }
1584
+
1585
+ /**
1586
+ * speToNonSpe: get the nonspecific OBL of a specific one
1587
+ *
1588
+ * @param {string} obl the specific obl
1589
+ * @returns {string} the unspecific obl
1590
+ */
1591
+ speToNonSpe(obl) {
1592
+ const [uObl, dObl] = obl.split('/');
1593
+ const u = uObl.split(' ').pop();
1594
+ const d = dObl.split(' ').pop();
1595
+ const candidates = SquanLib.POSSIBLE_OBL
1596
+ .filter(c => c.includes(u) && c.includes(d))
1597
+ .map(c => this.oblName(c));
1598
+ for (const cand of candidates) {
1599
+ const specials = SquanLib.OBL_TRANSLATION[cand] || [];
1600
+ for (const spe of specials) {
1601
+ if (spe === obl) return cand;
1602
+ const [s1, s2] = spe.split('/');
1603
+ if (`${s2}/${s1}` === obl) return this.layerFlipName(cand);
1604
+ }
1605
+ }
1606
+ throw new Error(`speToNonSpe: No non-specific OBL found for: ${obl}`);
1607
+ }
1608
+
1609
+ /**
1610
+ * isOBLCase: checks if a CSP-style OBL state is an OBL
1611
+ *
1612
+ * @param {string} l the CSP-style state
1613
+ * @param {string} target the OBL name (one layer)
1614
+ * @returns {number | boolean} the angle offset, 1-4, or false
1615
+ */
1616
+ isOBLCase(l, target) {
1617
+ const targetPattern = Object.entries(SquanLib.OBLToEnglish)
1618
+ .find(([, v]) => v === target
1619
+ )?.[0];
1620
+ if (!targetPattern) return false;
1621
+ // to corner first
1622
+ if (l[0] !== l[0].toUpperCase()) l = this.shift(l, -1);
1623
+ for (let m = 0; m < 4; m++) {
1624
+ if (targetPattern === this.shift(l, -3 * m)) return m;
1625
+ }
1626
+ const noTT = !['T', 'tie'].includes(target.split(' ').pop());
1627
+ if (noTT) {
1628
+ // free to change the color
1629
+ const fl = this.layerFlip(l);
1630
+ for (let m = 0; m < 4; m++) {
1631
+ if (targetPattern === this.shift(fl, -3 * m)) return m;
1632
+ }
1633
+ }
1634
+ return false;
1635
+ }
1636
+
1637
+ /**
1638
+ * layerToOBL: convert a CSP-style "BbWw" layer to the OBL name and angle offset
1639
+ *
1640
+ * @param {string} layer CSP-style OBL layer
1641
+ * @returns {{obl: string, angleOffset: number}} the obl and and angle offset
1642
+ */
1643
+ layerToOBL(layer) {
1644
+ for (const obl of Object.keys(SquanLib.OBLToState))
1645
+ if (this.isOBLCase(layer, obl))
1646
+ return { obl, angleOffset: this.isOBLCase(layer, obl) };
1647
+ throw new Error('layerToOBL: no OBL matched layer: ' + layer);
1648
+ }
1649
+
1650
+
1651
+
1652
+ /**
1653
+ * getAngle: get the angle from the OBL cases and angle offsets
1654
+ *
1655
+ * @param {string} u new naming U layer OBL
1656
+ * @param {string} d new naming D layer OBL
1657
+ * @param {number} au U layer angle offset
1658
+ * @param {number} ad D layer angle offset
1659
+ * @returns {string} the angle inside a <>
1660
+ * @example "left pair", "right arrow", "1", "2" → "DL DL"
1661
+ */
1662
+ getAngle(u, d, au, ad) {
1663
+ let uAngle = SquanLib.OBL_ANGLES[u];
1664
+ let dAngle = SquanLib.OBL_ANGLES[d];
1665
+ for (let i = 0; i < au % 4; i++) uAngle = SquanLib.nextAngle[uAngle];
1666
+ for (let i = 0; i < ad % 4; i++) dAngle = SquanLib.nextAngle[dAngle];
1667
+ return `${uAngle} ${dAngle}`;
1668
+ }
1669
+
1670
+ /**
1671
+ * cubeToSpe: convert an OBL cube state to the OBL of the layers and their
1672
+ * angle offset
1673
+ *
1674
+ * @param {string} state the CSP-style OBL cube state
1675
+ * @returns {[{obl: string, angleOffset: number}, {obl: string, angleOffset: number}]}
1676
+ * the result
1677
+ */
1678
+ cubeToSpe(state) {
1679
+ return [
1680
+ this.layerToOBL(state.slice(0, SquanLib.LAYERL)),
1681
+ this.layerToOBL(state.slice(SquanLib.LAYERL))
1682
+ ];
1683
+ }
1684
+
1685
+ /**
1686
+ * getOBLLen: get the optimal slicecount for the OBL
1687
+ *
1688
+ * @param {string} o nonspe OBL
1689
+ * @returns {number} optimal slicecount for it
1690
+ * @example "good bunny/thumb" → 4
1691
+ */
1692
+ getOBLLen(o) {
1693
+ if (o in OBL_LEN) return OBL_LEN[o];
1694
+ return OBL_LEN[layer_flip_name(o)];
1695
+ }
1696
+
1697
+ /**
1698
+ * stateToLen: get the optimal slicecount for this CSP-style OBL cube state
1699
+ *
1700
+ * @param {string} u U layer CSP-style OBL state
1701
+ * @param {string} d D layer CSP-style OBL state
1702
+ * @returns {number} the optimal slicecount for the OBL
1703
+ */
1704
+ stateToLen(u, d) {
1705
+ return this.getOBLLen(this.speToNonSpe(u + '/' + d));
1706
+ }
1707
+
1708
+ /**
1709
+ * getOBLNaming: get the new naming from the old naming
1710
+ *
1711
+ * @param {string} u the U layer old naming
1712
+ * @param {string} d the D layer old naming
1713
+ * @returns {string} the matt naming for the case, slash separated
1714
+ * @example "left bunny", "right thumb" → "Uc/Thc"
1715
+ */
1716
+ getOBLNaming(u, d) {
1717
+ return SquanLib.NAMING[u] + '/' + SquanLib.NAMING[d];
1718
+ }
1719
+
1720
+
1721
+ /**
1722
+ * stateToMatt: convert an CSP-style OBL cube state to matt tracing memo
1723
+ *
1724
+ * @param {string} s CSP-style OBL cube state
1725
+ * @returns {string} matt tracing memo for the state
1726
+ */
1727
+ stateToMatt(s) {
1728
+ let u = s.slice(0, SquanLib.LAYERL), d = s.slice(SquanLib.LAYERL);
1729
+ u = (u[0] !== u[0].toLowerCase()) ? this.shift(u, 3) : this.shift(u, 2);
1730
+ d = (d[0] !== d[0].toLowerCase()) ? this.shift(d, 3) : this.shift(d, 2);
1731
+ let mem = '';
1732
+ let p = 1;
1733
+ for (let x = 0; x < SquanLib.LAYERL; x += 3) {
1734
+ // do one "pair" at one time
1735
+ if (u[x] === 'B') mem += p;
1736
+ if (u[x + 2] === 'b') mem += (p + 1);
1737
+ p += 2;
1738
+ }
1739
+ // if it's just the solved state
1740
+ mem = (mem === '') ? '- ' : mem + ' ';
1741
+ p = 1;
1742
+ for (let x = 0; x < SquanLib.LAYERL; x += 3) {
1743
+ if (d[x] === 'B') mem += p;
1744
+ if (d[x + 2] === 'b') mem += (p + 1);
1745
+ p += 2;
1746
+ }
1747
+ return (mem[mem.length - 1] === ' ') ? mem + '-' : mem;
1748
+ }
1749
+
1750
+ /**
1751
+ * mattToLayer: converts one layer matt tracing memo to OBL layer state
1752
+ *
1753
+ * @param {string} m matt tracing memo, one layer
1754
+ * @returns {string} CSP-style OBL layer
1755
+ */
1756
+ mattToLayer(m) {
1757
+ const bw = ['W', 'W', 'w', 'W', 'W', 'w', 'W', 'W', 'w', 'W', 'W', 'w'];
1758
+ for (const ch of m) {
1759
+ const num = parseInt(ch, 10);
1760
+ if (num % 2 !== 0) {
1761
+ // corner
1762
+ bw[Math.floor(num / 2) * 3] = 'B';
1763
+ bw[Math.floor(num / 2) * 3 + 1] = 'B';
1764
+ } else {
1765
+ // edge
1766
+ bw[Math.floor(num / 2) * 3 - 1] = 'b';
1767
+ }
1768
+ }
1769
+ return bw.join('');
1770
+ }
1771
+
1772
+ /**
1773
+ * mattToNonSpe: converts a full matt tracing memo to non specific OBL name
1774
+ *
1775
+ * @param {string} m a full matt tracing memo
1776
+ * @returns {string} a 2-layer nonspecific OBL name
1777
+ */
1778
+ mattToNonSpe(m) {
1779
+ const [u, d] = m.split(' ');
1780
+ return this.speToNonSpe(
1781
+ `${this.layerToOBL(this.mattToLayer(u))}/${this.layerToOBL(this.mattToLayer(d))}`
1782
+ );
1783
+ }
1784
+
1785
+ /**
1786
+ * sortOblp: sort one layer of matt tracing OBLP memo
1787
+ *
1788
+ * @param {string} seq one layer of matt tracing OBLP memo
1789
+ * @returns {string} the sorted memo
1790
+ */
1791
+ sortOblp(seq) {
1792
+ return [...seq].sort().join('');
1793
+ }
1048
1794
  }