aspirinV2: tap to start with a random solvable deal

The first tap (or SHUFFLE) loads the solved board and deals it with a
Fisher-Yates pass driven by the free-running logistic-map RNG, so tap
timing picks the board. A parity latch adds one swap of cells 1 and 2
when needed, so every deal is solvable.

Tile moves and deal swaps share one cell-swap mechanism, cutting RAM
from 56.4 kB (failed to start on the Orion) to 34.6 kB (runs). Adds
deal checks to verify_logic.py --v2 and the hint label to the viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 22:01:59 +03:00
co-authored by Claude Opus 5.5
parent d1f4e87526
commit f078dc99f3
7 changed files with 1860 additions and 2303 deletions
+41 -8
View File
@@ -345,7 +345,7 @@ def main():
print("re-shuffle OK")
def astar(s0):
def astar(s0, weight=1.5):
"""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])
@@ -370,11 +370,19 @@ def astar(s0):
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]))
heapq.heappush(pq, (gc + 1 + weight * h(ns), gc + 1, ns, p + [n]))
if len(seen) > 2_000_000:
return None
def solvable(s):
"""4x4: permutation parity (blank as 16) == parity of the blank's distance from home."""
perm = [v if v else 16 for v in s]
inv = sum(1 for a in range(16) for b in range(a + 1, 16) if perm[a] > perm[b])
z = s.index(0)
return inv % 2 == (3 - z // 4 + 3 - z % 4) % 2
def main_v2():
"""aspirinV2.sax: fixed cells, numbers shown via each cell's `in`, empty cell recoloured."""
tops, paths = load("aspirinV2.sax")
@@ -416,11 +424,18 @@ def main_v2():
cell[k].v["touch"] = False
step(2)
hint = paths["/ox/Game/Hint"]
step(3)
assert state() == goal, "boot did not load the solved board: %s" % (state(),)
step(120)
step(random.randint(20, 80)) # idle: no shuffle until the first tap
check()
print("after shuffle:\n" + show())
assert state() == goal and hint.g("in", False) and not solved() and moves() == 0
press(0) # first tap (cell 1, not next to the gap) deals
assert not hint.g("in", False)
step(30)
check()
print("after deal:\n" + show())
assert solvable(state()), "dealt an unsolvable board"
assert state() != goal and not solved() and moves() == 0
z = state().index(0)
@@ -442,7 +457,7 @@ def main_v2():
assert state() == before and moves() == 2, "hold did not move exactly once"
check()
path = astar(state())
path = astar(state(), weight=4) # random deals are deep; any solution will do
assert path is not None, "solver gave up"
print("solving in", len(path), "presses")
for k in path:
@@ -456,10 +471,28 @@ def main_v2():
paths["/ox/Game/Shuffle"].v["touch"] = True
step(2)
paths["/ox/Game/Shuffle"].v["touch"] = False
step(120)
step(30)
check()
assert state() != goal and not solved() and moves() == 0
print("re-shuffle OK")
assert state() != goal and not solved() and moves() == 0 and solvable(state())
print("re-deal OK")
# many deals: always solvable, all different, every number reaches every cell
seen, where = set(), [set() for _ in range(16)]
for _ in range(300):
step(random.randint(1, 40))
paths["/ox/Game/Shuffle"].v["touch"] = True
step(1)
paths["/ox/Game/Shuffle"].v["touch"] = False
step(20)
s = state()
check()
assert solvable(s), s
seen.add(s)
assert s[15] == 0, "deal moved the blank out of cell 16"
for k, v in enumerate(s):
where[k].add(v)
assert all(len(w) == 15 for w in where[:15]), [len(w) for w in where]
print(f"300 deals: {len(seen)} distinct, all solvable, every number seen in cells 1-15")
if __name__ == "__main__":