diff --git a/Solvers/brownan/brownan_solver_wrapper.py b/Solvers/brownan/brownan_solver_wrapper.py index 8b63c56..3065654 100644 --- a/Solvers/brownan/brownan_solver_wrapper.py +++ b/Solvers/brownan/brownan_solver_wrapper.py @@ -2,9 +2,8 @@ """ HyperTwist IPC wrapper for brownan GPL Rubik's Cube solver oracle. -Input: 120-character brownan raw cube string (NOT 54-char facelet). - Brownan uses a cubie-based format documented at: - https://github.com/brownan/Rubiks-Cube-Solver +Input: Either a 54-character facelet string (kociemba order: U R F D L B) + or a 120-character brownan raw cube string. Output: Space-separated WCA move notation (U, D, F, B, L, R, with ' and 2 suffixes). Brownan native output uses faces F, T, L, B, D, R where T=U in WCA. @@ -62,12 +61,10 @@ def ensure_tables(brownan_exe: str) -> bool: def convert_move(brownan_move: str) -> str: """Convert a single brownan move token to WCA notation.""" - # brownan tokens look like: "F ", "T' ", "2L " token = brownan_move.strip() if not token: return "" - # Handle double-turn prefix if token.startswith("2"): face = token[1] suffix = "2" @@ -82,10 +79,144 @@ def convert_move(brownan_move: str) -> str: return f"{wca_face}{suffix}" -def solve(raw_cube: str, brownan_exe: str = None) -> str: - """Solve a cube from a brownan 120-character raw string.""" - if len(raw_cube) != 120: - print(f"ERROR: Brownan raw cube string must be 120 characters, got {len(raw_cube)}", file=sys.stderr) +def facelet_to_brownan_raw(facelet: str) -> str: + """Convert a 54-character kociemba facelet string to brownan 120-char raw format.""" + if len(facelet) != 54: + raise ValueError(f"Facelet string must be 54 characters, got {len(facelet)}") + + # Map input face labels/colors to brownan's fixed color scheme based on centers. + # Brownan fixed centers: U=r, L=b, F=w, R=g, B=y, D=o + color_map = { + facelet[4]: "r", # U center + facelet[13]: "g", # R center + facelet[22]: "w", # F center + facelet[31]: "o", # D center + facelet[40]: "b", # L center + facelet[49]: "y", # B center + } + + if len(set(color_map.keys())) != 6: + raise ValueError("Invalid facelet string: center pieces are not all distinct") + + def remap(s: str) -> str: + return "".join(color_map.get(c, c) for c in s) + + # Build 48-position brownan layout string (1-indexed internally). + # brownan layout (centers fixed): + # 1 2 3 + # 4 R 5 + # 6 7 8 + # 9 10 11 12 13 14 15 16 17 18 19 20 + # 21 B 22 23 W 24 25 G 26 27 Y 28 + # 29 30 31 32 33 34 35 36 37 38 39 40 + # 41 42 43 + # 44 O 45 + # 46 47 48 + layout = [""] * 49 + + u = remap(facelet[0:9]) + layout[1], layout[2], layout[3] = u[0], u[1], u[2] + layout[4], layout[5] = u[3], u[5] + layout[6], layout[7], layout[8] = u[6], u[7], u[8] + + r = remap(facelet[9:18]) + layout[15], layout[16], layout[17] = r[0], r[1], r[2] + layout[25], layout[26] = r[3], r[5] + layout[35], layout[36], layout[37] = r[6], r[7], r[8] + + f = remap(facelet[18:27]) + layout[12], layout[13], layout[14] = f[0], f[1], f[2] + layout[23], layout[24] = f[3], f[5] + layout[32], layout[33], layout[34] = f[6], f[7], f[8] + + d = remap(facelet[27:36]) + layout[41], layout[42], layout[43] = d[0], d[1], d[2] + layout[44], layout[45] = d[3], d[5] + layout[46], layout[47], layout[48] = d[6], d[7], d[8] + + l = remap(facelet[36:45]) + layout[9], layout[10], layout[11] = l[0], l[1], l[2] + layout[21], layout[22] = l[3], l[5] + layout[29], layout[30], layout[31] = l[6], l[7], l[8] + + # Back face may need mirroring/rotation in the unfolded net. + # Direct mapping is used here; adjust if brownan rejects valid cubes. + b = remap(facelet[45:54]) + layout[18], layout[19], layout[20] = b[0], b[1], b[2] + layout[27], layout[28] = b[3], b[5] + layout[38], layout[39], layout[40] = b[6], b[7], b[8] + + # Original cube_convert.py prepends '@' because the author used wrong indexes. + cc = "@" + "".join(layout[1:49]) + + # Extract 20 cubies exactly as cube_convert.py does. + cubies = [ + "nn%s%s%sn" % (cc[29], cc[40], cc[46]), + "nnn%s%sn" % (cc[39], cc[47]), + "nnn%s%s%s" % (cc[38], cc[48], cc[37]), + "nn%s%snn" % (cc[21], cc[28]), + "nnn%sn%s" % (cc[27], cc[26]), + "n%s%s%snn" % (cc[1], cc[9], cc[20]), + "n%sn%snn" % (cc[2], cc[19]), + "n%sn%sn%s" % (cc[3], cc[18], cc[17]), + "nn%sn%sn" % (cc[30], cc[44]), + "nnnn%s%s" % (cc[45], cc[36]), + "n%s%snnn" % (cc[4], cc[10]), + "n%snnn%s" % (cc[5], cc[16]), + "%sn%sn%sn" % (cc[32], cc[31], cc[41]), + "%snnn%sn" % (cc[33], cc[42]), + "%snnn%s%s" % (cc[34], cc[43], cc[35]), + "%sn%snnn" % (cc[23], cc[22]), + "%snnnn%s" % (cc[24], cc[25]), + "%s%s%snnn" % (cc[12], cc[6], cc[11]), + "%s%snnnn" % (cc[13], cc[7]), + "%s%snnn%s" % (cc[14], cc[8], cc[15]), + ] + + def select(pred): + matches = [c for c in cubies if pred(c)] + if not matches: + raise ValueError("Invalid cube: not all cubies accounted for") + return matches[0] + + fc = "" + fc += select(lambda a: "y" in a and "b" in a and "o" in a) + fc += select(lambda a: "y" in a and "o" in a and a.count("n") == 4) + fc += select(lambda a: "y" in a and "o" in a and "g" in a) + fc += select(lambda a: "y" in a and "b" in a and a.count("n") == 4) + fc += select(lambda a: "y" in a and "g" in a and a.count("n") == 4) + fc += select(lambda a: "y" in a and "b" in a and "r" in a) + fc += select(lambda a: "y" in a and "r" in a and a.count("n") == 4) + fc += select(lambda a: "y" in a and "r" in a and "g" in a) + fc += select(lambda a: "o" in a and "b" in a and a.count("n") == 4) + fc += select(lambda a: "o" in a and "g" in a and a.count("n") == 4) + fc += select(lambda a: "b" in a and "r" in a and a.count("n") == 4) + fc += select(lambda a: "g" in a and "r" in a and a.count("n") == 4) + fc += select(lambda a: "w" in a and "b" in a and "o" in a) + fc += select(lambda a: "w" in a and "o" in a and a.count("n") == 4) + fc += select(lambda a: "w" in a and "o" in a and "g" in a) + fc += select(lambda a: "w" in a and "b" in a and a.count("n") == 4) + fc += select(lambda a: "w" in a and "g" in a and a.count("n") == 4) + fc += select(lambda a: "w" in a and "b" in a and "r" in a) + fc += select(lambda a: "w" in a and "r" in a and a.count("n") == 4) + fc += select(lambda a: "w" in a and "r" in a and "g" in a) + + if len(fc) != 120: + raise ValueError(f"Conversion error: final code length is {len(fc)}, expected 120") + + return fc + + +def solve(cube_input: str, brownan_exe: str = None) -> str: + """Solve a cube from either a 54-char facelet or 120-char brownan raw string.""" + cube_input = cube_input.strip() + + if len(cube_input) == 54: + raw_cube = facelet_to_brownan_raw(cube_input) + elif len(cube_input) == 120: + raw_cube = cube_input + else: + print(f"ERROR: Input must be 54-char facelet or 120-char brownan raw, got {len(cube_input)}", file=sys.stderr) sys.exit(1) if brownan_exe is None: @@ -94,7 +225,6 @@ def solve(raw_cube: str, brownan_exe: str = None) -> str: print("ERROR: brownan-solver executable not found", file=sys.stderr) sys.exit(1) - # Optionally warn about missing tables (non-fatal) ensure_tables(brownan_exe) exe_dir = os.path.dirname(os.path.abspath(brownan_exe)) @@ -113,7 +243,6 @@ def solve(raw_cube: str, brownan_exe: str = None) -> str: print(f"ERROR: {e}", file=sys.stderr) sys.exit(1) - # brownan prints "Solution found!" and then moves, may continue searching deeper lines = result.stdout.splitlines() in_solution = False moves = [] @@ -125,7 +254,6 @@ def solve(raw_cube: str, brownan_exe: str = None) -> str: if in_solution: if not stripped: break - # A solution line is a sequence of move tokens like "F T' 2L ..." for token in stripped.split(): wca = convert_move(token) if wca: @@ -137,9 +265,9 @@ def solve(raw_cube: str, brownan_exe: str = None) -> str: if __name__ == "__main__": parser = argparse.ArgumentParser(description="HyperTwist Brownan Solver Wrapper") - parser.add_argument("raw_cube", help="120-character brownan raw cube string") + parser.add_argument("cube_input", help="54-char facelet string or 120-char brownan raw string") parser.add_argument("--exe", help="Path to brownan-solver executable") args = parser.parse_args() - result = solve(args.raw_cube.strip(), args.exe) + result = solve(args.cube_input, args.exe) print(result)