Add aspirinV2: fixed-cell 15-puzzle

Cells never move; each NumericSelectLabel shows the number it holds
on white and the empty cell takes the board colour via a linked
backgroundColor. Adds the V2 generator, build step (with a direct-Java
sedonac fallback), offline verifier mode (--v2) and viewer support.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 19:14:16 +03:00
co-authored by Claude Opus 5.5
parent 0728acd99a
commit d1f4e87526
8 changed files with 3242 additions and 10 deletions
+123 -1
View File
@@ -345,6 +345,128 @@ def main():
print("re-shuffle OK")
def astar(s0):
"""Weighted A* on a 16-tuple (0 = blank); returns the list of cell indices to press."""
import heapq
goal = tuple(list(range(1, 16)) + [0])
def h(s):
return sum(abs(i // 4 - (v - 1) // 4) + abs(i % 4 - (v - 1) % 4)
for i, v in enumerate(s) if v)
pq = [(h(s0), 0, s0, [])]
seen = {s0: 0}
while pq:
_, gc, s, p = heapq.heappop(pq)
if s == goal:
return p
z = s.index(0)
for dz in (-1, 1, -4, 4):
n = z + dz
if n < 0 or n > 15 or (dz in (-1, 1) and n // 4 != z // 4):
continue
l = list(s)
l[z], l[n] = l[n], l[z]
ns = tuple(l)
if seen.get(ns, 1e9) > gc + 1:
seen[ns] = gc + 1
heapq.heappush(pq, (gc + 1 + 1.5 * h(ns), gc + 1, ns, p + [n]))
if len(seen) > 2_000_000:
return None
def main_v2():
"""aspirinV2.sax: fixed cells, numbers shown via each cell's `in`, empty cell recoloured."""
tops, paths = load("aspirinV2.sax")
for c in paths.values():
c.start()
now = 0
goal = tuple(list(range(1, 16)) + [0])
cell = [paths[f"/ox/Game/Cell{i:02d}"] for i in range(1, 17)]
def step(n=1):
nonlocal now
for _ in range(n):
now += SCAN_MS + random.randint(-3, 3)
run_scan(tops, now)
def state():
return tuple(int(c.g("in")) for c in cell)
def check():
s = state()
assert sorted(s) == list(range(16)), s
for v, c in zip(s, cell): # empty cell = board colour, numbers on white
assert int(c.g("backgroundColor")) == (0x31A6 if v == 0 else 0xFFFF), (s, v)
def show():
s = state()
return "\n".join(" ".join(f"{v:3d}" if v else " ." for v in s[r * 4:r * 4 + 4])
for r in range(4))
def solved():
return paths["/logic/post/Solved"].g("out", False)
def moves():
return paths["/logic/post/CntF"].g("out")
def press(k):
cell[k].v["touch"] = True
step(2)
cell[k].v["touch"] = False
step(2)
step(3)
assert state() == goal, "boot did not load the solved board: %s" % (state(),)
step(120)
check()
print("after shuffle:\n" + show())
assert state() != goal and not solved() and moves() == 0
z = state().index(0)
far = next(k for k in range(16) if abs(k // 4 - z // 4) + abs(k % 4 - z % 4) >= 2)
before = state()
press(far)
assert state() == before, "non-adjacent cell moved"
press(z)
assert state() == before, "pressing the empty cell changed the board"
n = z - 1 if z % 4 else z + 1
v = before[n]
press(n)
s = state()
assert s[n] == 0 and s[z] == v and moves() == 1, "valid move failed"
cell[z].v["touch"] = True # hold: exactly one move back
step(10)
cell[z].v["touch"] = False
step(2)
assert state() == before and moves() == 2, "hold did not move exactly once"
check()
path = astar(state())
assert path is not None, "solver gave up"
print("solving in", len(path), "presses")
for k in path:
press(k)
step(5)
print(show())
check()
assert state() == goal and solved(), "solved flag not set"
print("SOLVED flag OK, moves:", moves())
paths["/ox/Game/Shuffle"].v["touch"] = True
step(2)
paths["/ox/Game/Shuffle"].v["touch"] = False
step(120)
check()
assert state() != goal and not solved() and moves() == 0
print("re-shuffle OK")
if __name__ == "__main__":
import sys
main_solver() if "--solver" in sys.argv else main()
if "--solver" in sys.argv:
main_solver()
elif "--v2" in sys.argv:
main_v2()
else:
main()