cube-state-engine 1.6.0 → 1.7.1

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.
@@ -3,7 +3,13 @@
3
3
  "allow": [
4
4
  "Bash(npm run *)",
5
5
  "Bash(node -e \"const m=require\\('./dist/index.js'\\); console.log\\(Object.keys\\(m\\).filter\\(k=>/analyze|invert|MovePerm/.test\\(k\\)\\)\\)\")",
6
- "Bash(npm test *)"
6
+ "Bash(npm test *)",
7
+ "Read(//mnt/d/workspace/**)",
8
+ "Bash(node -e ' *)",
9
+ "Bash(node *)",
10
+ "Bash(cd /tmp)",
11
+ "Bash(sed -i 's/console.log\\(`\\\\\\\\nsideA.*cmll\\)}`\\);/console.log\\(\"\\\\\\\\nsideA=\"+sideA+\" up=\"+up+\": aFirst=\"+firstTrue\\(aDone\\)+\" aLast=\"+aDone[n-1]+\" bFirst=\"+firstTrue\\(bDone\\)+\" bLast=\"+bDone[n-1]+\" cmllFirst=\"+firstTrue\\(cmll\\)\\);/' trace.mjs)",
12
+ "Bash(npx jest *)"
7
13
  ]
8
14
  }
9
15
  }
package/dist/index.d.mts CHANGED
@@ -327,15 +327,22 @@ function rouxBlockPieces(geo, sideFace, upFace) {
327
327
  return { edges, corners };
328
328
  }
329
329
 
330
- // A Roux block is done when its three edges and two corners are all placed.
331
- function rouxBlockDone(st, centers, geo, sideFace, upFace) {
330
+ // A Roux block is done when its three edges and two corners all match the
331
+ // centers. The block can be built while the slice between the two blocks is
332
+ // rotated away from the centers; that offset is handled by the caller, which
333
+ // evaluates each orientation against snapshots pre-rotated by a fixed slice
334
+ // amount (see the candidate enumeration). Keeping the offset FIXED per
335
+ // orientation -- rather than accepting any of the four rotations move-by-move --
336
+ // is what makes detection match how the solve was actually framed instead of
337
+ // firing on coincidental alignments.
338
+ function rouxBlockDone(st, geo, sideFace, upFace) {
332
339
  const { edges, corners } = rouxBlockPieces(geo, sideFace, upFace);
333
340
  if (edges.length !== 3 || corners.length !== 2) return false;
334
- for (const e of edges)
335
- if (!slotCorrect(st, centers, e.indices, geo.per)) return false;
336
- for (const c of corners)
337
- if (!slotCorrect(st, centers, c.indices, geo.per)) return false;
338
- return true;
341
+ const centers = centersOf(st, geo);
342
+ return (
343
+ edges.every((e) => slotCorrect(st, centers, e.indices, geo.per)) &&
344
+ corners.every((c) => slotCorrect(st, centers, c.indices, geo.per))
345
+ );
339
346
  }
340
347
 
341
348
  // Applies a permutation (out[i] = st[perm[i]]) to a flat sticker array.
@@ -345,21 +352,14 @@ function applyPerm(st, perm) {
345
352
  return out;
346
353
  }
347
354
 
348
- // CMLL is done when the four corners touching the up face are solved, allowing
349
- // the M slice to be unaligned: we accept the state if ANY of the four M-slice
350
- // rotations makes those corners correct. M moves neither the corners nor the
351
- // L/R blocks, only the U/F/D/B centers (and M-slice edges), so this captures
352
- // exactly "last-layer corners solved, M not necessarily aligned yet".
353
- function cmllDone(st, geo, upFace, mPerm) {
354
- let cur = st;
355
- const corners = geo.cornersByFace(upFace);
356
- for (let m = 0; m < 4; m++) {
357
- if (m > 0) cur = applyPerm(cur, mPerm);
358
- const centers = centersOf(cur, geo);
359
- if (corners.every((c) => slotCorrect(cur, centers, c.indices, geo.per)))
360
- return true;
361
- }
362
- return false;
355
+ // CMLL corners are done when the four corners touching the up face match the
356
+ // centers. As with the blocks, any fixed slice offset is applied by the caller
357
+ // via pre-rotated snapshots, so this is a plain match against the centers.
358
+ function cmllCornersDone(st, geo, upFace) {
359
+ const centers = centersOf(st, geo);
360
+ return geo
361
+ .cornersByFace(upFace)
362
+ .every((c) => slotCorrect(st, centers, c.indices, geo.per));
363
363
  }
364
364
 
365
365
  // Index of the move that COMPLETES a stage: the first move after which the
@@ -374,6 +374,19 @@ function completionIndex(bools) {
374
374
  return null;
375
375
  }
376
376
 
377
+ // Index of the FIRST move strictly after `after` at which the condition holds.
378
+ // Used for a stage whose completion is "the first time X happens once the
379
+ // previous stage is done" -- e.g. the last-layer corners getting solved after
380
+ // the second block is built, even if the blocks are momentarily disturbed at
381
+ // that instant. Returns null if `after` is null or the condition never holds.
382
+ function firstIndexAfter(bools, after) {
383
+ if (after == null) return null;
384
+ for (let i = after + 1; i < bools.length; i++) {
385
+ if (bools[i]) return i;
386
+ }
387
+ return null;
388
+ }
389
+
377
390
  // Builds the milestone indices for one assumed cross color. Detection is
378
391
  // cumulative: an F2L pair only counts while the cross is solved, and OLL only
379
392
  // counts once the full F2L is solved. This rejects transient false positives.
@@ -429,58 +442,65 @@ function isCFOP(build) {
429
442
  return ollIdx >= lastF2L && pllIdx >= ollIdx;
430
443
  }
431
444
 
432
- // Builds the Roux milestone indices for one (sideFace, upFace) orientation.
433
- // Detection is cumulative, mirroring buildForCross: the second block only counts
434
- // while the first block holds, CMLL only once both blocks hold, and LSE is the
435
- // fully solved cube. The first block is whichever of the two opposite side faces
436
- // finishes its block earliest; the other side is the second block.
437
- function buildForRoux(snapshots, geo, sideA, upFace, mPerm) {
438
- snapshots.length;
445
+ // Builds the Roux milestone indices for one (sideFace, upFace) orientation,
446
+ // using snapshots already pre-rotated by this orientation's fixed slice offset.
447
+ //
448
+ // Milestones are taken at FIRST achievement, not while a condition holds
449
+ // continuously: once a stage is reached the solver moves on, and on face-move
450
+ // Roux later stages routinely break and rebuild earlier ones (a CMLL alg breaks
451
+ // a block; a face-move LSE breaks everything). So:
452
+ // 1st block = first instant either side's block is built;
453
+ // 2nd block = first instant the OTHER side's block is built afterwards -- NOT
454
+ // gated on the first block still being intact, which it often is
455
+ // not while the second is being inserted;
456
+ // CMLL = first instant the last-layer corners are solved afterwards;
457
+ // LSE = the fully solved cube (`lseIdx`, computed once by the caller and
458
+ // passed in, since "solved" is the same in every orientation).
459
+ function buildForRoux(snapshots, geo, sideA, upFace, lseIdx) {
439
460
  const sideB = geo.opposite[sideA];
440
461
 
441
- const centersAt = snapshots.map((st) => centersOf(st, geo));
442
- const aDone = snapshots.map((st, i) =>
443
- rouxBlockDone(st, centersAt[i], geo, sideA, upFace)
444
- );
445
- const bDone = snapshots.map((st, i) =>
446
- rouxBlockDone(st, centersAt[i], geo, sideB, upFace)
447
- );
448
- const aIdx = completionIndex(aDone);
449
- const bIdx = completionIndex(bDone);
462
+ const aDone = snapshots.map((st) => rouxBlockDone(st, geo, sideA, upFace));
463
+ const bDone = snapshots.map((st) => rouxBlockDone(st, geo, sideB, upFace));
464
+ const aIdx = firstIndexAfter(aDone, -1);
465
+ const bIdx = firstIndexAfter(bDone, -1);
466
+ if (aIdx == null || bIdx == null) return null;
450
467
 
451
- // First block = the side that completes earliest; second block = the other.
452
- let firstSide = sideA;
453
- let secondSide = sideB;
454
- let fbBools = aDone;
455
- let sbBools = bDone;
456
- if ((bIdx ?? Infinity) < (aIdx ?? Infinity)) {
457
- firstSide = sideB;
458
- secondSide = sideA;
459
- fbBools = bDone;
460
- sbBools = aDone;
461
- }
462
-
463
- const fbIdx = completionIndex(fbBools);
464
- // Second block gated by the first block holding at the same instant.
465
- const secondBlockBools = snapshots.map((_, i) => fbBools[i] && sbBools[i]);
466
- const sbIdx = completionIndex(secondBlockBools);
468
+ // First block = the side built earliest; the other side is the second block.
469
+ const aFirst = aIdx <= bIdx;
470
+ const firstSide = aFirst ? sideA : sideB;
471
+ const fbIdx = aFirst ? aIdx : bIdx;
472
+ const sbIdx = firstIndexAfter(aFirst ? bDone : aDone, fbIdx);
473
+ if (sbIdx == null) return null;
467
474
 
468
- const cmllIdx = completionIndex(
469
- snapshots.map(
470
- (st, i) => secondBlockBools[i] && cmllDone(st, geo, upFace, mPerm)
471
- )
472
- );
473
- const lseIdx = completionIndex(snapshots.map((st) => isSolvedFlat(st, geo.per)));
475
+ const cornerBools = snapshots.map((st) => cmllCornersDone(st, geo, upFace));
476
+ const cmllIdx = firstIndexAfter(cornerBools, sbIdx);
477
+ if (cmllIdx == null) return null;
474
478
 
475
- return { firstSide, secondSide, upFace, fbIdx, sbIdx, cmllIdx, lseIdx };
479
+ return {
480
+ firstSide,
481
+ secondSide: aFirst ? sideB : sideA,
482
+ upFace,
483
+ fbIdx,
484
+ sbIdx,
485
+ cmllIdx,
486
+ lseIdx,
487
+ };
476
488
  }
477
489
 
478
490
  // Does this breakdown follow the Roux order: 1st block -> 2nd block -> CMLL -> LSE?
491
+ // Shortest plausible length (in moves) of a genuine Roux stage. The block and
492
+ // corner conditions flicker true at scattered transient instants throughout a
493
+ // solve; a "stage" only a move or two long is one of those coincidences, not a
494
+ // real second block or CMLL. Requiring a minimum span rejects them so a spurious
495
+ // orientation cannot masquerade as a clean Roux staging.
496
+ const ROUX_MIN_STAGE = 5;
497
+
479
498
  function isRoux(build) {
480
499
  const { fbIdx, sbIdx, cmllIdx, lseIdx } = build;
481
500
  if (fbIdx == null || sbIdx == null || cmllIdx == null || lseIdx == null)
482
501
  return false;
483
- return fbIdx <= sbIdx && sbIdx <= cmllIdx && cmllIdx <= lseIdx;
502
+ if (!(fbIdx < sbIdx && sbIdx < cmllIdx && cmllIdx < lseIdx)) return false;
503
+ return sbIdx - fbIdx >= ROUX_MIN_STAGE && cmllIdx - sbIdx >= ROUX_MIN_STAGE;
484
504
  }
485
505
 
486
506
  /**
@@ -609,20 +629,60 @@ function analyzeSolution(moves, options = {}) {
609
629
  const cfopValid = !!cfopChosen && isCFOP(cfopChosen.build);
610
630
 
611
631
  // Stage the solve as Roux (1st block -> 2nd block -> CMLL -> LSE) on every
612
- // orientation. Each candidate fixes a side face and a perpendicular up face;
613
- // buildForRoux assigns first/second block by which side finishes earliest.
614
- // Pick the valid candidate whose first block completes earliest.
615
- const mPerm = getMovePermutations(size)["M"].cw;
632
+ // orientation. A candidate fixes a side face, a perpendicular up face, and a
633
+ // slice offset `d`: the blocks can be assembled with the slice between them
634
+ // turned away from the centers, so each orientation is tested against
635
+ // snapshots pre-rotated by d quarter-turns of its axis slice (M for L/R blocks,
636
+ // E for U/D, S for F/B). Holding that offset fixed for the whole solve (rather
637
+ // than re-matching it move-by-move) is what stops a block reading as "built"
638
+ // on a coincidental alignment.
639
+ //
640
+ // Many orientations still yield a technically ordered staging. The one that
641
+ // reflects the real solve is the one whose three build milestones land
642
+ // EARLIEST in aggregate (smallest fb + sb + cmll): a false orientation only
643
+ // assembles its blocks and corners by coincidence, which does not happen
644
+ // earlier than the genuine build. (isRoux discards orientations whose stages
645
+ // are too short to be anything but a coincidence.) "Solved" is orientation-
646
+ // independent, so the LSE index is computed once and shared.
647
+ const perms = getMovePermutations(size);
648
+ const sliceCwOf = (face) => {
649
+ if (face === 1 || face === 3) return perms["M"].cw; // L/R axis
650
+ if (face === 0 || face === 5) return perms["E"].cw; // U/D axis
651
+ return perms["S"].cw; // F/B axis
652
+ };
653
+ const lseIdx = completionIndex(
654
+ snapshots.map((st) => isSolvedFlat(st, geo.per))
655
+ );
656
+ // Snapshots rotated by d quarter-turns of a given slice, built incrementally
657
+ // and cached so each (axis, d) is computed once and reused across its up faces.
658
+ const rotCache = new Map();
659
+ const rotatedSnaps = (sliceCw, d) => {
660
+ const key = `${sliceCw}:${d}`;
661
+ if (rotCache.has(key)) return rotCache.get(key);
662
+ const snaps =
663
+ d === 0
664
+ ? snapshots
665
+ : rotatedSnaps(sliceCw, d - 1).map((st) => applyPerm(st, sliceCw));
666
+ rotCache.set(key, snaps);
667
+ return snaps;
668
+ };
616
669
  const rouxCandidates = [];
617
670
  for (let s = 0; s < 6; s++) {
671
+ const sliceCw = sliceCwOf(s);
618
672
  for (const u of geo.neighbors[s]) {
619
- rouxCandidates.push(buildForRoux(snapshots, geo, s, u, mPerm));
673
+ for (let d = 0; d < 4; d++) {
674
+ const build = buildForRoux(rotatedSnaps(sliceCw, d), geo, s, u, lseIdx);
675
+ if (build) rouxCandidates.push(build);
676
+ }
620
677
  }
621
678
  }
622
679
  const rouxBuild =
623
680
  rouxCandidates
624
681
  .filter((b) => isRoux(b))
625
- .sort((a, b) => a.fbIdx - b.fbIdx)[0] ?? null;
682
+ .sort(
683
+ (a, b) =>
684
+ a.fbIdx + a.sbIdx + a.cmllIdx - (b.fbIdx + b.sbIdx + b.cmllIdx)
685
+ )[0] ?? null;
626
686
 
627
687
  // A solved cube satisfies many orderings, so both stagings can be technically
628
688
  // valid. Disambiguate by which method's FIRST milestone is genuinely reached
package/dist/index.d.ts CHANGED
@@ -327,15 +327,22 @@ function rouxBlockPieces(geo, sideFace, upFace) {
327
327
  return { edges, corners };
328
328
  }
329
329
 
330
- // A Roux block is done when its three edges and two corners are all placed.
331
- function rouxBlockDone(st, centers, geo, sideFace, upFace) {
330
+ // A Roux block is done when its three edges and two corners all match the
331
+ // centers. The block can be built while the slice between the two blocks is
332
+ // rotated away from the centers; that offset is handled by the caller, which
333
+ // evaluates each orientation against snapshots pre-rotated by a fixed slice
334
+ // amount (see the candidate enumeration). Keeping the offset FIXED per
335
+ // orientation -- rather than accepting any of the four rotations move-by-move --
336
+ // is what makes detection match how the solve was actually framed instead of
337
+ // firing on coincidental alignments.
338
+ function rouxBlockDone(st, geo, sideFace, upFace) {
332
339
  const { edges, corners } = rouxBlockPieces(geo, sideFace, upFace);
333
340
  if (edges.length !== 3 || corners.length !== 2) return false;
334
- for (const e of edges)
335
- if (!slotCorrect(st, centers, e.indices, geo.per)) return false;
336
- for (const c of corners)
337
- if (!slotCorrect(st, centers, c.indices, geo.per)) return false;
338
- return true;
341
+ const centers = centersOf(st, geo);
342
+ return (
343
+ edges.every((e) => slotCorrect(st, centers, e.indices, geo.per)) &&
344
+ corners.every((c) => slotCorrect(st, centers, c.indices, geo.per))
345
+ );
339
346
  }
340
347
 
341
348
  // Applies a permutation (out[i] = st[perm[i]]) to a flat sticker array.
@@ -345,21 +352,14 @@ function applyPerm(st, perm) {
345
352
  return out;
346
353
  }
347
354
 
348
- // CMLL is done when the four corners touching the up face are solved, allowing
349
- // the M slice to be unaligned: we accept the state if ANY of the four M-slice
350
- // rotations makes those corners correct. M moves neither the corners nor the
351
- // L/R blocks, only the U/F/D/B centers (and M-slice edges), so this captures
352
- // exactly "last-layer corners solved, M not necessarily aligned yet".
353
- function cmllDone(st, geo, upFace, mPerm) {
354
- let cur = st;
355
- const corners = geo.cornersByFace(upFace);
356
- for (let m = 0; m < 4; m++) {
357
- if (m > 0) cur = applyPerm(cur, mPerm);
358
- const centers = centersOf(cur, geo);
359
- if (corners.every((c) => slotCorrect(cur, centers, c.indices, geo.per)))
360
- return true;
361
- }
362
- return false;
355
+ // CMLL corners are done when the four corners touching the up face match the
356
+ // centers. As with the blocks, any fixed slice offset is applied by the caller
357
+ // via pre-rotated snapshots, so this is a plain match against the centers.
358
+ function cmllCornersDone(st, geo, upFace) {
359
+ const centers = centersOf(st, geo);
360
+ return geo
361
+ .cornersByFace(upFace)
362
+ .every((c) => slotCorrect(st, centers, c.indices, geo.per));
363
363
  }
364
364
 
365
365
  // Index of the move that COMPLETES a stage: the first move after which the
@@ -374,6 +374,19 @@ function completionIndex(bools) {
374
374
  return null;
375
375
  }
376
376
 
377
+ // Index of the FIRST move strictly after `after` at which the condition holds.
378
+ // Used for a stage whose completion is "the first time X happens once the
379
+ // previous stage is done" -- e.g. the last-layer corners getting solved after
380
+ // the second block is built, even if the blocks are momentarily disturbed at
381
+ // that instant. Returns null if `after` is null or the condition never holds.
382
+ function firstIndexAfter(bools, after) {
383
+ if (after == null) return null;
384
+ for (let i = after + 1; i < bools.length; i++) {
385
+ if (bools[i]) return i;
386
+ }
387
+ return null;
388
+ }
389
+
377
390
  // Builds the milestone indices for one assumed cross color. Detection is
378
391
  // cumulative: an F2L pair only counts while the cross is solved, and OLL only
379
392
  // counts once the full F2L is solved. This rejects transient false positives.
@@ -429,58 +442,65 @@ function isCFOP(build) {
429
442
  return ollIdx >= lastF2L && pllIdx >= ollIdx;
430
443
  }
431
444
 
432
- // Builds the Roux milestone indices for one (sideFace, upFace) orientation.
433
- // Detection is cumulative, mirroring buildForCross: the second block only counts
434
- // while the first block holds, CMLL only once both blocks hold, and LSE is the
435
- // fully solved cube. The first block is whichever of the two opposite side faces
436
- // finishes its block earliest; the other side is the second block.
437
- function buildForRoux(snapshots, geo, sideA, upFace, mPerm) {
438
- snapshots.length;
445
+ // Builds the Roux milestone indices for one (sideFace, upFace) orientation,
446
+ // using snapshots already pre-rotated by this orientation's fixed slice offset.
447
+ //
448
+ // Milestones are taken at FIRST achievement, not while a condition holds
449
+ // continuously: once a stage is reached the solver moves on, and on face-move
450
+ // Roux later stages routinely break and rebuild earlier ones (a CMLL alg breaks
451
+ // a block; a face-move LSE breaks everything). So:
452
+ // 1st block = first instant either side's block is built;
453
+ // 2nd block = first instant the OTHER side's block is built afterwards -- NOT
454
+ // gated on the first block still being intact, which it often is
455
+ // not while the second is being inserted;
456
+ // CMLL = first instant the last-layer corners are solved afterwards;
457
+ // LSE = the fully solved cube (`lseIdx`, computed once by the caller and
458
+ // passed in, since "solved" is the same in every orientation).
459
+ function buildForRoux(snapshots, geo, sideA, upFace, lseIdx) {
439
460
  const sideB = geo.opposite[sideA];
440
461
 
441
- const centersAt = snapshots.map((st) => centersOf(st, geo));
442
- const aDone = snapshots.map((st, i) =>
443
- rouxBlockDone(st, centersAt[i], geo, sideA, upFace)
444
- );
445
- const bDone = snapshots.map((st, i) =>
446
- rouxBlockDone(st, centersAt[i], geo, sideB, upFace)
447
- );
448
- const aIdx = completionIndex(aDone);
449
- const bIdx = completionIndex(bDone);
462
+ const aDone = snapshots.map((st) => rouxBlockDone(st, geo, sideA, upFace));
463
+ const bDone = snapshots.map((st) => rouxBlockDone(st, geo, sideB, upFace));
464
+ const aIdx = firstIndexAfter(aDone, -1);
465
+ const bIdx = firstIndexAfter(bDone, -1);
466
+ if (aIdx == null || bIdx == null) return null;
450
467
 
451
- // First block = the side that completes earliest; second block = the other.
452
- let firstSide = sideA;
453
- let secondSide = sideB;
454
- let fbBools = aDone;
455
- let sbBools = bDone;
456
- if ((bIdx ?? Infinity) < (aIdx ?? Infinity)) {
457
- firstSide = sideB;
458
- secondSide = sideA;
459
- fbBools = bDone;
460
- sbBools = aDone;
461
- }
462
-
463
- const fbIdx = completionIndex(fbBools);
464
- // Second block gated by the first block holding at the same instant.
465
- const secondBlockBools = snapshots.map((_, i) => fbBools[i] && sbBools[i]);
466
- const sbIdx = completionIndex(secondBlockBools);
468
+ // First block = the side built earliest; the other side is the second block.
469
+ const aFirst = aIdx <= bIdx;
470
+ const firstSide = aFirst ? sideA : sideB;
471
+ const fbIdx = aFirst ? aIdx : bIdx;
472
+ const sbIdx = firstIndexAfter(aFirst ? bDone : aDone, fbIdx);
473
+ if (sbIdx == null) return null;
467
474
 
468
- const cmllIdx = completionIndex(
469
- snapshots.map(
470
- (st, i) => secondBlockBools[i] && cmllDone(st, geo, upFace, mPerm)
471
- )
472
- );
473
- const lseIdx = completionIndex(snapshots.map((st) => isSolvedFlat(st, geo.per)));
475
+ const cornerBools = snapshots.map((st) => cmllCornersDone(st, geo, upFace));
476
+ const cmllIdx = firstIndexAfter(cornerBools, sbIdx);
477
+ if (cmllIdx == null) return null;
474
478
 
475
- return { firstSide, secondSide, upFace, fbIdx, sbIdx, cmllIdx, lseIdx };
479
+ return {
480
+ firstSide,
481
+ secondSide: aFirst ? sideB : sideA,
482
+ upFace,
483
+ fbIdx,
484
+ sbIdx,
485
+ cmllIdx,
486
+ lseIdx,
487
+ };
476
488
  }
477
489
 
478
490
  // Does this breakdown follow the Roux order: 1st block -> 2nd block -> CMLL -> LSE?
491
+ // Shortest plausible length (in moves) of a genuine Roux stage. The block and
492
+ // corner conditions flicker true at scattered transient instants throughout a
493
+ // solve; a "stage" only a move or two long is one of those coincidences, not a
494
+ // real second block or CMLL. Requiring a minimum span rejects them so a spurious
495
+ // orientation cannot masquerade as a clean Roux staging.
496
+ const ROUX_MIN_STAGE = 5;
497
+
479
498
  function isRoux(build) {
480
499
  const { fbIdx, sbIdx, cmllIdx, lseIdx } = build;
481
500
  if (fbIdx == null || sbIdx == null || cmllIdx == null || lseIdx == null)
482
501
  return false;
483
- return fbIdx <= sbIdx && sbIdx <= cmllIdx && cmllIdx <= lseIdx;
502
+ if (!(fbIdx < sbIdx && sbIdx < cmllIdx && cmllIdx < lseIdx)) return false;
503
+ return sbIdx - fbIdx >= ROUX_MIN_STAGE && cmllIdx - sbIdx >= ROUX_MIN_STAGE;
484
504
  }
485
505
 
486
506
  /**
@@ -609,20 +629,60 @@ function analyzeSolution(moves, options = {}) {
609
629
  const cfopValid = !!cfopChosen && isCFOP(cfopChosen.build);
610
630
 
611
631
  // Stage the solve as Roux (1st block -> 2nd block -> CMLL -> LSE) on every
612
- // orientation. Each candidate fixes a side face and a perpendicular up face;
613
- // buildForRoux assigns first/second block by which side finishes earliest.
614
- // Pick the valid candidate whose first block completes earliest.
615
- const mPerm = getMovePermutations(size)["M"].cw;
632
+ // orientation. A candidate fixes a side face, a perpendicular up face, and a
633
+ // slice offset `d`: the blocks can be assembled with the slice between them
634
+ // turned away from the centers, so each orientation is tested against
635
+ // snapshots pre-rotated by d quarter-turns of its axis slice (M for L/R blocks,
636
+ // E for U/D, S for F/B). Holding that offset fixed for the whole solve (rather
637
+ // than re-matching it move-by-move) is what stops a block reading as "built"
638
+ // on a coincidental alignment.
639
+ //
640
+ // Many orientations still yield a technically ordered staging. The one that
641
+ // reflects the real solve is the one whose three build milestones land
642
+ // EARLIEST in aggregate (smallest fb + sb + cmll): a false orientation only
643
+ // assembles its blocks and corners by coincidence, which does not happen
644
+ // earlier than the genuine build. (isRoux discards orientations whose stages
645
+ // are too short to be anything but a coincidence.) "Solved" is orientation-
646
+ // independent, so the LSE index is computed once and shared.
647
+ const perms = getMovePermutations(size);
648
+ const sliceCwOf = (face) => {
649
+ if (face === 1 || face === 3) return perms["M"].cw; // L/R axis
650
+ if (face === 0 || face === 5) return perms["E"].cw; // U/D axis
651
+ return perms["S"].cw; // F/B axis
652
+ };
653
+ const lseIdx = completionIndex(
654
+ snapshots.map((st) => isSolvedFlat(st, geo.per))
655
+ );
656
+ // Snapshots rotated by d quarter-turns of a given slice, built incrementally
657
+ // and cached so each (axis, d) is computed once and reused across its up faces.
658
+ const rotCache = new Map();
659
+ const rotatedSnaps = (sliceCw, d) => {
660
+ const key = `${sliceCw}:${d}`;
661
+ if (rotCache.has(key)) return rotCache.get(key);
662
+ const snaps =
663
+ d === 0
664
+ ? snapshots
665
+ : rotatedSnaps(sliceCw, d - 1).map((st) => applyPerm(st, sliceCw));
666
+ rotCache.set(key, snaps);
667
+ return snaps;
668
+ };
616
669
  const rouxCandidates = [];
617
670
  for (let s = 0; s < 6; s++) {
671
+ const sliceCw = sliceCwOf(s);
618
672
  for (const u of geo.neighbors[s]) {
619
- rouxCandidates.push(buildForRoux(snapshots, geo, s, u, mPerm));
673
+ for (let d = 0; d < 4; d++) {
674
+ const build = buildForRoux(rotatedSnaps(sliceCw, d), geo, s, u, lseIdx);
675
+ if (build) rouxCandidates.push(build);
676
+ }
620
677
  }
621
678
  }
622
679
  const rouxBuild =
623
680
  rouxCandidates
624
681
  .filter((b) => isRoux(b))
625
- .sort((a, b) => a.fbIdx - b.fbIdx)[0] ?? null;
682
+ .sort(
683
+ (a, b) =>
684
+ a.fbIdx + a.sbIdx + a.cmllIdx - (b.fbIdx + b.sbIdx + b.cmllIdx)
685
+ )[0] ?? null;
626
686
 
627
687
  // A solved cube satisfies many orderings, so both stagings can be technically
628
688
  // valid. Disambiguate by which method's FIRST milestone is genuinely reached
package/dist/index.js CHANGED
@@ -276,30 +276,20 @@ function rouxBlockPieces(geo, sideFace, upFace) {
276
276
  const corners = geo.cornersByFace(sideFace).filter((c) => c.faces.includes(downFace));
277
277
  return { edges, corners };
278
278
  }
279
- function rouxBlockDone(st, centers, geo, sideFace, upFace) {
279
+ function rouxBlockDone(st, geo, sideFace, upFace) {
280
280
  const { edges, corners } = rouxBlockPieces(geo, sideFace, upFace);
281
281
  if (edges.length !== 3 || corners.length !== 2) return false;
282
- for (const e of edges)
283
- if (!slotCorrect(st, centers, e.indices, geo.per)) return false;
284
- for (const c of corners)
285
- if (!slotCorrect(st, centers, c.indices, geo.per)) return false;
286
- return true;
282
+ const centers = centersOf(st, geo);
283
+ return edges.every((e) => slotCorrect(st, centers, e.indices, geo.per)) && corners.every((c) => slotCorrect(st, centers, c.indices, geo.per));
287
284
  }
288
285
  function applyPerm(st, perm) {
289
286
  const out = new Array(st.length);
290
287
  for (let i = 0; i < st.length; i++) out[i] = st[perm[i]];
291
288
  return out;
292
289
  }
293
- function cmllDone(st, geo, upFace, mPerm) {
294
- let cur = st;
295
- const corners = geo.cornersByFace(upFace);
296
- for (let m = 0; m < 4; m++) {
297
- if (m > 0) cur = applyPerm(cur, mPerm);
298
- const centers = centersOf(cur, geo);
299
- if (corners.every((c) => slotCorrect(cur, centers, c.indices, geo.per)))
300
- return true;
301
- }
302
- return false;
290
+ function cmllCornersDone(st, geo, upFace) {
291
+ const centers = centersOf(st, geo);
292
+ return geo.cornersByFace(upFace).every((c) => slotCorrect(st, centers, c.indices, geo.per));
303
293
  }
304
294
  function completionIndex(bools) {
305
295
  const n = bools.length;
@@ -307,6 +297,13 @@ function completionIndex(bools) {
307
297
  for (let i = 0; i < n; i++) if (bools[i]) return i;
308
298
  return null;
309
299
  }
300
+ function firstIndexAfter(bools, after) {
301
+ if (after == null) return null;
302
+ for (let i = after + 1; i < bools.length; i++) {
303
+ if (bools[i]) return i;
304
+ }
305
+ return null;
306
+ }
310
307
  function buildForCross(snapshots, geo, color) {
311
308
  const n = snapshots.length;
312
309
  const crossBools = snapshots.map((st) => crossDone(st, geo, color));
@@ -346,44 +343,38 @@ function isCFOP(build) {
346
343
  const lastF2L = f2lSlots[3].idx;
347
344
  return ollIdx >= lastF2L && pllIdx >= ollIdx;
348
345
  }
349
- function buildForRoux(snapshots, geo, sideA, upFace, mPerm) {
350
- const n = snapshots.length;
346
+ function buildForRoux(snapshots, geo, sideA, upFace, lseIdx) {
351
347
  const sideB = geo.opposite[sideA];
352
- const centersAt = snapshots.map((st) => centersOf(st, geo));
353
- const aDone = snapshots.map(
354
- (st, i) => rouxBlockDone(st, centersAt[i], geo, sideA, upFace)
355
- );
356
- const bDone = snapshots.map(
357
- (st, i) => rouxBlockDone(st, centersAt[i], geo, sideB, upFace)
358
- );
359
- const aIdx = completionIndex(aDone);
360
- const bIdx = completionIndex(bDone);
361
- let firstSide = sideA;
362
- let secondSide = sideB;
363
- let fbBools = aDone;
364
- let sbBools = bDone;
365
- if ((bIdx != null ? bIdx : Infinity) < (aIdx != null ? aIdx : Infinity)) {
366
- firstSide = sideB;
367
- secondSide = sideA;
368
- fbBools = bDone;
369
- sbBools = aDone;
370
- }
371
- const fbIdx = completionIndex(fbBools);
372
- const secondBlockBools = snapshots.map((_, i) => fbBools[i] && sbBools[i]);
373
- const sbIdx = completionIndex(secondBlockBools);
374
- const cmllIdx = completionIndex(
375
- snapshots.map(
376
- (st, i) => secondBlockBools[i] && cmllDone(st, geo, upFace, mPerm)
377
- )
378
- );
379
- const lseIdx = completionIndex(snapshots.map((st) => isSolvedFlat(st, geo.per)));
380
- return { firstSide, secondSide, upFace, fbIdx, sbIdx, cmllIdx, lseIdx };
348
+ const aDone = snapshots.map((st) => rouxBlockDone(st, geo, sideA, upFace));
349
+ const bDone = snapshots.map((st) => rouxBlockDone(st, geo, sideB, upFace));
350
+ const aIdx = firstIndexAfter(aDone, -1);
351
+ const bIdx = firstIndexAfter(bDone, -1);
352
+ if (aIdx == null || bIdx == null) return null;
353
+ const aFirst = aIdx <= bIdx;
354
+ const firstSide = aFirst ? sideA : sideB;
355
+ const fbIdx = aFirst ? aIdx : bIdx;
356
+ const sbIdx = firstIndexAfter(aFirst ? bDone : aDone, fbIdx);
357
+ if (sbIdx == null) return null;
358
+ const cornerBools = snapshots.map((st) => cmllCornersDone(st, geo, upFace));
359
+ const cmllIdx = firstIndexAfter(cornerBools, sbIdx);
360
+ if (cmllIdx == null) return null;
361
+ return {
362
+ firstSide,
363
+ secondSide: aFirst ? sideB : sideA,
364
+ upFace,
365
+ fbIdx,
366
+ sbIdx,
367
+ cmllIdx,
368
+ lseIdx
369
+ };
381
370
  }
371
+ var ROUX_MIN_STAGE = 5;
382
372
  function isRoux(build) {
383
373
  const { fbIdx, sbIdx, cmllIdx, lseIdx } = build;
384
374
  if (fbIdx == null || sbIdx == null || cmllIdx == null || lseIdx == null)
385
375
  return false;
386
- return fbIdx <= sbIdx && sbIdx <= cmllIdx && cmllIdx <= lseIdx;
376
+ if (!(fbIdx < sbIdx && sbIdx < cmllIdx && cmllIdx < lseIdx)) return false;
377
+ return sbIdx - fbIdx >= ROUX_MIN_STAGE && cmllIdx - sbIdx >= ROUX_MIN_STAGE;
387
378
  }
388
379
  function analyzeSolution(moves, options = {}) {
389
380
  var _a, _b;
@@ -469,14 +460,36 @@ function analyzeSolution(moves, options = {}) {
469
460
  });
470
461
  const cfopChosen = (_a = ordered.find((c) => isCFOP(c.build))) != null ? _a : ordered[0];
471
462
  const cfopValid = !!cfopChosen && isCFOP(cfopChosen.build);
472
- const mPerm = getMovePermutations(size)["M"].cw;
463
+ const perms = getMovePermutations(size);
464
+ const sliceCwOf = (face) => {
465
+ if (face === 1 || face === 3) return perms["M"].cw;
466
+ if (face === 0 || face === 5) return perms["E"].cw;
467
+ return perms["S"].cw;
468
+ };
469
+ const lseIdx = completionIndex(
470
+ snapshots.map((st) => isSolvedFlat(st, geo.per))
471
+ );
472
+ const rotCache = /* @__PURE__ */ new Map();
473
+ const rotatedSnaps = (sliceCw, d) => {
474
+ const key = `${sliceCw}:${d}`;
475
+ if (rotCache.has(key)) return rotCache.get(key);
476
+ const snaps = d === 0 ? snapshots : rotatedSnaps(sliceCw, d - 1).map((st) => applyPerm(st, sliceCw));
477
+ rotCache.set(key, snaps);
478
+ return snaps;
479
+ };
473
480
  const rouxCandidates = [];
474
481
  for (let s = 0; s < 6; s++) {
482
+ const sliceCw = sliceCwOf(s);
475
483
  for (const u of geo.neighbors[s]) {
476
- rouxCandidates.push(buildForRoux(snapshots, geo, s, u, mPerm));
484
+ for (let d = 0; d < 4; d++) {
485
+ const build2 = buildForRoux(rotatedSnaps(sliceCw, d), geo, s, u, lseIdx);
486
+ if (build2) rouxCandidates.push(build2);
487
+ }
477
488
  }
478
489
  }
479
- const rouxBuild = (_b = rouxCandidates.filter((b) => isRoux(b)).sort((a, b) => a.fbIdx - b.fbIdx)[0]) != null ? _b : null;
490
+ const rouxBuild = (_b = rouxCandidates.filter((b) => isRoux(b)).sort(
491
+ (a, b) => a.fbIdx + a.sbIdx + a.cmllIdx - (b.fbIdx + b.sbIdx + b.cmllIdx)
492
+ )[0]) != null ? _b : null;
480
493
  let method = "unknown";
481
494
  if (cfopValid && rouxBuild) {
482
495
  method = cfopChosen.build.crossIdx <= rouxBuild.fbIdx ? "CFOP" : "Roux";
package/dist/index.mjs CHANGED
@@ -248,30 +248,20 @@ function rouxBlockPieces(geo, sideFace, upFace) {
248
248
  const corners = geo.cornersByFace(sideFace).filter((c) => c.faces.includes(downFace));
249
249
  return { edges, corners };
250
250
  }
251
- function rouxBlockDone(st, centers, geo, sideFace, upFace) {
251
+ function rouxBlockDone(st, geo, sideFace, upFace) {
252
252
  const { edges, corners } = rouxBlockPieces(geo, sideFace, upFace);
253
253
  if (edges.length !== 3 || corners.length !== 2) return false;
254
- for (const e of edges)
255
- if (!slotCorrect(st, centers, e.indices, geo.per)) return false;
256
- for (const c of corners)
257
- if (!slotCorrect(st, centers, c.indices, geo.per)) return false;
258
- return true;
254
+ const centers = centersOf(st, geo);
255
+ return edges.every((e) => slotCorrect(st, centers, e.indices, geo.per)) && corners.every((c) => slotCorrect(st, centers, c.indices, geo.per));
259
256
  }
260
257
  function applyPerm(st, perm) {
261
258
  const out = new Array(st.length);
262
259
  for (let i = 0; i < st.length; i++) out[i] = st[perm[i]];
263
260
  return out;
264
261
  }
265
- function cmllDone(st, geo, upFace, mPerm) {
266
- let cur = st;
267
- const corners = geo.cornersByFace(upFace);
268
- for (let m = 0; m < 4; m++) {
269
- if (m > 0) cur = applyPerm(cur, mPerm);
270
- const centers = centersOf(cur, geo);
271
- if (corners.every((c) => slotCorrect(cur, centers, c.indices, geo.per)))
272
- return true;
273
- }
274
- return false;
262
+ function cmllCornersDone(st, geo, upFace) {
263
+ const centers = centersOf(st, geo);
264
+ return geo.cornersByFace(upFace).every((c) => slotCorrect(st, centers, c.indices, geo.per));
275
265
  }
276
266
  function completionIndex(bools) {
277
267
  const n = bools.length;
@@ -279,6 +269,13 @@ function completionIndex(bools) {
279
269
  for (let i = 0; i < n; i++) if (bools[i]) return i;
280
270
  return null;
281
271
  }
272
+ function firstIndexAfter(bools, after) {
273
+ if (after == null) return null;
274
+ for (let i = after + 1; i < bools.length; i++) {
275
+ if (bools[i]) return i;
276
+ }
277
+ return null;
278
+ }
282
279
  function buildForCross(snapshots, geo, color) {
283
280
  const n = snapshots.length;
284
281
  const crossBools = snapshots.map((st) => crossDone(st, geo, color));
@@ -318,44 +315,38 @@ function isCFOP(build) {
318
315
  const lastF2L = f2lSlots[3].idx;
319
316
  return ollIdx >= lastF2L && pllIdx >= ollIdx;
320
317
  }
321
- function buildForRoux(snapshots, geo, sideA, upFace, mPerm) {
322
- const n = snapshots.length;
318
+ function buildForRoux(snapshots, geo, sideA, upFace, lseIdx) {
323
319
  const sideB = geo.opposite[sideA];
324
- const centersAt = snapshots.map((st) => centersOf(st, geo));
325
- const aDone = snapshots.map(
326
- (st, i) => rouxBlockDone(st, centersAt[i], geo, sideA, upFace)
327
- );
328
- const bDone = snapshots.map(
329
- (st, i) => rouxBlockDone(st, centersAt[i], geo, sideB, upFace)
330
- );
331
- const aIdx = completionIndex(aDone);
332
- const bIdx = completionIndex(bDone);
333
- let firstSide = sideA;
334
- let secondSide = sideB;
335
- let fbBools = aDone;
336
- let sbBools = bDone;
337
- if ((bIdx != null ? bIdx : Infinity) < (aIdx != null ? aIdx : Infinity)) {
338
- firstSide = sideB;
339
- secondSide = sideA;
340
- fbBools = bDone;
341
- sbBools = aDone;
342
- }
343
- const fbIdx = completionIndex(fbBools);
344
- const secondBlockBools = snapshots.map((_, i) => fbBools[i] && sbBools[i]);
345
- const sbIdx = completionIndex(secondBlockBools);
346
- const cmllIdx = completionIndex(
347
- snapshots.map(
348
- (st, i) => secondBlockBools[i] && cmllDone(st, geo, upFace, mPerm)
349
- )
350
- );
351
- const lseIdx = completionIndex(snapshots.map((st) => isSolvedFlat(st, geo.per)));
352
- return { firstSide, secondSide, upFace, fbIdx, sbIdx, cmllIdx, lseIdx };
320
+ const aDone = snapshots.map((st) => rouxBlockDone(st, geo, sideA, upFace));
321
+ const bDone = snapshots.map((st) => rouxBlockDone(st, geo, sideB, upFace));
322
+ const aIdx = firstIndexAfter(aDone, -1);
323
+ const bIdx = firstIndexAfter(bDone, -1);
324
+ if (aIdx == null || bIdx == null) return null;
325
+ const aFirst = aIdx <= bIdx;
326
+ const firstSide = aFirst ? sideA : sideB;
327
+ const fbIdx = aFirst ? aIdx : bIdx;
328
+ const sbIdx = firstIndexAfter(aFirst ? bDone : aDone, fbIdx);
329
+ if (sbIdx == null) return null;
330
+ const cornerBools = snapshots.map((st) => cmllCornersDone(st, geo, upFace));
331
+ const cmllIdx = firstIndexAfter(cornerBools, sbIdx);
332
+ if (cmllIdx == null) return null;
333
+ return {
334
+ firstSide,
335
+ secondSide: aFirst ? sideB : sideA,
336
+ upFace,
337
+ fbIdx,
338
+ sbIdx,
339
+ cmllIdx,
340
+ lseIdx
341
+ };
353
342
  }
343
+ var ROUX_MIN_STAGE = 5;
354
344
  function isRoux(build) {
355
345
  const { fbIdx, sbIdx, cmllIdx, lseIdx } = build;
356
346
  if (fbIdx == null || sbIdx == null || cmllIdx == null || lseIdx == null)
357
347
  return false;
358
- return fbIdx <= sbIdx && sbIdx <= cmllIdx && cmllIdx <= lseIdx;
348
+ if (!(fbIdx < sbIdx && sbIdx < cmllIdx && cmllIdx < lseIdx)) return false;
349
+ return sbIdx - fbIdx >= ROUX_MIN_STAGE && cmllIdx - sbIdx >= ROUX_MIN_STAGE;
359
350
  }
360
351
  function analyzeSolution(moves, options = {}) {
361
352
  var _a, _b;
@@ -441,14 +432,36 @@ function analyzeSolution(moves, options = {}) {
441
432
  });
442
433
  const cfopChosen = (_a = ordered.find((c) => isCFOP(c.build))) != null ? _a : ordered[0];
443
434
  const cfopValid = !!cfopChosen && isCFOP(cfopChosen.build);
444
- const mPerm = getMovePermutations(size)["M"].cw;
435
+ const perms = getMovePermutations(size);
436
+ const sliceCwOf = (face) => {
437
+ if (face === 1 || face === 3) return perms["M"].cw;
438
+ if (face === 0 || face === 5) return perms["E"].cw;
439
+ return perms["S"].cw;
440
+ };
441
+ const lseIdx = completionIndex(
442
+ snapshots.map((st) => isSolvedFlat(st, geo.per))
443
+ );
444
+ const rotCache = /* @__PURE__ */ new Map();
445
+ const rotatedSnaps = (sliceCw, d) => {
446
+ const key = `${sliceCw}:${d}`;
447
+ if (rotCache.has(key)) return rotCache.get(key);
448
+ const snaps = d === 0 ? snapshots : rotatedSnaps(sliceCw, d - 1).map((st) => applyPerm(st, sliceCw));
449
+ rotCache.set(key, snaps);
450
+ return snaps;
451
+ };
445
452
  const rouxCandidates = [];
446
453
  for (let s = 0; s < 6; s++) {
454
+ const sliceCw = sliceCwOf(s);
447
455
  for (const u of geo.neighbors[s]) {
448
- rouxCandidates.push(buildForRoux(snapshots, geo, s, u, mPerm));
456
+ for (let d = 0; d < 4; d++) {
457
+ const build2 = buildForRoux(rotatedSnaps(sliceCw, d), geo, s, u, lseIdx);
458
+ if (build2) rouxCandidates.push(build2);
459
+ }
449
460
  }
450
461
  }
451
- const rouxBuild = (_b = rouxCandidates.filter((b) => isRoux(b)).sort((a, b) => a.fbIdx - b.fbIdx)[0]) != null ? _b : null;
462
+ const rouxBuild = (_b = rouxCandidates.filter((b) => isRoux(b)).sort(
463
+ (a, b) => a.fbIdx + a.sbIdx + a.cmllIdx - (b.fbIdx + b.sbIdx + b.cmllIdx)
464
+ )[0]) != null ? _b : null;
452
465
  let method = "unknown";
453
466
  if (cfopValid && rouxBuild) {
454
467
  method = cfopChosen.build.crossIdx <= rouxBuild.fbIdx ? "CFOP" : "Roux";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cube-state-engine",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "An efficient representation in memory for tracking the Rubik's cube state on each movement.",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",