# Wes World -- build a little guy on your Tufty 2350, send him through the door, and he
# hangs out at https://wes.imadeyousomethingwithai.com with everyone else's little guy.
#
# Buttons
#   BUILD   UP/DOWN pick a row, A/C change it (hold to zip), B next
#   NAME    A/C reroll the name, UP/DOWN pick a personality, B send him in
#   WORLD   A say something, C change his mood, B bring him home, HOME back to the menu
#
# Host protocol over USB serial (newline-terminated), for tools/bridge.py and friends:
#   host  -> badge : HELLO | SAY <text> | NAME <text> | MOOD <mood> | SAYINGS <json list>
#                    ENTER | LEAVE | SHUFFLE | STAT | WW RES <n> <json>
#   badge -> host  : WW READY v<ver> <device> | WW REQ <n> <action> <json> | WW IDENT <id> <token>
#                    WW CHAT <json> | WW STAT <json> | WW BYE
# When Wi-Fi is down and a host has said HELLO, API calls travel over serial instead
# (WW REQ ... answered by WW RES ...), so a badge on USB alone can still join the room.

VERSION = "1.1.0"
APP_DIR = "/system/apps/wesworld"
SITE = "wes.imadeyousomethingwithai.com"
BASE_URL = "https://" + SITE + "/"
# Development: point the badge at a local server by writing its URL into this file,
# e.g. http://192.168.0.190:8080/  (tools/harness.py base <url> does it for you)
try:
    with open("/state/wesworld_base.txt") as _f:
        _b = _f.read().strip()
    if _b.startswith("http"):
        BASE_URL = _b if _b.endswith("/") else _b + "/"
        SITE = BASE_URL.split("//", 1)[1].rstrip("/")
except OSError:
    pass
STATE_FILE = "/state/wesworld.json"
BEAT_EVERY_MS = 15000
HTTP_TIMEOUT = 12

import sys
import gc

# A previous run of this app (relaunched over the REPL) may still be registered: evict it so
# its caches are freed before we build our own.
for _k in list(sys.modules):
    if _k.endswith("wesworld") and getattr(sys.modules[_k], "VERSION", None) is not None:
        sys.modules.pop(_k)
gc.collect()

import os
import math
import time
import json
import select
import random
import machine
import badgeware as bw
from picovector import io, color, shape, vec2

try:
    screen  # noqa: F821  -- a Badgeware builtin; globals() does not see it
except NameError:
    import __main__
    screen = __main__.screen
try:
    image  # noqa: F821
except NameError:
    import __main__
    image = __main__.image

bw.mode(bw.HIRES)
W, H = screen.width, screen.height
screen.antialias = screen.X2

DEVICE = "".join("%02x" % b for b in machine.unique_id())
random.seed(time.ticks_us() ^ int.from_bytes(machine.unique_id()[:4], "little"))

# ---------------------------------------------------------------- palette & options
# Every option list is mirrored in the web canvas renderer (site/wes/index.php): keep them
# in the same order, the look is sent as indexes into these lists.
SKINS = [(255, 224, 196), (240, 200, 165), (224, 172, 130), (198, 134, 92), (160, 100, 62),
         (120, 72, 42), (84, 50, 30), (150, 215, 140), (120, 180, 255), (200, 150, 230)]
SKIN_NAMES = ["porcelain", "fair", "tan", "olive", "bronze", "brown", "deep", "alien", "ice", "lilac"]
HAIRS = ["none", "short", "spiky", "long", "bun", "afro", "mohawk", "curly", "bob", "ponytail"]
HAIRCS = [(30, 26, 28), (72, 46, 30), (130, 86, 50), (232, 196, 110), (200, 80, 40),
          (235, 235, 240), (255, 120, 190), (70, 130, 255), (60, 200, 120), (160, 80, 220)]
HAIRC_NAMES = ["black", "dark brown", "brown", "blonde", "ginger", "white", "pink", "blue", "green", "purple"]
EYES = ["dots", "big", "happy", "sleepy", "glasses", "shades"]
MOUTHS = ["smile", "grin", "flat", "whoa", "stache"]
TOPS = ["tee", "hoodie", "shirt+tie", "tank", "jacket", "dress"]
CLOTHC = [(225, 60, 60), (245, 140, 40), (245, 210, 60), (70, 180, 90), (50, 190, 190),
          (60, 120, 230), (90, 70, 200), (160, 70, 200), (245, 120, 180), (240, 240, 240),
          (130, 130, 140), (40, 40, 48)]
CLOTHC_NAMES = ["red", "orange", "yellow", "green", "teal", "blue", "indigo", "purple", "pink", "white", "grey", "black"]
BOTS = ["jeans", "shorts", "skirt", "joggers"]
SHOEC = [(40, 36, 40), (245, 245, 245), (220, 50, 50), (50, 90, 220), (120, 80, 40),
         (60, 170, 90), (250, 120, 180), (250, 210, 60)]
SHOEC_NAMES = ["black", "white", "red", "blue", "brown", "green", "pink", "yellow"]
HATS = ["none", "cap", "beanie", "crown", "top hat", "headphones", "halo", "propeller"]
ACCS = ["none", "cape", "backpack", "scarf", "wings", "balloon"]
SIZES = ["smol", "regular", "tall"]

# (label, key, option names, palette or None)
ROWS = [
    ("skin", "skin", SKIN_NAMES, SKINS),
    ("hair", "hair", HAIRS, None),
    ("hair colour", "hairc", HAIRC_NAMES, HAIRCS),
    ("eyes", "eyes", EYES, None),
    ("mouth", "mouth", MOUTHS, None),
    ("top", "top", TOPS, None),
    ("top colour", "topc", CLOTHC_NAMES, CLOTHC),
    ("bottoms", "bot", BOTS, None),
    ("bottom colour", "botc", CLOTHC_NAMES, CLOTHC),
    ("shoes", "shoes", SHOEC_NAMES, SHOEC),
    ("hat", "hat", HATS, None),
    ("extra", "acc", ACCS, None),
    ("size", "size", SIZES, None),
]
ROW_SHUFFLE = len(ROWS)          # the last row is the dice

def random_look():
    return {
        "skin": random.randrange(len(SKINS)), "hair": random.randrange(len(HAIRS)),
        "hairc": random.randrange(len(HAIRCS)), "eyes": random.randrange(len(EYES)),
        "mouth": random.randrange(len(MOUTHS)), "top": random.randrange(len(TOPS)),
        "topc": random.randrange(len(CLOTHC)), "bot": random.randrange(len(BOTS)),
        "botc": random.randrange(len(CLOTHC)), "shoes": random.randrange(len(SHOEC)),
        "hat": random.randrange(len(HATS)) if random.random() < 0.5 else 0,
        "acc": random.randrange(len(ACCS)) if random.random() < 0.5 else 0,
        "size": random.randrange(len(SIZES)),
    }

# Names are built from syllables, so there are millions and no two badges clash. Each word
# is 3-7 letters: "Brelo Snix", "Quae Toldar", "Pim Flounk".
ONSETS = ["b", "br", "c", "ch", "d", "dr", "f", "fl", "g", "gr", "h", "j", "k", "kl", "l", "m", "n", "p", "pr",
          "qu", "r", "s", "sk", "sl", "sn", "sp", "st", "t", "tr", "v", "w", "z", "th", "wh", "y"]
VOWELS = ["a", "e", "i", "o", "u", "ai", "ee", "oo", "ou", "ia", "ey", "io"]
CODAS = ["", "", "", "", "n", "r", "s", "l", "m", "t", "x", "sh", "nk", "ld", "rt", "ck", "mp", "zz"]

SOFT = ["b", "d", "f", "g", "k", "l", "m", "n", "p", "r", "s", "t", "v", "z"]

def _word():
    while True:                        # cluster only at the start, coda only at the end
        w_ = ""
        for i in range(random.choice((1, 2, 2, 3))):
            w_ += random.choice(ONSETS if i == 0 else SOFT) + random.choice(VOWELS)
        w_ += random.choice(CODAS)
        if 3 <= len(w_) <= 7:
            return w_[0].upper() + w_[1:]

def random_name():
    return _word() + " " + _word()

# Personality packs: the default things he says in the room. Replace them any time with
# tools/talk.py (or the SAYINGS serial command, or the ?a=set API).
PACKS = [
    ("chill", ["just vibing", "nice floor in here", "anyone got snacks?", "no thoughts, head empty",
               "this is the good room", "hey. hi. hello.", "5 more minutes", "love this for us"]),
    ("hype", ["LET'S GOOO", "who's ready?!", "best room on the internet", "I LIVE HERE NOW",
              "high five! anyone!", "wooooo", "did you see that?!", "can't stop won't stop"]),
    ("nerd", ["it's a feature", "have you tried turning it off and on?", "const me = awesome",
              "I run on 8 MB of PSRAM", "tabs, obviously", "ship it", "works on my badge", "git push --force"]),
    ("poet", ["the door opened. I walked.", "we are all pixels in the end", "a room, a floor, a friend",
              "softly the canvas hums", "I came from a tiny screen", "what is a wall but a pause?",
              "stay a while", "moonlight on the hardwood"]),
    ("grumpy", ["who put a door there", "I was napping", "fine. I'm here.", "this floor is too shiny",
                "don't talk to me before coffee", "ugh, people", "I'm not lost, you're lost", "whatever"]),
    ("chaos", ["I licked the wall", "where are my pants", "BEES", "I am the floor now",
               "plot twist: I'm a door", "run", "the balloon knows", "unplug everything"]),
]
MOODS = ["auto", "wander", "dance", "chill", "sleep", "zoom"]

def C(rgb, a=255):
    return color.rgb(rgb[0], rgb[1], rgb[2], a)

def shade(rgb, k):
    return (max(0, min(255, int(rgb[0] * k))), max(0, min(255, int(rgb[1] * k))), max(0, min(255, int(rgb[2] * k))))

BG = color.rgb(14, 14, 16)
PANEL = color.rgb(30, 30, 34)
PANEL_HI = color.rgb(62, 58, 44)
INK = color.rgb(245, 240, 225)
DIM = color.rgb(150, 150, 150)
ACCENT = color.rgb(255, 210, 60)          # yellow
ACCENT2 = color.rgb(255, 150, 50)         # orange
FLOOR = color.rgb(90, 60, 40)
FLOOR_LINE = color.rgb(70, 46, 30)
SPOT = color.rgb(0, 0, 0, 70)
WHITE = color.rgb(255, 255, 255)
BLACK = color.rgb(20, 18, 24)

FONT_BIG = bw.rom_font.nope          # titles
FONT = bw.rom_font.absolute          # body text
FONT_SMALL = bw.rom_font.winds       # footers

# ---------------------------------------------------------------- drawing helpers
def circ(dst, x, y, r):
    # the direct raster circle is ~8x faster than shape.circle; good enough for a cartoon
    dst.circle(int(x + 0.5), int(y + 0.5), max(1, int(r + 0.5)))

def box(dst, x, y, w, h):
    dst.rectangle(int(x), int(y), max(1, int(w + 0.5)), max(1, int(h + 0.5)))

def rbox(dst, x, y, w, h, r):
    dst.shape(shape.rounded_rectangle(x, y, w, h, r))

def poly(dst, pts):
    dst.shape(shape.custom([vec2(p[0], p[1]) for p in pts]))

def seg(dst, x1, y1, x2, y2, t):
    dst.shape(shape.line(x1, y1, x2, y2, t))

def limb(dst, x1, y1, x2, y2, t):
    seg(dst, x1, y1, x2, y2, t)
    circ(dst, x1, y1, t / 2)
    circ(dst, x2, y2, t / 2)

def text(dst, s, x, y, pen=None, font=None):
    if font:
        dst.font = font
    if pen:
        dst.pen = pen
    dst.text(s, int(x), int(y))

def measure(dst, s, font=None):
    if font:
        dst.font = font
    return dst.measure_text(s)

def center_text(dst, s, cx, y, pen=None, font=None):
    w, _ = measure(dst, s, font)
    text(dst, s, cx - w / 2, y, pen)

def wrap(dst, s, max_w, font=None):
    if font:
        dst.font = font
    words, lines, cur = s.split(), [], ""
    for w_ in words:
        t = (cur + " " + w_).strip()
        if cur and dst.measure_text(t)[0] > max_w:
            lines.append(cur)
            cur = w_
        else:
            cur = t
    if cur:
        lines.append(cur)
    return lines

# ---------------------------------------------------------------- the little guy
def draw_guy(dst, x, gy, R, look, phase=0.0, d=1, walking=True, mood="wander", t=0.0):
    """A chibi character. x: centre, gy: ground line, R: head radius, d: 1 faces right,
    -1 faces left, phase: 0..1 through the walk cycle. Mirrors the JS renderer on the site."""
    skin = SKINS[look["skin"]]
    hairc = HAIRCS[look["hairc"]]
    topc = CLOTHC[look["topc"]]
    botc = CLOTHC[look["botc"]]
    shoec = SHOEC[look["shoes"]]
    top, bot, hair, hat, acc = look["top"], look["bot"], look["hair"], look["hat"], look["acc"]
    sz = (0.85, 1.0, 1.18)[look["size"]]

    legL = R * 0.95 * sz
    bodyH = R * 1.35 * sz
    bodyW = R * 1.5
    sw = math.sin(2 * math.pi * phase) if walking else 0.0
    bob = -abs(math.sin(2 * math.pi * phase)) * R * 0.08 if walking else 0.0
    if mood == "dance":
        bob += -abs(math.sin(t * 9)) * R * 0.25
        sw = math.sin(t * 9) * 0.8
    if mood == "sleep":
        bob += R * 0.05
    sitting = mood == "sit"
    if sitting:
        sw, bob = 0.0, 0.0
    hipY = gy - legL * (0.55 if sitting else 1.0) + bob
    shoulderY = hipY - bodyH * 0.9
    headY = hipY - bodyH - R * 0.92
    headX = x + d * R * 0.05
    leg_t = R * 0.42
    arm_t = R * 0.3

    # pool of light on the floor
    dst.pen = SPOT
    dst.shape(shape.custom([vec2(x + math.cos(a) * R * 1.1, gy + 1 + math.sin(a) * R * 0.22)
                            for a in [i * math.pi / 7 for i in range(14)]]))

    # extras behind the body
    if acc == 1:    # cape
        dst.pen = C(shade(topc, 0.55) if look["topc"] != 0 else (120, 20, 30))
        flap = math.sin(t * 6) * R * 0.15 if walking else 0
        poly(dst, [(x - d * bodyW * 0.45, shoulderY), (x + d * bodyW * 0.45, shoulderY),
                   (x - d * (bodyW * 0.7 + flap), gy - legL * 0.2), (x - d * (bodyW * 1.1 + flap), gy - legL * 0.5)])
    elif acc == 2:  # backpack
        dst.pen = C(shade(botc, 0.8))
        rbox(dst, x - d * bodyW * 0.95 - (bodyW * 0.3 if d < 0 else 0) - (0 if d > 0 else 0),
             shoulderY + R * 0.1, bodyW * 0.5, bodyH * 0.7, R * 0.2)
    elif acc == 4:  # wings
        dst.pen = color.rgb(255, 255, 255, 230)
        beat = math.sin(t * 12) * R * 0.2
        for s_ in (-1, 1):
            poly(dst, [(x + s_ * bodyW * 0.3, shoulderY + R * 0.1),
                       (x + s_ * (bodyW * 1.3), shoulderY - R * 0.6 - beat),
                       (x + s_ * (bodyW * 1.45), shoulderY + R * 0.2 - beat),
                       (x + s_ * (bodyW * 0.9), shoulderY + R * 0.8)])

    # legs: far one first. Foot x swings with the walk, lifts on the forward swing.
    def leg(side, near):
        s_ = 1 if near else -1
        hx_ = x + s_ * R * 0.28 * (1 if d > 0 else -1) * -1
        hx_ = x - d * s_ * R * 0.28 if True else hx_
        swing = sw * s_
        fx = hx_ + d * swing * R * 0.5
        lift = max(0.0, swing) * R * 0.28 if walking else 0.0
        fy = gy + bob - lift
        if sitting:                     # thigh forward, shin down: legs over the seat edge
            kx_, ky_ = hx_ + d * R * 0.55, hipY + R * 0.08
            fx, fy = kx_ + d * R * 0.12, gy
            legc = skin if (top == 5 and bot != 1) or bot == 2 else botc
            if bot == 1:
                legc = skin
            dst.pen = C(legc if near else shade(legc, 0.75))
            limb(dst, hx_, hipY, kx_, ky_, leg_t * (0.8 if legc is skin else 1.0))
            limb(dst, kx_, ky_, fx, fy - R * 0.1, leg_t * (0.8 if legc is skin else 0.95))
            if bot == 1:
                dst.pen = C(botc if near else shade(botc, 0.7))
                limb(dst, hx_, hipY, (hx_ + kx_) / 2, (hipY + ky_) / 2, leg_t)
            dst.pen = C(shoec if near else shade(shoec, 0.7))
            rbox(dst, fx - R * 0.3 + d * R * 0.1, fy - R * 0.26, R * 0.62, R * 0.3, R * 0.12)
            return
        if top == 5 and bot != 1:       # under a dress: skin legs
            dst.pen = C(skin if near else shade(skin, 0.75))
            limb(dst, hx_, hipY, fx, fy - R * 0.1, leg_t * 0.8)
        elif bot == 0 or bot == 3:      # jeans / joggers
            dst.pen = C(botc if near else shade(botc, 0.7))
            limb(dst, hx_, hipY, fx, fy - R * 0.1, leg_t)
            if bot == 3:                # cuffs
                dst.pen = C(shade(botc, 0.5))
                circ(dst, fx, fy - R * 0.18, leg_t * 0.55)
        elif bot == 1:                  # shorts
            dst.pen = C(skin if near else shade(skin, 0.75))
            limb(dst, hx_, hipY, fx, fy - R * 0.1, leg_t * 0.85)
            dst.pen = C(botc if near else shade(botc, 0.7))
            mx, my = (hx_ + fx) / 2, (hipY + fy) / 2
            limb(dst, hx_, hipY, mx, my, leg_t)
        else:                           # skirt: skin legs, skirt drawn with the body
            dst.pen = C(skin if near else shade(skin, 0.75))
            limb(dst, hx_, hipY, fx, fy - R * 0.1, leg_t * 0.8)
        # shoe
        dst.pen = C(shoec if near else shade(shoec, 0.7))
        rbox(dst, fx - R * 0.33 + d * R * 0.08, fy - R * 0.26, R * 0.66, R * 0.3, R * 0.12)
        dst.pen = color.rgb(255, 255, 255, 90)
        box(dst, fx - R * 0.3 + d * R * 0.08, fy - R * 0.06, R * 0.6, R * 0.07)

    def arm(near):
        s_ = 1 if near else -1
        ax = x - d * s_ * bodyW * 0.42
        ay = shoulderY + R * 0.08
        swing = -sw * s_
        if mood == "dance":
            hx_, hy_ = ax + d * s_ * R * 0.5 * (-1 if near else 1) * 0.0 + d * R * 0.5, ay - R * 0.9 - abs(math.sin(t * 9 + (0 if near else 1.5))) * R * 0.3
            hx_ = ax - d * s_ * R * 0.55
        elif sitting:                   # hands on the lap
            hx_, hy_ = ax + d * R * 0.5, hipY - R * 0.05
        elif mood == "sleep":
            hx_, hy_ = ax - d * s_ * R * 0.1, ay + R * 0.95
        else:
            hx_ = ax + d * swing * R * 0.55
            hy_ = ay + R * 0.95 - abs(swing) * R * 0.1
        sleevec = topc
        if top == 3:                    # tank: bare arms
            sleevec = skin
        if top == 2:                    # shirt: white sleeves
            sleevec = (245, 245, 245)
        dst.pen = C(sleevec if near else shade(sleevec, 0.7))
        mx, my = (ax + hx_) / 2, (ay + hy_) / 2
        limb(dst, ax, ay, mx, my, arm_t)
        dst.pen = C(skin if near else shade(skin, 0.75))
        limb(dst, mx, my, hx_, hy_, arm_t * 0.85)
        circ(dst, hx_, hy_, arm_t * 0.62)

    leg(0, False)
    arm(False)

    # body
    bx, by = x - bodyW / 2, hipY - bodyH
    if top == 5:                        # dress: a trapezoid down to the knees
        dst.pen = C(topc)
        poly(dst, [(bx + R * 0.1, by), (bx + bodyW - R * 0.1, by),
                   (bx + bodyW + R * 0.35, gy - legL * 0.35), (bx - R * 0.35, gy - legL * 0.35)])
        dst.pen = C(shade(topc, 0.75))
        poly(dst, [(x + d * R * 0.1, by + R * 0.3), (x + d * R * 0.35, by + R * 0.3),
                   (x + d * R * 0.65, gy - legL * 0.35), (x + d * R * 0.2, gy - legL * 0.35)])
    else:
        if bot == 2:                    # skirt
            dst.pen = C(botc)
            poly(dst, [(bx + R * 0.05, hipY - R * 0.3), (bx + bodyW - R * 0.05, hipY - R * 0.3),
                       (bx + bodyW + R * 0.3, hipY + R * 0.45), (bx - R * 0.3, hipY + R * 0.45)])
        dst.pen = C(topc if top != 2 else (245, 245, 245))
        rbox(dst, bx, by, bodyW, bodyH + R * 0.12, R * 0.35)
        if top == 1:                    # hoodie: pocket, strings, hood behind the head
            dst.pen = C(shade(topc, 0.75))
            rbox(dst, x - bodyW * 0.3, hipY - bodyH * 0.35, bodyW * 0.6, bodyH * 0.3, R * 0.15)
            dst.pen = C(shade(topc, 0.8))
            circ(dst, headX - d * R * 0.1, headY + R * 0.75, R * 0.75)
            dst.pen = C((240, 240, 240))
            seg(dst, x - R * 0.15, by + R * 0.2, x - R * 0.2, by + R * 0.8, 2)
            seg(dst, x + R * 0.15, by + R * 0.2, x + R * 0.2, by + R * 0.8, 2)
        elif top == 2:                  # shirt + tie
            dst.pen = C(botc if look["botc"] != 9 else (60, 60, 70))
            poly(dst, [(x - R * 0.12, by + R * 0.05), (x + R * 0.12, by + R * 0.05),
                       (x + R * 0.16, by + bodyH * 0.65), (x, by + bodyH * 0.8), (x - R * 0.16, by + bodyH * 0.65)])
            dst.pen = C((200, 200, 210))
            seg(dst, x - R * 0.35, by, x, by + R * 0.3, 2)
            seg(dst, x + R * 0.35, by, x, by + R * 0.3, 2)
        elif top == 3:                  # tank: straps
            dst.pen = C(skin)
            box(dst, bx + R * 0.1, by, bodyW * 0.22, R * 0.3)
            box(dst, bx + bodyW - R * 0.1 - bodyW * 0.22, by, bodyW * 0.22, R * 0.3)
        elif top == 4:                  # jacket: lapels over a lighter inner shirt
            dst.pen = C(shade(topc, 1.6) if look["topc"] == 11 else (240, 240, 245))
            poly(dst, [(x - R * 0.3, by), (x + R * 0.3, by), (x + R * 0.18, hipY + R * 0.1), (x - R * 0.18, hipY + R * 0.1)])
            dst.pen = C(shade(topc, 0.7))
            poly(dst, [(x - R * 0.42, by), (x - R * 0.12, by), (x - R * 0.2, by + R * 0.75)])
            poly(dst, [(x + R * 0.42, by), (x + R * 0.12, by), (x + R * 0.2, by + R * 0.75)])
        elif top == 0:                  # tee: a little print
            dst.pen = color.rgb(255, 255, 255, 110)
            circ(dst, x + d * R * 0.05, by + bodyH * 0.45, R * 0.22)
        # highlight edge
        dst.pen = color.rgb(255, 255, 255, 45)
        rbox(dst, bx + d * bodyW * 0.55 if d > 0 else bx, by + R * 0.1, bodyW * 0.45, bodyH * 0.9, R * 0.3)

    if acc == 3:                        # scarf
        dst.pen = C(CLOTHC[(look["topc"] + 4) % len(CLOTHC)])
        rbox(dst, x - bodyW * 0.55, by - R * 0.18, bodyW * 1.1, R * 0.4, R * 0.15)
        rbox(dst, x - d * R * 0.6, by, R * 0.35, R * 0.9, R * 0.1)

    leg(1, True)
    arm(True)

    # head
    dst.pen = C(skin)
    circ(dst, headX, headY, R)
    dst.pen = C(shade(skin, 0.85))
    circ(dst, headX - d * R * 0.95, headY + R * 0.1, R * 0.22)       # ear
    dst.pen = C(skin)
    circ(dst, headX - d * R * 0.95, headY + R * 0.1, R * 0.12)
    # hair behind-face parts
    hair_pen = C(hairc)
    if hair == 3:                       # long
        dst.pen = hair_pen
        rbox(dst, headX - R * 1.05, headY - R * 0.4, R * 2.1, R * 2.0, R * 0.5)
        dst.pen = C(skin)
        circ(dst, headX, headY, R)
    elif hair == 9:                     # ponytail
        dst.pen = hair_pen
        sway = math.sin(t * 6) * R * 0.15 if walking else 0
        poly(dst, [(headX - d * R * 0.6, headY - R * 0.4), (headX - d * R * 1.2, headY + R * 0.2 + sway),
                   (headX - d * R * 1.45, headY + R * 1.1 + sway), (headX - d * R * 0.8, headY + R * 0.3)])
    # face
    ex = headX + d * R * 0.22
    ey = headY - R * 0.05
    eye_dx = R * 0.36
    eyes = look["eyes"]
    if mood == "sleep":
        eyes = 3
    if eyes == 0:                       # dots
        dst.pen = BLACK
        circ(dst, ex - eye_dx, ey, R * 0.1)
        circ(dst, ex + eye_dx, ey, R * 0.1)
    elif eyes == 1:                     # big
        dst.pen = WHITE
        circ(dst, ex - eye_dx, ey, R * 0.26)
        circ(dst, ex + eye_dx, ey, R * 0.26)
        dst.pen = BLACK
        circ(dst, ex - eye_dx + d * R * 0.08, ey + R * 0.03, R * 0.14)
        circ(dst, ex + eye_dx + d * R * 0.08, ey + R * 0.03, R * 0.14)
        dst.pen = WHITE
        circ(dst, ex - eye_dx + d * R * 0.13, ey - R * 0.04, R * 0.05)
        circ(dst, ex + eye_dx + d * R * 0.13, ey - R * 0.04, R * 0.05)
    elif eyes == 2:                     # happy arcs
        dst.pen = BLACK
        for cx_ in (ex - eye_dx, ex + eye_dx):
            poly(dst, [(cx_ - R * 0.2, ey + R * 0.06), (cx_, ey - R * 0.14), (cx_ + R * 0.2, ey + R * 0.06),
                       (cx_ + R * 0.14, ey + R * 0.1), (cx_, ey - R * 0.02), (cx_ - R * 0.14, ey + R * 0.1)])
    elif eyes == 3:                     # sleepy
        dst.pen = BLACK
        seg(dst, ex - eye_dx - R * 0.18, ey, ex - eye_dx + R * 0.18, ey + R * 0.02, 2)
        seg(dst, ex + eye_dx - R * 0.18, ey, ex + eye_dx + R * 0.18, ey + R * 0.02, 2)
    elif eyes == 4:                     # glasses
        dst.pen = BLACK
        circ(dst, ex - eye_dx, ey, R * 0.1)
        circ(dst, ex + eye_dx, ey, R * 0.1)
        dst.pen = color.rgb(40, 40, 60)
        dst.shape(shape.circle(vec2(ex - eye_dx, ey), R * 0.3).stroke(2))
        dst.shape(shape.circle(vec2(ex + eye_dx, ey), R * 0.3).stroke(2))
        seg(dst, ex - eye_dx + R * 0.3, ey, ex + eye_dx - R * 0.3, ey, 2)
    else:                               # shades
        dst.pen = BLACK
        rbox(dst, ex - eye_dx - R * 0.32, ey - R * 0.2, R * 0.6, R * 0.38, R * 0.1)
        rbox(dst, ex + eye_dx - R * 0.28, ey - R * 0.2, R * 0.6, R * 0.38, R * 0.1)
        seg(dst, ex - eye_dx + R * 0.2, ey - R * 0.05, ex + eye_dx - R * 0.2, ey - R * 0.05, 2)
        dst.pen = color.rgb(255, 255, 255, 90)
        box(dst, ex - eye_dx - R * 0.2, ey - R * 0.12, R * 0.15, R * 0.06)
    # mouth
    my_ = headY + R * 0.42
    mx_ = headX + d * R * 0.22
    mouth = look["mouth"]
    if mood == "sleep":
        dst.pen = BLACK
        circ(dst, mx_, my_, R * 0.08)
    elif mouth == 0:                    # smile
        dst.pen = BLACK
        poly(dst, [(mx_ - R * 0.25, my_ - R * 0.05), (mx_, my_ + R * 0.14), (mx_ + R * 0.25, my_ - R * 0.05),
                   (mx_ + R * 0.2, my_), (mx_, my_ + R * 0.06), (mx_ - R * 0.2, my_)])
    elif mouth == 1:                    # grin
        dst.pen = BLACK
        poly(dst, [(mx_ - R * 0.3, my_ - R * 0.08), (mx_ + R * 0.3, my_ - R * 0.08), (mx_, my_ + R * 0.25)])
        dst.pen = WHITE
        box(dst, mx_ - R * 0.2, my_ - R * 0.07, R * 0.4, R * 0.07)
    elif mouth == 2:                    # flat
        dst.pen = BLACK
        seg(dst, mx_ - R * 0.2, my_, mx_ + R * 0.2, my_, 2)
    elif mouth == 3:                    # whoa
        dst.pen = BLACK
        circ(dst, mx_, my_ + R * 0.03, R * 0.13)
    else:                               # moustache
        dst.pen = C(hairc)
        poly(dst, [(mx_ - R * 0.4, my_ - R * 0.02), (mx_, my_ - R * 0.14), (mx_ + R * 0.4, my_ - R * 0.02),
                   (mx_ + R * 0.3, my_ + R * 0.12), (mx_, my_ + R * 0.0), (mx_ - R * 0.3, my_ + R * 0.12)])
    # blush
    dst.pen = color.rgb(255, 140, 90, 70)
    circ(dst, ex - eye_dx - R * 0.1, ey + R * 0.42, R * 0.16)
    circ(dst, ex + eye_dx + R * 0.15, ey + R * 0.42, R * 0.16)

    # hair on top
    dst.pen = hair_pen
    if hair == 1:                       # short
        poly(dst, [(headX - R * 1.0, headY - R * 0.05), (headX - R * 0.9, headY - R * 0.7), (headX - R * 0.3, headY - R * 1.05),
                   (headX + R * 0.4, headY - R * 1.02), (headX + R * 0.95, headY - R * 0.55), (headX + R * 1.0, headY - R * 0.2),
                   (headX + R * 0.6, headY - R * 0.55), (headX, headY - R * 0.62), (headX - R * 0.6, headY - R * 0.5)])
    elif hair == 2:                     # spiky
        pts = [(headX - R * 1.0, headY - R * 0.1)]
        for i in range(6):
            a0 = math.pi + math.pi * i / 6
            a1 = math.pi + math.pi * (i + 0.5) / 6
            pts.append((headX + math.cos(a0) * R * 0.95, headY + math.sin(a0) * R * 0.95))
            pts.append((headX + math.cos(a1) * R * 1.45, headY + math.sin(a1) * R * 1.45))
        pts.append((headX + R * 1.0, headY - R * 0.1))
        pts.append((headX, headY - R * 0.55))
        poly(dst, pts)
    elif hair == 3:                     # long: fringe
        poly(dst, [(headX - R * 1.0, headY - R * 0.1), (headX - R * 0.7, headY - R * 0.95), (headX + R * 0.3, headY - R * 1.05),
                   (headX + R * 1.0, headY - R * 0.4), (headX + R * 0.55, headY - R * 0.6), (headX - R * 0.2, headY - R * 0.7)])
    elif hair == 4:                     # bun
        poly(dst, [(headX - R * 1.0, headY - R * 0.1), (headX - R * 0.75, headY - R * 0.8), (headX, headY - R * 1.03),
                   (headX + R * 0.75, headY - R * 0.8), (headX + R * 1.0, headY - R * 0.1), (headX + R * 0.5, headY - R * 0.55),
                   (headX - R * 0.5, headY - R * 0.55)])
        circ(dst, headX - d * R * 0.35, headY - R * 1.15, R * 0.42)
    elif hair == 5:                     # afro
        circ(dst, headX, headY - R * 0.35, R * 1.35)
        dst.pen = C(skin)
        circ(dst, headX, headY, R)
        dst.pen = hair_pen
        poly(dst, [(headX - R * 1.0, headY - R * 0.1), (headX - R * 0.6, headY - R * 0.7), (headX + R * 0.6, headY - R * 0.7),
                   (headX + R * 1.0, headY - R * 0.1), (headX + R * 0.6, headY - R * 0.55), (headX - R * 0.6, headY - R * 0.55)])
    elif hair == 6:                     # mohawk
        poly(dst, [(headX - R * 0.3, headY - R * 0.9), (headX - R * 0.2, headY - R * 1.75), (headX, headY - R * 1.5),
                   (headX + R * 0.2, headY - R * 1.8), (headX + R * 0.3, headY - R * 0.9)])
    elif hair == 7:                     # curly
        for i in range(7):
            a = math.pi + math.pi * i / 6
            circ(dst, headX + math.cos(a) * R * 0.85, headY + math.sin(a) * R * 0.85, R * 0.4)
    elif hair == 8:                     # bob
        rbox(dst, headX - R * 1.08, headY - R * 1.05, R * 2.16, R * 1.6, R * 0.7)
        dst.pen = C(skin)
        poly(dst, [(headX - R * 0.8, headY - R * 0.2), (headX - R * 0.5, headY - R * 0.65), (headX + R * 0.5, headY - R * 0.65),
                   (headX + R * 0.8, headY - R * 0.2), (headX + R * 0.95, headY + R * 0.6), (headX - R * 0.95, headY + R * 0.6)])
        dst.pen = hair_pen
        poly(dst, [(headX - R * 1.0, headY - R * 0.2), (headX - R * 0.6, headY - R * 0.98), (headX + R * 0.5, headY - R * 1.0),
                   (headX + R * 1.0, headY - R * 0.3), (headX + R * 0.4, headY - R * 0.62), (headX - R * 0.4, headY - R * 0.62)])
    elif hair == 9:                     # ponytail: a cap
        poly(dst, [(headX - R * 1.0, headY - R * 0.1), (headX - R * 0.7, headY - R * 0.85), (headX + R * 0.2, headY - R * 1.03),
                   (headX + R * 1.0, headY - R * 0.3), (headX + R * 0.5, headY - R * 0.6), (headX - R * 0.4, headY - R * 0.65)])

    # hat
    topY = headY - R * (1.25 if hair in (2, 5, 6) else 0.95)
    if hat == 1:                        # cap
        dst.pen = C(CLOTHC[(look["topc"] + 6) % 12])
        poly(dst, [(headX - R * 1.0, headY - R * 0.35), (headX - R * 0.8, headY - R * 0.95), (headX, headY - R * 1.15),
                   (headX + R * 0.8, headY - R * 0.95), (headX + R * 1.0, headY - R * 0.35)])
        rbox(dst, headX + (d * R * 0.4 if d > 0 else -R * 1.7), headY - R * 0.5, R * 1.3, R * 0.22, R * 0.08)
    elif hat == 2:                      # beanie
        dst.pen = C(CLOTHC[(look["botc"] + 3) % 12])
        poly(dst, [(headX - R * 1.05, headY - R * 0.15), (headX - R * 0.85, headY - R * 0.95), (headX, headY - R * 1.3),
                   (headX + R * 0.85, headY - R * 0.95), (headX + R * 1.05, headY - R * 0.15)])
        dst.pen = C(shade(CLOTHC[(look["botc"] + 3) % 12], 0.75))
        rbox(dst, headX - R * 1.05, headY - R * 0.4, R * 2.1, R * 0.3, R * 0.1)
        dst.pen = WHITE
        circ(dst, headX, headY - R * 1.35, R * 0.22)
    elif hat == 3:                      # crown
        dst.pen = color.rgb(250, 205, 60)
        poly(dst, [(headX - R * 0.7, headY - R * 0.75), (headX - R * 0.7, headY - R * 1.45), (headX - R * 0.35, headY - R * 1.1),
                   (headX, headY - R * 1.55), (headX + R * 0.35, headY - R * 1.1), (headX + R * 0.7, headY - R * 1.45),
                   (headX + R * 0.7, headY - R * 0.75)])
        dst.pen = color.rgb(230, 60, 90)
        circ(dst, headX, headY - R * 0.95, R * 0.12)
    elif hat == 4:                      # top hat
        dst.pen = BLACK
        rbox(dst, headX - R * 1.1, headY - R * 1.0, R * 2.2, R * 0.25, R * 0.08)
        box(dst, headX - R * 0.75, headY - R * 2.1, R * 1.5, R * 1.2)
        dst.pen = C(CLOTHC[look["topc"]])
        box(dst, headX - R * 0.75, headY - R * 1.25, R * 1.5, R * 0.22)
    elif hat == 5:                      # headphones: a band over the top only, then the cups
        dst.pen = color.rgb(40, 40, 50)
        pts = []
        for i in range(9):
            a_ = math.pi + math.pi * i / 8
            pts.append((headX + math.cos(a_) * R * 1.14, headY - R * 0.05 + math.sin(a_) * R * 1.14))
        for i in range(8, -1, -1):
            a_ = math.pi + math.pi * i / 8
            pts.append((headX + math.cos(a_) * R * 0.98, headY - R * 0.05 + math.sin(a_) * R * 0.98))
        poly(dst, pts)
        circ(dst, headX - R * 1.0, headY + R * 0.05, R * 0.32)
        circ(dst, headX + R * 1.0, headY + R * 0.05, R * 0.32)
        dst.pen = ACCENT2
        circ(dst, headX - R * 1.0, headY + R * 0.05, R * 0.14)
        circ(dst, headX + R * 1.0, headY + R * 0.05, R * 0.14)
    elif hat == 6:                      # halo
        dst.pen = color.rgb(255, 230, 120)
        dst.shape(shape.circle(vec2(headX, topY - R * 0.35), R * 0.7).stroke(3))
    elif hat == 7:                      # propeller beanie
        dst.pen = C(CLOTHC[(look["topc"] + 9) % 12])
        poly(dst, [(headX - R * 1.0, headY - R * 0.3), (headX - R * 0.8, headY - R * 0.95), (headX, headY - R * 1.15),
                   (headX + R * 0.8, headY - R * 0.95), (headX + R * 1.0, headY - R * 0.3)])
        dst.pen = color.rgb(60, 60, 70)
        seg(dst, headX, headY - R * 1.15, headX, headY - R * 1.45, 2)
        dst.pen = color.rgb(250, 80, 80)
        spin = math.cos(t * 24) if walking else 1.0
        seg(dst, headX - R * 0.6 * spin, headY - R * 1.45, headX + R * 0.6 * spin, headY - R * 1.45, 3)

    if acc == 5:                        # balloon
        bx_ = x - d * bodyW * 0.75
        by_ = headY - R * 1.9 + math.sin(t * 6) * R * 0.1
        dst.pen = color.rgb(200, 200, 220)
        seg(dst, bx_ + d * R * 0.15, hipY - bodyH * 0.1, bx_, by_ + R * 0.5, 1)
        dst.pen = C(CLOTHC[(look["topc"] + 5) % 12])
        circ(dst, bx_, by_, R * 0.5)
        dst.pen = color.rgb(255, 255, 255, 130)
        circ(dst, bx_ - R * 0.18, by_ - R * 0.18, R * 0.12)

    if mood == "sleep":
        dst.pen = INK
        dst.font = FONT_SMALL
        zz = int(t * 2) % 3
        text(dst, "z" * (zz + 1), headX + d * R * 1.1, headY - R * 1.4 - (t * 10 % 8))

def guy_height(R, look):
    sz = (0.85, 1.0, 1.18)[look["size"]]
    return R * 0.95 * sz + R * 1.35 * sz + R * 1.92

# Walk poses are rendered once per look into transparent images (the Tufty has 8 MB of
# PSRAM) and blitted at ~4 ms instead of ~27 ms a frame. The cache empties when the look,
# size or mood changes and refills lazily, one pose per frame, so there is no hitch.
SPR_N = 16
_spr = {}
_spr_key = None

def guy_sprite(lk, R, idx, d, mood="wander"):
    global _spr_key
    key = "%s|%d|%s" % (json.dumps(lk), R, mood)
    if key != _spr_key:
        _spr.clear()
        gc.collect()
        _spr_key = key
    k = (idx, d)
    img = _spr.get(k)
    if img is None:
        w, h = int(R * 5.2), int(R * 6.6)
        img = image(w, h)
        img.antialias = img.X2
        img.pen = color.rgb(0, 0, 0, 0)
        img.clear()
        ph = idx / SPR_N
        # time-based flourishes (cape, wings, propeller) run on multiples of 6 rad/s so one
        # walk cycle loops seamlessly when t is derived from the phase
        draw_guy(img, w / 2, h - R * 0.5, R, lk, ph, d, True, mood, ph * 2 * math.pi / 6)
        _spr[k] = img
    return img

def blit_guy(dst, x, gy, R, lk, phase, d, mood="wander"):
    idx = int(phase * SPR_N) % SPR_N
    img = guy_sprite(lk, R, idx, d, mood)
    dst.blit(img, vec2(int(x - img.width / 2), int(gy + R * 0.5 - img.height)))

# ---------------------------------------------------------------- speech bubbles
def draw_bubble(dst, s, ax, ay, max_w=200, font=None):
    """A bubble with its tail at (ax, ay), kept inside the screen."""
    dst.font = font or FONT
    lines = wrap(dst, s, max_w)
    lh = int(dst.measure_text("Hg")[1]) - 1
    tw = max(int(dst.measure_text(l)[0]) for l in lines)
    pad = 5
    bw_, bh = tw + pad * 2, len(lines) * lh + pad * 2 - 2
    bx = min(max(ax - bw_ / 2, 2), W - bw_ - 2)
    by = max(2, ay - bh - 8)
    tx = min(max(ax, bx + 10), bx + bw_ - 10)
    dst.pen = WHITE
    poly(dst, [(tx - 5, by + bh - 1), (tx + 5, by + bh - 1), (ax, ay)])
    rbox(dst, bx, by, bw_, bh, 6)
    dst.pen = BLACK
    y = by + pad - 2
    for l in lines:
        dst.text(l, int(bx + pad), int(y))
        y += lh
    return (bx, by, bw_, bh)

# ---------------------------------------------------------------- state
state = {
    "look": None, "name": None, "pack": 0, "sayings": None, "mood": "auto",
    "id": None, "token": None,
}

def load_state():
    try:
        with open(STATE_FILE) as f:
            data = json.load(f)
        for k in state:
            if k in data:
                state[k] = data[k]
        if state["look"]:
            for key in ("skin", "hair", "hairc", "eyes", "mouth", "top", "topc", "bot", "botc", "shoes", "hat", "acc", "size"):
                if key not in state["look"]:
                    raise ValueError("old look")
        return bool(state["look"] and state["name"])
    except Exception:
        return False

def save_state():
    try:
        with open(STATE_FILE, "w") as f:
            json.dump(state, f)
    except OSError as e:
        print("WW ERR save", e)

def sayings():
    if state["sayings"]:
        return state["sayings"]
    try:
        with open(APP_DIR + "/sayings.txt") as f:
            lines = [l.strip() for l in f.read().split("\n") if l.strip() and not l.startswith("#")]
        if lines:
            return lines[:12]
    except OSError:
        pass
    return PACKS[state["pack"] % len(PACKS)][1]

# ---------------------------------------------------------------- wi-fi (never blocks the UI)
wlan = None
wifi_state = "off"
wifi_next_try = 0
wifi_tries = 0
ip_addr = None
_ssid = _psk = None

def wifi_start():
    global wlan, wifi_state, wifi_next_try, _ssid, _psk
    try:
        import secrets
        _ssid, _psk = secrets.WIFI_SSID, secrets.WIFI_PASSWORD
    except Exception:
        _ssid = None
    if not _ssid:
        wifi_state = "nocreds"
        return
    try:
        import network
        wlan = network.WLAN(network.STA_IF)
        wlan.active(True)
        try:
            wlan.config(pm=0xa11140)       # no power saving: it makes requests crawl
        except Exception:
            pass
        if wlan.isconnected():             # another app (or a previous run) already got us on
            wifi_state = "connecting"
            wifi_next_try = time.ticks_add(time.ticks_ms(), 25000)
            return
        wlan.connect(_ssid, _psk)
        wifi_state = "connecting"
        wifi_next_try = time.ticks_add(time.ticks_ms(), 25000)
    except Exception as e:
        wifi_state = "error"
        print("WW ERR wifi", e)

def wifi_poll():
    global wifi_state, wifi_next_try, wifi_tries, ip_addr
    if wifi_state == "connecting":
        if wlan.isconnected():
            wifi_state = "up"
            wifi_tries = 0
            ip_addr = wlan.ifconfig()[0]
            print("WW WIFI", ip_addr)
        elif time.ticks_diff(time.ticks_ms(), wifi_next_try) > 0:
            wifi_tries += 1
            wait = 25000
            if wifi_tries % 4 == 0:
                wlan.active(False)
                time.sleep_ms(300)
                wlan.active(True)
                wait = 90000
            try:
                wlan.connect(_ssid, _psk)
            except Exception:
                pass
            wifi_next_try = time.ticks_add(time.ticks_ms(), wait)
    elif wifi_state == "up":
        if not wlan.isconnected():
            wifi_state = "connecting"
            try:
                wlan.connect(_ssid, _psk)
            except Exception:
                pass
            wifi_next_try = time.ticks_add(time.ticks_ms(), 25000)

# ---------------------------------------------------------------- serial host link
poller = select.poll()
poller.register(sys.stdin, select.POLLIN)
_serial_buf = ""
host_seen_ms = None          # a bridge said HELLO
_req_n = 0
_pending_res = {}

def emit(line):
    print(line)

def serial_lines():
    """Yield complete lines waiting on USB serial, without blocking."""
    global _serial_buf
    out = []
    while poller.poll(0):
        ch = sys.stdin.read(1)
        if not ch:
            break
        if ch in ("\n", "\r"):
            if _serial_buf:
                out.append(_serial_buf)
                _serial_buf = ""
        else:
            _serial_buf += ch
            if len(_serial_buf) > 4000:
                _serial_buf = ""
    return out

def host_alive():
    return host_seen_ms is not None and time.ticks_diff(time.ticks_ms(), host_seen_ms) < 60000

# ---------------------------------------------------------------- the room API
last_error = None

def api(action, payload, timeout=HTTP_TIMEOUT):
    """POST to the room. Wi-Fi when it is up, otherwise through a USB bridge if one is
    listening. Returns the decoded JSON dict, or None (last_error says why)."""
    global last_error, _req_n
    payload = dict(payload)
    payload["device"] = DEVICE
    payload["v"] = VERSION
    if wifi_state == "up":
        try:
            import requests
            gc.collect()
            url = BASE_URL + "?a=" + action
            body = json.dumps(payload)
            try:
                r = requests.post(url, data=body, headers={"Content-Type": "application/json"}, timeout=timeout)
            except TypeError:
                r = requests.post(url, data=body, headers={"Content-Type": "application/json"})
            try:
                data = r.json()
            finally:
                r.close()
            if not isinstance(data, dict):
                raise ValueError("bad reply")
            if not data.get("ok"):
                last_error = data.get("error", "room said no")
                return None
            last_error = None
            return data
        except Exception as e:
            last_error = "wifi: " + str(e)[:40]
            print("WW ERR api", action, e)
            # fall through to the bridge if there is one
    if host_alive():
        _req_n += 1
        n = _req_n
        emit("WW REQ %d %s %s" % (n, action, json.dumps(payload)))
        deadline = time.ticks_add(time.ticks_ms(), int(timeout * 1000))
        while time.ticks_diff(deadline, time.ticks_ms()) > 0:
            for line in serial_lines():
                handle_serial(line)
            if n in _pending_res:
                data = _pending_res.pop(n)
                if not data.get("ok"):
                    last_error = data.get("error", "room said no")
                    return None
                last_error = None
                return data
            time.sleep_ms(20)
        last_error = "bridge timed out"
        return None
    if wifi_state == "nocreds":
        last_error = "no wifi in secrets.py"
    elif wifi_state != "up":
        last_error = "wifi not connected"
    return None

# ---------------------------------------------------------------- screens
# room.py sits next to this file; the launcher puts the app folder on sys.path, a dev
# harness importing us as a package does not, so make sure of it from __file__.
try:
    _here = __file__.rsplit("/", 1)[0]
    if _here and _here not in sys.path:
        sys.path.insert(0, _here)
except Exception:
    pass
import room

scene = "build"              # build | name | door | world | welcome
row = 0
look = None
name = None
hold_ms = 0
hold_dir = 0
t0 = time.ticks_ms()
door = {"phase": 0, "t": 0, "err": None, "leaving": False}
world = {"here": [], "chat": [], "last_beat": 0, "next_beat": 0, "syncing": False, "said": None, "said_t": 0,
         "scroll": 0, "entered_ms": 0, "dirty": True, "drawn": 0,
         "view": "room", "avatars": [], "present_s": 75, "now0": 0, "t0": 0, "count_drawn": -1,
         # 1.1: manual control, the menu, the keyboard, the machines
         "me_walk": None, "move_due": False, "move_sent": 0, "move_fail": False, "dir_t": 0, "dir_rep": 0,
         "menu": None, "menu_dirty": True, "game": None, "game_id": None, "tops": {}, "nagged": False}

def sync_clock(data):
    if data and "now" in data:
        world["now0"], world["t0"] = data["now"], time.ticks_ms()

def server_now():
    """Server time as (int seconds, fraction): floats are 32-bit here, a Unix timestamp
    as a float would only tick every 128 s."""
    el = time.ticks_diff(time.ticks_ms(), world["t0"])
    return (world["now0"] + el // 1000, (el % 1000) / 1000.0)

def take_room(data):
    if data and "room" in data:
        world["avatars"] = data["room"]
        world["present_s"] = data.get("present_s", world["present_s"])
        if world["me_walk"] and world["move_due"]:      # a move the room has not heard yet
            me = my_av()
            if me:
                me.update(world["me_walk"])
        if world["game_id"]:
            me = my_av()
            if me:
                me["act"], me["act_t"] = world["game_id"], server_now()[0]
flash = None                 # (text, until_ms)
_qr_img = None

def now_s():
    return time.ticks_diff(time.ticks_ms(), t0) / 1000.0

def say_flash(msg, ms=2500):
    global flash
    flash = (msg, time.ticks_add(time.ticks_ms(), ms))

def go(new_scene):
    global scene, force_full, dirty
    scene = new_scene
    force_full = True
    dirty = True
    door["phase"], door["t"], door["err"] = 0, now_s(), None

def start_build(fresh=False):
    global look, name
    if fresh or not look:
        look = state["look"] or random_look()
        name = state["name"] or random_name()
    go("build")

def change_row(delta):
    global look, name, dirty
    dirty = True
    if row == ROW_SHUFFLE:
        look = random_look()
        return
    label, key, names, _ = ROWS[row]
    look[key] = (look[key] + delta) % len(names)

# -- cached backdrops. A full-screen blit costs ~28 ms at 320x240, so each screen's chrome
# is drawn once into an image, blitted whole when the screen first appears, and after that
# only the strips that change are restored from it (image.window) before redrawing on top.
_bd = {}
_shown = None            # which backdrop is currently on the display
force_full = False       # something (a flash, an overlay) dirtied the whole screen
dirty = True             # the right-hand panel needs redrawing

def ensure_backdrop(key, draw_fn):
    img = _bd.get(key)
    if img is None:
        img = image(W, H)
        img.antialias = img.X2
        img.pen = BG
        img.clear()
        draw_fn(img)
        _bd[key] = img
        gc.collect()
    return img

def show(key, draw_fn):
    """Put the backdrop on screen if it is not there already. Returns True on a full repaint."""
    global _shown, force_full, dirty
    img = ensure_backdrop(key, draw_fn)
    if _shown != key or force_full:
        screen.blit(img, vec2(0, 0))
        _shown = key
        force_full = False
        dirty = True
        return True
    return False

def restore(key, x, y, w, h):
    """Repaint one rectangle of the backdrop (cost scales with its area)."""
    x, y, w, h = int(x), int(y), int(w), int(h)
    screen.blit(_bd[key].window(x, y, w, h), vec2(x, y))

STAGE = (6, 28, 140, H - 48)
RIGHT = (152, 28, W - 158, H - 48)
STARS = [(random.random(), random.random(), random.random() * 6.3) for _ in range(14)]

def stage_static(dst, x0, y0, w, h):
    dst.pen = PANEL
    rbox(dst, x0, y0, w, h, 8)
    dst.pen = color.rgb(24, 26, 32)
    rbox(dst, x0 + 10, y0 + 8, w - 20, h * 0.42, 6)
    dst.pen = color.rgb(255, 230, 150)
    circ(dst, x0 + w - 30, y0 + 24, 9)
    dst.pen = color.rgb(24, 26, 32)
    circ(dst, x0 + w - 34, y0 + 21, 8)
    gy = y0 + h - 18
    dst.pen = FLOOR
    rbox(dst, x0 + 6, gy - 2, w - 12, 16, 4)
    dst.pen = FLOOR_LINE
    for i in range(1, 6):
        box(dst, x0 + 6 + (w - 12) * i / 6, gy - 2, 1, 16)

def panel_backdrop(img):
    img.pen = PANEL
    box(img, 0, 0, W, 22)
    box(img, 0, H - 16, W, 16)
    stage_static(img, STAGE[0], STAGE[1], STAGE[2], STAGE[3])
    img.pen = PANEL
    rbox(img, RIGHT[0], RIGHT[1], RIGHT[2], RIGHT[3], 8)

STAR_PENS = [color.rgb(255, 255, 255, 90 + i * 20) for i in range(8)]

def stage_live(guy_x=None, R=24, bubble=None, walking=True, mood="wander"):
    x0, y0, w, h = STAGE
    restore("panel", x0, y0, w, h)
    tnow = now_s()
    for sx, sy, sp in STARS:
        tw = 0.5 + 0.5 * math.sin(tnow * 2 + sp)
        screen.pen = STAR_PENS[int(tw * 7.99)]
        box(screen, x0 + 14 + sx * (w - 30), y0 + 12 + sy * (h * 0.42 - 12), 2, 2)
    gy = y0 + h - 18
    gx = guy_x if guy_x is not None else x0 + w / 2
    ph = (tnow * 1.6) % 1.0
    if walking and mood in ("wander", "zoom"):
        blit_guy(screen, gx, gy, R, look, ph, 1)
    else:
        draw_guy(screen, gx, gy, R, look, ph, 1, walking, mood, tnow)
    if bubble:
        draw_bubble(screen, bubble, gx + R * 0.3, gy - guy_height(R, look) - 4, 130, FONT_SMALL)

def draw_header(title, right=None):
    text(screen, title, 8, 3, INK, FONT_BIG)
    if right:
        rw, _ = measure(screen, right, FONT_SMALL)
        text(screen, right, W - rw - 8, 7, DIM, FONT_SMALL)
    draw_pip(right)

def draw_pip(right=None):
    """The connectivity dot in the header: cyan wifi, amber USB bridge, red nothing."""
    rw = measure(screen, right, FONT_SMALL)[0] if right else 0
    x = W - rw - 18 if right else W - 10
    restore(_shown, x - 5, 5, 11, 12)
    screen.pen = ACCENT if wifi_state == "up" else (ACCENT2 if host_alive() else color.rgb(110, 110, 110))
    circ(screen, x, 11, 3)

def draw_footer(s):
    center_text(screen, s, W / 2, H - 13, DIM, FONT_SMALL)

_flash_shown = False

def draw_flash():
    global flash, _flash_shown, force_full
    if flash:
        msg, until = flash
        if time.ticks_diff(until, time.ticks_ms()) < 0:
            flash = None
            if _flash_shown:
                force_full = True
                _flash_shown = False
            return
        screen.font = FONT
        w, h = screen.measure_text(msg)
        screen.pen = ACCENT
        rbox(screen, W / 2 - w / 2 - 8, H / 2 - h / 2 - 6, w + 16, h + 10, 6)
        center_text(screen, msg, W / 2, H / 2 - h / 2 - 1, BLACK, FONT)
        _flash_shown = True

last_repeat = 0

def scene_build(pressed, held):
    global row, hold_ms, hold_dir, last_repeat, dirty
    if io.BUTTON_UP in pressed:
        row = (row - 1) % (len(ROWS) + 1)
        dirty = True
    if io.BUTTON_DOWN in pressed:
        row = (row + 1) % (len(ROWS) + 1)
        dirty = True
    if io.BUTTON_A in pressed:
        change_row(-1)
        hold_ms, hold_dir = io.ticks, -1
    if io.BUTTON_C in pressed:
        change_row(1)
        hold_ms, hold_dir = io.ticks, 1
    if (io.BUTTON_A in held or io.BUTTON_C in held) and io.ticks - hold_ms > 450 and row != ROW_SHUFFLE:
        if io.ticks - last_repeat > 110:
            change_row(hold_dir)
            last_repeat = io.ticks
    if io.BUTTON_B in pressed:
        go("name")
        return

    full = show("panel", panel_backdrop)
    if full:
        draw_header("build your lil guy", "wes world")
        draw_footer("UP/DOWN pick   A/C change   B next")
    else:
        draw_pip("wes world")
    stage_live()
    if dirty:
        x0, y0, pw, ph_ = RIGHT
        restore("panel", x0 + 2, y0 + 2, pw - 4, ph_ - 4)
        rh = 14
        visible = (ph_ - 6) // rh
        first = max(0, row - visible + 1)
        for i in range(first, min(len(ROWS) + 1, first + visible)):
            y = y0 + 4 + (i - first) * rh
            sel = i == row
            if sel:
                screen.pen = PANEL_HI
                box(screen, x0 + 3, y - 1, pw - 6, rh)
            if i == ROW_SHUFFLE:
                text(screen, "shuffle everything", x0 + 8, y + 2, ACCENT if sel else DIM, FONT_SMALL)
                if sel:
                    text(screen, "< A / C >", x0 + 110, y + 2, ACCENT, FONT_SMALL)
                continue
            label, key, names, pal = ROWS[i]
            text(screen, label, x0 + 8, y + 2, INK if sel else DIM, FONT_SMALL)
            val = names[look[key]]
            vx = x0 + 92
            if pal:
                screen.pen = C(pal[look[key]])
                box(screen, vx, y + 2, 9, 9)
                vx += 13
            text(screen, val, vx, y + 2, ACCENT if sel else INK, FONT_SMALL)
        dirty = False

def scene_name(pressed, held):
    global name, dirty
    if io.BUTTON_A in pressed or io.BUTTON_C in pressed:
        name = random_name()
        dirty = True
    if io.BUTTON_UP in pressed:
        state["pack"] = (state["pack"] - 1) % len(PACKS)
        state["sayings"] = None
        dirty = True
    if io.BUTTON_DOWN in pressed:
        state["pack"] = (state["pack"] + 1) % len(PACKS)
        state["sayings"] = None
        dirty = True
    if io.BUTTON_B in pressed:
        state["look"], state["name"] = look, name
        save_state()
        door["leaving"] = False
        go("door")
        return
    full = show("panel", panel_backdrop)
    if full:
        draw_header("name & personality", "wes world")
        draw_footer("B  send him through the door")
    else:
        draw_pip("wes world")
    pack = PACKS[state["pack"]][1]
    bub = pack[int(now_s() / 3) % len(pack)] if not state["sayings"] else None
    stage_live(bubble=bub)
    if dirty:
        x0, y0, pw, ph_ = RIGHT
        restore("panel", x0 + 2, y0 + 2, pw - 4, ph_ - 4)
        text(screen, "his name is", x0 + 8, y0 + 8, DIM, FONT_SMALL)
        lines = wrap(screen, name, pw - 16, FONT_BIG)
        y = y0 + 20
        for l in lines:
            text(screen, l, x0 + 8, y, INK, FONT_BIG)
            y += 18
        text(screen, "A / C  reroll", x0 + 8, y + 2, ACCENT, FONT_SMALL)
        text(screen, "personality", x0 + 8, y0 + 86, DIM, FONT_SMALL)
        pk = PACKS[state["pack"]][0] if not state["sayings"] else "custom"
        text(screen, "< " + pk + " >", x0 + 8, y0 + 98, ACCENT2, FONT_BIG)
        text(screen, "UP / DOWN  change", x0 + 8, y0 + 118, ACCENT, FONT_SMALL)
        text(screen, "edit later: tools/talk.py", x0 + 8, y0 + 140, DIM, FONT_SMALL)
        dirty = False

def enter_room():
    """Blocking: announce him to the room. True on success."""
    if state["mood"] == "walk":
        state["mood"] = "auto"
    world["me_walk"], world["move_due"], world["game"], world["game_id"], world["menu"] = None, False, None, None, None
    data = api("enter", {"name": name, "look": look, "sayings": sayings(), "mood": state["mood"],
                         "token": state["token"]})
    if not data:
        return False
    state["id"], state["token"] = data.get("id"), data.get("token", state["token"])
    save_state()
    emit("WW IDENT %s %s" % (state["id"], state["token"]))
    world["here"] = data.get("here", [])
    world["chat"] = data.get("chat", [])
    world["entered_ms"] = time.ticks_ms()
    sync_clock(data)
    take_room(data)
    world["next_beat"] = time.ticks_add(time.ticks_ms(), BEAT_EVERY_MS)
    return True

def leave_room():
    if state["id"]:
        api("leave", {"id": state["id"], "token": state["token"]}, timeout=6)

DOOR_GY, DOOR_X, DOOR_W, DOOR_H = H - 40, W - 70, 56, 110

def door_backdrop(img):
    gy, door_x, dw, dh = DOOR_GY, DOOR_X, DOOR_W, DOOR_H
    img.pen = PANEL
    box(img, 0, 0, W, 22)
    box(img, 0, H - 16, W, 16)
    img.pen = color.rgb(30, 30, 36)
    box(img, 0, 22, W, gy - 22)
    img.pen = color.rgb(52, 52, 58)                     # skirting board
    box(img, 0, gy - 7, W, 7)
    img.pen = color.rgb(46, 46, 52)                     # a framed picture on the wall
    rbox(img, 40, 50, 60, 44, 3)
    img.pen = color.rgb(255, 170, 90)
    circ(img, 70, 66, 8)
    img.pen = color.rgb(90, 90, 100)
    box(img, 44, 76, 52, 14)
    img.pen = FLOOR
    box(img, 0, gy, W, H - gy - 16)
    img.pen = FLOOR_LINE
    for i in range(0, W, 40):
        box(img, i, gy, 1, H - gy - 16)
    img.pen = color.rgb(120, 80, 50)
    rbox(img, door_x - 4, gy - dh - 4, dw + 8, dh + 6, 4)
    center_text(img, "the room", door_x + dw / 2, gy - dh - 22, ACCENT2, FONT_SMALL)

def scene_door(pressed, held):
    """He walks to the door, knocks (the API call), the door opens and he goes through.
    Leaving plays the same thing backwards."""
    global force_full
    tt = now_s() - door["t"]
    leaving = door["leaving"]
    gy, door_x, dw, dh = DOOR_GY, DOOR_X, DOOR_W, DOOR_H
    R = 26
    if door["err"]:
        if io.BUTTON_A in pressed:
            door["err"] = None
            door["t"] = now_s()
            door["phase"] = 0
            force_full = True
        if io.BUTTON_B in pressed:
            start_build()
            return
    full = show("door", door_backdrop)
    if full:
        draw_header("the door" if not leaving else "coming home", SITE)
        draw_footer("he's on his way..." if not leaving else "he's coming back...")
    else:
        restore("door", 0, gy - dh - 34, W, dh + 48)      # the band he walks in, door included
        draw_pip(SITE)
    open_amt = 0.0
    if door["phase"] >= 2:
        open_amt = min(1.0, (now_s() - door["_open_t"]) / 0.7)
    if door["phase"] == 4:
        open_amt = max(0.0, 1.0 - (now_s() - door["_close_t"]) / 0.7)
    # the room beyond glows as the door swings open
    screen.pen = color.rgb(255, 200, 120, int(220 * open_amt))
    box(screen, door_x, gy - dh, dw, dh)
    screen.pen = color.rgb(70, 40, 25)
    sw_ = dw * (1 - open_amt * 0.92)
    rbox(screen, door_x, gy - dh, max(3, sw_), dh, 3)
    screen.pen = color.rgb(250, 210, 90)
    if sw_ > 14:
        circ(screen, door_x + sw_ - 8, gy - dh * 0.5, 3)

    start_x, knock_x = 60, door_x - 26
    walk_ph = (now_s() * 1.8) % 1.0
    if not leaving:
        if door["phase"] == 0:           # walk up
            p = min(1.0, tt / 2.2)
            gx = start_x + (knock_x - start_x) * p
            blit_guy(screen, gx, gy, R, look, walk_ph, 1)
            if p >= 1.0:
                door["phase"] = 1
                door["_knock_t"] = now_s()
        elif door["phase"] == 1:         # knock, then the (blocking) API call
            draw_guy(screen, knock_x, gy, R, look, 0, 1, False, "wander", now_s())
            waited = now_s() - door["_knock_t"]
            no_link = wifi_state != "up" and not host_alive()
            if no_link and waited > 0.6 and waited < 30 and wifi_state in ("connecting", "off"):
                dots = "." * (1 + int(waited * 2) % 3)
                draw_bubble(screen, "hold on, finding wifi" + dots, knock_x, gy - guy_height(R, look) - 4, 120, FONT_SMALL)
            else:
                draw_bubble(screen, "knock knock", knock_x, gy - guy_height(R, look) - 4, 120, FONT_SMALL)
            if waited > 0.6 and not door.get("_called") and not door["err"] and not (no_link and waited < 30 and wifi_state in ("connecting", "off")):
                door["_called"] = True
                bw.display.update(True)
                ok = enter_room()
                door["_called"] = False
                if ok:
                    door["phase"] = 2
                    door["_open_t"] = now_s()
                else:
                    door["err"] = last_error or "no answer"
        elif door["phase"] == 2:         # door opens, he walks in and shrinks into the distance
            el = now_s() - door["_open_t"]
            p = min(1.0, max(0.0, (el - 0.5) / 1.4))
            gx = knock_x + (door_x + dw * 0.5 - knock_x) * p
            draw_guy(screen, gx, gy - 6 * p, R * (1.0 - 0.35 * p), look, walk_ph, 1, True, "wander", now_s())
            if el > 2.1:
                door["phase"] = 4
                door["_close_t"] = now_s()
        elif door["phase"] == 4:
            if now_s() - door["_close_t"] > 0.9:
                go("world")
                say_flash("he's in!", 1800)
                return
    else:
        if door["phase"] == 0:
            door["phase"] = 2
            door["_open_t"] = now_s()
        if door["phase"] == 2:           # door opens, he comes back out
            el = now_s() - door["_open_t"]
            p = min(1.0, max(0.0, (el - 0.5) / 1.4))
            gx = door_x + dw * 0.5 + (knock_x - door_x - dw * 0.5) * p
            draw_guy(screen, gx, gy - 6 * (1 - p), R * (0.65 + 0.35 * p), look, walk_ph, -1, True, "wander", now_s())
            if el > 2.1:
                door["phase"] = 4
                door["_close_t"] = now_s()
        elif door["phase"] == 4:         # and walks off left
            el = now_s() - door["_close_t"]
            gx = knock_x + (start_x - knock_x) * min(1.0, el / 2.0)
            blit_guy(screen, gx, gy, R, look, walk_ph, -1)
            if el > 2.3:
                start_build()
                return
    if door["err"]:
        screen.pen = color.rgb(0, 0, 0, 160)
        box(screen, 0, 22, W, H - 38)
        screen.pen = PANEL
        rbox(screen, 30, 70, W - 60, 90, 8)
        center_text(screen, "nobody answered", W / 2, 78, ACCENT, FONT_BIG)
        for i, l in enumerate(wrap(screen, str(door["err"]), W - 90, FONT_SMALL)[:3]):
            center_text(screen, l, W / 2, 100 + i * 11, INK, FONT_SMALL)
        center_text(screen, "A try again    B edit him", W / 2, 142, DIM, FONT_SMALL)

def qr_image(url):
    global _qr_img
    if _qr_img:
        return _qr_img
    try:
        import qrcode
        q = qrcode.QRCode()
        q.set_text(url)
        n = q.get_size()
        if isinstance(n, tuple):
            n = n[0]
        px = max(1, 66 // n)
        size = n * px + 8
        img = image(size, size)
        img.pen = WHITE
        img.clear()
        img.pen = BLACK
        for y in range(n):
            for x in range(n):
                if q.get_module(x, y):
                    img.rectangle(4 + x * px, 4 + y * px, px, px)
        _qr_img = img
    except Exception as e:
        print("WW ERR qr", e)
        _qr_img = None
    return _qr_img

def beat(force=False, say=None):
    world["syncing"] = True
    world["dirty"] = True
    payload = {"id": state["id"], "token": state["token"], "mood": state["mood"]}
    if say:
        payload["say"] = say
    if world["chat"]:
        payload["since"] = max(c.get("t", 0) for c in world["chat"])
    data = api("beat", payload, timeout=8)
    world["syncing"] = False
    world["dirty"] = True
    world["next_beat"] = time.ticks_add(time.ticks_ms(), BEAT_EVERY_MS)
    if data:
        sync_clock(data)
        take_room(data)
        world["here"] = data.get("here", world["here"])
        new = data.get("chat", [])
        if new:
            seen = set((c.get("t"), c.get("who")) for c in world["chat"])
            for c in new:
                if (c.get("t"), c.get("who")) not in seen:
                    world["chat"].append(c)
            world["chat"] = world["chat"][-12:]
            emit("WW CHAT " + json.dumps(new))
        world["last_beat"] = time.ticks_ms()
        if "tops" in data:
            world["tops"] = data["tops"] or {}
        v = data.get("version")
        if v and v != VERSION and not world["nagged"]:
            world["nagged"] = True
            say_flash("newer app on the site: rerun the installer", 3500)
        if data.get("kicked"):
            say_flash("the room forgot him; sending him back in")
            go("door")
        return True
    return False

WORLD_GY = 28 + 98

def world_backdrop(img):
    img.pen = PANEL
    box(img, 0, 0, W, 22)
    box(img, 0, H - 16, W, 16)
    rbox(img, 6, 28, 110, H - 48, 8)
    rbox(img, 122, 28, W - 128, H - 48, 8)
    img.pen = FLOOR
    rbox(img, 12, WORLD_GY - 2, 98, 10, 3)
    qr = qr_image(BASE_URL)
    if qr:
        img.blit(qr, vec2(int(61 - qr.width / 2), WORLD_GY + 10))
        center_text(img, "watch the room", 61, WORLD_GY + 12 + qr.height, DIM, FONT_SMALL)
    img.pen = PANEL_HI
    box(img, 130, 28 + 49, W - 144, 1)
    text(img, "overheard", 130, 28 + 52, DIM, FONT_SMALL)

def studio_backdrop(img):
    rb = room.backdrop()
    if not getattr(room, "_chrome", False):
        rb.pen = color.rgb(0, 0, 0, 150)
        rb.rectangle(0, 0, W, 13)
        rb.rectangle(0, H - 12, W, 12)
        rb.font = FONT_SMALL
        rb.pen = ACCENT
        rb.text("wes world", 4, 2)
        rb.pen = DIM
        hint = "UP/DOWN/A/C walk   B menu"
        rb.text(hint, W - 4 - int(rb.measure_text(hint)[0]), H - 11)
        room._chrome = True
    img.blit(rb, vec2(0, 0))

def scene_studio():
    """The room itself, live, on the badge: everyone who is in it, walking around."""
    full = show("studio", studio_backdrop)
    if full:
        room.reset()
        world["count_drawn"] = -1
    now_i, now_f = server_now()
    n = room.draw(screen, world["avatars"], world["chat"], now_i, now_f, world["present_s"], state["id"])
    if n != world["count_drawn"] or world["syncing"]:
        restore("studio", W - 110, 0, 110, 13)
        label = "syncing..." if world["syncing"] else ("%d in the room" % n if n != 1 else "just him in here")
        screen.font = FONT_SMALL
        screen.pen = INK
        screen.text(label, W - 16 - int(screen.measure_text(label)[0]), 2)
        world["count_drawn"] = n
    screen.pen = ACCENT if wifi_state == "up" else (ACCENT2 if host_alive() else color.rgb(110, 110, 110))
    circ(screen, W - 8, 6, 3)

# ---------------------------------------------------------------- the room, live
STEP_X, STEP_Z, STEP_MS = 0.08, 0.05, 150
DIRS = {}

def my_av():
    for av in world["avatars"]:
        if av.get("id") == state["id"]:
            return av
    return None

def my_pos():
    av = my_av()
    if av:
        try:
            now_i, now_f = server_now()
            return room.room_pos(av, now_i, now_f, world["present_s"])
        except Exception as e:
            print("WW ERR pos", e)
    return (-0.5, 0.5)

def set_target(x, z, tx, tz):
    """Manual control: he is at (x, z) now and heads for (tx, tz). Shown locally at once,
    posted to the room shortly (every viewer walks him the same way from the same moment)."""
    now_i, now_f = server_now()
    mw = {"mood": "walk", "px": x, "pz": z, "pt": now_i, "pf": now_f, "tx": tx, "tz": tz}
    world["me_walk"] = mw
    world["move_due"] = True
    state["mood"] = "walk"
    av = my_av()
    if av:
        av.update(mw)

def walk(dx, dz):
    av = my_av()
    if not av:
        return
    now_i, now_f = server_now()
    if (now_i - av.get("entered", now_i)) + now_f < room.ENTRY_S:
        return                                         # still coming through the door
    x, z = my_pos()
    if av.get("mood") == "walk" and av.get("tx") is not None:
        tx, tz = av["tx"], av["tz"]
    else:
        tx, tz = x, z
    ntx = min(room.WALK[1], max(room.WALK[0], tx + dx))
    ntz = min(room.WALK[3], max(room.WALK[2], tz + dz))
    if room.in_desk(ntx, ntz):                         # the desk is a wall
        ntx, ntz = (tx, ntz) if dx else (ntx, tz)
        if room.in_desk(ntx, ntz):
            return
    set_target(x, z, ntx, ntz)

def send_move():
    mw = world["me_walk"]
    world["move_sent"] = time.ticks_ms()
    if not mw:
        world["move_due"] = False
        return
    data = api("move", {"id": state["id"], "token": state["token"], "x": mw["px"], "z": mw["pz"],
                        "tx": mw["tx"], "tz": mw["tz"]}, timeout=6)
    world["move_fail"] = data is None
    if data:
        world["move_due"] = False
        sync_clock(data)

def set_mood(m):
    """A mood from the menu; 'walk' only ever comes from the buttons."""
    state["mood"] = m
    save_state()
    av = my_av()
    if av:
        if av.get("mood") == "walk":
            av["mt"] = server_now()[0]
        av["mood"] = m
    world["me_walk"], world["move_due"] = None, False
    beat()

def menu_items():
    items = []
    x, z = my_pos()
    st = room.station_near(x, z) if state["mood"] == "walk" else None
    if st:
        top = (world["tops"].get(st[0]) or [None])[0]
        items.append(("play " + st[1] + ((" (top: %s %d)" % (str(top.get("name", ""))[:8], top.get("score", 0))) if top else ""), ("play", st)))
    items.append(("type a message", ("type", None)))
    items.append(("say a line from his pack", ("say", None)))
    items.append(("mood: %s  (B changes)" % ("manual" if state["mood"] == "walk" else state["mood"]), ("mood", None)))
    for s2 in room.STATIONS:
        if s2 is not st:
            items.append(("walk to the " + s2[1], ("goto", s2)))
    items.append(("info & QR page", ("info", None)))
    items.append(("leave the room", ("leave", None)))
    items.append(("back to the room", ("close", None)))
    return items

def draw_menu(items, sel):
    n = len(items)
    w, h = 236, 22 + n * 13 + 6
    x0, y0 = (W - w) // 2, (H - h) // 2
    screen.pen = color.rgb(0, 0, 0, 120)
    box(screen, 0, 13, W, H - 25)
    screen.pen = PANEL
    rbox(screen, x0, y0, w, h, 8)
    screen.pen = ACCENT
    box(screen, x0, y0, w, 18)
    text(screen, "what now?", x0 + 8, y0 + 4, BLACK, FONT_SMALL)
    text(screen, "UP/DOWN  B pick", x0 + w - 86, y0 + 4, BLACK, FONT_SMALL)
    for i, (label, _act) in enumerate(items):
        y = y0 + 22 + i * 13
        if i == sel:
            screen.pen = PANEL_HI
            box(screen, x0 + 3, y - 1, w - 6, 13)
        text(screen, label, x0 + 10, y + 1, ACCENT if i == sel else INK, FONT_SMALL)

def close_menu():
    global force_full
    world["menu"] = None
    force_full = True
    room.reset()

def menu_pick(act, arg):
    global force_full
    if act == "play":
        close_menu()
        start_game(arg)
    elif act == "type":
        close_menu()
        kb["text"], kb["r"], kb["c"] = "", 0, 0
        world["view"] = "type"
    elif act == "say":
        close_menu()
        line = random.choice(sayings())
        world["said"], world["said_t"] = line, now_s()
        beat(say=line)
    elif act == "mood":
        cur = state["mood"] if state["mood"] in MOODS else MOODS[-1]
        set_mood(MOODS[(MOODS.index(cur) + 1) % len(MOODS)])
        world["menu_dirty"] = True
    elif act == "goto":
        close_menu()
        x, z = my_pos()
        set_target(x, z, arg[2], arg[3])
        say_flash("walking to the " + arg[1], 1200)
    elif act == "info":
        close_menu()
        world["view"] = "info"
        world["dirty"] = True
    elif act == "leave":
        close_menu()
        leave_room()
        door["leaving"] = True
        go("door")
    else:
        close_menu()

def start_game(st):
    global force_full
    try:
        import games
    except Exception as e:
        print("WW ERR games", e)
        say_flash("games.py is missing: rerun the installer", 3000)
        return
    top = (world["tops"].get(st[0]) or [None])[0]
    world["game"] = games.start(st[0], {"screen": screen, "look": look, "draw_guy": draw_guy, "font_small": FONT_SMALL,
                                        "font_big": FONT_BIG, "font": FONT, "top": top, "name": name})
    world["game_id"] = st[0]
    api("set", {"id": state["id"], "token": state["token"], "act": st[0]}, timeout=5)
    me = my_av()
    if me:
        me["act"], me["act_t"] = st[0], server_now()[0]

def end_game(result):
    global force_full
    kind, score = result
    gid = world["game_id"]
    world["game"], world["game_id"] = None, None
    force_full = True
    room.reset()
    me = my_av()
    if me:
        me["act"] = None
    if kind == "done" and score is not None:
        data = api("score", {"id": state["id"], "token": state["token"], "game": gid, "score": score}, timeout=6)
        if data:
            if data.get("top"):
                world["tops"][gid] = [data["top"]]
            say_flash("new top score on the %s!" % room.GAME_LABELS.get(gid, gid) if data.get("rank") == 1 and score > 0
                      else "scored %d (your best %d)" % (score, data.get("best", score)), 2500)
        else:
            say_flash("score not posted: " + str(last_error)[:24], 2500)
    else:
        api("set", {"id": state["id"], "token": state["token"], "act": ""}, timeout=4)

# -- the mini keyboard
KB_ROWS = ("abcdefghij", "klmnopqrst", "uvwxyz'.!?", "1234567890")
KB_BOTTOM = (("space", 3), ("del", 2), ("Aa", 2), ("send", 2), ("back", 1))
KX0, KY0, KW, KH = 10, 70, 30, 24
kb = {"text": "", "r": 0, "c": 0, "caps": False, "hold": 0, "rep": 0, "bhold": 0, "brep": 0}

def kb_bottom_index(col):
    acc = 0
    for i, (_l, w) in enumerate(KB_BOTTOM):
        acc += w
        if col < acc:
            return i
    return len(KB_BOTTOM) - 1

def kb_bottom_col(i):
    return sum(w for _l, w in KB_BOTTOM[:i])

def kb_move(dr, dc):
    r, c = kb["r"], kb["c"]
    nrows = len(KB_ROWS) + 1
    if dr:
        if r == len(KB_ROWS):                     # leaving the bottom row
            c = kb_bottom_col(c)
        r = (r + dr) % nrows
        if r == len(KB_ROWS):
            c = kb_bottom_index(c)
    if dc:
        width = len(KB_BOTTOM) if r == len(KB_ROWS) else 10
        c = (c + dc) % width
    kb["r"], kb["c"] = r, c

def kb_pick():
    r, c = kb["r"], kb["c"]
    if r < len(KB_ROWS):
        if len(kb["text"]) < 140:
            ch = KB_ROWS[r][c]
            kb["text"] += ch.upper() if kb["caps"] else ch
        return None
    key = KB_BOTTOM[c][0]
    if key == "space":
        if kb["text"] and len(kb["text"]) < 140:
            kb["text"] += " "
    elif key == "del":
        kb["text"] = kb["text"][:-1]
    elif key == "Aa":
        kb["caps"] = not kb["caps"]
    elif key == "send":
        return "send"
    elif key == "back":
        return "back"
    return None

def keyboard_backdrop(img):
    img.pen = PANEL
    box(img, 0, 0, W, 22)
    box(img, 0, H - 16, W, 16)
    img.pen = color.rgb(22, 22, 26)
    rbox(img, 8, 26, W - 16, 38, 6)
    for r, row in enumerate(KB_ROWS):
        for c, ch in enumerate(row):
            img.pen = PANEL
            rbox(img, KX0 + c * KW + 1, KY0 + r * KH + 1, KW - 2, KH - 2, 4)
    y = KY0 + len(KB_ROWS) * KH
    for i, (label, w) in enumerate(KB_BOTTOM):
        x = KX0 + kb_bottom_col(i) * KW
        img.pen = PANEL_HI if label in ("send",) else PANEL
        rbox(img, x + 1, y + 1, w * KW - 2, KH - 2, 4)

def scene_type(pressed, held):
    """The mini keyboard: UP/DOWN/A/C move, B picks, hold B deletes."""
    global force_full
    now = time.ticks_ms()
    moved = False
    for btn, dr, dc in ((io.BUTTON_UP, -1, 0), (io.BUTTON_DOWN, 1, 0), (io.BUTTON_A, 0, -1), (io.BUTTON_C, 0, 1)):
        if btn in pressed:
            kb_move(dr, dc)
            kb["hold"], moved = now, True
        elif btn in held and time.ticks_diff(now, kb["hold"]) > 420 and time.ticks_diff(now, kb["rep"]) > 110:
            kb_move(dr, dc)
            kb["rep"], moved = now, True
    if io.BUTTON_B in pressed:
        kb["bhold"] = now
        res = kb_pick()
        if res == "send":
            msg = kb["text"].strip()
            if msg:
                world["said"], world["said_t"] = msg, now_s()
                world["view"] = "room"
                force_full = True
                room.reset()
                beat(say=msg)
                say_flash("sent", 900)
                return
        elif res == "back":
            world["view"] = "room"
            force_full = True
            room.reset()
            return
    elif io.BUTTON_B in held and time.ticks_diff(now, kb["bhold"]) > 500 and time.ticks_diff(now, kb["brep"]) > 140:
        kb["text"] = kb["text"][:-1]
        kb["brep"] = now
    full = show("type", keyboard_backdrop)
    if full:
        draw_header("type a message", SITE)
        draw_footer("UP/DOWN/A/C move   B pick   hold B delete")
    else:
        draw_pip(SITE)
        restore("type", 8, 26, W - 16, 38)
        restore("type", KX0 - 2, KY0 - 2, KW * 10 + 4, KH * (len(KB_ROWS) + 1) + 4)
    txt = kb["text"] + ("_" if (now // 400) % 2 else " ")
    lines = wrap(screen, txt, W - 36, FONT)[-2:]
    for i, l in enumerate(lines):
        text(screen, l, 16, 31 + i * 15, INK if kb["text"] else DIM, FONT)
    if not kb["text"]:
        text(screen, "say something to the room...", 16, 31, DIM, FONT)
    text(screen, "%d" % (140 - len(kb["text"])), W - 34, 50, DIM, FONT_SMALL)
    for r, row in enumerate(KB_ROWS):
        for c, ch in enumerate(row):
            sel = kb["r"] == r and kb["c"] == c
            x, y = KX0 + c * KW, KY0 + r * KH
            if sel:
                screen.pen = ACCENT
                rbox(screen, x + 1, y + 1, KW - 2, KH - 2, 4)
            center_text(screen, ch.upper() if (kb["caps"] and r < 3) else ch, x + KW / 2, y + 7, BLACK if sel else INK, FONT)
    y = KY0 + len(KB_ROWS) * KH
    for i, (label, w) in enumerate(KB_BOTTOM):
        x = KX0 + kb_bottom_col(i) * KW
        sel = kb["r"] == len(KB_ROWS) and kb["c"] == i
        if sel:
            screen.pen = ACCENT
            rbox(screen, x + 1, y + 1, w * KW - 2, KH - 2, 4)
        lab = ("AA" if kb["caps"] else "Aa") if label == "Aa" else label
        center_text(screen, lab, x + w * KW / 2, y + 8, BLACK if sel else (ACCENT if label == "send" and not sel else INK), FONT_SMALL)

def scene_world(pressed, held):
    global force_full
    now = time.ticks_ms()
    if time.ticks_diff(now, world["next_beat"]) > 0:
        beat()
    # a game owns the screen
    if world["game"]:
        try:
            res = world["game"].update(pressed, held)
        except Exception as e:
            print("WW ERR game", e)
            res = ("quit", None)
        if res:
            end_game(res)
        return
    if world["view"] == "type":
        scene_type(pressed, held)
        return
    if world["view"] == "info":
        if io.BUTTON_B in pressed or io.BUTTON_UP in pressed or io.BUTTON_DOWN in pressed:
            world["view"] = "room"
            force_full = True
            room.reset()
            return
        draw_info_page()
        return
    # the menu
    if world["menu"] is not None:
        items = menu_items()
        if io.BUTTON_UP in pressed:
            world["menu"] = (world["menu"] - 1) % len(items)
            world["menu_dirty"] = True
        if io.BUTTON_DOWN in pressed:
            world["menu"] = (world["menu"] + 1) % len(items)
            world["menu_dirty"] = True
        if io.BUTTON_B in pressed:
            label, (act, arg) = items[min(world["menu"], len(items) - 1)]
            menu_pick(act, arg)
            return
        if io.BUTTON_A in pressed or io.BUTTON_C in pressed:
            close_menu()
            return
        if world["menu_dirty"]:
            draw_menu(items, min(world["menu"], len(items) - 1))
            world["menu_dirty"] = False
        return
    if io.BUTTON_B in pressed:
        world["menu"], world["menu_dirty"] = 0, True
        return
    # manual control: a press steps, holding keeps walking
    stepped = False
    for btn, dx, dz in ((io.BUTTON_UP, 0, STEP_Z), (io.BUTTON_DOWN, 0, -STEP_Z), (io.BUTTON_A, -STEP_X, 0), (io.BUTTON_C, STEP_X, 0)):
        if btn in pressed:
            walk(dx, dz)
            world["dir_t"], stepped = now, True
        elif btn in held and time.ticks_diff(now, world["dir_t"]) > 250 and time.ticks_diff(now, world["dir_rep"]) > STEP_MS:
            walk(dx, dz)
            world["dir_rep"], stepped = now, True
    if world["move_due"]:
        since = time.ticks_diff(now, world["move_sent"])
        if world["move_fail"]:
            if since > 2500:
                send_move()
        elif (not stepped and since > 250) or since > 900:
            send_move()
    scene_studio()

def draw_info_page():
    mood = state["mood"]
    full = show("world", world_backdrop)
    if full:
        draw_header("he's in the room", SITE)
    else:
        restore("world", 8, 30, 106, WORLD_GY - 30 + 8)   # where he walks
        draw_pip(SITE)
    gy = WORLD_GY
    mood = state["mood"]
    tnow = now_s()
    ph = (tnow * (3.2 if mood == "zoom" else 1.6)) % 1.0
    if mood in ("wander", "zoom"):
        blit_guy(screen, 61, gy, 15, look, ph, 1)
    else:
        draw_guy(screen, 61, gy, 15, look, ph, 1, False, mood, tnow)
    if world["said"] and tnow - world["said_t"] < 5:
        draw_bubble(screen, world["said"], 66, gy - guy_height(15, look) - 2, 96, FONT_SMALL)
    if not (full or world["dirty"] or time.ticks_diff(time.ticks_ms(), world["drawn"]) > 1000):
        return
    world["dirty"] = False
    world["drawn"] = time.ticks_ms()
    x0, y0 = 122, 28
    restore("world", x0 + 2, y0 + 2, W - 128 - 4, 46)       # the names
    restore("world", x0 + 2, y0 + 62, W - 128 - 4, H - 48 - 64)   # chat + status
    restore("world", 0, H - 16, W, 16)
    n = len(world["here"])
    text(screen, "%d in the room" % n if n != 1 else "just him in here", x0 + 8, y0 + 6, ACCENT2, FONT_BIG)
    names = [h_.get("name", "?") for h_ in world["here"] if h_.get("id") != state["id"]]
    line, lines = "", []
    for nm in names:
        t_ = (line + ", " + nm) if line else nm
        if measure(screen, t_, FONT_SMALL)[0] > W - x0 - 24:
            lines.append(line)
            line = nm
        else:
            line = t_
    if line:
        lines.append(line)
    y = y0 + 24
    for l in lines[:2]:
        text(screen, l, x0 + 8, y, INK, FONT_SMALL)
        y += 11
    if not names:
        text(screen, "share the site. invite a friend.", x0 + 8, y, DIM, FONT_SMALL)
    y = y0 + 64
    chat = world["chat"][::-1]
    shown = chat[world["scroll"]:world["scroll"] + 3]
    if not chat:
        text(screen, "...quiet so far", x0 + 8, y, DIM, FONT_SMALL)
    for c in shown:
        who = c.get("name", "someone")
        for l in wrap(screen, who + ": " + str(c.get("text", "")), W - x0 - 24, FONT_SMALL)[:2]:
            text(screen, l, x0 + 8, y, ACCENT if c.get("who") == state["id"] else INK, FONT_SMALL)
            y += 11
        y += 2
    ago = time.ticks_diff(time.ticks_ms(), world["last_beat"]) // 1000 if world["last_beat"] else None
    status = "syncing..." if world["syncing"] else ("synced %ds ago" % ago if ago is not None else "waiting")
    if last_error and not world["syncing"]:
        status = "offline: " + last_error[:22]
    text(screen, status, x0 + 8, H - 32, DIM, FONT_SMALL)
    draw_footer("B  back to the room")

def scene_welcome(pressed, held):
    if io.BUTTON_A in pressed:
        door["leaving"] = False
        go("door")
        return
    if io.BUTTON_C in pressed:
        start_build()
        return
    if io.BUTTON_B in pressed:
        look.update(random_look())
        start_build()
        return
    full = show("panel", panel_backdrop)
    stage_live(bubble="hey, it's me, " + name.split(" ")[0] + "!")
    if not full:
        draw_pip(SITE)
        return
    draw_header("welcome back", SITE)
    draw_footer("HOME  back to the menu")
    x0, y0 = RIGHT[0], RIGHT[1]
    text(screen, name, x0 + 8, y0 + 8, INK, FONT_BIG)
    text(screen, "is ready to go.", x0 + 8, y0 + 28, DIM, FONT_SMALL)
    text(screen, "A  send him in", x0 + 8, y0 + 56, ACCENT2, FONT_BIG)
    text(screen, "C  edit him", x0 + 8, y0 + 80, INK, FONT_BIG)
    text(screen, "B  start over", x0 + 8, y0 + 104, DIM, FONT_BIG)

# ---------------------------------------------------------------- serial commands
def handle_serial(line):
    global host_seen_ms, name, look, dirty
    dirty = True
    parts = line.strip().split(" ", 1)
    if not parts or not parts[0]:
        return
    cmd = parts[0].upper()
    arg = parts[1] if len(parts) > 1 else ""
    if cmd == "WW":
        sub = arg.split(" ", 2)
        if len(sub) >= 3 and sub[0].upper() == "RES":
            try:
                _pending_res[int(sub[1])] = json.loads(sub[2])
            except Exception as e:
                print("WW ERR res", e)
        host_seen_ms = time.ticks_ms()
    elif cmd == "HELLO":
        host_seen_ms = time.ticks_ms()
        emit("WW READY v%s %s" % (VERSION, DEVICE))
    elif cmd == "PING":
        host_seen_ms = time.ticks_ms()
        emit("WW PONG")
    elif cmd == "SAY":
        if scene == "world" and arg:
            world["said"], world["said_t"] = arg[:120], now_s()
            beat(say=arg[:120])
        else:
            say_flash(arg[:60])
    elif cmd == "NAME":
        if arg:
            name = arg[:24]
            state["name"] = name
            save_state()
            if scene == "world":
                api("set", {"id": state["id"], "token": state["token"], "name": name}, timeout=6)
    elif cmd == "MOOD":
        if arg in MOODS:
            state["mood"] = arg
            save_state()
            if scene == "world":
                beat()
    elif cmd == "SAYINGS":
        try:
            lst = json.loads(arg)
            if isinstance(lst, list):
                state["sayings"] = [str(s)[:100] for s in lst][:12] or None
                save_state()
                if scene == "world":
                    api("set", {"id": state["id"], "token": state["token"], "sayings": sayings()}, timeout=6)
        except Exception as e:
            print("WW ERR sayings", e)
    elif cmd == "SHUFFLE":
        if scene in ("build", "name", "welcome"):
            look = random_look()
    elif cmd == "ENTER":
        if scene in ("build", "name", "welcome"):
            state["look"], state["name"] = look, name
            save_state()
            door["leaving"] = False
            go("door")
    elif cmd == "LEAVE":
        if scene == "world":
            leave_room()
            door["leaving"] = True
            go("door")
    elif cmd == "STAT":
        emit("WW STAT " + json.dumps({"v": VERSION, "device": DEVICE, "scene": scene, "wifi": wifi_state, "ip": ip_addr,
                                      "id": state["id"], "name": name, "mood": state["mood"], "look": look,
                                      "here": len(world["here"]), "bridge": host_alive(), "err": last_error}))
    elif cmd == "LOOK":
        try:
            lk = json.loads(arg)
            for k in look:
                if k in lk:
                    look[k] = int(lk[k])
        except Exception as e:
            print("WW ERR look", e)
    else:
        emit("WW ERR unknown " + cmd)

# ---------------------------------------------------------------- app entry points
FAKE = None                  # a test harness sets this to a set of buttons for one frame

DEBUG = bw.file_exists("/state/wesworld_debug") if hasattr(bw, "file_exists") else False
_frames = 0

def debug(msg):
    if DEBUG:
        try:
            with open("/state/wesworld_debug.txt", "a") as f:
                f.write(msg + "\n")
        except OSError:
            pass

def init():
    global look, name
    emit("WW READY v%s %s" % (VERSION, DEVICE))
    room.setup(draw_guy=draw_guy, guy_height=guy_height, draw_bubble=draw_bubble, font_small=FONT_SMALL,
               font_big=FONT_BIG, image=image, screen=screen)
    debug("init: W,H=%d,%d screen=%dx%d mode=%s id=%x" % (W, H, screen.width, screen.height, bw._current_mode, id(screen)))
    had = load_state()
    if state["mood"] == "walk":          # manual control never survives a restart
        state["mood"] = "auto"
    look = state["look"] or random_look()
    name = state["name"] or random_name()
    wifi_start()
    if had:
        go("welcome")
    else:
        go("build")

def update():
    """Called once by the launcher's badgeware.run(). That loop clears the screen before every
    frame, which would wipe the dirty-rectangle painting, so take the loop over and never
    return (HOME still works: the launcher's button IRQ calls on_exit and resets)."""
    while True:
        io.poll()
        frame()
        bw.display.update(True)

def frame():
    global _frames
    pressed = FAKE if FAKE is not None else io.pressed
    held = FAKE if FAKE is not None else io.held
    _frames += 1
    if DEBUG and _frames in (2, 60, 200):
        debug("frame %d: scene=%s screen=%dx%d shown=%s dirty=%s mode=%s pressed=%s" % (_frames, scene, screen.width, screen.height, _shown, dirty, bw._current_mode, list(pressed)))
        try:
            with open("/state/_dbg%d.raw" % _frames, "wb") as f:
                f.write(screen.raw)
        except OSError as e:
            debug("raw err %s" % e)
    for line in serial_lines():
        handle_serial(line)
    wifi_poll()
    if scene == "build":
        scene_build(pressed, held)
    elif scene == "name":
        scene_name(pressed, held)
    elif scene == "door":
        scene_door(pressed, held)
    elif scene == "world":
        scene_world(pressed, held)
    elif scene == "welcome":
        scene_welcome(pressed, held)
    draw_flash()

def on_exit():
    """The launcher calls this on HOME: tell the room he left, best effort."""
    try:
        if scene == "world":
            leave_room()
    except Exception:
        pass
    emit("WW BYE")

if __name__ == "__main__":
    init()
    update()
