"""play.py -- the reef's first play: Lego grows by taste, no plan (step 1, Oct 23 2026).
Ivy, Oct 22: "there would be no plan. That's the point. It plays. It puts pieces it likes on."
Each turn: the engine (~/lego/grow.py's own legal move) proposes up to K different legal additions anywhere on the build;
each is MEASURED (relations.py between the new piece and the piece it joins, every KEPT ruler from the loop, and numbers about
the whole build); Jev picks the one this taste would keep, asked twice (reversed and relabelled); the pick is committed.
Not yet: taking pieces off, Haiku's words, Opus changing the taste. One thing at a time.
    python3 play.py --taste tidy --turns 15 --seed 1     -> runs/<taste>_s<seed>/frame_NN.png, build.ldr, log.jsonl"""
import sys, os, json, argparse, copy, subprocess, importlib.util
import numpy as np
sys.path.insert(0, os.path.expanduser('~/lego')); B = os.path.expanduser('~/projects/brick_reef')
for p in ('joins', 'jev', 'loop'): sys.path.insert(0, f'{B}/{p}')
import kit; kit.ALLOW_ANY = True    # the reef plays with VIRTUAL pieces (Ivy, Oct 23 2026: "More pieces!"). Ivy's Sep 15
                                    # bag rule (owned or printable only) still applies to anything we build for real.
import grow as G
from kit import Part
_fresh = getattr(G, '_orig_fresh', G.fresh); G._orig_fresh = _fresh   # safe to reload: always wrap the ORIGINAL fresh once
TYRE_HOLDERS = {'122c01'}            # its hubs are stud-sized cylinders in LDCad too; a tyre goes there, not a brick
def _upright_fresh(name):
    """the engine's geometry finder sees upright studs only, and read 122c01's two wheel hubs as studs -- so its sideways ones go, and
    real side studs come from LDCad's shadow library instead (Oct 24 2026: this filter threw away EVERY side stud -- 87087, the
    headlight brick, 11211, 30414 -- so Pin asked four turns running for Liberty's tablet on a side stud and heard 'it fit nowhere')"""
    p = _fresh(name); p.studs = [t for t in p.studs if t['axis'][2] > 0.99]
    if name not in TYRE_HOLDERS:
        try:
            for d in SN.snaps(name):
                if d['kind'] == 'cyl' and d['gender'] == 'M' and SN.is_engine_stud(d) and abs(d['axis'][2]) < 0.01:
                    p.studs = list(p.studs) + [{'c': np.asarray(d['c'], float), 'axis': np.asarray(d['axis'], float)}]
        except Exception: pass
    # a shallow vertical stud-width hole opening on the BOTTOM is a stud receptacle, i.e. a socket (Oct 23 2026: the frog 33320
    # read as no studs, no sockets and one 'hole' -- d 4.75 mm, 1.5 deep, at its feet -- so it could never be pressed onto a stud)
    if p.holes:
        z0 = float(p.mesh0.bounds[0][2]) if hasattr(p, 'mesh0') else 0.0
        keep = []
        for h in p.holes:
            c = np.asarray(h['c'], float)
            if abs(h['axis'][2]) > 0.99 and 4.4 < h['d'] < 5.1 and h['length'] <= 2.5 and c[2] - h['length'] / 2 <= z0 + 0.3:
                at = np.array([c[0], c[1], z0])
                if not any(np.linalg.norm(np.asarray(k['c']) - at) < 1.0 for k in p.sockets):
                    p.sockets = list(p.sockets) + [{'c': at, 'axis': np.array([0.0, 0.0, -1.0])}]
                continue
            keep.append(h)
        p.holes = keep
    # tiles' undersides: the geometry finder saw 3 sockets under a 2x4 tile and 1 under a 2x2 (Oct 23 2026: Pin asked for the orange 2x4
    # tile 19 turns running and the engine never once offered it). LDCad's shadow library has the exact anti-studs -- on bricks they match
    # the engine's to 0.0 mm -- so when it knows more of them, use those.
    try:
        anti = [d for d in SN.snaps(name) if d['kind'] == 'cyl' and d['gender'] == 'F' and SN.is_engine_stud(d) and d['axis'][2] > 0.99]
        # a tie goes to the shadow library too (Oct 23 2026): the heart tile 39739's socket was GUESSED from its lopsided outline,
        # 0.8 mm off centre, so every stud it was offered collided and the fit bot recorded 0 of 1035; LDCad has it at the origin
        V0 = p.mesh0.vertices                          # only ones AT the underside right there (15068 is stepped: 0 in front, 3.2 behind)
        def at_underside(d):
            c = np.asarray(d['c'], float); sel = V0[(np.abs(V0[:, 0] - c[0]) < 3.0) & (np.abs(V0[:, 1] - c[1]) < 3.0)]
            return len(sel) > 0 and abs(float(sel[:, 2].min()) - c[2]) < 0.5
        anti = [d for d in anti if at_underside(d)]
        if anti and len(anti) >= len(p.sockets): p.sockets = [{'c': np.asarray(d['c'], float), 'axis': np.array([0.0, 0.0, -1.0])} for d in anti]
    except Exception: pass
    return p
_upright_fresh.n = _fresh.n            # grow.fresh counts colours on whatever its module-level name `fresh` is
G.fresh = _upright_fresh
import jev
from facts import facts
from relations import relations, GROUPS
import ctx as CX
HERE = os.path.dirname(os.path.abspath(__file__))
PALETTE = ['3700', '3701', '3894', '3702', '32524', '32316', '32017', '2905', '3023', '3020', '3022', '3024', '3001', '3034',
           # Oct 23 2026, a real bin: wheels, slopes, round pieces, cones, wings, tiles, an arch, plain bricks and plates.
           # Wheels are BUILT: the bare wheel plate 122c01, then each 3641 tyre on its own turn (Ivy, Oct 23: "it should have to
           # attach the tires one at a time to the base thing"); the pre-tyred 122c03/05/06 shortcuts are out. Checked for detected studs/sockets; dropped 3068b (a 2x2 tile reads as no studs AND no sockets: unattachable).
           '122c01', '3641', '3039', '3040b', '3665a', '3298', '3747b', '3062b', '6141', '4589', '3941', '4032a', '4740',
           '41769a', '41770a', '3069b', '2412b', '3070b', '3659', '6091', '3005', '3004', '3622', '3010', '3710', '3666', '2420']
try:                                            # everything added from the catalog since (partbin.py, bin.json)
    PALETTE += [b['part'] for b in json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'bin.json'))) if b['part'] not in PALETTE]
except (OSError, ValueError): pass
BASEPLATES = ('3867', '3865')     # chosen as a first piece or slid under later; never dealt as cards
for _n in PALETTE + list(BASEPLATES):     # plain names for pieces facts.py has never seen, from the LDraw header line
    if _n not in CX.NAMES:
        try: CX.NAMES[_n] = open(os.path.expanduser(f'~/lego/ldraw/parts/{_n}.dat')).readline()[2:].strip().split('  (')[0]
        except OSError: pass
TASTES = {'tidy': "tidy building: nothing hanging off, everything centred, flush and compact",
          'reach': "pieces that reach out into the air, hang off, lean and look almost about to fall; tidy and compact bores them"}
def kept_rulers():
    L = [json.loads(l) for l in open(f'{B}/loop/ledger.jsonl')]
    out = []
    for r in L:
        if r['verdict'] == 'keep':
            spec = importlib.util.spec_from_file_location(r['name'], f"{B}/loop/rulers/{r['name']}.py")
            m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m); out.append(m)
    return out
# where a 3641 tyre sits on a 122c01 wheel plate, read from the library's own 122c03.dat (LDraw units -> ours)
_C = np.array([[0.4, 0, 0, 0], [0, 0, 0.4, 0], [0, -0.4, 0, 0], [0, 0, 0, 1]])
_TYRE_SLOTS = [(_C @ M @ np.linalg.inv(_C)) for M in (
    np.array([[0, 0, -1, -31], [0, 1, 0, 6], [1, 0, 0, 0], [0, 0, 0, 1.0]]),
    np.array([[0, 0, 1, 31], [0, 1, 0, 6], [-1, 0, 0, 0], [0, 0, 0, 1.0]]))]
sys.path.insert(0, os.path.expanduser('~/lego'))
import snaps as SN
def new_snaps(name):
    """the connections the old engine didn't know -- bars, clips, bar holes, axles, axle holes, hinge fingers, ball joints --
    from LDCad's shadow library (CONNECTIONS.md). Studs and anti-studs stay the engine's own."""
    try: return [d for d in SN.snaps(name) if not SN.is_engine_stud(d)]
    except Exception: return []
def _snap_len(d):
    """how long a snap is along its axis (mm): the sum of its sections, or a clip's length"""
    return sum(float(x[2]) for x in (d.get('secs') or [])) or float(d.get('length', 0) or 0)
def snap_world(b, d):
    F = SN.frame(d, b.T); return F[:3, 3] + F[:3, 1] * SN.anchor(d), F[:3, 1]
def snaps_mated(bodies):
    """(id(body), snap index) pairs already joined to another body's snap (same place, same or opposite axis, and they fit)"""
    W = [(b, i, d) + snap_world(b, d) for b in bodies for i, d in enumerate(new_snaps(b.name))]
    used = set()
    if len(W) < 2: return used
    from scipy.spatial import cKDTree              # only snaps within 0.6 mm can be mated (every pair checked took 13 s on a 479-piece build)
    for a, c in cKDTree(np.array([w[3] for w in W])).query_pairs(0.6):
        ba, ia, da, pa, xa = W[a]; bc, ic, dc, pc, xc = W[c]
        if ba is bc or abs(abs(xa @ xc) - 1) > 0.05: continue
        if SN.fits(da, dc) or SN.fits(dc, da): used |= {(id(ba), ia), (id(bc), ic)}
    return used

class FloorHost:
    """the huge invisible baseplate under the build (Ivy, Oct 23 2026: "assume the bottom piece is attached to a huge baseplate
    that's invisible" -> "the huge base plate should still have clutch and gravity"). One per floor stud, standing in for a
    real 3867 baseplate whose stud sits there, so descriptions and pair pictures work like any other host."""
    name = '3867'; colour = 'invisible'; is_floor = True; studs = (); sockets = (); holes = ()
    def __init__(s, pt):
        s.pt = np.asarray(pt, float); b = G.fresh('3867'); s.T = np.eye(4); s.T[:3, 3] = s.pt - b.studs[0]['c']
GRID = 8.0
def on_grid(xy, off, tol=0.6):
    d = np.mod(np.asarray(xy) - off + GRID / 2, GRID) - GRID / 2
    return bool(np.all(np.abs(d) < tol))
def upright(b):
    """is this piece the right way up? Its studs point up -- or, for a piece with no studs on top (curved slopes, tiles), its sockets
    point down. Oct 23 2026: every check used studs only, so a 2x2 curved slope as a first piece was never 'upright': it couldn't
    clutch the baseplate (falsely flagged as tipping), nothing could go under it, and the game ended on 'too few moves' (Ivy: "I
    think the possibility of tipping over is breaking it somehow. It ends early")"""
    if b.studs: return b.world_dir(b.studs[0]['axis'])[2] > 0.99
    return bool(b.sockets) and b.world_dir(b.sockets[0]['axis'])[2] < -0.99
def between_points(b):
    """the centre of every 2x2 square of b's own upward studs, at the studs' base -- where a 1x1 round piece presses in between four
    studs (Ivy, Oct 23 2026: "Need to be able to press 1x1 round things in between four studs")"""
    if not upright(b) or len(b.studs) < 4: return []
    P = np.array([b.world(t['c']) for t in b.studs if b.world_dir(t['axis'])[2] > 0.99])
    if len(P) < 4: return []
    out = []
    for a in P:
        near = [q for q in P if abs(q[2] - a[2]) < 0.2 and abs(np.linalg.norm(q[:2] - a[:2]) - 8.0) < 0.3]
        for i, u in enumerate(near):
            for v in near[i + 1:]:
                du, dv = u[:2] - a[:2], v[:2] - a[:2]
                if abs(du @ dv) > 1.0: continue                     # the two neighbours must be at a right angle
                far = a[:2] + du + dv
                if any(abs(q[2] - a[2]) < 0.2 and np.linalg.norm(q[:2] - far) < 0.3 for q in P):
                    c = np.array([a[0] + (du[0] + dv[0]) / 2, a[1] + (du[1] + dv[1]) / 2, a[2]])
                    if not any(np.linalg.norm(c - o) < 0.5 for o in out): out.append(c)
    return out

_ROUND1 = {}
def round_1x1(name):
    """a 1x1 round piece or 1x1 cone: one socket, a footprint no wider than one stud -- the kind that presses in between four studs"""
    if name not in _ROUND1:
        nm = (CX.NAMES.get(name, '') or '').lower().replace('  ', ' ')
        b = G.fresh(name); lo, hi = b.mesh0.bounds
        _ROUND1[name] = ('1 x 1' in nm and ('round' in nm or 'cone' in nm)) and len(b.sockets) >= 1 and (hi - lo)[0] < 8.3 and (hi - lo)[1] < 8.3
    return _ROUND1[name]

def floor_grid(bodies, off=None):
    """(floor z, stud grid offset xy, a piece clutched to the baseplate or None). The grid is FIXED in the world once a game
    starts (pass off): a build that gets turned over lands wherever it lands and only clutches if an underside meets the studs"""
    if not bodies: return 0.0, (np.array([4.0, 4.0]) if off is None else off), None
    z0 = min(b.mesh.bounds[0][2] for b in bodies)
    for b in bodies:
        if b.mesh.bounds[0][2] < z0 + 0.3 and upright(b) and b.sockets:
            for k in b.sockets:
                w = b.world(k['c'])
                if abs(w[2] - z0) >= 0.6: continue
                if off is None: return z0, np.mod(w[:2], GRID), b
                if on_grid(w[:2], off): return z0, off, b
    return z0, (np.array([4.0, 4.0]) if off is None else off), None
def floor_points(bodies, reach=3, off=None):
    """free baseplate studs within `reach` studs of the build's footprint on the floor"""
    z0, off, _ = floor_grid(bodies, off)
    if not bodies: return []
    V = np.vstack([b.mesh.vertices for b in bodies]); lo, hi = V[:, :2].min(0) - reach * GRID, V[:, :2].max(0) + reach * GRID
    xs = np.arange(np.floor((lo[0] - off[0]) / GRID), np.ceil((hi[0] - off[0]) / GRID) + 1) * GRID + off[0]
    ys = np.arange(np.floor((lo[1] - off[1]) / GRID), np.ceil((hi[1] - off[1]) / GRID) + 1) * GRID + off[1]
    low = [b.mesh.bounds for b in bodies if b.mesh.bounds[0][2] < z0 + 0.3]
    out = []
    for x in xs:
        for y in ys:
            if any(bb[0][0] - 0.5 < x < bb[1][0] + 0.5 and bb[0][1] - 0.5 < y < bb[1][1] + 0.5 for bb in low): continue   # covered
            out.append(np.array([x, y, z0]))
    return out

class Reef:
    def __init__(s, seed, taste, outdir):
        s.g = G.Grower(seed); s.rng = np.random.default_rng(seed); s.taste = taste; s.out = outdir; os.makedirs(outdir, exist_ok=True)
        # the first piece varies by seed (every run used to start on the same red 1x8 and so opened with 'fire truck')
        FIRSTS = ['3702', '3001', '3020', '3034', '3039', '3941', '3666', '32524', '3010', '2420', '3298', '3004']
        first = G.fresh(FIRSTS[seed % len(FIRSTS)]); first.colour = int(G.COLOURS[(seed * 7) % len(G.COLOURS)]); s.g.asm.place(first); s.rulers = kept_rulers(); s.turn = 0
        s._g_undo = s.g.undo; s.g.undo = s._undo          # a lift (building under the bottom) undoes its lift too
    def bodies(s): return s.g.bodies()
    def _undo(s, new, n0):
        dz = getattr(new, '_lift', 0.0)
        if dz:
            for q in s.g.asm.parts: q.T[2, 3] -= dz
        return s._g_undo(new, n0)
    def conns(s):
        """every place a piece could join: the engine's free hole ends and studs, PLUS free sockets on the underside of any
        upright piece (Oct 23 2026: the reef asked for landing wheels 'tucked under the body' eight turns running and the
        engine could not build downward at all)"""
        bs = s.bodies()
        # Sideways studs ARE places to build (SNOT); the bug was only that the engine stacked pieces on them UPRIGHT ("it was just
        # putting the wheels backwards", Ivy, Oct 23 2026). aligned() now turns the new piece to face the stud.
        out = list(s.g.free())
        # a stud pushed into a Technic hole (Ivy: "studs into technic" -- legal, and the engine never had it)
        out += [('studhole', c[1], c[2], c[3]) for c in s.g.free() if c[0] == 'hole']
        studs = [b.world(t['c']) for b in bs for t in b.studs]
        from scipy.spatial import cKDTree               # nearest-stud lookups, not every socket against every stud (2M distances a turn)
        stree = cKDTree(np.array(studs)) if studs else None
        floor = min(b.mesh.bounds[0][2] for b in bs) if bs else 0.0
        for b in bs:
            if b.name in BASEPLATES: continue
            lift = b.mesh.bounds[0][2] < floor + 0.3                   # on the baseplate: a piece under it lifts the whole build                       # nothing goes UNDER a baseplate (its 'sockets' are a detector artefact)
            if not upright(b): continue
            for k, so in enumerate(b.sockets):
                w = b.world(so['c'])
                if stree is None or not stree.query_ball_point(w, 0.6): out.append(('lift' if lift else 'socket', b, k))
        socks = [b.world(k['c']) for b in bs for k in b.sockets]
        ktree = cKDTree(np.array(socks)) if socks else None
        out += [('between', b, i) for b in bs if b.name not in BASEPLATES for i, q in enumerate(between_points(b))
                if ktree is None or not ktree.query_ball_point(q, 0.6)]      # in between four studs, if nothing sits there yet
        used_s = snaps_mated(bs)
        out += [('snap', b, i) for b in bs for i, _ in enumerate(new_snaps(b.name)) if (id(b), i) not in used_s]   # bars, clips, axles, hinges...
        out += [('floor', FloorHost(q), (float(q[0]), float(q[1]))) for q in floor_points(bs, off=getattr(s, 'floor_off', None))]   # clutch: build on the baseplate
        return out + s.tyre_slots()
    TYRE_SLOTS = _TYRE_SLOTS
    def tyre_slots(s):
        """free tyre slots: each bare wheel on a 122c01 that has no tyre on it yet"""
        out, bs = [], s.bodies(); tyres = [b.T[:3, 3] for b in bs if b.name == '3641']
        for b in bs:
            if b.name != '122c01': continue
            for k, M in enumerate(s.TYRE_SLOTS):
                w = (b.T @ M)[:3, 3]
                if not any(np.linalg.norm(w - t) < 1.0 for t in tyres): out.append(('tyre', b, k))
        return out
    @staticmethod
    def aligned(new, local_pt, local_axis, world_pt, world_axis, spin):
        """place new so its local_axis points along world_axis (spun about it by `spin` degrees) and local_pt lands on world_pt"""
        import trimesh
        a = np.asarray(local_axis, float) / np.linalg.norm(local_axis); b = np.asarray(world_axis, float) / np.linalg.norm(world_axis)
        v = np.cross(a, b); c = float(a @ b)
        if np.linalg.norm(v) < 1e-9:
            R = np.eye(4) if c > 0 else trimesh.transformations.rotation_matrix(np.pi, [1, 0, 0] if abs(a[0]) < 0.9 else [0, 1, 0])
        else: R = trimesh.transformations.rotation_matrix(np.arccos(np.clip(c, -1, 1)), v)
        R = trimesh.transformations.rotation_matrix(np.radians(spin), b) @ R
        p = (R @ np.r_[local_pt, 1])[:3]; R[:3, 3] += np.asarray(world_pt) - p; new.T = R; new.attached = True
    def under(s, host, k, new, j, spin):
        """new piece UNDER host: new's stud j pressed up into host's socket k, both upright, spun about vertical"""
        import trimesh
        R = trimesh.transformations.rotation_matrix(np.radians(spin), [0, 0, 1])
        sc = (R @ np.r_[new.studs[j]['c'], 1])[:3]; T = R.copy(); T[:3, 3] += host.world(host.sockets[k]['c']) - sc
        new.T = T; new.attached = True; s.g.asm.parts.append(new); s.g.asm.joins.append((host.name, new.name, 'under'))
    def collides(s, new):
        """the engine's own check, but with fewer sample points while previewing ghosts (s.quick); placing always does the full one"""
        if not getattr(s, 'quick', False): return s.g.collides(new)
        m = new.mesh; bn = m.bounds
        for b in s.bodies():
            if b is new: continue
            mb = b.mesh; bp = mb.bounds
            if (bn[0] > bp[1] + 0.1).any() or (bp[0] > bn[1] + 0.1).any(): continue
            # boxes shrunk by a stud's height: pieces that only touch or stack never overlap after this, so most tries stop here
            lo, hi = np.maximum(bn[0], bp[0]) + 0.9, np.minimum(bn[1], bp[1]) - 0.9
            if (hi <= lo).any(): continue
            if G.Assembly.penetration(mb, m, n=350) > G.DEPTH_TOL: return 'collides'
        return None
    def try_move(s, c, name, idx, spin):
        """apply one legal move; returns (new part, n_before) or None. Caller undoes or keeps."""
        g = s.g; new = G.fresh(name); n0 = len(g.asm.parts)
        bs0 = s.bodies(); floor0 = min(b.mesh.bounds[0][2] for b in bs0) if bs0 else None      # the baseplate, BEFORE this move
        if (c[0] == 'tyre') != (name == '3641'): return None          # a tyre only goes on a bare wheel, and only a tyre does
        if c[0] == 'tyre':
            new.T = c[1].T @ s.TYRE_SLOTS[c[2]]; new.attached = True; new.colour = 0;   # tyres are black (it can repaint)
            g.asm.parts.append(new); g.asm.joins.append((c[1].name, '3641', 'tyre'))
            bad = [b for b in s.bodies() if b is not new and b is not c[1] and b.name != '3641'
                   and not ((b.mesh.bounds[0] > new.mesh.bounds[1] + 0.1).any() or (new.mesh.bounds[0] > b.mesh.bounds[1] + 0.1).any())
                   and G.Assembly.penetration(b.mesh, new.mesh) > G.DEPTH_TOL]
            if bad: g.undo(new, n0); return None
            return new, n0
        try:
            if c[0] == 'hole':
                if not new.holes: return None
                g.asm.pin(c[1], c[1].holes[c[2]], new, new.holes[idx % len(new.holes)], side=c[3], spin=spin)
            elif c[0] == 'floor':                  # an upright piece pressed onto the invisible baseplate's stud
                if not new.sockets: return None
                import trimesh
                R = trimesh.transformations.rotation_matrix(np.radians(spin), [0, 0, 1]); so = new.sockets[idx % len(new.sockets)]
                R[:3, 3] += c[1].pt - (R @ np.r_[so['c'], 1])[:3]; new.T = R; new.attached = True
                g.asm.parts.append(new); g.asm.joins.append(('baseplate', new.name, 'studs'))
            elif c[0] == 'between':                # a 1x1 round piece pressed in between four studs
                if not round_1x1(name): return None
                pts = between_points(c[1])
                if c[2] >= len(pts): return None
                import trimesh
                R = trimesh.transformations.rotation_matrix(np.radians(spin), [0, 0, 1]); so = new.sockets[0]
                R[:3, 3] += pts[c[2]] - (R @ np.r_[so['c'], 1])[:3]; new.T = R; new.attached = True
                g.asm.parts.append(new); g.asm.joins.append((c[1].name, new.name, 'between'))
            elif c[0] == 'snap':                   # a bar in a clip, an axle in its hole, hinge fingers, a ball joint (CONNECTIONS.md)
                hd = new_snaps(c[1].name)[c[2]]
                compat = [(j, k) for j, nd in enumerate(new_snaps(name)) for k in [SN.fits(nd, hd) or SN.fits(hd, nd)] if k]
                if not compat: return None
                j, (step, may_flip) = compat[idx % len(compat)]
                flip = may_flip and (idx // len(compat)) % 2 == 1
                sp = spin if not step else round(spin / step) * step
                nd = new_snaps(name)[j]
                # slide it along the axis until it seats (Oct 23 2026): a through-hole, a clip or a bar has no stop, so the engine put
                # their MIDDLES together -- a cone's bar hole centred on the tip of the segmented leg's bar, half inside the leg, and
                # every bar-fitting piece in the bin was refused for sharing ~1 mm with it. Nearest depth first, first clear one wins.
                slides = [0.0]
                slidable = 'none' in (nd.get('caps'), hd.get('caps')) or 'clp' in (nd.get('kind'), hd.get('kind'))   # an open end can slide
                if slidable:
                    L = (_snap_len(nd) + _snap_len(hd)) / 2
                    slides = sorted(np.arange(-L, L + 1e-6, 0.8), key=abs) if L > 0 else slides
                # ...and WHERE along it is a choice (Ivy, Oct 23 2026: "When it puts a rod thru it needs to decide which end is flush or how
                # far along it is"): mode 0 = seated at the nearest clear depth, 1 / 2 = flush with one end / the other. Each is its own
                # candidate with its own picture, so Pin decides by looking.
                mode = (idx // (2 * len(compat))) % 3
                d = (_snap_len(hd) - _snap_len(nd)) / 2 if slidable else 0.0   # (was keyed on anchor != 0, which every axle and hole has)
                if mode and abs(d) > 0.4: slides = [d if mode == 1 else -d]
                for sl in slides:
                    new.T = SN.place(nd, hd, c[1].T, sp, flip, float(sl))
                    if len(slides) == 1 or not s.collides(new): break
                new.attached = True
                g.asm.parts.append(new); g.asm.joins.append((c[1].name, new.name, 'snap'))
            elif c[0] == 'lift':                   # Ivy, Oct 23 2026: "It should still be able to build under the baseplate forcing that layer up"
                ups = [i for i, t in enumerate(new.studs) if t['axis'][2] > 0.99]
                if not ups: return None
                floor = min(b.mesh.bounds[0][2] for b in s.bodies())
                s.under(c[1], c[2], new, ups[idx % len(ups)], spin)
                dz = floor - new.mesh.bounds[0][2]
                if dz <= 0.05: del g.asm.parts[n0:]; return None
                for q in g.asm.parts: q.T[2, 3] += dz
                new._lift = dz
            elif c[0] == 'socket':
                ups = [i for i, t in enumerate(new.studs) if t['axis'][2] > 0.99]
                if not ups: return None
                s.under(c[1], c[2], new, ups[idx % len(ups)], spin)
            elif c[0] == 'studhole':              # new piece's stud into the host's hole end: stud axis points INTO the hole
                if not new.studs: return None
                h = c[1].holes[c[2]]; out_dir = c[1].world_dir(h['axis']) * c[3]
                face = c[1].world(h['c']) + out_dir * h['face_len'] / 2; st = new.studs[idx % len(new.studs)]
                s.aligned(new, st['c'], st['axis'], face, -out_dir, spin); g.asm.parts.append(new); g.asm.joins.append((c[1].name, new.name, 'stud-in-hole'))
            elif c[0] == 'stud' and c[1].world_dir(c[1].studs[c[2]]['axis'])[2] < 0.99:    # a sideways stud: turn the piece to face it
                if not new.sockets: return None
                st = c[1].studs[c[2]]; so = new.sockets[idx % len(new.sockets)]
                s.aligned(new, so['c'], [0, 0, 1], c[1].world(st['c']), c[1].world_dir(st['axis']), spin)
                g.asm.parts.append(new); g.asm.joins.append((c[1].name, new.name, 'studs, sideways'))
            else:
                if not new.sockets: return None
                g.asm.stack(new, c[1], socket_ix=idx % len(new.sockets), stud_ix=c[2], spin=spin)
        except Exception:
            del g.asm.parts[n0:]; return None
        if s.collides(new): g.undo(new, n0); return None
        # the bottom piece sits on a huge invisible baseplate (Ivy, Oct 23 2026: "It should assume the bottom piece is attached to a
        # huge baseplate that's invisible"), so nothing can go below the floor -- except a REAL baseplate slid under everything, which
        # becomes the new floor (this rule broke add_baseplate from the moment it went in; found Oct 23 2026 3:24 PM)
        if floor0 is not None and not getattr(new, 'is_pin', False) and new.name not in BASEPLATES and new.mesh.bounds[0][2] < floor0 - 0.3:
            # ...except onto a connector of a piece standing ON the floor, pointing down: then the whole build lifts to make room,
            # the way a piece under a floor-level piece does (Ivy, Oct 23 2026: "I don't think it's true nothing fits it. It should
            # fit in holes that size" -- 39 bin pieces fit the segmented leg's bar, but the leg stands on that bar, so every one
            # went below the floor and was refused, and the fit bot recorded 0/168)
            if c[0] in ('snap', 'hole', 'studhole') and c[1].mesh.bounds[0][2] < floor0 + 0.3 and not getattr(new, '_lift', 0.0):
                dz = floor0 - new.mesh.bounds[0][2]
                for q in g.asm.parts: q.T[2, 3] += dz
                new._lift = dz
            else:
                g.undo(new, n0); return None
        return new, n0
    def propose(s, k=6, tries=60, pool=None, floor=True):
        """up to k distinct legal moves (not committed): each (conn, name, idx, spin, host, T, pins_T)"""
        out, seen = [], set(); conns = s.conns()        # once: every try is undone, so the connectors don't change between tries
        fl = [c for c in conns if c[0] == 'floor']
        # at most ONE baseplate spot among the random surprises: on a small build the floor's dozens of studs drowned the build's few,
        # and it kept turning down pieces lying loose on the floor (Ivy, Oct 23 2026: "It looks like it's trying to connect new pieces
        # to the baseplate and ignoring them because they're bad choices and picking the only one that connects")
        conns = [c for c in conns if c[0] != 'floor']
        if fl and floor: conns += [fl[int(s.rng.integers(len(fl)))]]
        # a real baseplate's 256 studs drowned the build's own spots the same way (Ivy, Oct 23 2026: "it shouldn't suggest spots on
        # the baseplate often. It should focus on other pieces"): its studs count as ONE spot among the random surprises
        # snap spots (bar holes, tubes, clips) are most of a big build's 'free' spots and almost nothing fits them: one bucket, like the
        # floor (Oct 23 2026: 1,351 of 2,472 spots on a 479-piece Liberty were snaps, and 60 tries found one move)
        sn = [c for c in conns if c[0] == 'snap']
        if len(sn) > 6:
            conns = [c for c in conns if c[0] != 'snap'] + [sn[int(s.rng.integers(len(sn)))] for _ in range(3)]
        bp = [c for c in conns if c[1].name in BASEPLATES]
        if bp:
            conns = [c for c in conns if c[1].name not in BASEPLATES]
            if not conns or (floor and s.rng.random() < 0.34): conns += [bp[int(s.rng.integers(len(bp)))]]   # about one turn in three
        for _ in range(tries):
            if len(out) >= k: break
            if not conns: break
            c = conns[s.rng.integers(len(conns))]; src = pool or PALETTE; name = src[s.rng.integers(len(src))]
            idx = int(s.rng.integers(8)); spin = float(s.rng.choice(G.SPINS))
            r = s.try_move(c, name, idx, spin)
            if not r: continue
            new, n0 = r
            key = (name, tuple(np.round(new.T[:3, 3], 0)), tuple(np.round(new.T[:3, :3].ravel(), 0)))
            pins = [p.T.copy() for p in s.g.asm.parts[n0:] if getattr(p, 'is_pin', False)]
            onplate = c[1].name in BASEPLATES
            if key not in seen and not (c[0] in ('floor', 'lift') and any(m['conn'][0] == c[0] for m in out)) \
                    and not (onplate and any(m['host'].name in BASEPLATES for m in out)):    # one floor surprise, one lift, one baseplate spot, at most
                seen.add(key); out.append({'conn': c, 'name': name, 'idx': idx, 'spin': spin, 'host': c[1], 'T': new.T.copy(), 'pins': pins})
            s.g.undo(new, n0)
        return out
    def pair_ldr(s, m, path):
        """host grey 71 + candidate red 4 + its pins, so relations.py / the rulers measure the new piece against its host"""
        from build import Model
        M = Model('pair'); M.add(m['host'].name, rot=m['host'].T, colour=71); M.add(m['name'], rot=m['T'], colour=4)
        for T in m['pins']: M.add('2780', rot=T, colour=0)
        M.write(path)
    def build_numbers(s, m):
        bs = s.bodies(); new = G.fresh(m['name']); new.T = m['T']
        meshes = [p.mesh for p in bs] + [new.mesh]
        lo = np.min([x.bounds[0] for x in meshes], 0); hi = np.max([x.bounds[1] for x in meshes], 0)
        base = bs[0].mesh.bounds; areas = np.array([x.area for x in meshes]); cents = np.array([x.vertices.mean(0) for x in meshes])
        com = (areas[:, None] * cents).sum(0) / areas.sum()
        out_x = max(0, com[0] - base[1][0], base[0][0] - com[0]); out_y = max(0, com[1] - base[1][1], base[0][1] - com[1])
        bc = np.array([x.vertices.mean(0) for x in [p.mesh for p in bs]]).mean(0)
        return {'build height after, mm': round(float(hi[2] - lo[2]), 1),
                'build footprint after, studs': f"{(hi[0] - lo[0]) / 8:.1f} x {(hi[1] - lo[1]) / 8:.1f}",
                'new piece distance from the build centre, studs': round(float(np.linalg.norm(new.mesh.vertices.mean(0) - bc)) / 8, 2),
                'build centre of mass outside the first piece footprint, mm': round(float(np.hypot(out_x, out_y)), 1),
                'pieces in the build after': len(bs) + 1}
    def describe(s, m, i):
        path = f'{s.out}/cand_{i}.ldr'; s.pair_ldr(m, path)
        c = CX.Ctx.__new__(CX.Ctx)
        # reuse ctx.Ctx's loader on an arbitrary path
        parts = CX.read(path); g = [p for p in parts if p[0] == 71][0]; r = [p for p in parts if p[0] == 4][0]
        c.id = path; c.grey, c.red = CX.Piece(g[1], g[2]), CX.Piece(r[1], r[2]); c.pins = sum(p[1] == '2780' for p in parts)
        la = c.grey.long.copy() if c.grey.long is not None else np.array([1.0, 0, 0]); la[2] = 0
        if np.linalg.norm(la) < 1e-6: la = np.array([1.0, 0, 0])
        c.along = la / np.linalg.norm(la); c.up = np.array([0, 0, 1.0]); c.across = np.cross(c.up, c.along); c.rel = relations(path)
        more = {}
        for r_ in s.rulers:
            try: more[r_.CLAIM] = round(float(r_.measure(c)), 3)
            except Exception: pass
        return {'description': facts(path).replace('grey', 'existing').replace('red', 'new'),
                'measurements against the piece it joins': {g_: c.rel[g_] for g_ in GROUPS}, 'more measurements': more,
                'the whole build': s.build_numbers(m)}
    def choose(s, cands):
        desc = [s.describe(m, i) for i, m in enumerate(cands)]
        q = (f"Someone is playing with Lego with no plan, adding one piece at a time to what they have built. Their taste: they "
             f"love {TASTES[s.taste]}. Each option is one piece they could add next, measured against the piece it would join "
             f"and against the whole build. Which one would they add?")
        L = 'ABCDEF'
        def ask(order):
            r = jev.call(q, {'add': {'type': 'choice', 'instructions': q, 'criteria': {L[i]: desc[j] for i, j in enumerate(order)}}})
            p = r['answers']['add']['probabilities']; return {j: p.get(L[i], 0) for i, j in enumerate(order)}
        o = list(range(len(cands))); p1 = ask(o); p2 = ask(o[::-1]); tot = {j: p1[j] + p2[j] for j in o}
        pick = max(tot, key=tot.get)
        return pick, {'p1': p1, 'p2': p2, 'agree': max(p1, key=p1.get) == max(p2, key=p2.get), 'desc': desc[pick]['description']}
    def commit(s, m):
        r = s.try_move(m['conn'], m['name'], m['idx'], m['spin'])
        if not r: return False
        c = m['conn']
        if c[0] == 'studhole': s.g.used.add((id(c[1]), 'hole', c[2], c[3]))
        if c[0] not in ('socket', 'tyre', 'studhole', 'floor', 'lift', 'snap'): s.g.used.add((id(c[1]), 'hole', c[2], c[3]) if c[0] == 'hole' else (id(c[1]), 'stud', c[2]))
        s.g.reconcile()
        return True
    def frame(s):
        stem = f'{s.out}/frame_{s.turn:02d}'; s.g.asm.write_ldr(stem + '.ldr')
        subprocess.run(['flatpak', 'run', '--filesystem=home', 'org.leocad.LeoCAD', '-l', '/home/ivy/lego/ldraw', '-i', stem + '.png', '-w', '700', '-h', '700',
                        '--viewpoint', 'home', stem + '.ldr'], capture_output=True)
        try:
            import to_glb; to_glb.ldr_to_glb(stem + '.ldr')        # the spinnable one for the page
        except Exception as e: print('glb failed:', e, flush=True)
    def play(s, turns):
        log = open(f'{s.out}/log.jsonl', 'w'); s.frame()
        for s.turn in range(1, turns + 1):
            cands = s.propose()
            if len(cands) < 2: print('turn', s.turn, 'too few moves', flush=True); break
            pick, info = s.choose(cands); ok = s.commit(cands[pick])
            rec = {'turn': s.turn, 'options': len(cands), 'picked': cands[pick]['name'], 'committed': ok, **info}
            log.write(json.dumps(rec) + '\n'); log.flush(); s.frame()
            print(f"turn {s.turn}: {len(cands)} options, picked {cands[pick]['name']} {'(same both orders)' if info['agree'] else '(orders disagreed)'} -- {info['desc'][:110]}", flush=True)
        s.g.asm.write_ldr(f'{s.out}/build.ldr')
if __name__ == '__main__':
    ap = argparse.ArgumentParser(); ap.add_argument('--taste', default='tidy'); ap.add_argument('--turns', type=int, default=15)
    ap.add_argument('--seed', type=int, default=1); a = ap.parse_args()
    Reef(a.seed, a.taste, f'{HERE}/runs/{a.taste}_s{a.seed}').play(a.turns)
