Add aspirin 15-puzzle and auto-solver apps for Ontrol Orion
- gen_aspirin.py generates aspirin.sax (playable) and aspirin_solver.sax (30-move shuffle, then one inverse move per second) from stock kits - build.py compiles with sedonac and packages simulator backup zips - verify_logic.py models the Sedona scan engine to test both apps offline - viewer/OxViewer.java shows the headless simulator's screen live over SOX Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+350
@@ -0,0 +1,350 @@
|
||||
"""Offline check of aspirin.sax logic: models the Sedona scan (children first, then
|
||||
propagate links into a component, then execute) for the component types the app uses."""
|
||||
import random
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
SCAN_MS = 100
|
||||
|
||||
|
||||
class C:
|
||||
def __init__(self, typ, props):
|
||||
self.typ = typ.split("::")[1]
|
||||
self.v = {}
|
||||
self.kids = []
|
||||
self.links_to = []
|
||||
self.state = {}
|
||||
for k, val in props.items():
|
||||
if val in ("true", "false"):
|
||||
self.v[k] = val == "true"
|
||||
else:
|
||||
try:
|
||||
self.v[k] = float(val)
|
||||
except ValueError:
|
||||
self.v[k] = val
|
||||
|
||||
def g(self, k, d=0.0):
|
||||
return self.v.get(k, d)
|
||||
|
||||
def start(self):
|
||||
if self.typ == "Count":
|
||||
self.v["out"] = self.g("preset", 0)
|
||||
self.state["last"] = False
|
||||
if self.typ in ("OneShot", "DlyOn"):
|
||||
self.state["last"] = False
|
||||
self.state["end"] = 0
|
||||
if self.typ == "SRLatch":
|
||||
self.state["ls"] = self.state["lr"] = False
|
||||
|
||||
def execute(self, now):
|
||||
g, v, t = self.g, self.v, self.typ
|
||||
if t in ("Add2", "Add4"):
|
||||
v["out"] = g("in1") + g("in2") + g("in3") + g("in4")
|
||||
elif t == "Sub2":
|
||||
v["out"] = g("in1") - g("in2")
|
||||
elif t == "Mul2":
|
||||
v["out"] = g("in1") * g("in2")
|
||||
elif t == "Reset":
|
||||
i, a, b, c, d = g("in"), g("inMin"), g("inMax"), g("outMin"), g("outMax")
|
||||
v["out"] = c if i <= a else d if i >= b else (d - c) / (b - a) * (i - a) + c
|
||||
elif t == "Linearize":
|
||||
xs = [g(f"x{i}") for i in range(10)]
|
||||
ys = [g(f"y{i}") for i in range(10)]
|
||||
out = float("nan")
|
||||
i = g("in")
|
||||
for k in range(9):
|
||||
lo_ok = i >= xs[0] if k == 0 else i > xs[k]
|
||||
if lo_ok and i <= xs[k + 1]:
|
||||
m = (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k])
|
||||
out = m * i + (ys[k] - m * xs[k])
|
||||
v["out"] = out
|
||||
elif t == "InRange":
|
||||
v["out"] = g("lowLimit") <= g("in") <= g("highLimit")
|
||||
elif t == "Cmpr":
|
||||
v["xey"] = g("x") == g("y")
|
||||
elif t == "And2":
|
||||
v["out"] = bool(g("in1", False) and g("in2", False))
|
||||
elif t == "And4":
|
||||
v["out"] = all(g(f"in{k}", False) for k in range(1, 5))
|
||||
elif t in ("Or2", "Or4"):
|
||||
v["out"] = any(g(f"in{k}", False) for k in range(1, 5))
|
||||
elif t == "Not":
|
||||
v["out"] = not g("in", False)
|
||||
elif t == "OneShot":
|
||||
i = g("in", False)
|
||||
if i and not self.state["last"]:
|
||||
if not g("out", False):
|
||||
self.state["end"] = now + g("pulseWidth") * 1000
|
||||
v["out"] = self.state["end"] > now
|
||||
self.state["last"] = i
|
||||
elif t == "ADemux2":
|
||||
if g("s1", False):
|
||||
v["out2"] = g("in")
|
||||
else:
|
||||
v["out1"] = g("in")
|
||||
elif t == "SRLatch":
|
||||
if g("s", False) and not self.state["ls"]:
|
||||
v["out"] = True
|
||||
if g("r", False) and not self.state["lr"]:
|
||||
v["out"] = False
|
||||
self.state["ls"], self.state["lr"] = g("s", False), g("r", False)
|
||||
elif t == "ASW":
|
||||
v["out"] = g("in2") if g("s1", False) else g("in1")
|
||||
elif t == "TickTock":
|
||||
half = 500 / g("ticksPerSec", 1)
|
||||
v["out"] = (now // half) % 2 == 0
|
||||
elif t == "DlyOn":
|
||||
i = g("in", False)
|
||||
if i and not self.state["last"]:
|
||||
self.state["end"] = now + g("delayTime") * 1000
|
||||
if not i:
|
||||
self.state["end"] = 0
|
||||
v["out"] = False
|
||||
else:
|
||||
v["out"] = self.state["end"] < now
|
||||
if v["out"]:
|
||||
self.state["end"] = 0
|
||||
self.state["last"] = i
|
||||
elif t == "F2I":
|
||||
v["out"] = float(int(g("in")))
|
||||
elif t == "I2F":
|
||||
v["out"] = g("in")
|
||||
elif t == "Count":
|
||||
if not g("enable", False):
|
||||
return
|
||||
c = g("out")
|
||||
if g("in", False) and not self.state["last"]:
|
||||
c += 1
|
||||
if g("r", False):
|
||||
c = g("preset", 0)
|
||||
v["out"] = c
|
||||
self.state["last"] = g("in", False)
|
||||
|
||||
|
||||
def load(path):
|
||||
root = ET.parse(path).getroot()
|
||||
paths = {}
|
||||
order = []
|
||||
|
||||
def walk(el, prefix):
|
||||
comps = []
|
||||
for ce in el.findall("comp"):
|
||||
props = {p.get("name"): p.get("val") for p in ce.findall("prop")}
|
||||
c = C(ce.get("type"), props)
|
||||
p = f"{prefix}/{ce.get('name')}"
|
||||
paths[p] = c
|
||||
c.kids = walk(ce, p)
|
||||
comps.append(c)
|
||||
return comps
|
||||
|
||||
tops = walk(root.find("app"), "")
|
||||
for le in root.find("links").findall("link"):
|
||||
fp, fs = le.get("from").rsplit(".", 1)
|
||||
tp, ts = le.get("to").rsplit(".", 1)
|
||||
paths[tp].links_to.append((paths[fp], fs, ts))
|
||||
return tops, paths
|
||||
|
||||
|
||||
def run_scan(tops, now):
|
||||
def ex(c):
|
||||
for k in c.kids:
|
||||
ex(k)
|
||||
for src, fs, ts in c.links_to:
|
||||
c.v[ts] = src.v.get(fs, 0.0)
|
||||
c.execute(now)
|
||||
for t in tops:
|
||||
ex(t)
|
||||
|
||||
|
||||
def board(paths):
|
||||
grid = [[" ."] * 4 for _ in range(4)]
|
||||
for t in range(1, 16):
|
||||
w = paths[f"/ox/Game/Tile{t:02d}"]
|
||||
col = int((w.g("x") - 40) // 105)
|
||||
row = int((w.g("y") - 40) // 105)
|
||||
grid[row][col] = f"{t:3d}"
|
||||
return grid
|
||||
|
||||
|
||||
def main_solver():
|
||||
"""aspirin_solver.sax: exact-length shuffle, then one inverse move per second until solved."""
|
||||
tops, paths = load("aspirin_solver.sax")
|
||||
for c in paths.values():
|
||||
c.start()
|
||||
now = 0
|
||||
goal = [[" 1", " 2", " 3", " 4"], [" 5", " 6", " 7", " 8"],
|
||||
[" 9", " 10", " 11", " 12"], [" 13", " 14", " 15", " ."]]
|
||||
snapshots, solve_times = [], []
|
||||
last = board(paths)
|
||||
shuffle_done_at = None
|
||||
for _ in range(1200): # 120 s
|
||||
now += SCAN_MS + random.randint(-3, 3)
|
||||
run_scan(tops, now)
|
||||
b = board(paths)
|
||||
shuffling = paths["/logic/glob/Shuf"].g("out", False)
|
||||
if b != last and not shuffling and shuffle_done_at is not None:
|
||||
solve_times.append(now)
|
||||
if not shuffling and shuffle_done_at is None and now > 1000:
|
||||
shuffle_done_at = now
|
||||
snapshots.append(b)
|
||||
print("after shuffle:")
|
||||
print("\n".join(" ".join(r) for r in b))
|
||||
assert b != goal, "shuffle left board solved"
|
||||
last = b
|
||||
if shuffle_done_at and paths["/logic/post/Solved"].g("out", False):
|
||||
break
|
||||
assert paths["/logic/post/Solved"].g("out", False), "auto solver did not finish"
|
||||
assert board(paths) == goal
|
||||
gaps = [b - a for a, b in zip(solve_times, solve_times[1:])]
|
||||
print(f"solved with {len(solve_times)} moves, first move {solve_times[0]-shuffle_done_at} ms "
|
||||
f"after shuffle, gaps {min(gaps)}-{max(gaps)} ms, moves label "
|
||||
f"{paths['/logic/post/CntF'].g('out')}")
|
||||
assert len(solve_times) == 30 and min(gaps) >= 900
|
||||
assert paths["/logic/stkReg/Empty"].g("out", False)
|
||||
# SHUFFLE again
|
||||
paths["/ox/Game/Shuffle"].v["touch"] = True
|
||||
for _ in range(3):
|
||||
now += SCAN_MS
|
||||
run_scan(tops, now)
|
||||
paths["/ox/Game/Shuffle"].v["touch"] = False
|
||||
for _ in range(600):
|
||||
now += SCAN_MS
|
||||
run_scan(tops, now)
|
||||
if paths["/logic/post/Solved"].g("out", False) and not paths["/logic/glob/Shuf"].g("out", False):
|
||||
break
|
||||
assert board(paths) == goal and paths["/logic/post/Solved"].g("out", False)
|
||||
print("second shuffle + auto solve OK")
|
||||
|
||||
|
||||
def main():
|
||||
tops, paths = load("aspirin.sax")
|
||||
for c in paths.values():
|
||||
c.start()
|
||||
now = 0
|
||||
|
||||
def step(n=1):
|
||||
nonlocal now
|
||||
for _ in range(n):
|
||||
now += SCAN_MS + random.randint(-3, 3)
|
||||
run_scan(tops, now)
|
||||
|
||||
def check_perm():
|
||||
cells = [tuple(r) for r in board(paths)]
|
||||
flat = [x for r in cells for x in r]
|
||||
nums = sorted(int(x) for x in flat if x.strip() != ".")
|
||||
assert nums == list(range(1, 16)), cells
|
||||
|
||||
step(120) # boot shuffle (10 s) + settle
|
||||
check_perm()
|
||||
g = board(paths)
|
||||
print("after shuffle:")
|
||||
print("\n".join(" ".join(r) for r in g))
|
||||
solved = paths["/logic/post/Solved"].g("out", False)
|
||||
print("solved flag:", solved, "moves:", paths["/logic/post/CntF"].g("out"))
|
||||
assert not solved
|
||||
|
||||
def press(t):
|
||||
paths[f"/ox/Game/Tile{t:02d}"].v["touch"] = True
|
||||
step(2)
|
||||
paths[f"/ox/Game/Tile{t:02d}"].v["touch"] = False
|
||||
step(2)
|
||||
|
||||
# solve by replaying moves with a simple BFS-free strategy: random legal presses until solved is
|
||||
# impractical, so instead verify mechanics: invalid press does nothing, valid press swaps.
|
||||
def blank():
|
||||
g = board(paths)
|
||||
for r in range(4):
|
||||
for c in range(4):
|
||||
if g[r][c].strip() == ".":
|
||||
return r, c
|
||||
|
||||
def tile_at(r, c):
|
||||
return int(board(paths)[r][c])
|
||||
|
||||
br, bc = blank()
|
||||
# pick a non-adjacent tile
|
||||
far = next((r, c) for r in range(4) for c in range(4)
|
||||
if abs(r - br) + abs(c - bc) >= 2)
|
||||
before = board(paths)
|
||||
press(tile_at(*far))
|
||||
assert board(paths) == before, "non-adjacent tile moved"
|
||||
moves0 = paths["/logic/post/CntF"].g("out")
|
||||
# adjacent press
|
||||
nr, nc = (br, bc - 1) if bc > 0 else (br, bc + 1)
|
||||
t = tile_at(nr, nc)
|
||||
press(t)
|
||||
assert blank() == (nr, nc) and tile_at(br, bc) == t, "valid move failed"
|
||||
assert paths["/logic/post/CntF"].g("out") == moves0 + 1
|
||||
# holding the finger must not oscillate
|
||||
paths[f"/ox/Game/Tile{t:02d}"].v["touch"] = True
|
||||
step(10)
|
||||
paths[f"/ox/Game/Tile{t:02d}"].v["touch"] = False
|
||||
step(2)
|
||||
assert blank() == (br, bc), "hold did not move exactly once"
|
||||
check_perm()
|
||||
|
||||
# solve: undo using a solver on the current board (IDA* is overkill; use reverse of
|
||||
# a fresh scramble instead): press SHUFFLE-free path -> brute force with BFS on small scramble
|
||||
# Easier: drive tiles to solved using a known-solvable sequence from Python solver.
|
||||
from collections import deque
|
||||
|
||||
def state():
|
||||
g = board(paths)
|
||||
return tuple(0 if x.strip() == "." else int(x) for r in g for x in r)
|
||||
|
||||
goal = tuple(list(range(1, 16)) + [0])
|
||||
# A* with manhattan heuristic
|
||||
import heapq
|
||||
|
||||
def h(s):
|
||||
d = 0
|
||||
for i, v in enumerate(s):
|
||||
if v:
|
||||
d += abs(i // 4 - (v - 1) // 4) + abs(i % 4 - (v - 1) % 4)
|
||||
return d
|
||||
|
||||
s0 = state()
|
||||
pq = [(h(s0), 0, s0, [])]
|
||||
seen = {s0: 0}
|
||||
path = None
|
||||
while pq:
|
||||
f, gc, s, p = heapq.heappop(pq)
|
||||
if s == goal:
|
||||
path = p
|
||||
break
|
||||
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 + [s[n]]))
|
||||
if len(seen) > 2_000_000:
|
||||
break
|
||||
assert path is not None, "solver gave up"
|
||||
print("solving in", len(path), "presses")
|
||||
for t in path:
|
||||
press(t)
|
||||
step(25)
|
||||
print("\n".join(" ".join(r) for r in board(paths)))
|
||||
assert state() == goal
|
||||
assert paths["/logic/post/Solved"].g("out", False), "solved flag not set"
|
||||
print("SOLVED flag OK, moves:", paths["/logic/post/CntF"].g("out"))
|
||||
|
||||
# SHUFFLE button re-scrambles and clears the flag/counter
|
||||
paths["/ox/Game/Shuffle"].v["touch"] = True
|
||||
step(2)
|
||||
paths["/ox/Game/Shuffle"].v["touch"] = False
|
||||
step(120)
|
||||
check_perm()
|
||||
assert state() != goal and not paths["/logic/post/Solved"].g("out", False)
|
||||
assert paths["/logic/post/CntF"].g("out") == 0
|
||||
print("re-shuffle OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
main_solver() if "--solver" in sys.argv else main()
|
||||
Reference in New Issue
Block a user