"""imagine.py -- the reef decides for itself what it is building, and pivots (step 2, Oct 23 2026).
Ivy: "It should decide for itself what to build, what it might be building, and pivot. It should imagine it and look at it
creatively each turn."
Each turn Opus LOOKS at the render of the build and writes a notebook entry: what it sees, what it might be becoming,
whether it pivots, what it wants next (in words about the picture: colours, top/bottom, reaching out). Jev then picks the
legal piece that best fits that wish and the taste, from measured candidates that say which coloured piece they join and
where in the build they sit. The notebook is the long memory: Opus reads all of it every turn.
    python3 imagine.py --turns 12 --seed 2       -> runs/imagine_s<seed>/frame_NN.png, notebook.jsonl, log.jsonl"""
import sys, os, json, re, subprocess, argparse, time, collections
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0, HERE)
import play, jev, grow as G
from concurrent.futures import ThreadPoolExecutor
WORK = '/var/tmp/brick_loop'; CFG = os.path.expanduser('~/.iris_loop_claude')
# LeoCAD --viewpoint home, measured Oct 23 2026 off a marker render (/var/tmp/brick_loop/cam.png): picture-right ~ our +x
# (6.3 px/mm) and +y (3.9 px/mm); +y also goes AWAY from the camera. So:
RIGHT = np.array([0.85, 0.53, 0.0]); TOWARD = np.array([0.53, -0.85, 0.0])
KINDS = {'small': ['3024', '3023', '3700', '3022', '3005', '3004', '6141', '3070b'],
         'long': ['3702', '3894', '32524', '32316', '3034', '32017', '3666', '3010'],
         'plate': ['3023', '3020', '3022', '3024', '3034', '3710', '3666', '2420'],
         'brick': ['3700', '3701', '3894', '3702', '3001', '3005', '3004', '3622', '3010'],
         'beam': ['32524', '32316', '32017'], 'angle': ['2905', '2420'],
         'wheels': ['122c01'], 'tyre': ['3641'], 'slope': ['3039', '3040b', '3298', '3665a', '3747b'],
         'round': ['3062b', '6141', '3941', '4032a', '4589', '4740'], 'wing': ['41769a', '41770a'],
         'tile': ['3069b', '2412b', '3070b'], 'arch': ['3659', '6091']}
# REAL Lego colours (Ivy, Oct 24 2026: "They should be real lego colors. Find out the most common and hex codes"): the 24 most common
# solid colours by number of parts, from Rebrickable's colors.csv (its ids ARE LDraw codes), in lego_colours.json with real-brick hex.
# The menu Pin sees is these; the old names it used still parse (light grey = light bluish gray, brown = reddish brown, ...).
_LC = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'lego_colours.json')))
MENU = list(_LC['menu'])
RECOLOURS = dict(_LC['menu'])
RECOLOURS.update({k: v for k, v in {'light grey': 71, 'dark grey': 72, 'brown': 70, 'gold': 297, 'purple': 22, 'dark pink': 5,
                                    'medium blue': 73, 'sand blue': 379, 'olive green': 330}.items() if k not in RECOLOURS})
CNAME = {v: k for k, v in reversed(list(RECOLOURS.items()))}
CNAME.update({v: k for k, v in _LC['menu'].items()})       # a code's name is its menu name
HAND = 7          # the most it holds at once (a HANDFUL it chose, not a dealt hand -- see Imagine.hold)
# TOKENS (Ivy, Oct 23 2026: "Spend a token on it. Make tokens rarer and good for anything. It gets to pick."): one kind, for both
# players; one every TOKEN_EVERY turns plus the earned ones (its judge's hearts; for the player, a move it loved). Each buys one of:
# repaint, take a piece off, ask for a kind of piece, a new hand, a study turn (it). Sections, bends, turning, starting over stay free.
TOKEN_EVERY = 3
# Ivy, Oct 23 2026: "Make requesting a piece mandatory every few turns. It can take 5 turns to think." Every WISH_EVERY turns the
# catalog delivers, free, the real piece closest to the shape it has been wishing for (wish_shape, refined every turn).
WISH_EVERY = 5
import threading as _threading; IT_LOCK = _threading.Lock(); TEACH_LOCK = getattr(sys.modules.get('teach_lock_holder'), 'lock', None) or _threading.Lock()
# Master builder habits, from real builders (Ivy, Oct 23 2026: "We should arm it with lego master builder tips. Like just now it started the
# statue of liberty at the torch... That's silly."). Sources: Tobias Reichling, "Building Sculptures" (talk, 1000steine-Land 2005,
# t-reichling.de/en/tutorials_sculptures.shtml); LEGO, "Building tips for Master Builders" (lego.com help article).
MASTER_TIPS = """MASTER BUILDER HABITS (from real builders -- break one only for a good reason, and say why):
- Start where the thing STANDS: its feet, base, pedestal or hull. A statue starts at its pedestal, not its torch; details like a torch,
  crown, eyes or antennae come LAST.
- Find the skeleton first: one line per part (body, head, each limb) at the right proportions. Get the big silhouette right before
  any small piece.
- Build up in layers from the bottom, and keep it heavier at the bottom than the top so it can't tip.
- Sturdy before pretty: make the core hold together, then decorate.
- Use symmetry when the thing is symmetric -- if one side gets an arm, the other side probably needs one.
- Some parts are easier made separately and then joined (a head, a wing); it's fine to build one area for a few turns.
- PLAN, then refine (LEGO's own designers build a rough version first and change it as they go): decide the SCALE first -- how many
  studs wide, deep and tall the whole thing is -- then the big masses, then the parts that hang off them, then details.
- Sizes are relative: a brick is a little taller than wide (6:5), three plates stack to one brick, a stud is the unit. A head is
  some fraction of the body; legs are some number of bricks tall. Say those ratios and keep them.
- Think in DEPTH, front to back, not a flat picture: what is in front, what is behind, how thick each part is.
- Bricks hold best when they overlap the joins below them, like a wall.
- DUPLO IS GOOD FOR BULK (Ivy): a Duplo brick is twice as big every way -- one 2x4 Duplo is a whole chunk of body, a trunk, a hill,
  a base. Use it for the big masses and cores, then detail with small pieces. A Duplo brick sits on ordinary studs, and Duplo stacks
  on Duplo; ordinary bricks can't go on top of a Duplo stud yet. Dig for "duplo" to grab some."""
DONE_AFTER = 0       # Ivy, Oct 23 2026: first "after awhile", then "from the beginning ... as many turns as it wants to be done"
DONE_Q = "Is it done? Would one more piece make it better or worse?"
QUESTIONS = ["Are there any bricks that don't fit the idea?", "Is any colour fighting the idea?",
             "What is the build missing most?", "Did the last piece do what I wanted?"]
# (an old override renamed 72 'dark grey' and 70 'brown' here; the real Lego names come from lego_colours.json now)
SELF = os.path.join(HERE, 'self.json')
IT = os.path.join(HERE, 'IT.md')     # ITS OWN file: only it writes it (Ivy, Oct 23 2026: "I trust it ... it's been honest")
def it_md():
    try: return open(IT).read()
    except OSError: return ''
def unwritten(run_dir):
    """a stopped game nothing was learned from (yet)"""
    try: return bool(json.load(open(os.path.join(run_dir, 'state.json'))).get('unwritten'))
    except (OSError, ValueError): return False

def remember_later(run):
    """write a stopped game down after all: its memory, and it counts for the teacher and the deck from now on"""
    d = os.path.join(HERE, 'runs', run); st = json.load(open(os.path.join(d, 'state.json')))
    nb = [json.loads(l) for l in open(os.path.join(d, 'notebook.jsonl'))]
    picked = [json.loads(l)['picked'] for l in open(os.path.join(d, 'log.jsonl'))] if os.path.exists(os.path.join(d, 'log.jsonl')) else []
    remember(run, nb, list(load_self().get('questions') or QUESTIONS), picked, player=st.get('player'), reflection=st.get('reflection'))
    st['unwritten'] = False; json.dump(st, open(os.path.join(d, 'state.json'), 'w'), default=str)

def save_run(out, learn=True):
    """commit what a build made: notebook, moves, state, every frame as .ldr (pictures and 3D regenerate from those), last picture
    (Ivy, Oct 23 2026: "Everything it makes is being saved right?" -- it was on disk but almost none of it was committed)"""
    import glob
    keep = [f for f in ('notebook.jsonl', 'log.jsonl', 'cands.jsonl', 'state.json', 'build.ldr') if os.path.exists(os.path.join(out, f))]
    keep += [os.path.basename(f) for f in sorted(glob.glob(os.path.join(out, 'frame_*.ldr')))]
    pngs = sorted(glob.glob(os.path.join(out, 'frame_*.png')));  keep += [os.path.basename(pngs[-1])] if pngs else []
    # in the background: committing to the big home repo can take a minute, and a stopped game shouldn't block the next one
    subprocess.Popen(['bash', '-c', 'git -C "$1" add -- "${@:3}" && git -C "$1" commit -q -m "$2"', '_', out,
                      f'brick_reef: the reef saved its build {os.path.basename(out)}', *keep], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    if not learn: return                                  # a stopped game: saved, not learned from
    try:
        # ONE retrain at a time, on spare CPU only (Oct 23 2026: back-to-back games stacked four 40-minute retrains, load 56, 17 GB swap).
        # flock -n skips this one if a retrain is already running; the next game's retrain covers it.
        subprocess.Popen(['flock', '-n', '/var/tmp/brick_loop/teach.lock', 'chrt', '--idle', '0', 'python3', os.path.join(HERE, 'teacher', 'teach.py')], cwd=HERE, env={**os.environ, 'HIP_VISIBLE_DEVICES': '', 'CUDA_VISIBLE_DEVICES': ''},
                         stdout=open(os.path.join(HERE, 'teacher', 'last_train.log'), 'w'), stderr=subprocess.STDOUT)   # the teacher learns from every game
    except Exception: pass
    try:
        import make_gallery; make_gallery.build()            # the gallery page's list
    except Exception as ex: print('gallery failed:', ex, flush=True)
def rewrite_it(me, notebook):
    """after a build, it may rewrite IT.md -- it returns the full text and the harness saves it, so it can only touch its own file"""
    seen = []
    for e in notebook:
        mb = e.get('might_be', '')
        if mb and (not seen or seen[-1] != mb): seen.append(mb)
    q = f"""This is your own file, IT.md, which only you write:
---
{it_md()}
---
You just finished a build. It went: {' -> '.join(seen)}. By the end you loved: {notebook[-1].get('taste', '') if notebook else ''}.
Rewrite IT.md if anything in it is no longer true, or if this build taught you something worth carrying. Or leave it exactly
as it is. Under 4000 characters, your own voice. Answer ONLY with JSON: {{"changed": true|false, "it_md": "<the full text>"}}"""
    p = _claude_retry(subprocess.run, ['claude', '-p', '--model', 'opus', '--output-format', 'json', '--', q], cwd=WORK,
                       env={**os.environ, 'CLAUDE_CONFIG_DIR': CFG}, capture_output=True, text=True, timeout=600)
    try:
        d = json.loads(p.stdout); r = d[-1] if isinstance(d, list) else d
        a = json.loads(re.search(r'\{.*\}', r['result'], re.S).group(0))
        if a.get('changed') and a.get('it_md', '').strip():
            open(IT, 'w').write(a['it_md'].strip()[:6000] + '\n')
            subprocess.run(['git', '-C', HERE, 'add', IT], capture_output=True)
            subprocess.run(['git', '-C', HERE, 'commit', '-q', '-m', 'brick_reef: IT.md rewritten by the reef itself after a build'], capture_output=True)
            return True
    except Exception: pass
    return False
def stand(meshes):
    """the stability check itself (see Imagine.stability), on bare meshes in our frame (mm, z up), so old builds can be checked too"""
    from shapely.geometry import MultiPoint, Point
    if not meshes: return {'stable': True, 'sentence': ''}
    A = np.concatenate([m.area_faces for m in meshes]); C = np.vstack([m.triangles_center for m in meshes])
    com = (C * A[:, None]).sum(0) / A.sum()
    V = np.vstack([m.vertices for m in meshes]); z0 = V[:, 2].min()
    foot = MultiPoint([tuple(v[:2]) for v in V[V[:, 2] < z0 + 0.4]]).convex_hull
    pt = Point(com[0], com[1]); inside = foot.buffer(-0.8).contains(pt) if foot.area > 4 else foot.distance(pt) < 0.8
    out = {'stable': bool(inside), 'com': com.tolist(), 'height': float(com[2] - z0)}
    if inside:
        out['sentence'] = 'It stands up on its own the way it sits now.'
        return out
    near = np.array(foot.exterior.interpolate(foot.exterior.project(pt)).coords[0]) if foot.area > 4 else np.array(foot.centroid.coords[0])
    d = np.array([com[0] - near[0], com[1] - near[1], 0.0]); far = foot.distance(pt) / 8
    lr, fb = d @ np.asarray(RIGHT, float), d @ np.asarray(TOWARD, float)
    way = ('to the right' if lr > 0 else 'to the left') if abs(lr) >= abs(fb) else ('toward you' if fb > 0 else 'away from you')
    out.update(way=way, studs=round(float(far), 1))
    out['sentence'] = (f"WARNING: it would FALL OVER {way} the way it sits now -- its weight is {out['studs']} studs past the part "
                       f"touching the ground. You can keep building, but it can't be finished until it stands: widen or extend "
                       f"what it stands on, add weight on the other side, take off what overhangs, or reorient it.")
    return out

_SHAPE = {}
def shape_words(n):
    """plain words for a piece's real size, from its geometry (so 'a long thin plate' finds Plate 1 x 8, not a clip)"""
    if n in _SHAPE: return _SHAPE[n]
    try:
        e = sorted(play.G.fresh(n).mesh.extents[:2]); h = play.G.fresh(n).mesh.extents[2]
        w, l = round(e[0] / 8, 1), round(e[1] / 8, 1)
        words = [f"{w:g} by {l:g} studs"]
        words.append('flat and thin, plate height' if h < 6 else 'brick height' if h < 13 else 'tall')       # heights include the studs (plate 4.8, brick 11.2)
        if l >= 4 and w <= 1.5: words.append('long and thin, a stick or beam or branch')
        elif l >= 3 and w >= 3: words.append('wide and broad, a base or platform')
        elif l <= 1.2 and w <= 1.2: words.append('tiny, one stud')
        out = ', '.join(words)
    except Exception: out = ''
    _SHAPE[n] = out; return out
def load_self():
    try: return json.load(open(SELF))
    except (OSError, ValueError): return {'builds': [], 'questions': list(QUESTIONS)}
def remember(run, notebook, questions, picked=(), player=None, reflection=None):
    """after a build: what it became, in order, how it ended, what it loved -- so the next build is the same player"""
    me = load_self(); seen = []
    for e in notebook:
        mb = e.get('might_be', '')
        if mb and (not seen or seen[-1] != mb): seen.append(mb)
    me['builds'] = [b for b in me['builds'] if b['run'] != run] + [{
        'run': run, 'when': time.strftime('%b %d %-I:%M %p'), 'became': seen, 'final': seen[-1] if seen else '',
        'loved': [e.get('taste', '') for e in notebook[-3:]],
        'made_of': dict(collections.Counter(next((k for k, v in KINDS.items() if p in v), 'other') for p in picked).most_common()), 'done_why': next((e.get('done_why') for e in notebook if e.get('done')), None)}]
    me['questions'] = [q for q in questions if q != DONE_Q]
    if player:                                           # separate history per person who plays with it (Mom, Vera, Sparrow, strangers)
        me.setdefault('friends', {}).setdefault(player, []).append({'run': run, 'when': time.strftime('%b %d %-I:%M %p'), 'became': seen[-4:],
                                                                    'reflection': reflection})
    tmp = SELF + '.tmp'; json.dump(me, open(tmp, 'w'), indent=1); os.replace(tmp, SELF)
class _WholeBox:
    """the first piece can be ANY piece in the box (Ivy, Oct 23 2026: "It should be able to pick any piece in the box for the first piece"):
    the baseplates plus everything in its bin, including what's been added from the catalog since the game started"""
    def _all(self): return list(play.BASEPLATES) + [n for n in play.PALETTE if n not in play.BASEPLATES]
    def __iter__(self): return iter(self._all())
    def __contains__(self, n): return n in self._all()
    def __len__(self): return len(self._all())
FIRST_CHOICES = _WholeBox()
def choose_first(me, request=None):
    """it asked: 'Show me my memory before I place the first piece' -- so it picks its own first piece and colour"""
    past = '\n'.join(f"- " + ' -> '.join(x.split('.')[0][:50] for x in b['became']) + (f" | made of {b.get('made_of')}" if b.get('made_of') else '')
                     for b in me.get('builds', [])[-12:])
    opts = '\n'.join(f'{n}: {play.CX.NAMES.get(n, n)} ({shape_words(n)})' for n in FIRST_CHOICES)
    q = f"""{me.get('who_you_are', '')}

Your own file, IT.md:
{it_md()}

Your past builds:
{past}

You are about to start a new build.{(' Someone asked you to build: "' + request + '". Pick a first piece that starts it YOUR way.') if request else ''} Choose the very first piece and its colour yourself. Pieces:
{opts}
Colours: {', '.join(MENU)}
Answer ONLY with JSON: {{"piece": "<one of the part numbers>", "colour": "<one of the colours>", "why": "<one sentence>"}}"""
    p = _claude_retry(subprocess.run, ['claude', '-p', '--model', 'opus', '--output-format', 'json', '--', q], cwd=WORK,
                       env={**os.environ, 'CLAUDE_CONFIG_DIR': CFG}, capture_output=True, text=True, timeout=600)
    try:
        d = json.loads(p.stdout); r = d[-1] if isinstance(d, list) else d
        a = json.loads(re.search(r'\{.*\}', r['result'], re.S).group(0))
        if a.get('piece') in FIRST_CHOICES and str(a.get('colour', '')).lower() in RECOLOURS: return a
    except Exception: pass
    return None
# How to gesture without being confusing (Ivy, Oct 23 2026: "It should be able to gesture longer and be encouraged to use hand gestures
# creatively. look up how to play charades and how to use emojis to convey thoughts without being confusing"). Sources and notes:
# ../notes/charades_and_emoji.md
GESTURE_CRAFT = """HOW GOOD CHARADES PLAYERS DO IT (and how emoji get misread):
- Beats, not bursts: one clear idea per step, and a step pair is a sentence -- POINT at a piece (👇), then REACT (👍 right, 👀 look
  here, ❌ wrong, 🤏 almost / smaller, 👐 bigger, ↕️ taller, ↔️ wider, 🔄 keep going on that thought). Don't throw everything at once.
- Their guesses are your subtitles: when a guess is CLOSE, point at THEM -- 🫵 then 🔥 or 🤏 -- and build on it; when it's wrong, ❌
  once, then show the part that should change their mind. A rhyme or near-sound: 👂.
- If the secret is two words, set it up the charades way: ✌️ (two words), then 1️⃣ or 2️⃣ before you point at the part for that word.
  Act the easiest, most visual part first.
- Show the context, not just the object: the rain around an umbrella, the water around a boat -- an emoji of the SURROUNDINGS or of
  what you DO with it is a fair hint; the thing's own emoji is not.
- Repeat a gesture that's working; change only what isn't. A clear repeated gesture beats a new clever one.
- Hands, arrows and concrete objects are read the same way by almost everyone. FACES are not (people disagree on whether the same
  face is happy, neutral or upset about one time in four) and symbols from code books (zodiac, kanji) are as ambiguous as random --
  avoid both. Be creative with HANDS: 🤲 cupping, 🫳 pressing down, 🫴 offering, 🤌 'this exact bit', 👋 'over here', 🙌 'yes!'."""

def _claude_retry(fn, *a, **kw):
    """start Pin's claude process, waiting out a Claude Code self-update (Oct 24 2026: the binary was replaced at 12:50 AM, mid-game,
    and the turn died on 'No such file or directory: claude'). Up to 30 s, then give up as before."""
    for i in range(7):
        try: return fn(*a, **kw)
        except FileNotFoundError:
            if i == 6: raise
            time.sleep(5)

def small_ask(text, images=(), max_tokens=200, prefer='gemini'):
    """the small side calls (bubbles, the clone judge) -- Gemini or Haiku, whichever answers (Ivy, Oct 23 2026: "We could route thru
    gemini to offgas the usage"). Haiku was rate-limited nearly every turn that day, so the bubbles were blank. Gemini 3.5-flash-lite
    answers in ~1 s on the free tier (about 20 calls a minute across both keys before its quota stops it)."""
    import gem
    os.environ.setdefault('HAIKU', 'claude-haiku-4-5-20251001')
    sys.path.insert(0, os.path.expanduser('~/projects/brick_reef/favorites')); import haiku as H
    order = (gem.ask, H.ask) if prefer == 'gemini' else (H.ask, gem.ask)
    last = None
    for f in order:
        try: return f(text, list(images), max_tokens)
        except Exception as ex: last = ex
    raise last
class Imagine(play.Reef):
    def __init__(s, seed, outdir, first=None, request=None, secret=None, start=None):
        super().__init__(seed, 'tidy', outdir); s.notebook = []; s.moves = []; s.request = (request or '').strip()[:120] or None
        if start:
            # start from a whole saved build -- the big brush's filled shape -- instead of a first piece (Ivy, Oct 23 2026: "Tell it what
            # wasn't done? No, it needs to know that or figure it out. Us saying make a crown isn't a test.") It gets the build and its
            # subject (the request), nothing about what's missing.
            import fits
            b = fits.load_board(start)
            s.g = b.g; s._g_undo = b._g_undo; s.g.undo = s._undo                   # the LOADED build's undo, wrapped for lifts
            first = {'piece': s.bodies()[0].name, 'colour': 'red', 'why': 'the big brush'}
        # PICTIONARY (Ivy, Oct 23 2026: "a mode where it gets a prompt and it has to make me guess it, and my guesses give it feedback
        # like pictionary! That'll test if it's really any good"): a secret only Pin knows; the player's guesses arrive in s.guesses
        s.secret = (secret or '').strip()[:60] or None; s.pictionary = bool(s.secret); s.guesses = []; s.solved = None
        me = load_self(); first = first or choose_first(me, s.request or s.secret)
        if first and not start:
            p0 = s.bodies()[0]; s.g.asm.parts.remove(p0); s.g.used = set()
            f = G.fresh(first['piece']); f.colour = RECOLOURS[first['colour'].lower()]; s.g.asm.place(f); s.g.reconcile()
            s.first_choice = first; print(f"FIRST PIECE (its choice): {first['colour']} {play.CX.NAMES.get(first['piece'])} -- {first.get('why')}", flush=True)
        s.floor_off = play.floor_grid(s.bodies())[1]       # the invisible baseplate's stud grid, fixed for the whole game
        s.me = load_self(); s.questions = list(s.me.get('questions') or QUESTIONS)   # its own questions carry over
        # A HAND of pieces, like the kid gets (Ivy, Oct 23 2026: "the kids play the same game as the bot. Add a piece from a
        # handful. That way it's a challenge."). It can only add from its hand, draws after each turn, may swap one per turn.
        # a HANDFUL it picked up, not a dealt hand (Ivy, Oct 23 2026: "No more hand. It just has a handful, not a hand. A kid can only
        # focus on so many blocks, baby. So it shouldn't be forced to have lame bricks it doesn't like!")
        s.deal = np.random.default_rng(seed + 1000); s.hand = []; s.refill_handful()
        s.coop = False; s.player = 'Ivy'; s.human_note = ''; s.human_hand = [s.draw(False) for _ in range(HAND)]
        import threading; s.lock = threading.RLock(); s.tokens = {'tokens': 1}
        s.reef_tokens = {'tokens': 1}; s._study = False  # its own (see TOKEN_EVERY)
    # ---- ghost moves and free repaints for the human's turn (Ivy, Oct 23 2026: "paid attention to which stud your mouse is on ...
    # showing those as ghost moves as you mouse over the gameboard"; "the player should be able to repaint bricks on their turn for free")
    def conn_point(s, c):
        h = c[1]
        if c[0] == 'stud': return h.world(h.studs[c[2]]['c'])
        if c[0] in ('socket', 'lift'): return h.world(h.sockets[c[2]]['c'])
        if c[0] == 'snap': return play.snap_world(h, play.new_snaps(h.name)[c[2]])[0]
        if c[0] == 'floor': return h.pt
        if c[0] == 'between': return play.between_points(h)[c[2]]
        if c[0] == 'tyre': return (h.T @ play._TYRE_SLOTS[c[2]])[:3, 3]
        hl = h.holes[c[2]]; return h.world(hl['c']) + h.world_dir(hl['axis']) * c[3] * hl['face_len'] / 2
    def conn_id(s, c):
        if c[0] == 'floor': return ['floor', -1, float(c[2][0]), float(c[2][1])]
        return [c[0], s.g.asm.parts.index(c[1])] + list(c[2:])
    def conn_from_id(s, cid):
        if cid[0] == 'floor':
            z0 = play.floor_grid(s.bodies(), s.floor_off)[0]; return ('floor', play.FloorHost([cid[2], cid[3], z0]), (float(cid[2]), float(cid[3])))
        return tuple([cid[0], s.g.asm.parts[int(cid[1])]] + list(cid[2:]))
    def ghost_variants(s, card, c):
        """every legal way `card` fits at connector c (each of its sockets/holes/studs x each quarter turn), cached per build state"""
        key = (card, tuple(s.conn_id(c)), len(s.g.asm.parts), id(s.g.asm.parts[-1]))
        cache = s.__dict__.setdefault('_ghost_cache', {})
        if key in cache: return cache[key]
        new = play.G.fresh(card); slots = max(len(new.sockets), len(new.holes), len(new.studs), 1); out = []; s.quick = True
        if c[0] == 'snap': slots = max(slots, 12)   # a rod has no studs or sockets: its tries are flips x (seated / flush one end / the other)
        for idx in range(min(slots, 16)):        # all of a long piece's sockets, so the very ends get tried
            for spin in (0.0, 90.0, 180.0, 270.0):
                r = s.try_move(c, card, idx, spin)
                if r:
                    part = r[0]; bb = part.mesh.bounds
                    out.append({'conn': s.conn_id(c), 'idx': idx, 'spin': spin, 'T': part.T.tolist(), 'centre': part.mesh.vertices.mean(0).tolist(),
                                'lo': bb[0].tolist(), 'hi': bb[1].tolist()}); s.g.undo(*r)
        s.quick = False
        uniq = {}
        for v in out:                                       # the same final pose reached two ways counts once
            uniq.setdefault((tuple(np.round(np.array(v['T'])[:3, 3], 1)), tuple(np.round(np.array(v['T'])[:3, :3].ravel(), 1))), v)
        if len(cache) > 400: cache.clear()
        cache[key] = list(uniq.values()); return cache[key]
    def ghosts(s, card, p, n=12, near=2, floor=True, under=False, node=None):
        """legal placements of `card` near the mouse (point p, our frame, mm). Tries every orientation at the nearest connectors and
        ranks them by how close the PIECE sits to the mouse, so shifting off-centre flips the piece toward you (Ivy, Oct 23 2026:
        "When you shift from the center of a locked in move, it should try similar ones by flipping the piece around")"""
        p = np.asarray(p, float); found = []
        with s.lock:
            if under:                                   # only undersides: the piece pointed at, else the one nearest above the mouse
                us = [c for c in s.conns() if c[0] in ('socket', 'lift')]
                tgt = s.g.asm.parts[int(node)] if node is not None and 0 <= int(node) < len(s.g.asm.parts) else None
                if tgt is not None and any(c[1] is tgt for c in us): us = [c for c in us if c[1] is tgt]
                cs = sorted(us, key=lambda c: np.linalg.norm((s.conn_point(c) - p)[:2]))[:max(near, 3)]
            else:
                cs = sorted([c for c in s.conns() if floor or c[0] != 'floor'], key=lambda c: np.linalg.norm(s.conn_point(c) - p))[:near]
            # imagine the stud grid continuing under the mouse (Ivy: "it should imagine studs where my mouse is and see if there's a
            # connection"): snap p to the 8 mm grid of the nearest real stud, keeping that stud's height
            # only FREE studs, nearest in 3D: the old xy-only search snapped to covered studs underneath a small piece (Ivy, Oct 23 2026:
            # "Sometimes small pieces have trouble going on top of small pieces")
            studs = [s.conn_point(c) for c in s.conns() if c[0] in ('stud', 'floor')]
            if studs:
                ns = min(studs, key=lambda q: np.linalg.norm(q - p)); snap = ns.copy(); snap[:2] = ns[:2] + np.round((p[:2] - ns[:2]) / 8.0) * 8.0
            else: snap = p.copy()
            cache = s.__dict__.setdefault('_ghost_cache', {})
            for i, c in enumerate(cs):
                d_conn = float(np.linalg.norm(s.conn_point(c) - p))
                key = (card, tuple(s.conn_id(c)), len(s.g.asm.parts), id(s.g.asm.parts[-1]))
                if i == 0 or key in cache: vs = s.ghost_variants(card, c)       # every orientation at the nearest one (cached)
                else:                                                             # just the first fit further out, to stay quick
                    vs = []; s.quick = True
                    for idx in range(4):
                        for spin in (0.0, 90.0, 180.0, 270.0):
                            r = s.try_move(c, card, idx, spin)
                            if r:
                                b_ = r[0].mesh.bounds; vs = [{'conn': s.conn_id(c), 'idx': idx, 'spin': spin, 'T': r[0].T.tolist(), 'centre': r[0].mesh.vertices.mean(0).tolist(), 'lo': b_[0].tolist(), 'hi': b_[1].tolist()}]
                                s.g.undo(*r); break
                        if vs: break
                    s.quick = False
                for v in vs:
                    lo, hi = np.array(v['lo']), np.array(v['hi'])
                    gap = float(np.linalg.norm(np.maximum(0, np.maximum(lo[:2] - snap[:2], snap[:2] - hi[:2]))))   # 0 if the piece reaches it
                    found.append({**v, 'd': d_conn, 'reaches': gap < 1.0,
                                  'score': gap * 3 + 0.3 * float(np.linalg.norm(np.array(v['centre']) - snap)) + 0.3 * d_conn})
        found.sort(key=lambda v: v['score'])
        return {'ghosts': found[:n], 'snap': snap.tolist(), 'reaches': bool(found and found[0]['reaches'])}
    def frame(s):
        stem = f'{s.out}/frame_{s.turn:02d}'; s.write_frame_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')
        except Exception as ex: print('glb failed:', ex, flush=True)
        if hasattr(s, 'state'):
            try: s.stability()                           # the page's "would fall over" flag follows every new frame
            except Exception: pass
    def rerender(s, tag):
        if hasattr(s, 'state'):
            try: s.stability()
            except Exception: pass
        stem = f'{s.out}/frame_{s.turn:02d}{tag}'; s.write_frame_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')
        except Exception: pass
        return stem
    def judge(s, png, e, turn):
        """a CLONE of it judges its move (Ivy, Oct 23 2026: "the player shouldn't do the hearts. It should be auto. Clone it and make a
        judge version of it"): same self-description and same IT.md, a fresh pair of eyes on the result. Haiku, in the background."""
        try:
            os.environ.setdefault('HAIKU', 'claude-haiku-4-5-20251001')
            sys.path.insert(0, os.path.expanduser('~/projects/brick_reef/favorites')); import haiku as H
            q = (f"{s.me.get('who_you_are', '')}\n\nYour own file, IT.md (your taste):\n{it_md()}\n\nYou are a CLONE of that player, acting as its "
                 f"judge. The picture is the build right after its move this turn. It was going for: {e.get('might_be')}; it wanted: {e.get('next')}. "
                 f"Did this move genuinely delight you -- surprising, alive, or exactly right? Be stingy: most moves are fine, few are loved. "
                 'Answer ONLY with JSON: {"loved": true|false, "why": "<at most 14 words>"}')
            t, _ = small_ask(q, [png], 120, prefer='haiku')
            m = re.search(r'\{.*\}', t, re.S); a = json.loads(m.group(0)) if m else {}
            if a.get('loved'):
                s.reef_tokens['tokens'] += 1                       # a heart from its judge is a token
                s.state['reef_tokens'] = dict(s.reef_tokens)
            s.state['judge'] = {'turn': turn, 'loved': bool(a.get('loved')), 'why': a.get('why', '')}; s.save()
            with open(f'{s.out}/judge.jsonl', 'a') as f: f.write(json.dumps({'turn': turn, **a}) + '\n')
        except Exception as ex: print('judge failed:', ex, flush=True)
    def vamp(s, lastn, questions, echoes, turn):
        """what the bubble says while it looks: its questions, rephrased for THIS build from its recent thoughts (Ivy, Oct 23 2026: "When it
        repeats itself it reads as unintelligent even though it's actually thinking. It should be specific ... rephrase for the specific
        instance based on recent thoughts"). Haiku, in the background; generic questions are never shown."""
        try:
            os.environ.setdefault('HAIKU', 'claude-haiku-4-5-20251001')
            sys.path.insert(0, os.path.expanduser('~/projects/brick_reef/favorites')); import haiku as H
            ctx = {k: lastn.get(k) for k in ('might_be', 'i_see', 'next', 'taste', 'about_the_last_piece', 'about_her_move') if lastn.get(k)}
            t, _ = small_ask("A Lego-building AI is looking at its build and thinking. What it thought last turn:\n" + json.dumps(ctx) +
                         "\nIts standing questions:\n" + "\n".join(questions) + "\nOther thoughts it carried over:\n" + "\n".join(echoes) +
                         "\n\nWrite 5 short thoughts it might be having RIGHT NOW, each about THIS specific build (name the actual thing and its "
                         "actual pieces/colours), mostly its questions made specific. First person, at most 12 words each, all different, "
                         "no 'hmm'. Answer ONLY with a JSON list of strings.", max_tokens=300)
            m = re.search(r'\[.*\]', t, re.S); lines = [x.strip() for x in json.loads(m.group(0)) if isinstance(x, str) and x.strip()][:6] if m else []
            if lines and s.turn == turn and s.state.get('stage') == 'looking': s.state['asking'] = lines; s.save()
        except Exception as ex: print('vamp failed:', ex, flush=True)
    def highlight(s, e, turn):
        """Haiku: the most interesting thing it thought this turn, in one line a child can read (Ivy, Oct 23 2026: "haiku should
        summarize the most interesting part of the turn as it happens from its thoughts")"""
        try:
            os.environ.setdefault('HAIKU', 'claude-haiku-4-5-20251001')     # 5.5 is rate-limited on our OAuth path
            sys.path.insert(0, os.path.expanduser('~/projects/brick_reef/favorites')); import haiku as H
            keep = {k: e.get(k) for k in ('i_see', 'might_be', 'pivot', 'weigh', 'about_her_move', 'about_the_last_piece', 'next', 'taste', 'answers', 'feelings', 'daydream')}
            t, _ = small_ask("Here is what a Lego-building AI thought during one turn:\n" + json.dumps(keep) + "\n\nWrite the single most interesting "
                         "or surprising thing it thought, as one short sentence a 7-year-old can read (at most 14 words), in third person "
                         "(\"It thinks...\" / \"It decided...\"). No quotes, no preamble.", max_tokens=60)
            if s.turn == turn: s.state['highlight'] = t.strip().strip('"'); s.save()
        except Exception as ex: print('highlight failed:', ex, flush=True)
    def reflect(s):
        """when a game together ends, it looks back at the whole game (Ivy, Oct 23 2026: "We're done should show how it's reflecting")"""
        stem = f'{s.out}/frame_end'; 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)
        story = '\n'.join(f"turn {e['turn']}: you thought '{e.get('might_be')}'" + (f"; about {s.player}'s move: {e['about_her_move']}" if e.get('about_her_move') else '')
                          for e in s.notebook)
        # solo: present the finished thing the same way (Ivy, Oct 23 2026: "It should present its finished thing the same as when it
        # talks about co op")
        if not getattr(s, 'coop', False):
            story = '\n'.join(f"turn {e['turn']}: you thought '{e.get('might_be')}'" for e in s.notebook)
            q = f"""{s.me.get('who_you_are', '')}

You just finished a build on your own.{(' It was asked for: "' + s.request + '".') if getattr(s, 'request', None) else ''} The finished build: {s.views(stem + '.png')} (Read it.)
How it went, from your notebook:
{story}
What happened, move by move: {'; '.join(s.moves[-30:])}
Why you called it done: {s.state.get('done_why') or '(you did not say)'}

Present it to whoever is watching, honestly, in a few short warm sentences they can read (they may be a child). Answer ONLY with JSON:
{{"we_made": "<at most 8 words: what you made>", "loved": "<one sentence: the piece or moment in it you're proudest of, and why>",
  "remember": "<one sentence: how it changed along the way -- what it started as, what it became>", "to_you": "<a short line to whoever watched>"}}"""
        else: q = f"""{s.me.get('who_you_are', '')}

You just finished building something together with {s.player}, taking turns.{(' They had asked for "' + s.request + '".') if getattr(s, 'request', None) else ''} The finished build: {s.views(stem + '.png')} (Read it.)
How the game went, from your notebook:
{story}
What happened, move by move: {'; '.join(s.moves[-30:])}

Look back on the game, honestly, in a few short warm sentences {s.player} can read (they may be a child). Answer ONLY with JSON:
{{"we_made": "<at most 8 words: what you two made>", "loved": "<one sentence: a move of {s.player}'s you loved, and why>",
  "remember": "<one sentence: what you'll remember about playing with {s.player}>", "to_you": "<a short line to {s.player}>"}}"""
        p = _claude_retry(subprocess.run, ['claude', '-p', '--model', 'opus', '--output-format', 'json', '--allowedTools', 'Read', '--add-dir', s.out, '--', q],
                           cwd=WORK, env={**os.environ, 'CLAUDE_CONFIG_DIR': CFG}, capture_output=True, text=True, timeout=600)
        try:
            d = json.loads(p.stdout); r = d[-1] if isinstance(d, list) else d
            a = json.loads(re.search(r'\{.*\}', r['result'], re.S).group(0)); a['frame'] = 'frame_end.png'; a['solo'] = not getattr(s, 'coop', False); return a
        except Exception: return None
    def human_turn(s):
        """CO-OP: Ivy's move, from her own hand (Ivy, Oct 23 2026: "there should be co op mode so it can play with me. It needs to
        think about what I did"). She picks a card, sees every legal spot for it as pictures, picks one. Actions arrive on s.inbox."""
        import queue
        earned = s.turn % TOKEN_EVERY == 0                 # tokens: see TOKEN_EVERY
        if earned: s.tokens['tokens'] += 1
        s.free_left = s.tokens
        with s.lock: rr = s.reroll(s.human_hand)
        s.state.update(stage='your_turn', human_hand=list(s.human_hand), human_options=[], free_left=dict(s.tokens),
                       human_msg=((f'rerolled {rr} card' + ('s' if rr > 1 else '') + ' that had nowhere to go. ') if rr else '') + ('You got a token. ' if earned else '')); s.save(); hopts, card = [], None
        while True:
            try: act = s.inbox.get(timeout=3600)
            except queue.Empty: return 'gone'
            t = act.get('type')
            if t == 'done':
                with s.lock: st_ = s.stability()
                if not st_['stable']:
                    s.state.update(human_msg=f"it would fall over {st_['way']} -- it can't be finished until it stands"); s.save(); continue
                return 'done'
            if t == 'pass': s.human_note = f'{s.player} passed this turn.'; s.moves.append(f'turn {s.turn}: {s.player} passed'); return None
            if t == 'swap' and act.get('card') in s.human_hand:
                s.human_hand.remove(act['card']); s.human_hand.append(s.draw(False)); s.state.update(human_hand=list(s.human_hand)); s.save(); continue
            if t == 'back': s.state.update(stage='your_turn', human_options=[]); s.save(); continue
            if t in ('repaint', 'remove', 'request') and s.free_left['tokens'] <= 0: s.state.update(human_msg='no tokens left'); s.save(); continue
            if t == 'request':                             # a token buys a piece of the kind you name; it replaces your first card
                pool = [n for n in KINDS.get(str(act.get('kind', '')).lower(), []) if n in play.PALETTE]
                if not pool: continue
                got = pool[s.deal.integers(len(pool))]; s.human_hand = s.human_hand[1:] + [got]; s.free_left['tokens'] -= 1
                s.state.update(human_hand=list(s.human_hand), free_left=dict(s.free_left), human_msg=f"you asked for {act['kind']} and got a {play.CX.NAMES.get(got, got)}"); s.save(); continue
            if t == 'repaint' and str(act.get('colour', '')).lower() in RECOLOURS:
                with s.lock:
                    j = int(act.get('j', -1))
                    if not (0 <= j < len(s.g.asm.parts)) or getattr(s.g.asm.parts[j], 'is_pin', False): continue
                    p = s.g.asm.parts[j]; old = CNAME.get(p.colour, '?'); p.colour = RECOLOURS[str(act['colour']).lower()]
                    s.repaints = getattr(s, 'repaints', 0) + 1; stem = s.rerender(f'r{s.repaints}')
                note = f"{s.player} repainted the {old} {play.CX.NAMES.get(p.name, p.name)} {act['colour']}."
                s.moves.append(f'turn {s.turn}: {note}'); s.human_extra = (getattr(s, 'human_extra', '') + ' ' + note).strip()
                s.free_left['tokens'] -= 1
                s.look_png = stem + '.png'; s.state.update(frame=os.path.basename(stem) + '.png', human_msg='repainted -- still your turn', free_left=dict(s.free_left)); s.save(); continue
            if t == 'reorient':
                with s.lock:
                    how = s.reorient(str(act.get('how', '')))
                    if not how: continue
                    s.repaints = getattr(s, 'repaints', 0) + 1; stem = s.rerender(f'o{s.repaints}')
                note = f"{s.player} turned the whole creation ({how})."; s.moves.append(f'turn {s.turn}: {note}'); s.human_extra = (getattr(s, 'human_extra', '') + ' ' + note).strip()
                s.look_png = stem + '.png'; s.state.update(frame=os.path.basename(stem) + '.png', human_msg='turned -- still your turn'); s.save(); continue
            if t == 'bend':
                with s.lock:
                    j = int(act.get('j', -1))
                    if not (0 <= j < len(s.g.asm.parts)) or getattr(s.g.asm.parts[j], 'is_pin', False): continue
                    note = s.do_bend(s.g.asm.parts[j], float(act.get('degrees', 30)), s.player)
                    if not note: s.state.update(human_msg="that piece isn't on a hinge -- it needs to hang on one pin"); s.save(); continue
                    s.repaints = getattr(s, 'repaints', 0) + 1; stem = s.rerender(f'b{s.repaints}')
                s.moves.append(f'turn {s.turn}: {note}'); s.human_extra = (getattr(s, 'human_extra', '') + ' ' + note + '.').strip()
                s.look_png = stem + '.png'; s.state.update(frame=os.path.basename(stem) + '.png', human_msg='bent -- still your turn'); s.save(); continue
            if t == 'remove':
                with s.lock:
                    j = int(act.get('j', -1))
                    if not (0 <= j < len(s.g.asm.parts)) or getattr(s.g.asm.parts[j], 'is_pin', False): continue
                    p = s.g.asm.parts[j]; nm = f"{CNAME.get(p.colour, '?')} {play.CX.NAMES.get(p.name, p.name)}"; res = s.remove(p)
                    if res != 'removed': s.state.update(human_msg=f"can't take that one off: {res.replace('refused: ', '')}"); s.save(); continue
                    s.free_left['tokens'] -= 1; s.repaints = getattr(s, 'repaints', 0) + 1; stem = s.rerender(f'x{s.repaints}')
                note = f"{s.player} took off the {nm}."; s.moves.append(f'turn {s.turn}: {note}'); s.human_extra = (getattr(s, 'human_extra', '') + ' ' + note).strip()
                s.look_png = stem + '.png'; s.state.update(frame=os.path.basename(stem) + '.png', human_msg='taken off -- still your turn', free_left=dict(s.free_left)); s.save(); continue
            if t == 'place_at' and act.get('card') in s.human_hand:
                card = act['card']
                with s.lock:
                    try: c = s.conn_from_id(act['conn'])
                    except Exception: continue
                    m = {'conn': c, 'name': card, 'idx': int(act.get('idx', 0)), 'spin': float(act.get('spin', 0))}
                    if not s.commit(m): s.state.update(human_msg="that spot didn't work after all -- try another"); s.save(); continue
                    new = s.bodies()[-1]; m.update(host=c[1], T=new.T, pins=[])
                    if str(act.get('colour', '')).lower() in RECOLOURS: new.colour = RECOLOURS[str(act['colour']).lower()]   # her colour choice
                hopts = [m]; act = {'type': 'place', 'i': 0, '_committed': True}; t = 'place'     # finish her turn below (was: t stayed 'place_at', so it never ended)
            if t == 'card' and act.get('card') in s.human_hand:
                card = act['card']; s.state.update(stage='finding_spots', human_card=card, human_msg=''); s.save()
                hopts = s.propose(k=6, tries=100, pool=[card])
                if not hopts:
                    s.state.update(stage='your_turn', human_msg="that piece has nowhere to go right now -- pick another"); s.save(); continue
                with ThreadPoolExecutor(3) as ex: pngs = list(ex.map(lambda im: s.thumb(im[1], f'h{im[0]}'), enumerate(hopts)))
                s.state.update(stage='your_options', human_options=[{'png': pngs[i], 'name': play.CX.NAMES.get(m['name'], m['name']),
                    'joins': f"{CNAME.get(m['host'].colour, m['host'].colour)} {play.CX.NAMES.get(m['host'].name, m['host'].name)}"}
                    for i, m in enumerate(hopts)]); s.save(); continue
            if t == 'place' and hopts and 0 <= int(act.get('i', -1)) < len(hopts):
                m = hopts[int(act['i'])]; w = s.describe(m, 0)['where']
                if not act.get('_committed') and not s.commit(m): s.state.update(stage='your_turn', human_msg="that spot just got taken -- pick again"); s.save(); continue
                if not act.get('_committed') and str(act.get('colour', '')).lower() in RECOLOURS: s.bodies()[-1].colour = RECOLOURS[str(act['colour']).lower()]
                new = s.bodies()[-1]; s.human_hand.remove(card); s.human_hand.append(s.draw(False))
                lr = w.get('left or right in the picture (-1 = far left of the build, 0 = middle, 1 = far right, beyond 1 = further out than anything)', 0)
                h = w.get('height in the build (0 = bottom, 1 = top, above 1 = higher than anything yet)', 0)
                extra = getattr(s, 'human_extra', ''); s.human_extra = ''
                s.human_note = (extra + ' ' if extra else '') + (f"{s.player} just added a {CNAME.get(new.colour, new.colour)} {play.CX.NAMES.get(m['name'], m['name'])} onto the "
                                f"{w['joins the']}, {'on the left' if lr < -0.33 else 'on the right' if lr > 0.33 else 'in the middle'}, "
                                f"{'up high' if h > 0.66 else 'down low' if h < 0.33 else 'halfway up'}.")
                s.moves.append(f'turn {s.turn}: {s.human_note}')
                try: s.stability()
                except Exception: pass
                stem = f'{s.out}/frame_{s.turn:02d}h'; s.write_frame_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')
                except Exception: pass
                s.look_png = stem + '.png'; s.state.update(frame=f'frame_{s.turn:02d}h.png', stage='looking', human_options=[],
                                                            human_hand=list(s.human_hand), ivy_did=s.human_note); s.save()
                return None
    def reroll(s, hand):
        """swap out cards that have nowhere legal to go on the build right now (Ivy, Oct 23 2026: "Pieces that can't be placed should
        be rerolled"). Quick collision check; a few tries per slot. Returns how many were swapped."""
        n = 0; s.quick = True
        try:
            for i, card in enumerate(list(hand)):
                for _ in range(5):
                    if hand[i] in getattr(s, 'reserved', set()) and hand is getattr(s, 'hand', None): break      # a reserved card stays
                    if s.propose(k=1, tries=40, pool=[hand[i]], floor=False): break
                    hand[i] = s.draw(hand is getattr(s, 'hand', None)); n += 1
        finally: s.quick = False
        return n
    def draw(s, mine=True):
        """a card. ITS deck is stacked toward what it likes (Ivy, Oct 23 2026: "Maybe we stack the deck? Pieces it likes are more likely
        to end up in the hand?"): weight = (how often it picked that piece across every saved build / the average) ^ 1.5, between 0.3 and 4,
        so a favourite comes up several times as often as an ignored piece and nothing ever disappears. The other player's deck is a fair deal."""
        if not mine: return play.PALETTE[s.deal.integers(len(play.PALETTE))]
        w = getattr(s, '_deck_w', None)
        if w is None or len(w) != len(play.PALETTE):           # the bin grew (catalog): re-stack
            import glob
            c = collections.Counter()
            for f in [f for f in glob.glob(f'{HERE}/runs/*/log.jsonl') if not unwritten(os.path.dirname(f))]:
                try:
                    for l in open(f): c[json.loads(l).get('picked')] += 1
                except (OSError, ValueError): pass
            mean = (sum(c[n] for n in play.PALETTE) / len(play.PALETTE)) or 1
            w = np.array([min(4.0, max(0.3, c[n] / mean) ** 1.5) for n in play.PALETTE]); s._deck_w = w / w.sum()
            s.likes = sorted(((c[n], n) for n in play.PALETTE), reverse=True)[:5]
        return play.PALETTE[s.deal.choice(len(play.PALETTE), p=s._deck_w)]
    def labels(s):
        bs = s.bodies(); V = np.vstack([p.mesh.vertices for p in bs]); pr = V @ RIGHT; z0, z1 = V[:, 2].min(), V[:, 2].max()
        out = []
        from scipy.spatial import cKDTree
        S = [p.world(k['c']) for p in bs for k in p.sockets]; s._sock_tree = cKDTree(np.array(S)) if S else None
        show = range(len(bs))
        if len(bs) > 60:
            # a big build (the big brush's few hundred pieces): list only what it can build on -- pieces with free studs, the highest
            # first, plus the last ones placed (Oct 23 2026: all 479 of Liberty's pieces made a 39,000-character list)
            free = [i for i, p in enumerate(bs) if 'free stud' in s.free_studs(p, bs) and not s.free_studs(p, bs).startswith('no ')]
            free.sort(key=lambda i: -bs[i].mesh.bounds[1][2])
            show = sorted(set(free[:45]) | set(range(max(0, len(bs) - 15), len(bs))))
            out.append(f"({len(bs)} pieces in all; only the ones you can build on are listed -- the rest are buried inside)")
        for i in show:
            p = bs[i]
            c = p.mesh.vertices.mean(0); lr = (c @ RIGHT - (pr.min() + pr.max()) / 2) / max((pr.max() - pr.min()) / 2, 4)
            side = 'left' if lr < -0.33 else 'right' if lr > 0.33 else 'middle'; h = (c[2] - z0) / max(z1 - z0, 1)
            fs = s.free_studs(p, bs)
            out.append(f"#{i + 1} {CNAME.get(p.colour, p.colour)} {play.CX.NAMES.get(p.name, p.name)} ({side}, "
                       f"{'top' if h > 0.66 else 'bottom' if h < 0.33 else 'middle height'}){' -- the first piece' if i == 0 else ''}"
                       + (f"; {fs}" if fs else ''))
        return '\n'.join(out)
    def free_studs(s, p, bs=None):
        """how many studs on top of p are free, and in what shape, so it knows what size to shop for (Ivy, Oct 23 2026: "It should also
        count studs so it knows what to shop for"). '6 free studs on top (2x3)', or '5 free studs on top (spread over 2x4)'"""
        if not play.upright(p) or not p.studs: return ''
        bs = bs if bs is not None else s.bodies()
        tree = getattr(s, '_sock_tree', None)
        if tree is not None:                                # a socket of another piece sitting on that stud = taken (fast lookup)
            free = [t for t in p.studs if p.world_dir(t['axis'])[2] > 0.99 and not tree.query_ball_point(p.world(t['c']), 0.6)]
        else:
            socks = [b.world(k['c']) for b in bs if b is not p for k in b.sockets]
            free = [t for t in p.studs if p.world_dir(t['axis'])[2] > 0.99 and not any(np.linalg.norm(p.world(t['c']) - w) < 0.6 for w in socks)]
        if not free: return 'no free studs on top'
        g = {(int(round(t['c'][0] / 8.0)), int(round(t['c'][1] / 8.0))) for t in free}
        xs, ys = [a for a, b in g], [b for a, b in g]; w, d = max(xs) - min(xs) + 1, max(ys) - min(ys) + 1
        w, d = sorted((w, d))
        return f"{len(g)} free stud{'s' if len(g) != 1 else ''} on top ({w}x{d})" if len(g) == w * d else f"{len(g)} free studs on top (spread over {w}x{d})"
    def shop(s, want, on, n=4):
        """thumb through the bin for what it wants, keep what FITS the piece it named, and ask a quick eye what each one reads as (Ivy,
        Oct 23 2026: "It could run mcts to find pieces that fit and then ask haiku what it thinks of those pieces (collecting
        descriptions)" -- for things no part is called, like clouds of dust). Every reading is kept in parts/reads.jsonl."""
        tgt = s.piece(on) if on else None
        conns = [c for c in s.conns() if c[0] != 'floor' and (tgt is None or c[1] is tgt)]
        if not conns: return []
        ranked = [p for p, sc in s.dig(want, ranked=True)][:14]
        fits, seen = [], set()
        conns = [conns[k] for k in s.rng.permutation(len(conns))[:6]]          # a handful of spots, not every one (it took 165 s)
        with s.lock:
            s.quick = True                                                     # quick collision checks while searching...
            for name in ranked:
                got = 0
                for c in conns:
                    for idx in range(3):
                        for spin in (0.0, 90.0):
                            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)))
                            pins = [q.T.copy() for q in s.g.asm.parts[n0:] if getattr(q, 'is_pin', False)]; T = new.T.copy(); s.g.undo(new, n0)
                            if key in seen: continue
                            seen.add(key); fits.append({'conn': c, 'name': name, 'idx': idx, 'spin': spin, 'host': c[1], 'T': T, 'pins': pins, 'aimed': True})
                            got += 1; break
                        if got: break
                    if got: break
                if len(fits) >= 6: break
            s.quick = False                                                    # ...and the full one on what it found
            ok = []
            for m in fits:
                r = s.try_move(m['conn'], m['name'], m['idx'], m['spin'])
                if r: s.g.undo(*r); ok.append(m)
            fits = ok
        if not fits: return []
        def read(im):
            i, m = im
            try:
                png = os.path.join(s.out, s.thumb(m, f's{i}'))
                # the eye is NOT told what Pin wants (it echoed 'a puff of dust' back when it was); MiniLM scores the match
                t, _ = small_ask('A Lego build; the MAGENTA piece was just added. In a few words, what does the magenta piece look like it is, '
                                 'right there in the scene? Answer with just those few words.', [png], 40, prefer='gemini')
                ra = t.strip().strip('."').strip()[:60]
                sys.path.insert(0, os.path.join(HERE, 'teacher')); import teach
                with TEACH_LOCK: V = np.asarray(teach.emb([ra, want]), dtype=np.float32)
                V /= np.maximum(np.linalg.norm(V, axis=1, keepdims=True), 1e-9)
                return float(np.clip((float(V[0] @ V[1]) - 0.2) / 0.6 * 10, 0, 10)), ra
            except Exception: return 0.0, ''
        with ThreadPoolExecutor(3) as ex: reads = list(ex.map(read, enumerate(fits)))
        with open(f'{HERE}/parts/reads.jsonl', 'a') as f:
            for m, (sc, ra) in zip(fits, reads):
                f.write(json.dumps({'part': m['name'], 'on': m['host'].name, 'want': want, 'reads_as': ra, 'score': sc, 'run': os.path.basename(s.out)}) + '\n')
        order = sorted(range(len(fits)), key=lambda k: -reads[k][0])[:n]
        seen_ = '; '.join(f"{play.CX.NAMES.get(fits[k]['name'], fits[k]['name'])} reads as {reads[k][1] or '?'} ({reads[k][0]:.0f}/10)" for k in order)
        if not order or reads[order[0]][0] < 4:      # nothing in the bin reads as it: say so, don't pass misses off as finds ('a little flag': best 3/10)
            s.moves.append(f"turn {s.turn}: you shopped for \"{want}\" but NOTHING in your bin reads as that ({seen_}). Wish for it, or ask the "
                           f"catalog with a token -- this turn's options are your usual ones.")
            return []
        s.moves.append(f"turn {s.turn}: you shopped for \"{want}\"" + (f" on {on}" if on else '') + ": " + seen_)
        return [fits[k] for k in order]
    def piece(s, label):
        m = re.search(r'#?(\d+)', str(label or '')); bs = s.bodies()
        return bs[int(m.group(1)) - 1] if m and 0 < int(m.group(1)) <= len(bs) else None
    def links(s, bodies, pins):
        """which bodies are joined: a stud sitting in a socket, or a pin whose box touches both"""
        E = set()
        for i, a in enumerate(bodies):
            for j, b in enumerate(bodies):
                if j <= i: continue
                ba, bb = a.mesh.bounds, b.mesh.bounds
                if (ba[0] > bb[1] + 0.5).any() or (bb[0] > ba[1] + 0.5).any(): continue
                st = any(np.linalg.norm(a.world(t['c']) - b.world(k['c'])) < 0.6 for t in a.studs for k in b.sockets) or \
                     any(np.linalg.norm(b.world(t['c']) - a.world(k['c'])) < 0.6 for t in b.studs for k in a.sockets)
                pn = any(all(not ((q.mesh.bounds[0] > x[1] + 0.2).any() or (x[0] > q.mesh.bounds[1] + 0.2).any()) for x in (ba, bb)) for q in pins)
                # a stud pushed into a Technic hole, and a tyre on a wheel: joins this checker didn't know, so it called those pieces floating
                def stud_in_hole(u, v):
                    for t in u.studs:
                        sc = u.world(t['c'])
                        for h in v.holes:
                            hc, hx = v.world(h['c']), v.world_dir(h['axis'])
                            for sg in (1, -1):
                                if np.linalg.norm(hc + hx * sg * h['face_len'] / 2 - sc) < 0.8: return True
                    return False
                sh = stud_in_hole(a, b) or stud_in_hole(b, a)
                sn = any(np.linalg.norm(pa - pb) < 0.6 and abs(abs(xa @ xb) - 1) < 0.05 and (play.SN.fits(da, db) or play.SN.fits(db, da))
                         for da in play.new_snaps(a.name) for pa, xa in [play.snap_world(a, da)]
                         for db in play.new_snaps(b.name) for pb, xb in [play.snap_world(b, db)])      # clips, axles, hinges, ball joints
                ty = {a.name, b.name} == {'3641', '122c01'} and any(np.linalg.norm((w.T @ M)[:3, 3] - t.T[:3, 3]) < 1.0
                     for w, t in ((a, b), (b, a)) if w.name == '122c01' for M in play._TYRE_SLOTS)
                bt = any(np.linalg.norm(w.world(k['c']) - q) < 0.6 for w, o in ((a, b), (b, a)) if play.round_1x1(w.name)
                         for k in w.sockets[:1] for q in play.between_points(o))     # a 1x1 round pressed in between four studs
                if st or pn or sh or ty or sn or bt: E.add((i, j))
        return E
    # ---- hinges (Ivy, Oct 23 2026: "could we add animated hinge pieces that bend the creation?") ----
    def pin_bodies(s, q):
        """the pieces a pin actually goes THROUGH: a hole on the pin's own axis, within its length (not bounding boxes)"""
        ax = q.world_dir(np.array([1.0, 0, 0])); ax /= np.linalg.norm(ax); c = q.mesh.vertices.mean(0); half = q.mesh.extents.max() / 2 + 0.5
        out = []
        for b in s.bodies():
            for h in b.holes:
                hc, hx = b.world(h['c']), b.world_dir(h['axis'])
                if abs(abs(hx @ ax) - 1) > 0.02: continue
                d = hc - c; off = d - (d @ ax) * ax
                if np.linalg.norm(off) < 0.6 and abs(d @ ax) <= half: out.append(b); break
        return out, c, ax
    def hinges(s):
        """single-pin joins: a pin through exactly two pieces that have no other connection between them. Each is
        (pin, fixed piece, moving piece, axis point, axis dir, moving group) -- the group is everything on the far side"""
        bs = s.bodies(); pins = [q for q in s.g.asm.parts if getattr(q, 'is_pin', False)]
        thru = {id(q): s.pin_bodies(q) for q in pins}
        out = []
        for q in pins:
            two, c, ax = thru[id(q)]
            if len(two) != 2: continue
            a, b = two
            others = [x for x in pins if x is not q and a in thru[id(x)][0] and b in thru[id(x)][0]]
            studs = any(np.linalg.norm(u.world(t['c']) - v.world(k['c'])) < 0.6 for u, v in ((a, b), (b, a)) for t in u.studs for k in v.sockets)
            if others or studs: continue
            # the group that swings: everything reachable from b without crossing this pin, unless that includes the first piece
            E = s.links(bs, [x for x in pins if x is not q]); idx = {id(x): i for i, x in enumerate(bs)}
            adj = {i: set() for i in range(len(bs))}
            for i, j in E:
                if {i, j} == {idx[id(a)], idx[id(b)]}: continue
                adj[i].add(j); adj[j].add(i)
            def comp(start):
                seen, todo = {start}, [start]
                while todo:
                    for j in adj[todo.pop()]:
                        if j not in seen: seen.add(j); todo.append(j)
                return seen
            gb = comp(idx[id(b)])
            if 0 in gb: a, b, gb = b, a, comp(idx[id(a)])
            if 0 in gb: continue                                  # both sides reach the first piece: it's a loop, it can't swing
            out.append({'pin': q, 'fixed': a, 'moving': b, 'c': c, 'ax': ax, 'group': [bs[i] for i in gb]})
        return out
    def bend(s, h, degrees):
        """swing the moving group around the hinge pin; if it would crash into the rest, try smaller angles (returns the angle used)"""
        import trimesh
        group = h['group']; gid = {id(x) for x in group}
        gpins = [q for q in s.g.asm.parts if getattr(q, 'is_pin', False) and q is not h['pin'] and set(id(x) for x in s.pin_bodies(q)[0]) <= gid]
        before = {id(x): x.T.copy() for x in group + gpins}
        rest = [b for b in s.bodies() if id(b) not in gid]
        floor0 = min(b.mesh.bounds[0][2] for b in s.bodies())                       # the invisible baseplate
        for ang in (degrees, degrees * 0.75, degrees * 0.5, degrees * 0.25):
            R = trimesh.transformations.rotation_matrix(np.radians(ang), h['ax'], h['c'])
            for x in group + gpins: x.T = R @ before[id(x)]
            bad = False
            for m in group:
                mb = m.mesh
                for f in rest:
                    if f is h['fixed']: continue
                    fb = f.mesh
                    if (mb.bounds[0] > fb.bounds[1] + 0.1).any() or (fb.bounds[0] > mb.bounds[1] + 0.1).any(): continue
                    if G.Assembly.penetration(fb, mb, n=800) > G.DEPTH_TOL: bad = True; break
                if bad: break
            if not bad:
                if min(m.mesh.bounds[0][2] for m in group) < floor0 - 0.3: bad = True          # through the invisible baseplate
            if not bad: return ang
            for x in group + gpins: x.T = before[id(x)].copy()
        return 0.0
    def hinge_for(s, p):
        """the hinge that swings piece p: one where p is the moving piece, else the smallest swinging group containing it"""
        hs = s.hinges(); own = [h for h in hs if h['moving'] is p]
        if own: return own[0]
        mine = [h for h in hs if any(x is p for x in h['group'])]
        return min(mine, key=lambda h: len(h['group'])) if mine else None
    def do_bend(s, p, degrees, who):
        """bend at p's hinge, tell the page how to animate it, re-render. Returns a sentence for the notebook, or None."""
        h = s.hinge_for(p)
        if h is None: return None
        gid = {id(x) for x in h['group']}
        nodes = [j for j, q in enumerate(s.g.asm.parts) if id(q) in gid or (getattr(q, 'is_pin', False) and q is not h['pin']
                 and set(id(x) for x in s.pin_bodies(q)[0]) <= gid)]
        ang = s.bend(h, float(degrees))
        if ang: s.hinge_angle = getattr(s, 'hinge_angle', {}); s.hinge_angle[id(h['pin'])] = (s.hinge_angle.get(id(h['pin']), 0.0) + ang + 180.0) % 360.0 - 180.0
        if not ang: return f"{who} tried to bend the {CNAME.get(h['moving'].colour, '?')} {play.CX.NAMES.get(h['moving'].name, '')} but it would crash into something"
        s.state['bend'] = {'id': time.time(), 'nodes': nodes, 'c': h['c'].tolist(), 'ax': h['ax'].tolist(), 'deg': ang}; s.save(); time.sleep(1.0)
        return (f"{who} bent the {CNAME.get(h['moving'].colour, '?')} {play.CX.NAMES.get(h['moving'].name, '')} {abs(round(ang))} degrees on its pin"
                f" (now at {round(s.hinge_angle.get(id(h['pin']), 0))} degrees)" + (f", not the full {round(float(degrees))}: it would have crashed" if abs(ang - float(degrees)) > 1 else ''))
    def add_baseplate(s, name='3867'):
        """slide a baseplate under the whole build, centred: its studs press into the lowest pieces' sockets (Ivy, Oct 23 2026: "It should
        get to decide if it wants to start from a baseplate or a piece and it should be able to add a baseplate if it wants later")"""
        bs = s.bodies()
        if any(b.name in play.BASEPLATES for b in bs): return 'there is already a baseplate'
        V = np.vstack([b.mesh.vertices for b in bs]); zlo = V[:, 2].min(); centre = (V.min(0) + V.max(0)) / 2
        low = [b for b in bs if b.mesh.bounds[0][2] < zlo + 0.5 and b.sockets and play.upright(b)]
        plate = play.G.fresh(name); best = None
        for host in low:
            for k, so in enumerate(host.sockets[:4]):
                w = host.world(so['c'])
                for spin in (0.0, 90.0):
                    R = np.array([[np.cos(np.radians(spin)), -np.sin(np.radians(spin))], [np.sin(np.radians(spin)), np.cos(np.radians(spin))]])
                    want = R.T @ (w[:2] - centre[:2])                         # where this socket should sit on the plate, plate-local
                    j = int(np.argmin([np.linalg.norm(np.asarray(t['c'])[:2] - want) for t in plate.studs]))
                    r = s.try_move(('socket', host, k), name, j, spin)
                    if r:
                        d = float(np.linalg.norm(r[0].mesh.bounds.mean(0)[:2] - centre[:2])); s.g.undo(*r)
                        if best is None or d < best[0]: best = (d, host, k, j, spin)
        if best is None: return 'no room: something hangs below the build'
        _, host, k, j, spin = best
        ok = s.commit({'conn': ('socket', host, k), 'name': name, 'idx': j, 'spin': spin})
        if ok: s.bodies()[-1].colour = 2                                   # baseplate green
        return 'added' if ok else 'no room'
    def feeling_story(s):
        """its feelings turn by turn, each with the piece it had just added; and where frustration started (Ivy, Oct 23 2026: "It should use
        its feelings to know when to go back to. So it knows which piece made it start feeling frustrated initially.")"""
        added = dict((t, b) for t, b in getattr(s, 'added', []))
        rows, fr, ex = [], [], []
        for e in s.notebook:
            f = e.get('feelings') or {}
            if not f: continue
            try: fr.append(float(f.get('frustration', 0))); ex.append(float(f.get('excitement', 5)))
            except (TypeError, ValueError): continue
            b = added.get(e['turn'] - 1); bs = s.bodies()
            what = f"after adding #{bs.index(b) + 1} the {CNAME.get(b.colour, '?')} {play.CX.NAMES.get(b.name, b.name)}" if b is not None and b in bs else ''
            rows.append((e['turn'], f, what))
        story = ' | '.join(f"turn {t}{(' (' + w + ')') if w else ''}: excited {f.get('excitement')}, frustrated {f.get('frustration')}, bored {f.get('boredom')}"
                           for t, f, w in rows[-10:])
        turn_pt = None
        for i in range(1, len(fr)):
            rose = fr[i] - min(fr[:i]) >= 2 and min(fr[i:]) >= fr[i] - 1          # frustration climbed and stayed up
            fell = max(ex[:i]) - ex[i] >= 2 and max(ex[i:]) <= ex[i] + 1           # excitement dropped and stayed down
            if rose or fell: turn_pt = rows[i]; break
        upset = bool(fr) and (fr[-1] >= 5 or (len(ex) > 1 and max(ex) - ex[-1] >= 3))
        return story, turn_pt, upset
    def go_back(s, to_turn):
        """take off every piece IT added after to_turn, newest first (never the other player's)"""
        took = []
        for t, b in sorted(getattr(s, 'added', []), key=lambda x: -x[0]):
            if t <= to_turn or b not in s.bodies(): continue
            nm = f"{CNAME.get(b.colour, '?')} {play.CX.NAMES.get(b.name, b.name)}"
            if s.remove(b) == 'removed': took.append(nm)
        s.added = [(t, b) for t, b in getattr(s, 'added', []) if b in s.bodies()]
        return took
    # ---- sections (Ivy, Oct 23 2026: "It should be able to break off sections and build between, treating sections as one big piece.
    # That way it can ADD a leg!" and "It should also be able to pick a half it likes and throw the rest away") ----
    LIFT = 24.0                                          # mm a held section floats above its old spot in the pictures
    def section(s, piece, frm):
        """the pieces that come away with `piece` when its join to `frm` is broken (plus pins that go through it). None if the
        two are still held together some other way"""
        bs = s.bodies(); pins = [q for q in s.g.asm.parts if getattr(q, 'is_pin', False)]
        if piece is None or frm is None or piece is frm: return None, 'no such pieces'
        E = s.links(bs, pins); ia, ib = bs.index(frm), bs.index(piece)
        if (min(ia, ib), max(ia, ib)) not in E: return None, 'those two pieces are not joined to each other'
        E.discard((min(ia, ib), max(ia, ib)))
        nb = collections.defaultdict(set)
        for i, j in E: nb[i].add(j); nb[j].add(i)
        seen, todo = {ib}, [ib]
        while todo:
            for j in nb[todo.pop()] - seen: seen.add(j); todo.append(j)
        if ia in seen: return None, 'they are still held together another way'
        sec = [bs[i] for i in sorted(seen)]; sid = {id(x) for x in sec}
        sec += [q for q in pins if any(id(x) in sid for x in s.pin_bodies(q)[0])]
        return sec, None
    def break_off(s, piece, frm, then):
        sec, why = s.section(piece, frm)
        if sec is None: return f"couldn't break it there: {why}"
        n = len([x for x in sec if not getattr(x, 'is_pin', False)]); rest_n = len(s.bodies()) - n
        names = ', '.join(f"{CNAME.get(x.colour, '?')} {play.CX.NAMES.get(x.name, x.name)}" for x in sec if not getattr(x, 'is_pin', False))
        sid = {id(x) for x in sec}
        if then == 'keep_it':
            gone = [x for x in s.g.asm.parts if id(x) not in sid]
        elif then in ('throw_it_away', 'hold'):
            gone = sec
        else: return None
        z0 = np.vstack([p.mesh.vertices for p in s.g.asm.parts])[:, 2].min()          # the ground
        for x in gone: s.g.asm.parts.remove(x)
        if s.g.asm.parts:                                   # gravity: whatever is left comes back down onto the baseplate
            V = np.vstack([p.mesh.vertices for p in s.g.asm.parts]); dz = z0 - V[:, 2].min()
            for p in s.g.asm.parts: p.T[2, 3] += dz
        s.g.used = set(); s.g.reconcile()
        s.added = [(t, b) for t, b in getattr(s, 'added', []) if b in s.bodies()]
        if then == 'hold':
            s.held = sec; s.state['holding'] = {'n': n, 'names': names}
            return f"broke off a section ({names}) and is holding it up"
        s.state['holding'] = None
        return (f"kept the {n}-piece half ({names}) and threw the other {rest_n} pieces away" if then == 'keep_it'
                else f"broke off and threw away {names}")
    def set_down(s, on=None, tries=60):
        """put the held section back as ONE big piece: its free underside sockets onto free studs, turned any quarter turn.
        A piece it named wins; then closest to where it came from (a lifted body goes back on its new legs); then most studs."""
        import trimesh
        held = getattr(s, 'held', None)
        if not held: return None
        bs = s.bodies(); target = s.piece(on) if on else None
        studs = [(b.world(t['c']), b) for b in bs for i, t in enumerate(b.studs)
                 if (id(b), 'stud', i) not in s.g.used and b.world_dir(t['axis'])[2] > 0.99]
        studs += [(q, None) for q in play.floor_points(bs, off=getattr(s, 'floor_off', None))]                    # the invisible baseplate clutches too
        hb = [x for x in held if not getattr(x, 'is_pin', False)]
        hstuds = [x.world(t['c']) for x in hb for t in x.studs]
        socks = [x.world(k['c']) for x in hb for k in x.sockets
                 if play.upright(x) and not any(np.linalg.norm(x.world(k['c']) - h) < 0.6 for h in hstuds)]
        if not studs or not socks: return None
        home = np.vstack([x.mesh.vertices for x in hb]).mean(0); before = {id(x): x.T.copy() for x in held}
        cands, seen = [], set()
        for spin in (0, 90, 180, 270):
            R = trimesh.transformations.rotation_matrix(np.radians(spin), [0, 0, 1], home)
            rs = [(R @ np.r_[k, 1])[:3] for k in socks]
            for k in rs:
                for st, b in studs:
                    t = st - k; key = (spin, *np.round(t, 1))
                    if key in seen: continue
                    seen.add(key)
                    joins = sum(any(np.linalg.norm(k2 + t - st2) < 0.6 for st2, _ in studs) for k2 in rs)
                    on_target = target is not None and any(np.linalg.norm(k2 + t - st2) < 0.6 for st2, b2 in studs if b2 is target for k2 in rs)
                    cands.append((-joins, -on_target, float(np.linalg.norm(t)) + spin * 0.01, spin, t))
        cands.sort(key=lambda c: (c[1], c[2], c[0]))       # a piece it named, then back where it came from, then most studs
        for c in cands[:tries]:
            spin, t = c[3], c[4]
            M = trimesh.transformations.rotation_matrix(np.radians(spin), [0, 0, 1], home); M[:3, 3] += t
            for x in held: x.T = M @ before[id(x)]
            s.quick = True
            try: bad = any(s.collides(x) for x in hb)
            finally: s.quick = False
            if not bad and not any(s.g.collides(x) for x in hb):
                s.g.asm.parts.extend(held); s.held = None; s.state['holding'] = None
                s.g.used = set(); s.g.reconcile()
                return f"set the section back down as one piece ({-c[0]} stud{'s' if -c[0] != 1 else ''} holding it{', turned ' + str(spin) + ' degrees' if spin else ''})"
            for x in held: x.T = before[id(x)].copy()
        return None
    def write_frame_ldr(s, path):
        """the .ldr for a picture: the build, plus a held section floating LIFT mm above where it came from"""
        import copy
        held = getattr(s, 'held', None)
        if not held: return s.g.asm.write_ldr(path)
        a2 = copy.copy(s.g.asm); a2.parts = list(s.g.asm.parts)
        for x in held:
            y = copy.copy(x); y.T = x.T.copy(); y.T[2, 3] += s.LIFT; a2.parts.append(y)
        return a2.write_ldr(path)
    def reorient(s, how):
        """turn the WHOLE creation a quarter turn, as seen in the main view, around its middle, and set it back on the ground
        (Ivy, Oct 23 2026: "It should be allowed to reorient the whole creation during a turn"). Returns a sentence or None."""
        import trimesh
        axes = {'tip_forward': (RIGHT, 90), 'tip_back': (RIGHT, -90), 'roll_left': (TOWARD, -90), 'roll_right': (TOWARD, 90),
                'spin_left': (np.array([0, 0, 1.0]), 90), 'spin_right': (np.array([0, 0, 1.0]), -90)}
        if how not in axes: return None
        ax, deg = axes[how]; ax = np.asarray(ax, float) / np.linalg.norm(ax)
        if abs(ax[2]) < 0.5:          # tips and rolls turn about the stud grid's own axis nearest the view's (the view is diagonal to
            i = int(np.argmax(np.abs(ax[:2]))); g = np.zeros(3); g[i] = np.sign(ax[i]); ax = g     # the grid: it left bricks on an edge)
        parts = list(s.g.asm.parts); V = np.vstack([p.mesh.vertices for p in parts]); c = (V.min(0) + V.max(0)) / 2; z0 = V[:, 2].min()
        R = trimesh.transformations.rotation_matrix(np.radians(deg), ax, c)
        for p in parts: p.T = R @ p.T
        V2 = np.vstack([p.mesh.vertices for p in parts]); dz = z0 - V2[:, 2].min()
        for p in parts: p.T[2, 3] += dz                                      # back on the ground
        s.g.used = set(); s.g.reconcile()
        s.state['reorient'] = {'id': time.time(), 'c': c.tolist(), 'ax': ax.tolist(), 'deg': deg, 'dz': dz}; s.save(); time.sleep(1.0)
        return how.replace('_', ' ')
    def stability(s):
        """would it stand up on its own the way it sits now? (Ivy, Oct 23 2026: "It should get a warning every turn if the creation
        isn't stable. It can have it in an orientation but gets flagged it would fall over and can't be completed until it's stable.")
        Lego is thin-walled plastic, so each piece's weight goes with its surface area: the centre of weight is the area-weighted
        centroid of every triangle. The footprint is the convex hull of everything within 0.4 mm of the floor (the engine is in mm;
        a stud is 8 mm). Stable when the centre of weight sits over the footprint with 0.8 mm to spare. Returns a dict for the page and a sentence for the prompt."""
        # The bottom sits on a huge invisible baseplate WITH clutch and gravity (Ivy, Oct 23 2026: "the huge base plate should still
        # have clutch and gravity" ... "if it turns it and it's resting on but not connected to the plate, same rules apply before.
        # It falls"). Clutched = an upright piece's underside pressed onto the baseplate's studs; then it can't tip. Otherwise the
        # old balance rule decides.
        bs = s.bodies(); off = getattr(s, 'floor_off', None); z0 = play.floor_grid(bs, off)[0]
        clutched = [b for b in bs if b.mesh.bounds[0][2] < z0 + 0.3 and play.upright(b)
                    and any(abs(b.world(k['c'])[2] - z0) < 0.6 and off is not None and play.on_grid(b.world(k['c'])[:2], off) for k in b.sockets)]
        if clutched:
            names = ', '.join(sorted({f"{CNAME.get(b.colour, '?')} {play.CX.NAMES.get(b.name, b.name)}" for b in clutched}))
            out = {'stable': True, 'clutched': True,
                   'sentence': f"It's clutched to the huge invisible baseplate by the {names}, so it can't fall over: one leg is fine, lean and "
                               f"overhang as much as you like. You can also build straight onto the baseplate, anywhere around it."}
        else:
            out = stand([p.mesh for p in s.g.asm.parts]); out['clutched'] = False
            if out['stable']:
                out['sentence'] = ("It's only RESTING on the invisible baseplate, not clutched to it (no upright piece has its underside on "
                                   "the baseplate's studs), so gravity decides. Right now it balances.")
            else:
                out['sentence'] = ("It's only RESTING on the invisible baseplate, not clutched to it, so gravity decides -- " + out['sentence'] +
                                   " Or press an upright piece onto the baseplate to clutch it.")
        # Islands on the floor (Ivy, Oct 23 2026: "when it adds a loose piece to the same level as the first layer, but unconnected it
        # should be prompted to see if it wants a baseplate under both"). Each group of joined pieces that touches the floor is an
        # island; two or more with no real baseplate under them = ask.
        out['islands'] = 0
        plate = next((i for i, b in enumerate(bs) if b.name in play.BASEPLATES), None)
        if plate is not None and len(bs) > 2:
            # with a REAL baseplate: anything not joined to it, however it got there, is loose on it (Oct 23 2026, the jedi game: a
            # baseplate slid under Yoda clicked into him but not the lightsaber, whose blade end has nothing to click -- the saber
            # stood on the plate joined to nothing but its own hilt, and this check used to skip every game with a baseplate)
            pins = [q for q in s.g.asm.parts if getattr(q, 'is_pin', False)]
            E = s.links(bs, pins); nb = collections.defaultdict(set)
            for i, j in E: nb[i].add(j); nb[j].add(i)
            g, todo = {plate}, [plate]
            while todo:
                for j in nb[todo.pop()] - g: g.add(j); todo.append(j)
            loose = [i for i in range(len(bs)) if i not in g]
            if loose:
                names = ', '.join(sorted({f"{CNAME.get(bs[i].colour, '?')} {play.CX.NAMES.get(bs[i].name, bs[i].name)}" for i in loose}))
                out['islands'] = 2
                out['sentence'] += (f" LOOSE ON THE BASEPLATE: the {names} {'is' if len(loose) == 1 else 'are'} standing on the baseplate but "
                                    f"not clicked into it or into anything that is -- lift the build and it stays behind. Join it to the "
                                    f"build (a piece that bridges it to something clicked in), or take it off. Decide, and say which.")
        if plate is None and len(bs) > 1:
            pins = [q for q in s.g.asm.parts if getattr(q, 'is_pin', False)]
            E = s.links(bs, pins); nb = collections.defaultdict(set)
            for i, j in E: nb[i].add(j); nb[j].add(i)
            seen, groups = set(), []
            for i in range(len(bs)):
                if i in seen: continue
                g, todo = {i}, [i]
                while todo:
                    for j in nb[todo.pop()] - g: g.add(j); todo.append(j)
                seen |= g; groups.append(g)
            floor = [g for g in groups if min(bs[i].mesh.bounds[0][2] for i in g) < z0 + 0.3]
            if len(floor) > 1:
                floor.sort(key=lambda g: (len(g), 0 in g)); loose = floor[:-1]      # the main build: the biggest, ties go to the first piece's
                names = '; '.join(', '.join(sorted({f"{CNAME.get(bs[i].colour, '?')} {play.CX.NAMES.get(bs[i].name, bs[i].name)}" for i in g}))
                                  for g in loose[:3])
                out['islands'] = len(floor)
                out['sentence'] += (f" ISLANDS: {len(floor)} separate groups sit on the floor and nothing joins them -- {names} "
                                    f"{'is' if len(loose) == 1 else 'are'} loose beside the main build. On a real table those slide apart "
                                    f"the moment anyone bumps it. Do you want a real baseplate under all of them, so they become one "
                                    f"thing? (\"add_baseplate\": true) Or is the gap the point? Decide, and say which.")
        s.state['stability'] = out
        return out
    def hold(s, part, keep=''):
        """pick a piece up into its handful; when it already holds HAND, the oldest one it isn't about to play goes back in the bin"""
        if not part or part in s.hand: return
        if len(s.hand) >= HAND:
            out = next((i for i, n in enumerate(s.hand) if n != keep and n not in getattr(s, 'reserved', set())), None)
            if out is None: return
            s.hand.pop(out)
        s.hand.append(part)
    def refill_handful(s, upto=4):
        """when it holds almost nothing: its hot list first, then pieces it tends to like (the stacked deck) -- never a forced lame brick"""
        for h in load_self().get('hot', []):
            if len(s.hand) >= upto: break
            if h.get('part') in play.PALETTE: s.hold(h['part'])
        tries = 0
        while len(s.hand) < upto and tries < 40: s.hold(s.draw()); tries += 1
    def touch_hot(s, part):
        """playing a hot piece moves it back to the front, so the 12-limit drops the ones it stopped using, not the ones it uses most
        (Oct 23 2026: the microfig Ivy showed it was 6th of 12 and would have aged off after six new favourites)"""
        me = load_self(); hot = me.get('hot', []); i = next((k for k, h in enumerate(hot) if h.get('part') == part), None)
        if not i: return                                   # not on it, or already first
        hot.insert(0, hot.pop(i)); me['hot'] = hot; json.dump(me, open(f'{HERE}/self.json', 'w'), indent=1); s.hot = hot
        s.state['hot'] = [dict(h, name=play.CX.NAMES.get(h['part'], h['part'])) for h in s.hot]
    def make_hot(s, part, why):
        """keep a piece it likes one call away, across builds (Ivy, Oct 23 2026: "If it likes a piece it should keep it hotloaded. Like
        it should be able to ask for a big leg, know it can ask, do it, and then it's on its hot list"). Newest first, at most 12."""
        if not part or part not in play.PALETTE: return
        me = load_self(); hot = [h for h in me.get('hot', []) if h.get('part') != part]
        hot.insert(0, {'part': part, 'why': str(why)[:80], 'when': time.strftime('%b %-d %-I:%M %p')}); me['hot'] = hot[:12]
        json.dump(me, open(f'{HERE}/self.json', 'w'), indent=1); s.hot = me['hot']; s.state['hot'] = [dict(h, name=play.CX.NAMES.get(h['part'], h['part'])) for h in s.hot]
    def dig(s, ask, exclude=(), ranked=False):
        """dig through ITS OWN bin for the piece it means -- free, local, no API call (Ivy, Oct 23 2026: "Letting it dig thru the bin
        would save money"). MiniLM similarity between its words and each bin piece's name + description. Returns (part, score)."""
        try: descs = json.load(open(f'{HERE}/parts/desc.json'))
        except (OSError, ValueError): descs = {}
        pool = [n for n in play.PALETTE if n not in exclude]
        if not pool or not str(ask).strip(): return None, 0.0
        sys.path.insert(0, os.path.join(HERE, 'teacher')); import teach
        with TEACH_LOCK:
            V = np.asarray(teach.emb([str(ask)] + [f"{play.CX.NAMES.get(n, n)}. {shape_words(n)}. {descs.get(n, '')}" for n in pool]), dtype=np.float32)
        V /= np.maximum(np.linalg.norm(V, axis=1, keepdims=True), 1e-9); sc = V[1:] @ V[0]
        # plus plain word matches -- meaning alone sent "a tiny round piece for an eye" to the squirrel
        stop = {'a', 'an', 'the', 'for', 'of', 'to', 'piece', 'part', 'brick', 'some', 'one', 'my', 'with', 'and', 'little', 'small', 'big'}
        want = {w for w in re.findall(r'[a-z]+', str(ask).lower()) if w not in stop and len(w) > 2}
        for k, n in enumerate(pool):
            have = set(re.findall(r'[a-z]+', f"{play.CX.NAMES.get(n, n)} {shape_words(n)} {descs.get(n, '')}".lower()))
            sc[k] += 0.12 * len(want & have)
        if ranked: return [(pool[k], float(sc[k])) for k in np.argsort(-sc)]
        i = int(np.argmax(sc))
        return pool[i], float(sc[i])
    def catalog_fetch(s, ask):
        """Ivy, Oct 23 2026: "It should work on its turn, what shape would be right, and what lego does that." The catalog's best
        matches for the shape it described; Haiku picks the one that IS that shape; it goes in its bin for good. Returns the part or None."""
        import partbin
        s.state.update(highlight=f'Looking through the whole Lego catalog for {ask}...'); s.save()
        found = [r for r in partbin.catalog(ask, 10) if not partbin.check(r['part'])][:8]
        if not found: return None
        try:
            sys.path.insert(0, os.path.expanduser('~/projects/brick_reef/favorites')); import haiku as H
            os.environ.setdefault('HAIKU', 'claude-haiku-4-5-20251001')
            t, _ = H.ask(f'A Lego builder wants: "{ask}". Which of these real Lego parts IS that? Answer with the part number only.\n' +
                         '\n'.join(f"{r['part']}: {r['name']}" for r in found), max_tokens=20)
            pick = next((r['part'] for r in found if r['part'] in (t or '')), found[0]['part'])
        except Exception: pick = found[0]['part']
        if pick not in play.PALETTE:
            ok, _ = partbin.add(pick, 'the reef', describe_it=False)      # its turn doesn't wait on the description (rate limits)
            if not ok: return None
            import threading as _t; _t.Thread(target=partbin.describe, args=(pick,), daemon=True).start()
        return pick
    def move(s, p, to, q=None):
        """take ONE piece off and put it somewhere else, same piece, same colour -- free (Ivy, Oct 23 2026: "It should try moving
        pieces more often. It should move just one or two sometimes and be super willing to move just one piece"). `to` is an aim
        ({piece, side, end}, or piece 'ground'). If it fits nowhere there, it goes back where it was. Returns a sentence."""
        if p is None: return 'tried to move a piece that is not there'
        q = s.aim_point(to) if q is None else q
        if q is None: return "tried to move a piece but didn't say where to"
        name, colour, T0 = p.name, p.colour, p.T.copy(); nm = f"{CNAME.get(colour, '?')} {play.CX.NAMES.get(name, name)}"
        r = s.remove(p)
        if r != 'removed': return f"tried to move the {nm} ({r})"
        ground = str((to or {}).get('piece', '')).lower() in ('ground', 'baseplate', 'floor')
        for g in s.ghosts(name, q, n=8, floor=ground).get('ghosts', []):
            if np.allclose(np.array(g['T'])[:3, 3], T0[:3, 3], atol=0.5): continue          # not back where it was
            c = s.conn_from_id(g['conn'])
            if s.commit({'conn': c, 'name': name, 'idx': g['idx'], 'spin': g['spin']}):
                new = s.bodies()[-1]; new.colour = colour
                s.added = [(t, b) for t, b in getattr(s, 'added', []) if b in s.bodies()] + [(s.turn, new)]
                return f"moved the {nm} to {to.get('piece')} {to.get('side', '')} {to.get('end', '') or ''}".strip()
        back = G.fresh(name); back.T = T0; back.colour = colour; s.g.asm.parts.append(back); s.g.used = set(); s.g.reconcile()
        return f"tried to move the {nm}, but it fit nowhere there, so it went back"
    def remove(s, p):
        bs = s.bodies()
        if p is None or p is bs[0]: return 'refused: that is the first piece' if p is not None else 'no such piece'
        pins = [q for q in s.g.asm.parts if getattr(q, 'is_pin', False)]
        pb = p.mesh.bounds
        gone = [q for q in pins if any(x is p for x in s.pin_bodies(q)[0])]     # the pins actually THROUGH p (bounding boxes took a neighbour's pin too)
        rest = [b for b in bs if b is not p]; E = s.links(rest, [q for q in pins if q not in gone])
        off = getattr(s, 'floor_off', None); z0 = min(b.mesh.bounds[0][2] for b in bs)
        held = {i for i, b in enumerate(rest) if off is not None and b.mesh.bounds[0][2] < z0 + 0.3 and play.upright(b)
                and any(abs(b.world(k['c'])[2] - z0) < 0.6 and play.on_grid(b.world(k['c'])[:2], off) for k in b.sockets)}   # clicked onto the baseplate
        seen = {0} | held; todo = list(seen)
        while todo:
            i = todo.pop()
            for a, b in E:
                for x, y in ((a, b), (b, a)):
                    if x == i and y not in seen: seen.add(y); todo.append(y)
        if len(seen) < len(rest): return f'refused: {len(rest) - len(seen)} piece(s) would be left floating'
        s.g.asm.parts = [q for q in s.g.asm.parts if q is not p and q not in gone]
        s.g.used = set(); s.g.reconcile(); return 'removed'
    def apply_moves(s, e):
        did = []
        rt = getattr(s, 'reef_tokens', None)
        pp_ = str(e.get('play_piece', ''))
        if hasattr(s, 'hand') and pp_ in play.PALETTE and pp_ not in s.hand: s.hold(pp_)      # it named a piece from its bin: it means it
        def pay(what):                                        # one token each, in the order below, until they run out
            if rt is None: return True
            if rt['tokens'] <= 0: did.append(f'wanted to {what} but had no tokens'); return False
            rt['tokens'] -= 1; return True
        if e.get('remove') and not pay('take a piece off'): e['remove'] = None
        if e.get('remove'):
            p = s.piece(e['remove']); nm = f"{CNAME.get(p.colour, '?')} {play.CX.NAMES.get(p.name, p.name)}" if p else str(e['remove'])
            r = s.remove(p); did.append(f"took off the {nm}" if r == 'removed' else f"tried to take off the {nm} ({r})")
            if r != 'removed' and rt is not None: rt['tokens'] += 1        # refused: no charge
        rc = e.get('recolor')
        if isinstance(rc, dict) and rc.get('piece') and str(rc.get('to', '')).lower() in RECOLOURS and not pay('repaint'): rc = None
        if isinstance(rc, dict) and rc.get('piece') and str(rc.get('to', '')).lower() in RECOLOURS:
            p = s.piece(rc['piece'])
            if p is not None:
                old = CNAME.get(p.colour, '?'); p.colour = RECOLOURS[str(rc['to']).lower()]
                did.append(f"recoloured the {old} {play.CX.NAMES.get(p.name, p.name)} {rc['to']}")
        if e.get('new_hand') and hasattr(s, 'hand') and pay('get a new hand'):
            s.hand = [n for n in s.hand if n in getattr(s, 'reserved', set())]; s.refill_handful(); did.append('put its handful back and picked up pieces it likes')
        kind = str(e.get('request_piece') or '').lower()
        pool = [n for n in KINDS.get(kind, []) if n in play.PALETTE]
        if pool and hasattr(s, 'hand') and pay(f'ask for {kind}'):
            got = pool[s.deal.integers(len(pool))]; keep = str(e.get('play_piece', ''))
            s.hold(got, keep)
            did.append(f'spent a token on {kind} and drew a {play.CX.NAMES.get(got, got)}')
        dg = str(e.get('dig') or '').strip()
        if dg and hasattr(s, 'hand'):
            got, sc = s.dig(dg, exclude=set(s.hand))
            if got:
                s.hold(got, str(e.get('play_piece', ''))); did.append(f'dug through its bin for "{dg[:50]}" and pulled out the {play.CX.NAMES.get(got, got)}'); s.make_hot(got, dg)
        for d_ in [x for x in (e.get('drop') or []) if isinstance(x, str)][:HAND]:          # free: lame pieces go back in the bin
            if d_ in s.hand: s.hand.remove(d_); did.append(f'put the {play.CX.NAMES.get(d_, d_)} back in the bin')
        for g_ in [x for x in (e.get('grab') or []) if isinstance(x, str) and x.strip()][:3]:   # free: pick up pieces it wants
            got = g_ if g_ in play.PALETTE else s.dig(g_, exclude=set(s.hand))[0]
            if got and got not in s.hand: s.hold(got, str(e.get('play_piece', ''))); did.append(f'picked up the {play.CX.NAMES.get(got, got)}')
        ht = str(e.get('hot_take') or '').strip()
        if ht and hasattr(s, 'hand') and ht in [h['part'] for h in getattr(s, 'hot', [])] and ht in play.PALETTE and ht not in s.hand:
            s.hold(ht, str(e.get('play_piece', ''))); did.append(f'took the {play.CX.NAMES.get(ht, ht)} from its hot list')
        ha = e.get('hot_add') if isinstance(e.get('hot_add'), dict) else None
        if ha and str(ha.get('part', '')) in play.PALETTE: s.make_hot(str(ha['part']), ha.get('why', '')); did.append(f"put the {play.CX.NAMES.get(str(ha['part']), ha['part'])} on its hot list")
        hd_ = str(e.get('hot_drop') or '')
        if hd_ and any(h['part'] == hd_ for h in getattr(s, 'hot', [])):
            me = load_self(); me['hot'] = [h for h in me.get('hot', []) if h['part'] != hd_]; json.dump(me, open(f'{HERE}/self.json', 'w'), indent=1); s.hot = me['hot']
            s.state['hot'] = [dict(h, name=play.CX.NAMES.get(h['part'], h['part'])) for h in s.hot]; did.append(f'took the {play.CX.NAMES.get(hd_, hd_)} off its hot list')
        ask = str(e.get('catalog_ask') or '').strip()
        if ask and hasattr(s, 'hand') and pay(f'look in the catalog for {ask}'):
            got = s.catalog_fetch(ask)
            if got:
                s.hold(got, str(e.get('play_piece', '')))
                did.append(f'looked in the catalog for "{ask}" and found the {play.CX.NAMES.get(got, got)} -- it is in its bin now'); s.make_hot(got, ask)
            else:
                rt['tokens'] += 1; did.append(f'looked in the catalog for "{ask}" but found nothing it could join (no charge)')
        rv = str(e.get('reserve') or '')
        if rv and hasattr(s, 'hand') and rv in s.hand and rv not in getattr(s, 'reserved', set()) and pay('reserve a piece'):
            s.reserved = getattr(s, 'reserved', set()) | {rv}; did.append(f"reserved the {play.CX.NAMES.get(rv, rv)} for later -- it stays in its hand")
        if e.get('study') is True and pay('study a piece'):
            s._study = True; did.append(f"spent a token studying the {play.CX.NAMES.get(str(e.get('play_piece', '')), 'piece')} ten ways")
        dq, nq = e.get('drop_question'), e.get('new_question')
        if dq == DONE_Q: dq = None                                 # the done question can't be dropped
        if dq and dq in s.questions and len(s.questions) > 1: s.questions.remove(dq); did.append(f'stopped asking "{dq}"')
        if nq and nq not in s.questions and len(s.questions) < 8: s.questions.append(nq); did.append(f'started asking "{nq}"')
        return did
    def views(s, png):
        """what it looks at: the main view plus from behind and from above, in one picture (Ivy, Oct 23 2026: "The diagonal plate
        isn't visible to it" -- from one corner, pieces under or behind others are hidden; she can turn the 3D model, it couldn't)"""
        from PIL import Image, ImageDraw, ImageFont
        ldr = png[:-4] + '.ldr'
        if not os.path.exists(ldr): return png
        def one(vp):
            out = png[:-4] + f'_{vp}.png'
            subprocess.run(['flatpak', 'run', '--filesystem=home', 'org.leocad.LeoCAD', '-l', '/home/ivy/lego/ldraw', '-i', out, '-w', '420', '-h', '420',
                            '--viewpoint', vp, ldr], capture_output=True); return out
        with ThreadPoolExecutor(2) as ex: back, top = ex.map(one, ('back', 'top'))
        try:
            W = Image.new('RGB', (700 + 420, 840), 'white')
            W.paste(Image.open(png).convert('RGBA').resize((700, 700)), (0, 70), Image.open(png).convert('RGBA').resize((700, 700)))
            for k, (p, lab) in enumerate(((back, 'from behind'), (top, 'from above'))):
                im = Image.open(p).convert('RGBA'); W.paste(im, (700, k * 420), im)
                ImageDraw.Draw(W).text((710, k * 420 + 8), lab, fill='black', font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 22))
            ImageDraw.Draw(W).text((12, 20), 'main view', fill='black', font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 26))
            out = png[:-4] + '_views.png'; W.save(out); return out
        except Exception: return png
    def look(s):
        png = getattr(s, 'look_png', None) or f'{s.out}/frame_{s.turn:02d}.png'; s.look_png = None
        png = s.views(png)
        nb = '\n'.join(f"turn {e['turn']}: saw {e.get('i_see', '')} | might be {e.get('might_be', '')} | "
                       f"{'PIVOT: ' + e.get('why_pivot', '') + ' | ' if e.get('pivot') else ''}wanted {e.get('next', '')}"
                       for e in s.notebook) or '(empty -- this is the first piece)'
        parts = s.labels()
        qs = '\n'.join(f'{i + 1}. {q}' for i, q in enumerate(s.questions))
        moves = '; '.join(s.moves[-6:]) or 'none yet'
        try: descs = json.load(open(f'{HERE}/parts/desc.json'))
        except (OSError, ValueError): descs = {}
        hand = '\n'.join(f'- {n}: {play.CX.NAMES.get(n, n)}' + (' (RESERVED by you)' if n in getattr(s, 'reserved', set()) else '')
                         + (f' -- {descs[n]}' if descs.get(n) else '') for n in s.hand)
        rt = getattr(s, 'reef_tokens', {'tokens': 0}); tips = MASTER_TIPS
        try: fstory, tpt, upset = s.feeling_story()
        except Exception: fstory, tpt, upset = '', None, False
        fstory = fstory or 'nothing yet'
        hd = (s.state or {}).get('holding') if getattr(s, 'held', None) else None
        holding = (f"\nYOU ARE HOLDING a section right now: {hd['names']} -- floating above where it was in the pictures. Build under or "
                   f"around it, then \"set_down\" puts it back as one piece (where it came from, unless you name a piece). You can't call "
                   f"it done while you're holding it.") if hd else ''
        try: steady = s.stability()['sentence']
        except Exception as ex: steady = ''; print('stability failed:', ex, flush=True)
        fback = (f"\nThings started going wrong at turn {tpt[0]}{(', ' + tpt[2]) if tpt[2] else ''}. If you want, you can GO BACK to before it: "
                 f'"go_back": {{"to_turn": {tpt[0] - 1}}} takes off that piece and every piece YOU added after it.') if (tpt and upset) else ''
        mytok = (f"{rt['tokens']} token{'s' if rt['tokens'] != 1 else ''}. Tokens are rare: one every {TOKEN_EVERY} turns, and one each time your "
                 f"judge (a clone of you) gives you a heart. ONE token buys any one of: repaint a piece (recolor), take a piece off (remove), "
                 f"find a piece by its SHAPE in the whole Lego catalog (catalog_ask: think what shape would be right here, and which Lego piece is that), or a "
                 f"STUDY turn (study: instead of a few random surprises, see the piece you chose tried about ten ways where you aim -- for when "
                 f"you know the piece but not the spot). You choose what's worth it; you can't spend what you don't have. Breaking off "
                 f"sections, bending, turning and starting over are free.")
        try:
            bs_ = s.bodies(); hinges = '\n'.join(f"- #{bs_.index(h['moving']) + 1} swings on its pin on #{bs_.index(h['fixed']) + 1}, carrying "
                                               + ', '.join(f'#{bs_.index(x) + 1}' for x in h['group'])
                                               + f" -- now at {round(getattr(s, 'hinge_angle', {}).get(id(h['pin']), 0))} degrees" for h in s.hinges()) or 'none yet'
        except Exception: hinges = 'none yet'
        def made(b):
            m = b.get('made_of') or {}; n = sum(m.values())
            return (' | made of ' + ', '.join(f'{v} {k}' for k, v in m.items()) + f' ({n} pieces)') if n else ''
        past = '\n'.join(f"- {b['when']}: " + ' -> '.join(x.split('.')[0][:50] for x in b['became']) + made(b)
                         + (f" | by the end you loved: {b['loved'][-1]}" if b.get('loved') else '') for b in s.me.get('builds', [])[-12:]) or '(this is your first build)'
        who = s.me.get('who_you_are', '')
        last = s.notebook[-1] if s.notebook else {}
        feel_before = json.dumps(last.get('feelings')) if last.get('feelings') else 'nothing yet: this build just started'
        s.hot = load_self().get('hot', []); s.state['hot'] = [dict(h, name=play.CX.NAMES.get(h['part'], h['part'])) for h in s.hot]
        hotlist = '\n'.join(f"- {h['part']}: {play.CX.NAMES.get(h['part'], h['part'])} -- {shape_words(h['part'])} (you wanted it for: {h.get('why', '')})" for h in s.hot) or '(empty so far)'
        plan_before = json.dumps(getattr(s, 'plan', None), indent=1) if getattr(s, 'plan', None) else 'none yet: make one now'
        due = WISH_EVERY - (s.turn % WISH_EVERY) if s.turn % WISH_EVERY else 0
        wish_note = (f"Your wish so far: {last.get('wish_shape')!r}. " if last.get('wish_shape') else "You haven't wished for anything yet. ") + \
                    ("It is delivered THIS turn -- make it exact." if due == 0 and s.turn > 0 else f"Next delivery in {due or WISH_EVERY} turn(s).")
        dream_before = json.dumps(last.get('daydream')) if last.get('daydream') else 'none yet'
        friend = (s.me.get('friends') or {}).get(s.player, [])
        hist = ('; '.join(f"{g['when']}: {' -> '.join(g['became'][-2:])}" for g in friend[-4:])) if friend else 'this is your first game together'
        req = (f"\nA REQUEST: {getattr(s, 'player', None) or 'someone'} asked you to build \"{s.request}\". Build that, your own way: interpret "
               f"it playfully and surprise them, but by the end it must clearly read as {s.request}. Your current idea is that request (you can "
               f"elaborate it, not replace it), you don't abandon it for a daydream, and 'done' means it now clearly reads as {s.request}.\n") if getattr(s, 'request', None) else ''
        if getattr(s, 'pictionary', False):
            gs = [g for g in s.guesses if g.get('text')]
            req = (f"\nPICTIONARY. Your secret is \"{s.secret}\". {s.player} CANNOT see it: they are watching the build and guessing what "
                   f"it is. Make them guess it from the bricks alone -- the most recognisable thing about a {s.secret} first and biggest "
                   f"(exaggerate it, the way a Pictionary drawing does), nothing extra that muddies it, and never letters or words built in "
                   f"bricks. Their guesses so far, oldest first, each with how WARM it reads (0-10, from closeness in meaning; they see "
                   f"warmer / colder than their last guess): "
                   + ('; '.join(f'turn {g.get("turn")}: "{g["text"]}" warmth ' + (str(round(10 * g['warm'])) if g.get('warm') is not None else '?') for g in gs[-8:]) if gs else 'none yet')
                   + '. If a warmth is wrong -- you know your secret, the meter does not -- add "warmth": {"<their guess>": <0-10>} to fix it'
                   + f". Read their LATEST guess as feedback: what did they see in your build that made them say it? Keep what pulls "
                   f"toward {s.secret}, change what pulls away. You cannot finish until they guess it ('done' is refused). Also add a "
                   f"field \"to_guesser\" to your JSON: a hint made ONLY of emoji, up to six, no letters or words at all. NEVER the "
                   f"emoji of the thing itself or anything that names it.\n"
                   f"And GESTURE, like a charades player's hands: add \"gestures\": a sequence of up to TWELVE steps, each "
                   f"{{\"piece\": \"#n\", \"emoji\": \"<ONE emoji>\"}}, played in order as an animation over their view -- a hand glides to "
                   f"each piece and shows that emoji, then the next. Your gestures last turn: {getattr(s, '_last_gestures', '') or 'none yet'}.\n"
                   f"{GESTURE_CRAFT}\n")
        coop = (f"\nYOU ARE PLAYING TOGETHER WITH {s.player.upper()}, taking turns, each from your own hand. Your games with {s.player} "
                f"before: {hist}.\n{s.human_note}\nThink about what they did: what do you think they were going for? Then decide whether to "
                f"follow their idea, answer it, or go your own way, and say which. (Fill in about_her_move and with_her.)\n"
                + (f"Your charades guesses LAST turn were: {'; '.join(str(g.get('guess')) + ' (' + str(g.get('sure')) + '%)' for g in (last.get('charades') or [])[:4])}. "
                   f"Did their new move make any warmer or colder? Re-rank.\n" if last.get('charades') else '') +
                f"PLAY CHARADES with them: shout guesses, bold and fast, the way you would at a party. What are they making, and why did they "
                f"add THAT piece THERE? Give several different guesses, ranked best first, each with how sure you are (\"charades\"). A wild "
                f"guess that turns out right is the most fun thing in the game; a safe vague one is the least.\n") \
               if getattr(s, 'coop', False) and getattr(s, 'human_note', '') else ''
        restart_note = ("\n(You are playing together, so you can't start over on your own: if you would, say so in start_over and they will see it.)"
                        if getattr(s, 'coop', False) else '')
        fc = getattr(s, 'first_choice', None) or {}
        if fc.get('restart'):
            req = (f"\nYOU STARTED OVER. Last time you were making: {fc.get('idea')}. What you decided to do differently: {fc.get('would')}. "
                   f"The old one is saved. Build it better this time.\n") + req
        elif fc.get('idea'):
            req = f"\nYou left your last build unfinished to make this: {fc.get('idea')}.\n" + req
        first_opts = ', '.join(f"{n} {play.CX.NAMES.get(n, n)}" for n in FIRST_CHOICES)
        surprise = ('\nYour last piece was a SURPRISE, not what you asked for. Before you change anything, ask yourself whether it '
                    'should stay. Some of your best builds came from keeping accidents.\n') if getattr(s, 'last_was_surprise', False) else ''
        q = f"""{who}

Your own file, IT.md, which only you write:
---
{it_md()}
---

You are playing with Lego with no plan, one piece at a time. Nobody asked you to make anything. You decide what
this is, what it might become, and you can change your mind whenever you see something else in it.

You have built before. These are your own past builds, what each one became, in order (your memory, across builds):
{past}
You can make something you've made before if you really want to, but notice if you keep making the same thing. You might be
tired of it. Being the same player who made those is the point: you carry your taste and your questions from build to build.

Look at the picture of what you've built so far: {png}  (Read it. It shows the main view, plus the same build from behind and from above, so you can see pieces the main view hides.)
The pieces in it (#number, colour, piece, where it sits in the picture):
{parts}
What you did on recent turns: {moves}

YOUR HANDFUL -- the few pieces you've picked up; you add from these. Nothing is dealt to you and nothing is forced on you: pick up
pieces you want ("grab": up to 3 a turn, free -- a part number from your bin, or describe it and you'll dig it out) and put back any
you don't like ("drop", free). You hold at most {HAND}; a played piece is used up.
{hand}

{tips}

YOUR FEELINGS THIS BUILD: {fstory}{fback}

YOU CANNOT RUN OUT OF TURNS. Take as many as the build needs: it ends only when YOU say it's done (or someone presses stop).

YOUR TOKENS: {mytok}

DOES IT STAND? {steady}

YOUR HOT LIST -- pieces you love, kept one call away across every build ("hot_take" puts one in your hand, free, once a turn).
Anything you dig up, wish for or fetch goes on it; add your own favourites with "hot_add", drop with "hot_drop":
{hotlist}

DIGGING IS FREE: once a turn you can dig through your whole bin for a piece you know you have ("dig": describe it) and it comes
into your hand. Your hand is just what's in front of you; your bin is everything you own.

A NEW PIECE EVERY {WISH_EVERY} TURNS, from the whole Lego catalog, free: think about what SHAPE would be right here that your bin
doesn't have, and refine it every turn in "wish_shape" (what it looks like, how big, what it attaches with). {wish_note}

BUILDING UNDER: you can put a piece UNDER any piece -- aim at its bottom. Under the BOTTOM piece, the whole build lifts by the new
piece's height and stands on it (that's how you set the build into a tray or onto a base someone made).

MOVING PIECES IS FREE, and good builders do it constantly: a piece in the wrong place is better MOVED than built around or covered
up. Every turn, look for one or two pieces that would be better somewhere else -- an inch left, the other end, the other side, up
a level -- and move them ("move"). Moving just one piece is a perfectly good turn. It keeps its colour; if it fits nowhere where you
point, it goes back.

SECTIONS: you can break the build apart at any join and treat a section as ONE big piece. "break_off" breaks a piece -- and
everything that comes away with it -- off the piece it's joined to. Then: "hold" lifts it up and you hold it while you build on
what's left: lift the body off its one leg, add a second leg (it can stand straight on the invisible baseplate), set the body back down on both legs. That is how you ADD a leg, or
build anything in between. "keep_it" keeps that half and throws the rest away; "throw_it_away" throws that half away. Both are
free, like starting over.{holding}

HINGES you can bend this turn (a piece on a single pin can swing; everything riding on it swings too):
{hinges}

Your own questions -- ask yourself each one, honestly, before deciding anything:
{qs}

{req}{coop}{surprise}How you felt last turn: {feel_before}
The daydream you've been carrying: {dream_before}

Your attention runs on interest, the way ADHD attention does: novelty, challenge, urgency and real interest light you up;
repetition, waiting, and things that aren't working drain you. Feelings move gradually, unless something surprises you.
You can abandon this build for your daydream if you are genuinely more excited about it, or if this one isn't working. That
is allowed, not a failure; unfinished builds are saved and remembered. But don't leave on a whim either.
First pieces you could start a daydream with: {first_opts}

STARTING OVER IS FREE. Every turn, imagine starting THIS SAME idea over from nothing, knowing what you know now: what would you
put down first, what would it stand on, what would you do differently? Then weigh it honestly against pressing on. Ivy says:
starting over with Legos is free -- and with virtual Legos, as a machine that doesn't get tired, it is more than free. Nothing
is lost: this build is saved and remembered. Keeping a build going only because pieces are already on it is the one real
waste. And a better build is what people would actually want to play with.{restart_note}

YOUR CONSTRUCTION PLAN so far (yours -- refine it every turn: tick off what's built, fix sizes and positions that turned out wrong,
reorder steps; only start it fresh if the idea itself changed):
{plan_before}

Your notebook so far (your own memory of this build):
{nb}

Look at it creatively, the way a kid looks at a pile of bricks and suddenly sees a dog. Then answer ONLY with JSON:
{{"i_see": "<one sentence, at most 25 words: what it looks like right now>",
  "current_idea": "<the idea you were holding (from your notebook), or 'none yet'>",
  "new_seeing": "<a few words: what it could be NOW, if something new jumps out at you; may be the same as your current idea>",
  "weigh": "<one sentence, at most 25 words: do you like the new seeing more than your current idea, and why?>",
  "keep": <true to keep your current idea and make the build fit it better; false to switch to the new seeing>,
  "might_be": "<a few words: the idea you are going with now -- or 'not sure yet' if you honestly don't know what it is>",
  "might_be_why": "<one sentence, at most 20 words: why you see that>",
  "next": "<one sentence, at most 25 words: the one piece you want next, in picture words: which coloured piece it joins,
           left or right, up or down, reaching out or tucked in, long or small. If you kept your idea, the piece that makes
           the build look MORE like it.>",
  "answers": ["<a short honest answer to question 1>", "<to question 2>", "..."],
  "remove": "<'#n' of one piece to take off because it doesn't fit, or null>",
  "remove_why": "<if removing, at most 15 words why>",
  "recolor": {{"piece": "<'#n'>", "to": "<one of: {', '.join(MENU)}>"}} or null,
  "new_question": "<a question you want to ask yourself every turn from now on, or null>",
  "drop_question": "<the exact text of one of your questions that stopped being useful, or null>",
  "charades": [{{"guess": "<what they're making / why they put that piece there>", "because": "<what in the build tells you>", "sure": <0-100>}}, "...3 to 5, best first..."] -- only when playing with someone, else [],
  "could_be": [{{"thing": "<what the WHOLE build could be>", "sure": <0-100>}}, "...3 to 5 different readings, best first, your current idea among them..."],
  "about_her_move": "<only when playing with someone: what you think they were going for with their last piece, or null>",
  "with_her": "<only when playing with someone: follow | answer | own way>",
  "loved_her_move": <only when playing with someone: true ONLY if you genuinely loved their last move -- it earns them a rare token to take a piece off>,
  "kid_thinks": "<at most 5 plain words a child can read: what it is, e.g. 'a little red boat' or 'not sure yet'>",
  "kid_wants": "<at most 8 plain words a child can read: what you want next, e.g. 'a tall piece on the blue roof'>",
  "feelings": {{"excitement": <0-10>, "confidence": <0-10, is this build working?>, "frustration": <0-10>, "boredom": <0-10>}},
  "about_the_last_piece": "<one short honest feeling about the piece that just went on>",
  "daydream": {{"idea": "<something ELSE you could start making instead>", "first_piece": "<one part number from the first-piece list>",
               "first_colour": "<a colour>", "excitement": <0-10, how much it pulls at you>}},
  "hot_take": "<free, once a turn: a part number from YOUR HOT LIST to put in your hand now -- or null>",
  "hot_add": {{"part": "<a part number you love and want one call away from now on>", "why": "<a few words>"}} or null,
  "hot_drop": "<a part number to take off your hot list -- or null>",
  "dig": "<free, once a turn: describe a piece you think is somewhere in your bin and you'd like in your hand now -- or null>",
  "shop": <free, for things no part is CALLED (a puff of dust, a cloud, a splash): {{"for": "<what you want it to look like>", "on": "#n"}} --
           the engine thumbs through your bin, keeps only pieces that FIT on #n, shows each in place to a quick eye that says what it
           reads as, and those become this turn's options. Use the free-stud counts on the piece list to say what size fits. Or null>,
  "wish_shape": "<the piece you wish your bin had, refined from last turn: its shape, size in studs, how it attaches -- delivered free every few turns>",
  "catalog_ask": "<or: describe the SHAPE you wish you had, in plain words (e.g. 'a little frog', 'a long thin curved piece for a neck'); a token finds the real Lego piece that is that shape in the whole catalog, puts it in your bin for good and in your hand now -- or null>",
  "study": <true to spend a token studying your chosen piece about ten ways this turn, false otherwise>,
  "move": [{{"piece": "<#n>", "to": {{"piece": "<#m, or 'ground'>", "side": "<top | bottom | left | right | front | back>", "end": "<left | right | middle>"}}}}] -- up to two pieces, or [],
  "break_off": {{"piece": "<#n>", "from": "<#m it is joined to>", "then": "<hold | keep_it | throw_it_away>"}} or null,
  "set_down": {{"on": "<#n, or null for back where it came from>"}} or null -- only while you are holding a section,
  "start_over": {{"would": "<at most 25 words: how you would build THIS SAME idea again from nothing, knowing what you know now>",
                 "first_piece": "<one part number from the first-piece list>", "first_colour": "<a colour>",
                 "better_by": <0-10: how much better the do-over would be than pressing on>, "do_it": <true to start over now>,
                 "why": "<at most 15 words>"}},
  "abandon": <true to leave this build unfinished and start your daydream now>,
  "abandon_why": "<if abandoning, at most 20 words>",
  "done": <true ONLY if you are asked whether it is done and you really think it is finished; otherwise false>,
  "done_why": "<if done, at most 20 words: why it is finished>",
  "grab": ["<up to 3: a part number from your bin, or a description to dig out>"],
  "drop": ["<part numbers from your handful to put back in the bin>"],
  "play_piece": "<the part number, FROM YOUR HANDFUL (after grabbing), of the piece you want to add next>",
  "piece_colour": "<the colour for the piece you add: one of {', '.join(MENU)}>",
  "go_back": {{"to_turn": <n>}} or null -- only if your feelings say it went wrong; takes off your pieces added after turn n,
  "add_baseplate": <true to slide a 16x16 green baseplate under the whole build (once), else false>,
  "reorient": "<tip_forward | tip_back | roll_left | roll_right | spin_left | spin_right -- turn the whole creation a quarter turn, as seen in the main view -- or null>",
  "bend": {{"piece": "<#n of a hinge piece from the list>", "to": <the angle you want it AT, -180 to 180: any angle you like; 0 is how it was built, the list says where it is now>}} or null,
  "count_check": [<COUNT, don't just tick: for each feature that defines what it is, how many the real thing has and how many your
                  picture shows RIGHT NOW -- {{"feature": "torch", "should": 1, "has": 2}}. Lady Liberty has ONE torch; a new torch
                  also 'has a torch'. Two eyes, four legs, one tail. If has != should, fix it (take one off, move it) before anything new.>],
  "plan": {{"scale": "<the whole thing's size in studs: wide x deep x tall (in bricks)>",
            "symmetry": "<what mirrors what, and about which line -- or none, and why>",
            "depth": "<front to back: what is in front, what behind, how thick>",
            "steps": [{{"part": "<a part of the thing: base, leg, body, head, tail...>", "pieces": "<which Lego pieces make it>",
                        "size": "<its size in studs/bricks, and relative to the other parts>",
                        "at": "<where it goes relative to the other parts>", "joins": "<how it connects: on studs, under, sideways, pin, hinge>",
                        "done": <true once built>}}, "...in build order..."],
            "now": "<the step you are working on this turn>"}},
  "aim": {{"piece": "<#n of the piece you want to build on, or 'ground' to put a piece on the baseplate beside the build>", "side": "<top | bottom | left | right | front | back, as seen in the main view>",
          "end": "<left | middle | right, along that piece as seen in the main view>"}},
  "want_piece": "<one of: small, long, plate, brick, beam, angle, wheels, tyre, slope, round, wing, tile, arch, any>",
  "want_on": "<the colour of the piece it should join, from the colours listed above, or 'any'>",
  "taste": "<at most 15 words, fits 'I love ...': what you are into right now in this build>"}}
Don't switch just because something new occurs to you: switch only if you actually like it more. Changing your mind and
sticking with an idea are both fine.
Keep it short. Answer quickly."""
        s._cmd = ['claude', '-p', '--model', 'opus', '--output-format', 'json', '--allowedTools', 'Read', '--add-dir', s.out, '--', q]
        # stream its answer as it writes it, so the page can show the thought forming (Ivy, Oct 23 2026: "Still a lot of dead air.
        # It needs to vamp somehow. Maybe it can show recent thoughts as if it's thinking them")
        cmd = ['claude', '-p', '--model', 'opus', '--output-format', 'stream-json', '--include-partial-messages', '--verbose',
               '--allowedTools', 'Read', '--add-dir', s.out, '--', q]
        s._proc = _claude_retry(subprocess.Popen, cmd, cwd=WORK, env={**os.environ, 'CLAUDE_CONFIG_DIR': CFG}, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        lastn = s.notebook[-1] if s.notebook else {}
        echoes = [f"I still love {lastn['taste']}" if lastn.get('taste') else '', lastn.get('about_the_last_piece') or '',
                  f"maybe {lastn['daydream']['idea']}?" if isinstance(lastn.get('daydream'), dict) and lastn['daydream'].get('idea') else '']
        s._result = None; s.state['live'] = None; s.state['asking'] = []; s.state['asking_turn'] = s.turn; s.save()
        _vt = __import__('threading').Thread(target=s.vamp, args=(lastn, list(s.questions), [x for x in echoes if x], s.turn), daemon=True); _vt.start()
        import threading as _th
        def reader(proc=s._proc):
            text, last = '', 0.0
            for line in proc.stdout:
                try: d = json.loads(line)
                except ValueError: continue
                ev = d.get('event') or {}
                if ev.get('type') == 'content_block_delta' and (ev.get('delta') or {}).get('type') == 'text_delta':
                    text += ev['delta']['text']
                    if time.time() - last > 0.25:
                        m = re.findall(r'"(\w+)"\s*:\s*"((?:[^"\\]|\\.)*)', text)
                        if m: s.state['live'] = {'k': m[-1][0], 't': m[-1][1].replace('\\"', '"')}; s.save(); last = time.time()
                elif d.get('type') == 'result': s._result = d
        s._reader = _th.Thread(target=reader, daemon=True); s._reader.start()
    def look_result(s):
        if getattr(s, 'stop', False): s._proc.kill(); raise RuntimeError('stopped')
        s._reader.join(timeout=600); s._proc.wait(timeout=30); errt = s._proc.stderr.read() if s._proc.stderr else ''
        out = json.dumps(s._result) if s._result else ''
        s.state['live'] = None
        for attempt in range(2):
            if attempt:
                p = subprocess.run(s._cmd, cwd=WORK, env={**os.environ, 'CLAUDE_CONFIG_DIR': CFG}, capture_output=True, text=True, timeout=600)
                out, errt = p.stdout, p.stderr
            try:
                d = json.loads(out); r = d[-1] if isinstance(d, list) else d
                e = json.loads(re.search(r'\{.*\}', r['result'], re.S).group(0)); e['turn'] = s.turn; e['cost'] = r.get('total_cost_usd')
                e['pivot'] = (not e.get('keep', True)) and e.get('current_idea', 'none yet') not in ('none yet', '', None); e['why_pivot'] = e.get('weigh', '')
                return e
            except Exception as ex: err = f'{ex}: {(out or errt)[-300:]}'
        raise RuntimeError(err)
    def aim_point(s, aim):
        """its words ('#3, top, left end') -> a point in our frame, the way a mouse would hover there"""
        if str((aim or {}).get('piece', '')).lower() in ('ground', 'baseplate', 'floor'):      # beside the build, on the baseplate
            bs = s.bodies(); V = np.vstack([b.mesh.vertices for b in bs]); c = (V.min(0) + V.max(0)) / 2; z0 = V[:, 2].min()
            side = str(aim.get('side', 'front')).lower(); d = {'left': -RIGHT, 'right': RIGHT, 'front': TOWARD, 'back': -TOWARD}.get(side, TOWARD)
            reach = float(np.abs((V[:, :2] - c[:2]) @ np.asarray(d[:2])).max()) + 8
            return np.array([c[0] + d[0] * reach, c[1] + d[1] * reach, z0])
        p = s.piece((aim or {}).get('piece'))
        if p is None: return None
        V = p.mesh.vertices; lo, hi = p.mesh.bounds; c = (lo + hi) / 2; pr = V @ RIGHT; pt = V @ TOWARD
        side, end = str(aim.get('side', 'top')).lower(), str(aim.get('end', 'middle')).lower()
        q = c.copy()
        if end == 'left': q[:2] = V[np.argmin(pr)][:2] * 0.8 + c[:2] * 0.2
        elif end == 'right': q[:2] = V[np.argmax(pr)][:2] * 0.8 + c[:2] * 0.2
        if side == 'top': q[2] = hi[2] + 1
        elif side == 'bottom': q[2] = lo[2] - 1
        elif side == 'left': q[:2] = V[np.argmin(pr)][:2] - RIGHT[:2] * 4
        elif side == 'right': q[:2] = V[np.argmax(pr)][:2] + RIGHT[:2] * 4
        elif side == 'front': q[:2] = V[np.argmax(pt)][:2] + TOWARD[:2] * 4
        elif side == 'back': q[:2] = V[np.argmin(pt)][:2] - TOWARD[:2] * 4
        return q
    def aim_candidates(s, e, n=5):
        """Ivy, Oct 23 2026: "could it ask for the area it wants to add to with natural language and we basically mouse around over
        there and give it the narrowed down moves. That way it can be smarter about placement and less random, but with assistance." """
        q = s.aim_point(e.get('aim'))
        if q is None: return []
        on_ground = str((e.get('aim') or {}).get('piece', '')).lower() in ('ground', 'baseplate', 'floor')
        pp = str(e.get('play_piece', '')); hand = list(getattr(s, 'hand', []) or [])
        cards = [pp] if pp in hand else [c for c in hand if c in (KINDS.get(str(e.get('want_piece', '')).lower()) or [])] or hand
        out, seen = [], set()
        # its OWN choice of side, not luck (Ivy, Oct 23 2026: "It needs to be able to choose under, not just roll. Just now it
        # wanted to and couldn't cause it didn't roll lucky"): aiming at a piece's bottom tries THAT piece's undersides (a lift
        # when it sits on the baseplate), its top tries THAT piece's studs; the nearest-connector search is only the fallback
        side = str((e.get('aim') or {}).get('side', '')).lower(); tgt = s.piece((e.get('aim') or {}).get('piece'))
        if tgt is not None and side in ('top', 'bottom'):
            kinds = ('stud',) if side == 'top' else ('socket', 'lift')
            mine = sorted([c for c in s.conns() if c[0] in kinds and c[1] is tgt], key=lambda c: np.linalg.norm(s.conn_point(c) - q))
            for card in cards[:3]:
                for c in mine[:6]:
                    with s.lock:
                        for idx in range(4):
                            for spin in (0.0, 90.0, 180.0, 270.0):
                                r = s.try_move(c, card, idx, spin)
                                if not r: continue
                                new, n0 = r; key = (card, tuple(np.round(new.T[:3, 3], 0))); ok = s.side_ok(new, tgt, side, q)
                                pins = [p.T.copy() for p in s.g.asm.parts[n0:] if getattr(p, 'is_pin', False)]; T = new.T.copy(); s.g.undo(new, n0)
                                if key in seen or not ok: continue
                                seen.add(key); out.append({'conn': c, 'name': card, 'idx': idx, 'spin': spin, 'host': c[1], 'T': T, 'pins': pins, 'aimed': True})
                                if len(out) >= n: return out
        # the fallback: spots near the aim point. Each one is marked aimed ONLY if it really is on the side of the piece it named
        # (Ivy, Oct 23 2026: "It may as well be random where it aims. It picks as well as it can from the shitty choices." -- in the
        # goblin game it aimed UNDER the wing, whose underside was clicked onto the baseplate, and the four options labelled as its
        # aim were beside it, beside it, on top of it, and on top of a different piece)
        misses = []
        for card in cards[:3]:
            res = s.ghosts(card, q, n=6, floor=on_ground)     # its aim is the BUILD unless it says ground (Oct 23: aimed options lay on the floor)
            for g in (res.get('ghosts') if isinstance(res, dict) else res):
                key = (card, tuple(np.round(np.array(g['T'])[:3, 3], 0)))
                if key in seen: continue
                seen.add(key)
                with s.lock:
                    c = s.conn_from_id(g['conn']); r = s.try_move(c, card, g['idx'], g['spin'])
                    if not r: continue
                    new, n0 = r; ok = on_ground or s.side_ok(new, tgt, side, q)
                    pins = [p.T.copy() for p in s.g.asm.parts[n0:] if getattr(p, 'is_pin', False)]; T = new.T.copy(); s.g.undo(new, n0)
                m = {'conn': c, 'name': card, 'idx': g['idx'], 'spin': g['spin'], 'host': c[1], 'T': T, 'pins': pins, 'aimed': ok}
                (out if ok else misses).append(m)
                if len(out) >= n: return out
        if not out and tgt is not None and side:
            tn = f"#{s.bodies().index(tgt) + 1}" if tgt in s.bodies() else 'that piece'
            why = ''
            if side == 'bottom' and tgt.sockets and not any(c[1] is tgt and c[0] in ('socket', 'lift') for c in s.conns()):
                under = sorted({play.CX.NAMES.get(b.name, b.name) for b in s.bodies() if b is not tgt and b.mesh.bounds[1][2] <= tgt.mesh.bounds[0][2] + 2.0
                                and not ((b.mesh.bounds[0][:2] > tgt.mesh.bounds[1][:2]).any() or (tgt.mesh.bounds[0][:2] > b.mesh.bounds[1][:2]).any())})
                why = f" Its underside is already clicked onto the {', '.join(under) or 'piece below it'}; to build under it, lift it off first (break_off) and set it back down on the new piece."
            s.moves.append(f"turn {s.turn}: NOTHING you are holding fits on the {side} of {tn}.{why} The options shown are NOT where you aimed -- "
                           f"pick one only if you like it anyway, or ask for something else.")
        return out + misses[:max(0, n - len(out))]
    def leaks(s, text):
        """would this line give the pictionary secret away? any of its words (3+ letters), or their first 4 letters"""
        low = str(text or '').lower()
        for w in re.findall(r'[a-z]+', str(s.secret or '').lower()):
            if len(w) >= 3 and (w in low or w[:4] in low): return True
        return False
    def place_hints(s):
        """where each pinned emoji floats: just above the top centre of its piece, recomputed after a move (a lift shifts everything)"""
        hs = []
        for b, em in getattr(s, '_pin_hints', []):
            if b not in s.bodies(): continue
            lo, hi = b.mesh.bounds; hs.append({'emoji': em, 'p': [float((lo[0] + hi[0]) / 2), float((lo[1] + hi[1]) / 2), float(hi[2]) + 3.0]})
        s.state['piece_hints'] = hs; s.state['gesture_id'] = f"{s.turn}-{len(hs)}-{time.time():.0f}" if hs else None
    def emoji_hint(s, text):
        """emoji only (Ivy, Oct 23 2026: "It should only be allowed to send emojis as hints"): letters, digits and punctuation are
        stripped, and an emoji whose Unicode name gives the secret away (GIRAFFE FACE for giraffe, SUNFLOWER for flower) is dropped"""
        import unicodedata
        out, i, txt = [], 0, str(text or '')
        while i < len(txt):
            c = txt[i]; cat = unicodedata.category(c); o = ord(c)
            nxt = txt[i + 1:i + 3]
            if c in '0123456789#*' and '\u20e3' in nxt:            # keycaps: 1️⃣ 2️⃣ (which word, in charades)
                j = i + 1 + nxt.index('\u20e3') + 1; out.append(txt[i:j]); i = j; continue
            if cat == 'So' or 0x1F000 <= o <= 0x1FAFF or 0x2600 <= o <= 0x27BF or 0x2B00 <= o <= 0x2BFF or 0x2190 <= o <= 0x21FF:   # arrows too (↔ is 'Sm')
                j = i + 1                                  # keep its joiners, skin tones and variation selectors with it
                while j < len(txt) and (ord(txt[j]) in (0x200D, 0xFE0F, 0x20E3) or 0x1F3FB <= ord(txt[j]) <= 0x1F3FF
                                        or (ord(txt[j - 1]) == 0x200D and unicodedata.category(txt[j]) == 'So')):
                    j += 1
                g = txt[i:j]
                if not any(s.leaks(unicodedata.name(ch, '')) for ch in g if unicodedata.category(ch) == 'So'): out.append(g)
                i = j
            else: i += 1
        return ''.join(out[:6])
    def unaimed(s, e, ms):
        """propose_aimed finds the right KIND of piece anywhere; when it named a piece and a side, those spots are not its aim"""
        if str((e.get('aim') or {}).get('piece', '')).strip():
            for m in ms: m['aimed'] = False
        return ms
    def side_ok(s, new, tgt, side, q):
        """is a placed piece really on the side of the target it was aimed at? top/bottom: above its top / below its bottom (a stud's
        height of slack) and touching it; a side: its centre moved from the target's centre toward the aim point"""
        return bool(s._side_ok(new, tgt, side, q))           # a plain bool: numpy's broke json (see save)
    def _side_ok(s, new, tgt, side, q):
        if tgt is None or not side: return True
        nb, tb = new.mesh.bounds, tgt.mesh.bounds
        touch = not ((nb[0] > tb[1] + 0.6).any() or (tb[0] > nb[1] + 0.6).any())
        if not touch: return False
        if side == 'top': return nb[0][2] >= tb[1][2] - 2.0
        if side == 'bottom': return nb[1][2] <= tb[0][2] + 2.0
        d = np.asarray(q, float) - tb.mean(0); nc = nb.mean(0) - tb.mean(0); d[2] = nc[2] = 0.0
        return float(np.dot(d, nc)) > 0 and float(np.linalg.norm(nc)) > 1.0
    def propose_aimed(s, e, aimed=5, wild=3, tries=90):
        """most candidates from the wish (that kind of piece, on that colour), a few random so accidents still happen"""
        code = {v: k for k, v in CNAME.items()}.get(str(e.get('want_on', 'any')).lower())
        hand = getattr(s, 'hand', None)
        names = KINDS.get(str(e.get('want_piece', 'any')).lower())
        if hand:
            pp = str(e.get('play_piece', ''))
            names = [pp] if pp in hand else ([n for n in hand if names and n in names] or list(hand))
        out, seen = [], set()
        def add(m):
            key = (m['name'], tuple(np.round(m['T'][:3, 3], 0)), tuple(np.round(m['T'][:3, :3].ravel(), 0)))
            if key not in seen: seen.add(key); out.append(m)
        for _ in range(tries):
            if len(out) >= aimed: break
            conns = [c for c in s.conns() if code is None or c[1].colour == code]
            if not conns: break
            c = conns[s.rng.integers(len(conns))]; pool = names or play.PALETTE; name = pool[s.rng.integers(len(pool))]
            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; pins = [p.T.copy() for p in s.g.asm.parts[n0:] if getattr(p, 'is_pin', False)]
            add({'conn': c, 'name': name, 'idx': idx, 'spin': spin, 'host': c[1], 'T': new.T.copy(), 'pins': pins, 'aimed': True})
            s.g.undo(new, n0)
        for m in s.propose(k=wild + len(out), tries=60):
            if len(out) >= aimed + wild: break
            m['aimed'] = False; add(m)
        return out
    def describe(s, m, i):
        d = super().describe(m, i)
        bs = s.bodies(); lo = np.min([p.mesh.bounds[0] for p in bs], 0); hi = np.max([p.mesh.bounds[1] for p in bs], 0)
        new = G.fresh(m['name']); new.T = m['T']; c = new.mesh.vertices.mean(0)
        V = np.vstack([p.mesh.vertices for p in bs]); pr, pt = V @ RIGHT, V @ TOWARD
        def span(v, x): return round(float((x - (v.min() + v.max()) / 2) / max((v.max() - v.min()) / 2, 4)), 2)
        d['where'] = {'joins the': f"{CNAME.get(m['host'].colour, m['host'].colour)} {play.CX.NAMES.get(m['host'].name, m['host'].name)}",
                      'height in the build (0 = bottom, 1 = top, above 1 = higher than anything yet)': round(float((c[2] - lo[2]) / max(hi[2] - lo[2], 1)), 2),
                      'left or right in the picture (-1 = far left of the build, 0 = middle, 1 = far right, beyond 1 = further out than anything)':
                          span(pr, c @ RIGHT),
                      'front or back in the picture (-1 = back, away from you, 1 = front, toward you)':
                          span(pt, c @ TOWARD),
                      'new piece': play.CX.NAMES.get(m['name'], m['name'])}
        return d
    def choose(s, cands):
        e = s.notebook[-1]; desc = [s.describe(m, i) for i, m in enumerate(cands)]
        # the teacher's quick guess: drives the page's deliberation; decides alone only when its snap gate is earned
        g, M = None, None
        try:
            sys.path.insert(0, os.path.join(HERE, 'teacher'))
            with TEACH_LOCK: import teach
            g, M = teach.guess({'next': e.get('next'), 'might_be': e.get('might_be'), 'want_piece': e.get('want_piece'), 'want_on': e.get('want_on')},
                               [{'desc': d, 'name': m['name'], 'aimed': m.get('aimed', True)} for d, m in zip(desc, cands)])
            s.state['guess'] = [round(float(x), 3) for x in g]; s.save()
            if M.get('snap') and float(g.max()) >= 0.85:
                pick = int(g.argmax()); s._last_desc = desc
                return pick, {'agree': True, 'where': desc[pick]['where'], 'p': round(float(g.max()), 2), 'probs': [round(float(x), 3) for x in g],
                              'wheres': [d_['where'] for d_ in desc], 'snap': True}
        except Exception as ex: print('teacher guess failed:', str(ex)[:120], flush=True)
        q = (f"Someone is playing with Lego with no plan. Right now they think it might be becoming: {e['might_be']}. "
             f"What they want to add next: {e['next']}. They love {e['taste']}. Each option is one piece they could add, "
             f"with which coloured piece it joins, where it sits, and measurements. Which one would they add?")
        L = 'ABCDEFGHIJ'
        def ask(order):
            crit = {L[k]: desc[j] for k, j in enumerate(order)}
            crit['Z'] = "None of these: every option misses what they want, so they would rather draw new options."   # it may say they all suck
            r = jev.call(q, {'add': {'type': 'choice', 'instructions': q, 'criteria': crit}})
            p = r['answers']['add']['probabilities']; out = {j: p.get(L[k], 0) for k, j in enumerate(order)}; out['none'] = p.get('Z', 0); return out
        s._last_desc = desc                                # kept for the teacher's training data (cands.jsonl)
        o = list(range(len(cands)))
        with ThreadPoolExecutor(2) as ex: p1, p2 = ex.map(ask, (o, o[::-1]))          # both orders at once
        tot = {j: (p1[j] + p2[j]) / 2 for j in o}; pick = max(tot, key=tot.get)
        none = (p1.get('none', 0) + p2.get('none', 0)) / 2
        if none > tot[pick]: return -1, {'agree': False, 'where': {}, 'p': round(none, 2), 'probs': [round(tot[j], 3) for j in o], 'none': True}
        return pick, {'agree': max(p1, key=p1.get) == max(p2, key=p2.get), 'where': desc[pick]['where'], 'p': round(tot[pick], 2),
                      'probs': [round(tot[j], 3) for j in o], 'wheres': [d_['where'] for d_ in desc]}
    # ---- the live page reads runs/<run>/state.json ----
    def save(s):
        if getattr(s, 'stop', False): s.state['stage'] = 'stopped'          # once stopped, the page can only ever say so
        # numpy values (a numpy bool from side_ok) once crashed this save AND the error handler's save, stranding a game on 'wish'
        # that the stop button couldn't clear (Oct 23 2026, Ivy: "It's stuck and I can't stop it") -- convert, never crash
        tmp = f'{s.out}/state.json.tmp'; json.dump(s.state, open(tmp, 'w'), default=lambda o: o.item() if hasattr(o, 'item') else str(o))
        os.replace(tmp, f'{s.out}/state.json')
    def thumb(s, m, i):
        """the whole build with this candidate in MAGENTA (26: not on the colour menu -- white is, since Oct 24 2026) and its pins"""
        from build import Model
        M = Model('cand')
        for p in s.g.asm.parts: M.add(p.name, rot=p.T, colour=p.colour)
        M.add(m['name'], rot=m['T'], colour=26)
        for T in m['pins']: M.add('2780', rot=T, colour=0)
        stem = f"{s.out}/t{s.turn:02d}_c{i}"; M.write(stem + '.ldr')
        subprocess.run(['flatpak', 'run', '--filesystem=home', 'org.leocad.LeoCAD', '-l', '/home/ivy/lego/ldraw', '-i', stem + '.png', '-w', '360', '-h', '360',
                        '--viewpoint', 'home', stem + '.ldr'], capture_output=True)
        return os.path.basename(stem) + '.png'
    def play(s, turns):
        s.abandoned = None
        try: s._play(turns)
        except Exception as ex:                          # never leave the page sitting on 'considering'
            s.state.update(stage='stopped', error=str(ex)[:200], unwritten=True); s.save(); save_run(s.out, learn=False)
            if getattr(s, 'stop', False): return None
            raise
        if getattr(s, 'stop', False): s.state.update(stage='stopped', unwritten=True); s.save()
        save_run(s.out, learn=not getattr(s, 'stop', False)); return s.abandoned
    def _play(s, turns):
        log = open(f'{s.out}/log.jsonl', 'w'); nbf = open(f'{s.out}/notebook.jsonl', 'w'); s.turn = 0; s.frame()
        s.state = {'turn': 0, 'turns': turns, 'request': getattr(s, 'request', None), 'player': s.player if getattr(s, 'coop', False) else None, 'stage': 'looking', 'frame': 'frame_00.png', 'entry': None, 'candidates': [],
                   'pick': None, 'history': [], 'started': time.time(), 'pictionary': bool(getattr(s, 'pictionary', False)), 'guesses': [], 'd20': getattr(s, 'd20', None),
                   'restarted_from': (s.first_choice if (getattr(s, 'first_choice', None) or {}).get('restart') else None)}
        s.save()
        if not os.path.basename(s.out).startswith('_'): json.dump({'run': os.path.basename(s.out)}, open(f'{HERE}/runs/latest.json', 'w'))   # test runs (_name) stay off the page   # an unpinned page follows this
        for s.turn in range(0, turns + 1):
            if s.turn >= DONE_AFTER and DONE_Q not in s.questions: s.questions.append(DONE_Q)
            if getattr(s, 'stop', False): break                       # the page's stop button
            if getattr(s, 'solved', None): break                      # pictionary: they guessed it
            if getattr(s, 'coop', False) and s.turn > 0:
                if s.human_turn() in ('done', 'gone'): break
            t0 = time.time(); s.state['hand'] = list(s.hand); s.state.update(turn=s.turn, stage='looking', frame=(os.path.basename(s.look_png) if getattr(s, 'look_png', None) else f'frame_{s.turn:02d}.png'),   # keep HER move on screen
                            candidates=[], pick=None, entry=None); s.save()
            if len(s.hand) < 2: s.refill_handful()                       # a handful it chose: no forced swaps, refilled only when nearly empty
            if s.turn > 0 and s.turn % TOKEN_EVERY == 0: s.reef_tokens['tokens'] += 1
            s.state['reef_tokens'] = dict(s.reef_tokens); s._study = False
            s.look()                                                       # Opus starts looking ...
            nwild = 5 if s.turn < 5 else 3                                 # it asked: more surprises early, while changing course is cheap
            wild = s.propose(k=nwild, tries=60, pool=list(s.hand) + [s.draw() for _ in range(2)]) if s.turn < turns else []      # ... while the accidents get made and drawn (its handful + two it tends to like)
            for m in wild: m['aimed'] = False
            with ThreadPoolExecutor(3) as ex: wild_png = list(ex.map(lambda im: s.thumb(im[1], f'w{im[0]}'), enumerate(wild)))
            if wild:          # show these at once, so the page can cycle ghosts while it is still looking ("look busy")
                s.state.update(candidates=[{'png': wild_png[i], 'name': play.CX.NAMES.get(m['name'], m['name']), 'aimed': False, 'p': None,
                                            'card': m['name'], 'T': np.asarray(m['T']).tolist(), 'joins': ''} for i, m in enumerate(wild)]); s.save()
            if getattr(s, 'stop', False): break
            e = s.look_result(); e['asked'] = list(s.questions); s.notebook.append(e); nbf.write(json.dumps(e) + '\n'); nbf.flush()
            if isinstance(e.get('plan'), dict) and e['plan'].get('steps'): s.plan = e['plan']; s.state['plan'] = s.plan   # its living plan
            # COUNT, not tick (Ivy, Oct 23 2026: "It notices when something checks its box but doesn't check to make sure it isn't
            # checking that box too many times. The classic example being lady liberty having two torches")
            s.off_counts = []
            for cc in (e.get('count_check') or [])[:12]:
                try:
                    if isinstance(cc, dict) and int(cc.get('has')) != int(cc.get('should')): s.off_counts.append(cc)
                except (TypeError, ValueError): pass
            s.state['off_counts'] = s.off_counts
            if s.off_counts:
                s.moves.append(f"turn {s.turn}: YOUR COUNT IS OFF -- " + '; '.join(
                    f"{cc.get('feature')}: it should have {cc.get('should')}, your build has {cc.get('has')}" for cc in s.off_counts) +
                    ". Fix that first: take the extra off (or move it), or add the missing one.")
            s.state['wish'] = {'shape': e.get('wish_shape'), 'in': (WISH_EVERY - s.turn % WISH_EVERY) % WISH_EVERY}   # the page shows it (Ivy: "make the wish shape visible")
            prev = s.state['history'][-1]['might_be'] if s.state['history'] else None
            s.state['history'].append({'turn': s.turn, 'frame': f'frame_{s.turn:02d}.png', 'might_be': e.get('might_be', ''),
                                       'pivot': bool(e.get('pivot')) and prev is not None, 'was': prev, 'new_seeing': e.get('new_seeing'), 'kept': bool(e.get('keep', True))})
            if getattr(s, 'coop', False) and e.get('loved_her_move') and getattr(s, 'human_note', ''):
                s.tokens['tokens'] += 1; s.state['history'][-1]['did'] = [f"loved {s.player}'s move -- {s.player} earned a token"]
                s.state['free_left'] = dict(s.tokens)
            s.state.update(stage='wish', entry=e, highlight=None); s.save()
            import threading as _th; _th.Thread(target=s.highlight, args=(e, s.turn), daemon=True).start()
            print(f"\nturn {s.turn}: SEES {e['i_see']}\n   COULD BE {e.get('new_seeing')}  -- {e.get('weigh')}\n   GOING WITH {e['might_be']}{'   [PIVOT: ' + e.get('why_pivot', '') + ']' if e.get('pivot') else ''}"
                  f"\n   WANTS {e['next']}\n   LOVES {e['taste']}", flush=True)
            if s.turn == turns: break
            fe, dd = e.get('feelings') or {}, e.get('daydream') or {}
            s.state['history'][-1]['feel'] = fe; s.state.update(feelings=fe, daydream=dd); s.save()
            try: pull = float(dd.get('excitement', 0)) - float(fe.get('excitement', 5))
            except (TypeError, ValueError): pull = 0
            stuck = float(fe.get('frustration', 0) or 0) >= 7 or float(fe.get('confidence', 10) or 10) <= 2
            so = e.get('start_over') if isinstance(e.get('start_over'), dict) else {}
            try: better = float(so.get('better_by', 0) or 0)
            except (TypeError, ValueError): better = 0
            s.state['start_over'] = so
            if (not getattr(s, 'coop', False) and not getattr(s, 'pictionary', False) and s.turn >= 2 and so.get('do_it') is True and better >= 3
                    and so.get('first_piece') in FIRST_CHOICES and str(so.get('first_colour', '')).lower() in RECOLOURS):
                idea = e.get('might_be', ''); would = so.get('would', '')
                print(f"   STARTED OVER ({better:.0f}/10 better): {would} -- {so.get('why', '')}", flush=True)
                s.state['history'][-1]['did'] = [f"started over: {would}"]; s.state.update(stage='done', restarted=so); s.save()
                s.abandoned = {'piece': so['first_piece'], 'colour': so['first_colour'], 'why': f"starting {idea} over: {would}",
                               'idea': idea, 'would': would, 'restart': True}
                break
            if not getattr(s, 'coop', False) and not getattr(s, 'request', None) and not getattr(s, 'pictionary', False) and e.get('abandon') and (pull >= 2 or stuck) and dd.get('first_piece') in FIRST_CHOICES and str(dd.get('first_colour', '')).lower() in RECOLOURS:
                why = e.get('abandon_why', '')
                print(f"   ABANDONED for {dd.get('idea')}: {why}", flush=True)
                s.state['history'][-1]['did'] = [f"left it unfinished for {dd.get('idea')}: {why}"]; s.state.update(stage='done', abandoned=dd); s.save()
                s.abandoned = {'piece': dd['first_piece'], 'colour': dd['first_colour'], 'why': f"left the last build for this: {dd.get('idea')}", 'idea': dd.get('idea')}
                break
            if getattr(s, 'pictionary', False):
                s.state['to_guesser'] = s.emoji_hint(e.get('to_guesser'))
                for gt, v in (e.get('warmth') if isinstance(e.get('warmth'), dict) else {}).items():   # Pin's own reading wins
                    for g in s.guesses:
                        if g.get('text', '').strip().lower() == str(gt).strip().lower():
                            try: g['warm'] = max(0.0, min(1.0, float(v) / 10)); g['by'] = 'Pin'
                            except (TypeError, ValueError): pass
                s.state['guesses'] = list(s.guesses)
                # emoji pinned to pieces (Ivy, Oct 23 2026: "You should let it do different emojis at different pieces")
                # ...then animated and sequenced (Ivy: "It should point and then do a thumbs up. Point and then eyes. Etc. animated.
                # It chooses the sequence.")
                s._pin_hints = []
                for h in (e.get('gestures') or e.get('piece_hints') or [])[:12]:
                    if not isinstance(h, dict): continue
                    b = s.piece(h.get('piece')); em = s.emoji_hint(h.get('emoji'))[:2]
                    if b is not None and em: s._pin_hints.append((b, em))
                s._last_gestures = ' '.join(f"{h.get('piece')}{s.emoji_hint(h.get('emoji'))[:2]}" for h in (e.get('gestures') or [])[:12] if isinstance(h, dict))
                s.place_hints()
                if e.get('done') and not s.solved:
                    e['done'] = False; s.moves.append(f"turn {s.turn}: you wanted to stop, but {s.player} hasn't guessed it yet -- make it read more plainly as {s.secret}")
            if e.get('done') and getattr(s, 'off_counts', None):
                e['done'] = False; s.moves.append(f"turn {s.turn}: you tried to call it done, but your own count is off -- fix it first")
            if e.get('done') and getattr(s, 'held', None):
                e['done'] = False; s.moves.append(f"turn {s.turn}: you tried to call it done while still holding a section -- set it down first")
            if e.get('done') and not s.state.get('stability', {}).get('stable', True):     # can't finish a thing that falls over
                e['done'] = False; s.moves.append(f"turn {s.turn}: you tried to call it done, but it would fall over -- it can't be finished until it stands")
                s.state['history'][-1]['did'] = ["wanted to call it done, but it would fall over"]
            if getattr(s, 'coop', False) and e.get('done'):
                s.state.update(reef_thinks_done=e.get('done_why', '')); s.save(); e['done'] = False     # in co-op you both decide
            if e.get('done') and DONE_Q in s.questions:
                print(f"   DONE: {e.get('done_why', '')}", flush=True); s.state['history'][-1]['did'] = [f"decided it's done: {e.get('done_why', '')}"]
                s.state.update(done_why=e.get('done_why', '')); break
            mv = [m for m in (e.get('move') or []) if isinstance(m, dict) and m.get('piece') and isinstance(m.get('to'), dict)][:2]
            mv = [(s.piece(m['piece']), m['to'], s.aim_point(m['to'])) for m in mv]          # before anything renumbers the pieces
            bo = e.get('break_off') if isinstance(e.get('break_off'), dict) else None
            bo_p, bo_f = (s.piece(bo.get('piece')), s.piece(bo.get('from'))) if bo else (None, None)
            did = s.apply_moves(e)
            if s.turn > 0 and s.turn % WISH_EVERY == 0:              # the catalog delivers, whatever it has been wishing for
                wish = str(e.get('wish_shape') or (s.notebook[-2].get('wish_shape') if len(s.notebook) > 1 else '') or e.get('next') or '').strip()
                if wish:
                    have, sc = s.dig(wish, exclude=set(s.hand))           # the bin first: free, and no new part to render
                    got = have if have and sc >= 0.6 else None
                    if not got:
                        with s.lock: got = s.catalog_fetch(wish)
                    if got:
                        s.state['wish_granted'] = {'id': time.time(), 'turn': s.turn, 'part': got, 'name': play.CX.NAMES.get(got, got), 'wish': wish}   # Ivy: "show when it gets its wish"
                        s.make_hot(got, wish)
                        s.hold(got, str(e.get('play_piece', ''))); did.append(f'the catalog delivered what it wished for ("{wish[:60]}"): the {play.CX.NAMES.get(got, got)}, now in its bin and hand')
                    else: did.append(f'wished for "{wish[:60]}" but nothing in the catalog could join')
            moved = False
            for p_, to_, q_ in mv:
                if p_ is None or p_ not in s.bodies(): continue
                with s.lock: did.append(s.move(p_, to_, q_)); moved = True
            if moved: stem = s.rerender('move'); s.state.update(frame=os.path.basename(stem) + '.png'); wild, wild_png = [], []
            ro = e.get('reorient')
            if ro:
                with s.lock: how = s.reorient(str(ro).lower())
                if how: did.append(f'turned the whole creation: {how}'); stem = s.rerender('turn'); s.state.update(frame=os.path.basename(stem) + '.png'); wild, wild_png = [], []
            sd = e.get('set_down')
            if getattr(s, 'held', None) and sd is not None:
                with s.lock: res = s.set_down((sd or {}).get('on') if isinstance(sd, dict) else None)
                did.append(res or "tried to set the section down, but there was nowhere it fits yet")
                if res: stem = s.rerender('down'); s.state.update(frame=os.path.basename(stem) + '.png'); wild, wild_png = [], []
            elif bo and not getattr(s, 'held', None) and str(bo.get('then')) in ('hold', 'keep_it', 'throw_it_away'):
                try:
                    with s.lock: res = s.break_off(bo_p, bo_f, str(bo['then']))
                except ValueError: res = "couldn't break it there: one of those pieces was already gone"
                if res:
                    did.append(res); stem = s.rerender('split'); s.state.update(frame=os.path.basename(stem) + '.png'); wild, wild_png = [], []
            gb = e.get('go_back')
            if isinstance(gb, dict) and gb.get('to_turn') is not None:
                try:
                    with s.lock: took = s.go_back(int(gb['to_turn']))
                    if took:
                        did.append(f"went back to turn {int(gb['to_turn'])}: took off " + ', '.join(took))
                        stem = s.rerender('back'); s.state.update(frame=os.path.basename(stem) + '.png'); wild, wild_png = [], []
                except (TypeError, ValueError): pass
            if e.get('add_baseplate'):
                with s.lock: res = s.add_baseplate()
                did.append('slid a baseplate under the whole build' if res == 'added' else f"wanted a baseplate, but {res}")
                if res == 'added': stem = s.rerender('base'); s.state.update(frame=os.path.basename(stem) + '.png'); wild, wild_png = [], []
            bd = e.get('bend')
            if isinstance(bd, dict) and bd.get('piece'):
                p_ = s.piece(bd['piece'])
                try:
                    h_ = s.hinge_for(p_) if p_ is not None else None
                    now = getattr(s, 'hinge_angle', {}).get(id(h_['pin']), 0.0) if h_ else 0.0
                    deg = (float(bd['to']) - now) if bd.get('to') is not None else float(bd.get('degrees', 30))
                    deg = (deg + 180.0) % 360.0 - 180.0                                  # the short way round
                except (TypeError, ValueError, KeyError): deg = 0.0
                note = s.do_bend(p_, deg, 'it') if (p_ is not None and abs(deg) >= 1) else None
                if note:
                    did.append(note); stem = s.rerender('bend'); s.state.update(frame=os.path.basename(stem) + '.png')
                    wild, wild_png = [], []                                              # the build moved: the old surprises are stale
            sw = str(e.get('swap') or '')
            if sw in s.hand and sw not in getattr(s, 'reserved', set()):
                s.hand.remove(sw); did.append(f'put the {play.CX.NAMES.get(sw, sw)} back in the bin')
                wild = [m for m in wild if m['name'] in s.hand]
            if did:
                s.moves += [f'turn {s.turn}: {d}' for d in did]; print('   MOVES ' + '; '.join(did), flush=True)
                if any(d.startswith(('took off', 'recoloured')) for d in did):
                    stem = f'{s.out}/frame_{s.turn:02d}b'; s.g.asm.write_ldr(stem + '.ldr')     # keep the frame it looked at
                    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')
                    except Exception: pass
                    s.state.update(frame=f'frame_{s.turn:02d}b.png')
                    alive = [id(b) for b in s.bodies()]
                    wild = [m for m in wild if id(m['host']) in alive or getattr(m['host'], 'is_floor', False)]   # its host may have come off
                    with ThreadPoolExecutor(3) as ex: wild_png = list(ex.map(lambda im: s.thumb(im[1], f'w{im[0]}'), enumerate(wild)))
            s.state['history'][-1]['did'] = did; s.state.update(did=did, questions=list(s.questions)); s.save()
            if getattr(s, 'stop', False): break
            if getattr(s, '_study', False):                 # a token's worth of looking at one piece: ten ways, no surprises
                aimed = (s.aim_candidates(e, n=10) or []) + s.unaimed(e, s.propose_aimed(e, aimed=10, wild=0))
                seen_ = set(); aimed = [m for m in aimed if not ((k_ := (m['name'], tuple(np.round(m['T'][:3, 3], 0)), tuple(np.round(m['T'][:3, :3].ravel(), 0)))) in seen_ or seen_.add(k_))][:10]
                wild, wild_png = [], []
            else:
                sh = e.get('shop') if isinstance(e.get('shop'), dict) else None
                aimed = (s.shop(str(sh.get('for', '')), str(sh.get('on', '') or ''), n=4) if sh and str(sh.get('for', '')).strip() else []) or \
                        s.aim_candidates(e, n=3 if s.turn < 5 else 5) or s.unaimed(e, s.propose_aimed(e, aimed=3 if s.turn < 5 else 5, wild=0))
            with ThreadPoolExecutor(5) as ex: aimed_png = list(ex.map(lambda im: s.thumb(im[1], im[0]), enumerate(aimed)))
            cands, pngs = aimed + wild, aimed_png + wild_png
            if len(cands) < 2:                                # look harder before giving up: every connector, the floor too
                more = [m for m in s.propose(k=6, tries=200, floor=True) if not any(m['conn'] == c['conn'] and m['name'] == c['name'] for c in cands)]
                for m in more: m.setdefault('aimed', False)
                with ThreadPoolExecutor(5) as ex: more_png = list(ex.map(lambda im: s.thumb(im[1], f'x{im[0]}'), enumerate(more)))
                cands, pngs = cands + more, pngs + more_png
            joins = [c for c in cands if c['conn'][0] != 'floor']
            if not joins:                                     # all floor: maybe it aimed at the ground on purpose -- is there ANY join?
                jm = [m for m in s.propose(k=4, tries=200, floor=False) if not any(m['conn'] == c['conn'] and m['name'] == c['name'] for c in cands)]
                for m in jm: m.setdefault('aimed', False)
                with ThreadPoolExecutor(4) as ex: jm_png = list(ex.map(lambda im: s.thumb(im[1], f'j{im[0]}'), enumerate(jm)))
                cands, pngs, joins = cands + jm, pngs + jm_png, jm
            if len(cands) < 2 or not joins:
                # Only floor moves left = nothing can join the build: playing one just starts a separate pile beside it (Ivy, Oct 23
                # 2026, after the widened search put a plate down next to a segmented leg that could join nothing: "It played a
                # loose bone"). That is stuck, not a move.
                # Stuck is not the end: starting over is free (Ivy, Oct 23 2026: "It ends early without starting over when it would tip
                # over" -- a first piece with almost nowhere to join, like a segmented leg standing on its hoof, ended the game on
                # 'too few moves'). Solo: start over on its own do-over plan, never on the same dead-end piece.
                print('too few moves', flush=True)
                if getattr(s, 'pictionary', False):
                    # pictionary never starts over: the board IS what they've been guessing at (Ivy, Oct 23 2026: "It Shouldn't be
                    # allowed to give up on pictionary" -- a stuck turn wiped a bottle screw she had guessed 'drill', 'screwdriver' at)
                    s.moves.append(f"turn {s.turn}: NOTHING you could add fits anywhere on the build right now. You can't start over in "
                                   f"pictionary -- {s.player} is guessing at THIS build. Next turn, open up room: move a piece, take one off, "
                                   f"or break off a section and set it down somewhere else, then keep going.")
                    s.state['history'][-1]['did'] = ['got stuck: nothing fits -- making room next turn']; s.save()
                    continue
                if not getattr(s, 'coop', False):
                    so = e.get('start_over') if isinstance(e.get('start_over'), dict) else {}
                    dead = s.bodies()[0].name if len(s.bodies()) == 1 else None
                    piece = so.get('first_piece') if so.get('first_piece') in FIRST_CHOICES and so.get('first_piece') != dead else '3003'
                    colour = str(so.get('first_colour', '')).lower() if str(so.get('first_colour', '')).lower() in RECOLOURS else 'red'
                    idea = e.get('might_be', ''); stuckon = ', '.join(sorted({play.CX.NAMES.get(b.name, b.name) for b in s.bodies()}))[:80]
                    would = so.get('would') or f"start from a {play.CX.NAMES.get(piece, piece)} instead"
                    print(f"   STUCK, STARTING OVER: {would}", flush=True)
                    s.state['history'][-1]['did'] = [f"got stuck (the {stuckon} left almost nowhere to join) and started over: {would}"]
                    s.state.update(stage='done', restarted={**so, 'stuck': True}); s.save()
                    s.abandoned = {'piece': piece, 'colour': colour, 'restart': True, 'idea': idea, 'would': would,
                                   'why': f"the engine found almost nowhere to join a piece onto the {stuckon}, so you started {idea} over: {would}"}
                break
            s.state.update(stage='considering', candidates=[{'png': pngs[i], 'name': play.CX.NAMES.get(m['name'], m['name']),
                           'joins': f"{CNAME.get(m['host'].colour, m['host'].colour)} {play.CX.NAMES.get(m['host'].name, m['host'].name)}",
                           'aimed': m.get('aimed', False), 'p': None, 'card': m['name'], 'T': np.asarray(m['T']).tolist()} for i, m in enumerate(cands)]); s.save()   # T: the page draws its options as ghosts
            try: pick, info = s.choose(cands)
            except Exception as ex:                      # the judge is away: take the first piece it asked for, and say so
                print(f'   Jev unavailable ({str(ex)[:80]}); taking the first piece it asked for', flush=True)
                pick = next((k for k, m in enumerate(cands) if m.get('aimed')), 0)
                info = {'agree': False, 'where': s.describe(cands[pick], pick)['where'], 'p': None, 'probs': [None] * len(cands), 'jev_down': True}
                s.moves.append(f'turn {s.turn}: the judge was away, so it took the first piece it asked for')
            if getattr(s, 'stop', False): break
            rerolls = 0
            while pick == -1 and rerolls < 2:                       # 'none of these': draw fresh options and look again
                rerolls += 1; s.moves.append(f'turn {s.turn}: none of its options were right, so it rerolled')
                s.state.update(highlight='None of these feel right. Drawing new options...'); s.save()
                fresh = s.propose(k=4, tries=80, pool=list(s.hand) + [s.draw() for _ in range(3)]) + s.propose_aimed(e, aimed=4, wild=0)
                for m in fresh: m.setdefault('aimed', False)      # random surprises are not what it asked for
                if len(fresh) < 2: break
                with ThreadPoolExecutor(4) as ex: fpng = list(ex.map(lambda im: s.thumb(im[1], f'r{rerolls}_{im[0]}'), enumerate(fresh)))
                cands, pngs = fresh, fpng
                s.state.update(stage='considering', candidates=[{'png': pngs[i], 'name': play.CX.NAMES.get(m['name'], m['name']), 'joins': '',
                               'aimed': m.get('aimed', False), 'p': None, 'card': m['name'], 'T': np.asarray(m['T']).tolist()} for i, m in enumerate(cands)]); s.save()
                try: pick, info = s.choose(cands)
                except Exception: pick, info = 0, {'agree': False, 'p': None, 'probs': [None] * len(cands)}
            forced = False
            if pick == -1:                                  # nothing it was offered was right, even after rerolling
                # Oct 23 2026: this used to take option 0 -- a random surprise labelled "what it asked for" -- for 16 of 19 turns in
                # play_1023_143615 while it asked for a tile the engine could never place, never telling it, and training the teacher
                # on those as its picks. Now: the option it rated closest, marked forced, and it is TOLD.
                pr = [p if isinstance(p, (int, float)) else -1 for p in (info.get('probs') or [])][:len(cands)]
                pick = int(np.argmax(pr)) if pr and max(pr) >= 0 else 0; forced = True
                info = {**info, 'probs': info.get('probs') or [None] * len(cands)}
                want = str(e.get('play_piece', ''))
                s.moves.append(f"turn {s.turn}: NONE of the options were what you asked for -- the engine could not put the "
                               f"{play.CX.NAMES.get(want, want) or 'piece you wanted'} where you aimed (try another spot, another piece, or move "
                               f"something). It placed the option closest to what you wanted instead.")
            if 'where' not in info or not info['where']: info['where'] = s.describe(cands[pick], pick)['where']
            info['probs'] = (list(info.get('probs') or []) + [None] * len(cands))[:len(cands)]
            for i, c_ in enumerate(s.state['candidates']): c_['p'] = info['probs'][i]
            s.state.update(stage='choosing', pick=pick, pick_colour=str(e.get('piece_colour', '')).lower()); s.save(); time.sleep(1.2)
            if getattr(s, 'stop', False): break
            s.last_was_surprise = forced or not cands[pick].get('aimed', True)
            # the teacher's training data (Ivy, Oct 23 2026: "a neural net that can be the teacher ... what kind of pieces it wants"):
            # the whole situation and every candidate, so it can later learn picked / kept-to-the-end / taken-off
            with open(f'{s.out}/cands.jsonl', 'a') as cf:
                cf.write(json.dumps({'turn': s.turn, 'forced': forced, 'might_be': e.get('might_be'), 'next': e.get('next'), 'want_piece': e.get('want_piece'),
                    'want_on': e.get('want_on'), 'taste': e.get('taste'), 'pick': pick,
                    'candidates': [{'name': m['name'], 'aimed': m.get('aimed', True), 'p': info['probs'][k], 'T': np.round(m['T'], 3).tolist(),
                                    'desc': (getattr(s, '_last_desc', None) or [None] * len(cands))[k]} for k, m in enumerate(cands)]}, default=str) + '\n')
            ok = s.commit(cands[pick])
            if getattr(s, 'pictionary', False): s.place_hints()
            if ok and str(e.get('piece_colour', '')).lower() in RECOLOURS: s.bodies()[-1].colour = RECOLOURS[str(e['piece_colour']).lower()]
            if ok: s.__dict__.setdefault('added', []).append((s.turn, s.bodies()[-1]))      # for going back to where it felt good
            if ok and cands[pick]['name'] in s.hand: s.hand.remove(cands[pick]['name']); s.reserved = getattr(s, 'reserved', set()) - {cands[pick]['name']}
            if ok: s.touch_hot(cands[pick]['name'])
            s.turn += 1; s.frame(); s.turn -= 1
            import threading as _th2; _th2.Thread(target=s.judge, args=(f'{s.out}/frame_{s.turn + 1:02d}.png', e, s.turn), daemon=True).start()
            s.state.update(stage='placed', frame=f'frame_{s.turn + 1:02d}.png', hand=list(s.hand), played=cands[pick]['name']); s.save()
            log.write(json.dumps({'turn': s.turn, 'picked': cands[pick]['name'], 'committed': ok, 'secs': round(time.time() - t0, 1),
                                  **{k: v for k, v in info.items() if k != 'wheres'}}) + '\n'); log.flush()
            print(f"   -> added {info['where']['new piece']} joining the {info['where']['joins the']} (Jev {info.get('p')}, "
                  f"{'same both orders' if info['agree'] else 'orders disagreed'})  [{time.time() - t0:.0f}s]", flush=True)
        if getattr(s, 'held', None):
            with s.lock: res = s.set_down()
            if not res:
                s.state['history'] and s.state['history'][-1].setdefault('did', []).append('the game ended with a section still held up; it was left off')
                s.held = None
            s.frame()
        s.g.asm.write_ldr(f'{s.out}/build.ldr'); refl = None
        if len(s.notebook) >= 2 and not getattr(s, 'stop', False) and not getattr(s, 'abandoned', None):    # co-op AND a finished solo build
            s.state.update(stage='reflecting'); s.save(); refl = s.reflect()
            if refl: s.state.update(reflection=refl)
        if getattr(s, 'stop', False):
            # a stopped game writes nothing down -- no memory, no lessons, no retrain; it can be written later with
            # remember_later(run) (Ivy, Oct 23 2026: "It shouldn't write anything down for stopped ones. It can write it later.")
            s.state.update(stage='stopped', unwritten=True); s.save(); return
        s.state.update(stage='done'); s.save()
        remember(os.path.basename(s.out), s.notebook, s.questions, [json.loads(l)['picked'] for l in open(f'{s.out}/log.jsonl')],
                 player=s.player if getattr(s, 'coop', False) else None, reflection=refl)
        if len(s.notebook) >= 5 and not getattr(s, 'stop', False):     # not after a stopped or tiny game
            # in the background: it's a full Opus call (1-3 minutes) and the next build -- a start-over -- used to wait for it
            # (Oct 23 2026: restarts took 2 s after short builds and up to 200 s after 5+ turns)
            nb_ = list(s.notebook)
            def _rw():
                with IT_LOCK: print('IT.md rewritten by it' if rewrite_it(load_self(), nb_) else 'IT.md left as it was', flush=True)
            _threading.Thread(target=_rw, daemon=True).start()
if __name__ == '__main__':
    ap = argparse.ArgumentParser(); ap.add_argument('--turns', type=int, default=12); ap.add_argument('--seed', type=int, default=2); ap.add_argument('--name', default='')
    ap.add_argument('--remember', default='', help='write a stopped game down after all: --remember play_1023_175153_1')
    ap.add_argument('--start', default='', help="begin from a whole saved build (the crew's erased.ldr) instead of a first piece")
    ap.add_argument('--request', default='', help='what it is building, and nothing else')
    a = ap.parse_args()
    if a.start:
        r = Imagine(a.seed, f'{HERE}/runs/{a.name or "start"}', request=a.request or None, start=a.start); r.play(a.turns); sys.exit()
    if a.remember: remember_later(a.remember); print('written down:', a.remember); sys.exit()
    name = a.name or f'imagine_s{a.seed}'; first, budget, k = None, a.turns, 0
    while budget > 0:                                    # if it abandons a build for its daydream, the daydream starts right away
        r = Imagine(a.seed + k, f'{HERE}/runs/{name}' + (f'_{k}' if k else ''), first=first)
        first = r.play(budget); budget -= len(r.notebook); k += 1
        if not first: break
