const { useState, useEffect, useRef, useCallback, useMemo } = React;

/* ========================================================================== */
/*  Constants                                                                 */
/* ========================================================================== */
const CARD_TYPES = ["beast", "riddle", "trap", "buff", "debuff"];
const NODE_CYCLE = ["path", "beast", "riddle", "trap", "buff", "debuff"];
const TYPE_META = {
  start:   { color: "#8b93a0", glyph: "⚑", label: "Start" },
  path:    { color: "#5b6474", glyph: "•", label: "Path" },
  beast:   { color: "#e1533d", glyph: "☠", label: "Beast" },
  riddle:  { color: "#9a7bd6", glyph: "?", label: "Riddle" },
  trap:    { color: "#e0c24e", glyph: "△", label: "Trap" },
  buff:    { color: "#5fbf9b", glyph: "✦", label: "Buff" },
  debuff:  { color: "#e07ab0", glyph: "☾", label: "Debuff" },
  finish:  { color: "#e8a13a", glyph: "★", label: "Finish" },
};
const CARD_ACCENT = { beast:"#e1533d", riddle:"#9a7bd6", trap:"#e0c24e", buff:"#5fbf9b", debuff:"#e07ab0" };
const TRACK_COLORS = ["#e8a13a", "#4ea8e0"]; // A = amber, B = azure
const TRACK_LABEL = ["A", "B"];

/* ========================================================================== */
/*  Networking hook                                                           */
/* ========================================================================== */
function useGameSocket() {
  const [game, setGame] = useState(null);
  const [status, setStatus] = useState("idle"); // idle|connecting|open|closed
  const [pid, setPid] = useState(null);
  const wsRef = useRef(null);
  const cfgRef = useRef(null);
  const pingRef = useRef(null);
  const retryRef = useRef(0);
  const wantRef = useRef(false);

  const connect = useCallback((cfg) => {
    cfgRef.current = cfg;
    wantRef.current = true;
    const proto = location.protocol === "https:" ? "wss" : "ws";
    let stored = cfg.role === "player" ? localStorage.getItem("exp_pid_" + cfg.room) : null;
    const params = new URLSearchParams({ room: cfg.room, role: cfg.role, name: cfg.name || "" });
    if (stored) params.set("pid", stored);
    const url = `${proto}://${location.host}/ws?${params.toString()}`;
    setStatus("connecting");
    const ws = new WebSocket(url);
    wsRef.current = ws;

    ws.onopen = () => {
      setStatus("open");
      retryRef.current = 0;
      clearInterval(pingRef.current);
      pingRef.current = setInterval(() => {
        try { ws.send(JSON.stringify({ t: "ping" })); } catch {}
      }, 25000);
    };
    ws.onmessage = (e) => {
      let m; try { m = JSON.parse(e.data); } catch { return; }
      if (m.t === "welcome") {
        setPid(m.pid);
        if (cfg.role === "player" && m.pid) localStorage.setItem("exp_pid_" + cfg.room, m.pid);
      } else if (m.t === "state") {
        setGame(m.state);
      }
    };
    ws.onclose = () => {
      setStatus("closed");
      clearInterval(pingRef.current);
      if (!wantRef.current) return;
      const delay = Math.min(8000, 600 * Math.pow(1.6, retryRef.current++));
      setTimeout(() => { if (wantRef.current) connect(cfgRef.current); }, delay);
    };
    ws.onerror = () => { try { ws.close(); } catch {} };
  }, []);

  const disconnect = useCallback(() => {
    wantRef.current = false;
    clearInterval(pingRef.current);
    try { wsRef.current && wsRef.current.close(); } catch {}
  }, []);

  const send = useCallback((action) => {
    const ws = wsRef.current;
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ t: "action", action }));
    }
  }, []);

  useEffect(() => () => disconnect(), [disconnect]);
  return { game, status, pid, connect, disconnect, send };
}

/* ========================================================================== */
/*  Image helpers                                                             */
/* ========================================================================== */
function loadImg(src) {
  return new Promise((res, rej) => {
    const i = new Image();
    i.onload = () => res(i);
    i.onerror = rej;
    i.src = src;
  });
}
async function downscale(file, maxDim, quality = 0.85) {
  const img = await loadImg(URL.createObjectURL(file));
  const scale = Math.min(1, maxDim / Math.max(img.width, img.height));
  const w = Math.max(1, Math.round(img.width * scale));
  const h = Math.max(1, Math.round(img.height * scale));
  const c = document.createElement("canvas");
  c.width = w; c.height = h;
  c.getContext("2d").drawImage(img, 0, 0, w, h);
  return await new Promise((r) => c.toBlob(r, "image/jpeg", quality));
}
async function uploadImage(blob, { room, kind, id }) {
  const r = await fetch(`/upload?room=${encodeURIComponent(room)}&kind=${kind}&id=${encodeURIComponent(id)}`, {
    method: "POST", headers: { "content-type": blob.type || "image/jpeg" }, body: blob,
  });
  if (!r.ok) throw new Error("upload failed");
  return (await r.json()).key;
}
const imgUrl = (key) => (key ? `/img/${key}?v=${key.split("-").pop() || ""}` : null);

/* ========================================================================== */
/*  Sound — lazy Tone.js engine (needs a user gesture to arm)                 */
/* ========================================================================== */
const Sfx = {
  ready: false,
  async ensure() {
    if (this.ready || typeof Tone === "undefined") return;
    try {
      await Tone.start();
      this.membrane = new Tone.MembraneSynth({ volume: -6 }).toDestination();
      this.metal = new Tone.MetalSynth({ volume: -24, envelope: { attack: 0.001, decay: 0.3, release: 0.1 } }).toDestination();
      this.poly = new Tone.PolySynth(Tone.Synth).toDestination(); this.poly.volume.value = -12;
      this.noise = new Tone.NoiseSynth({ volume: -22, envelope: { attack: 0.005, decay: 0.15 } }).toDestination();
      this.ready = true;
    } catch {}
  },
  play(name) {
    if (!this.ready) return;
    const n = (typeof Tone !== "undefined") ? Tone.now() : 0;
    try {
      switch (name) {
        case "roll":
          this.membrane.triggerAttackRelease("C2", "16n", n);
          this.metal.triggerAttackRelease("32n", n + 0.06);
          this.metal.triggerAttackRelease("32n", n + 0.13); break;
        case "beast": case "debuff":
          this.poly.triggerAttackRelease(["C2", "C#3", "F#3"], "8n", n); break;
        case "trap":
          this.noise.triggerAttackRelease("8n", n);
          this.metal.triggerAttackRelease("16n", n + 0.02); break;
        case "riddle":
          this.poly.triggerAttackRelease(["D3", "A3"], "8n", n);
          this.poly.triggerAttackRelease(["E4"], "8n", n + 0.12); break;
        case "buff":
          this.poly.triggerAttackRelease(["C4", "E4", "G4"], "8n", n); break;
        case "hit":
          this.membrane.triggerAttackRelease("A1", "16n", n); break;
        case "slay":
          this.membrane.triggerAttackRelease("C1", "8n", n);
          this.poly.triggerAttackRelease(["C3", "G3"], "8n", n + 0.08); break;
        case "doomtick":
          this.membrane.triggerAttackRelease("E1", "16n", n); break;
        case "doomboom":
          this.membrane.triggerAttackRelease("C1", "2n", n);
          this.poly.triggerAttackRelease(["C2", "C#2"], "4n", n + 0.05); break;
        case "win":
          ["C4", "E4", "G4", "C5"].forEach((note, i) => this.poly.triggerAttackRelease(note, "8n", n + i * 0.12)); break;
      }
    } catch {}
  },
};

// Watches game state and fires sounds + a shake counter on notable changes.
function useGameFx(game) {
  const [shakeKey, setShakeKey] = useState(0);
  const prev = useRef({ roll: "", card: "", doom: 0, winner: null, threatHp: 0, threatN: 0 });
  useEffect(() => {
    if (!game) return;
    const p = prev.current;
    const r = game.lastRoll;
    const rollSig = r ? `${r.playerId}:${r.d1}${r.d2}:${r.to}` : "";
    if (rollSig && rollSig !== p.roll) Sfx.play("roll");

    const cardSig = game.activeCard ? game.activeCard.instanceId : "";
    if (cardSig && cardSig !== p.card) {
      Sfx.play(game.activeCard.type);
      if (game.activeCard.type === "trap" || game.activeCard.type === "beast") setShakeKey((k) => k + 1);
    }

    const doom = game.doom || 0;
    if (doom > p.doom) Sfx.play("doomtick");
    else if (doom < p.doom && p.doom > 0) { Sfx.play("doomboom"); setShakeKey((k) => k + 1); }

    if (game.winnerId && game.winnerId !== p.winner) Sfx.play("win");

    const threats = game.threats || [];
    const hp = threats.reduce((s, t) => s + t.hp, 0);
    const nT = threats.length;
    if (nT < p.threatN) { Sfx.play("slay"); setShakeKey((k) => k + 1); }
    else if (hp < p.threatHp) Sfx.play("hit");

    prev.current = { roll: rollSig, card: cardSig, doom, winner: game.winnerId, threatHp: hp, threatN: nT };
  }, [game]);
  return shakeKey;
}

// True when the viewport is wide enough for the GM's two-column layout.
function useIsWide(bp = 1024) {
  const [w, setW] = useState(typeof window !== "undefined" ? window.innerWidth : 1200);
  useEffect(() => {
    const on = () => setW(window.innerWidth);
    window.addEventListener("resize", on);
    return () => window.removeEventListener("resize", on);
  }, []);
  return w >= bp;
}

/* ========================================================================== */
/*  Small UI atoms                                                            */
/* ========================================================================== */
function Dot({ color, size = 10 }) {
  return <span style={{ width: size, height: size, borderRadius: "50%", background: color, display: "inline-block", boxShadow: `0 0 8px ${color}55` }} />;
}
function StatusLamp({ status }) {
  const map = { open: ["#5fbf9b", "Live"], connecting: ["#e0c24e", "Linking"], closed: ["#e1533d", "Reconnecting"], idle: ["#7b8494", "Idle"] };
  const [c, label] = map[status] || map.idle;
  return <span className="pill" style={{ borderColor: c + "55" }}><Dot color={c} size={8} />{label}</span>;
}

/* ========================================================================== */
/*  Dice                                                                      */
/* ========================================================================== */
const PIPS = { 1:[4], 2:[0,8], 3:[0,4,8], 4:[0,2,6,8], 5:[0,2,4,6,8], 6:[0,2,3,5,6,8] };
function Die({ n }) {
  return (
    <div className="tumbling" style={{
      width: 52, height: 52, borderRadius: 12, background: "linear-gradient(150deg,#f4ecd8,#d8cdb2)",
      display: "grid", gridTemplateColumns: "repeat(3,1fr)", gridTemplateRows: "repeat(3,1fr)",
      padding: 8, boxShadow: "inset 0 -3px 6px rgba(0,0,0,.2), 0 6px 14px rgba(0,0,0,.4)",
    }}>
      {Array.from({ length: 9 }).map((_, i) => (
        <span key={i} style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
          {(PIPS[n] || []).includes(i) && <span style={{ width: 9, height: 9, borderRadius: "50%", background: "#2a2013" }} />}
        </span>
      ))}
    </div>
  );
}
function DiceReadout({ roll, players }) {
  if (!roll) return null;
  const p = players[roll.playerId];
  const sig = `${roll.playerId}:${roll.d1}${roll.d2}:${roll.to}`;
  return (
    <div key={sig} className="pop-in" style={{ display: "flex", alignItems: "center", gap: 14 }}>
      <Die n={roll.d1} /><Die n={roll.d2} />
      <div>
        <div className="mono" style={{ fontSize: 30, lineHeight: 1, color: "var(--brass)" }}>{roll.total}</div>
        <div style={{ fontSize: 12, color: "var(--muted)" }}>{p ? p.name : "—"} · {roll.from}→{roll.to}</div>
      </div>
    </div>
  );
}

/* ========================================================================== */
/*  Board                                                                     */
/* ========================================================================== */
function NodePicker({ node, trackLabel, onSet, onDelete, onClose }) {
  const opts = [["path","Plain"],["beast","Beast"],["riddle","Riddle"],["trap","Trap"],["buff","Buff"],["debuff","Debuff"],["finish","Finish"]];
  const px = node.x, py = node.y;
  // Flip horizontally / vertically so the popover never spills past the clipped board edge.
  const horiz = px > 0.6 ? "right" : px < 0.4 ? "left" : "center";
  const below = py < 0.45;
  const pos = { position: "absolute", zIndex: 20 };
  if (horiz === "right") pos.right = `${(1 - px) * 100}%`;
  else pos.left = `${px * 100}%`;
  const txX = horiz === "center" ? "-50%" : horiz === "right" ? "8px" : "-8px";
  const txY = below ? "22px" : "-112%";
  pos.transform = `translate(${txX}, ${txY})`;
  return (
    <div className="pop-in" onClick={(e) => e.stopPropagation()}
      style={{
        ...pos,
        background: "var(--panel-2)", border: "1px solid var(--line-strong)", borderRadius: 10,
        padding: 8, boxShadow: "var(--shadow)", width: 208, maxWidth: "min(208px, 92%)",
      }}>
      <div style={{ fontSize: 10, letterSpacing: ".12em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 6 }}>Pathway {trackLabel} tile — cards auto-prompt the GM</div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 5 }}>
        {opts.map(([t, label]) => {
          const meta = TYPE_META[t];
          const active = node.type === t;
          return (
            <button key={t} className="btn sm"
              onClick={() => onSet(t)}
              style={{ padding: ".35rem .2rem", fontSize: 11, borderColor: active ? meta.color : "var(--line-strong)", color: active ? "#12151b" : meta.color, background: active ? meta.color : "var(--panel)" }}>
              {meta.glyph} {label}
            </button>
          );
        })}
      </div>
      <div style={{ display: "flex", gap: 6, marginTop: 6 }}>
        <button className="btn sm danger" style={{ flex: 1 }} onClick={onDelete}>Delete point</button>
        <button className="btn sm ghost" onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

function Board({ game, editMode, editTrack = 0, maxH = "min(70vh, 640px)", shakeKey = 0, atmosphere = true, onAddNode, onSetNodeType, onRemoveNode, onAttackThreat, onDamageThreat, onRemoveThreat, isGM }) {
  const wrapRef = useRef(null);
  const [ratio, setRatio] = useState(1.6);
  const [sel, setSel] = useState(null); // { track, index }
  const [shaking, setShaking] = useState(false);
  const map = game.maps.find((m) => m.id === game.activeMapId);
  const paths = map ? [(map.paths && map.paths[0]) || [], (map.paths && map.paths[1]) || []] : [[], []];
  const threats = game.threats || [];

  useEffect(() => { setSel(null); }, [editMode, editTrack, game.activeMapId]);
  useEffect(() => { if (!shakeKey) return; setShaking(true); const t = setTimeout(() => setShaking(false), 500); return () => clearTimeout(t); }, [shakeKey]);

  const handleBoardClick = (e) => {
    if (sel !== null) { setSel(null); return; }
    if (!editMode || !onAddNode) return;
    if (e.target.dataset && e.target.dataset.node !== undefined) return;
    const rect = wrapRef.current.getBoundingClientRect();
    const x = (e.clientX - rect.left) / rect.width;
    const y = (e.clientY - rect.top) / rect.height;
    onAddNode({ x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) });
  };

  // Group tokens by "track:pos" for fanning.
  const byNode = {};
  Object.values(game.players).forEach((p) => {
    const k = `${p.track || 0}:${p.pos}`;
    (byNode[k] = byNode[k] || []).push(p);
  });

  const stageStyle = map
    ? { position: "relative", width: "100%", maxHeight: maxH, maxWidth: `calc(${maxH} * ${ratio})`,
        aspectRatio: String(ratio), margin: "0 auto", borderRadius: 10, overflow: "hidden",
        cursor: editMode ? "crosshair" : "default", background: "#0e1116" }
    : { position: "relative", width: "100%", maxHeight: maxH, aspectRatio: "16/10", margin: "0 auto",
        borderRadius: 10, overflow: "hidden",
        background: "repeating-linear-gradient(45deg,#171b23,#171b23 12px,#191e28 12px,#191e28 24px)" };

  return (
    <div className="card-surface" style={{ padding: 10, position: "relative", display: "flex", justifyContent: "center" }}>
      <div ref={wrapRef} onClick={handleBoardClick} className={shaking ? "shake" : ""} style={stageStyle}>
        {map ? (
          <img key={map.key} src={imgUrl(map.key)} alt={map.name} draggable={false}
               onLoad={(e) => setRatio((e.target.naturalWidth || 16) / (e.target.naturalHeight || 10))}
               style={{ width: "100%", height: "100%", objectFit: "fill", display: "block", userSelect: "none" }} />
        ) : (
          <div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", color: "var(--muted)", textAlign: "center", padding: 20 }}>
            <div>
              <div style={{ fontSize: 40, opacity: .5 }}>🗺</div>
              <div className="display" style={{ marginTop: 6 }}>No map on the table yet</div>
              <div style={{ fontSize: 13, marginTop: 4 }}>The GM uploads a map to begin the journey.</div>
            </div>
          </div>
        )}

        {/* Path lines — one polyline per pathway */}
        <svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }}>
          {paths.map((path, t) => path.length > 1 && (
            <polyline key={t}
              points={path.map((n) => `${n.x * 100},${n.y * 100}`).join(" ")}
              fill="none" stroke={TRACK_COLORS[t]} strokeOpacity={editMode && editTrack !== t ? 0.3 : 0.7}
              strokeWidth="0.7" strokeLinejoin="round" strokeDasharray="1.6 1.4" vectorEffect="non-scaling-stroke"
              style={{ filter: `drop-shadow(0 0 3px ${TRACK_COLORS[t]}88)` }} />
          ))}
        </svg>

        {/* Nodes for both pathways */}
        {paths.map((path, t) => path.map((n, i) => {
          const meta = TYPE_META[n.type] || TYPE_META.path;
          const big = n.type !== "path";
          let sz = big ? 26 : 16;
          if (editMode) sz += 8;
          const dim = editMode && editTrack !== t;
          const selected = sel && sel.track === t && sel.index === i;
          return (
            <div key={`${t}-${i}`} data-node={i} data-track={t}
              onClick={(e) => { e.stopPropagation(); if (editMode) setSel({ track: t, index: i }); }}
              title={editMode ? `Path ${TRACK_LABEL[t]} #${i} ${meta.label} — click to mark` : `Path ${TRACK_LABEL[t]} #${i} ${meta.label}`}
              style={{
                position: "absolute", left: `${n.x * 100}%`, top: `${n.y * 100}%`,
                width: sz, height: sz, transform: "translate(-50%,-50%)", opacity: dim ? 0.4 : 1,
                borderRadius: "50%", background: meta.color + (big ? "" : "aa"),
                border: `2px solid ${meta.color}`, boxShadow: `0 0 0 2px ${TRACK_COLORS[t]}, 0 0 10px ${meta.color}66${selected ? `, 0 0 0 5px ${TRACK_COLORS[t]}66` : ""}`,
                display: "grid", placeItems: "center", color: "#12151b", fontSize: big ? 13 : 9, fontWeight: 700,
                cursor: editMode ? "pointer" : "default", zIndex: selected ? 15 : 2,
              }}>
              {big ? meta.glyph : ""}
            </div>
          );
        }))}

        {/* Tile-type picker */}
        {editMode && sel && paths[sel.track] && paths[sel.track][sel.index] && (
          <NodePicker
            node={paths[sel.track][sel.index]} trackLabel={TRACK_LABEL[sel.track]}
            onSet={(ty) => onSetNodeType && onSetNodeType(sel.track, sel.index, ty)}
            onDelete={() => { onRemoveNode && onRemoveNode(sel.track, sel.index); setSel(null); }}
            onClose={() => setSel(null)}
          />
        )}

        {/* Tokens — each player sits on their own pathway */}
        {Object.entries(byNode).map(([k, group]) =>
          group.map((p, gi) => {
            const [t, pos] = k.split(":").map(Number);
            const n = paths[t] && paths[t][pos];
            if (!n) return null;
            const fan = (gi - (group.length - 1) / 2) * 22;
            return (
              <div key={p.id} className="fade-in"
                   style={{ position: "absolute", left: `${n.x * 100}%`, top: `${n.y * 100}%`,
                            transform: `translate(calc(-50% + ${fan}px), -140%)`, zIndex: 5, transition: "left .5s ease, top .5s ease" }}>
                <div style={{ width: 40, height: 40, borderRadius: "50%", overflow: "hidden",
                  border: `2.5px solid ${p.color}`, boxShadow: `0 6px 14px rgba(0,0,0,.5), 0 0 0 2px #12151b`,
                  background: p.color, display: "grid", placeItems: "center", opacity: p.connected ? 1 : .45 }}>
                  {p.artKey
                    ? <img src={imgUrl(p.artKey)} alt={p.name} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
                    : <span style={{ fontFamily: "Cinzel,serif", fontWeight: 700, color: "#12151b" }}>{p.name.slice(0, 1).toUpperCase()}</span>}
                </div>
                <div style={{ position: "absolute", left: "50%", top: "100%", transform: "translateX(-50%)", marginTop: 2, fontSize: 10, whiteSpace: "nowrap", color: "var(--ink)", textShadow: "0 1px 3px #000" }}>{p.name}</div>
              </div>
            );
          })
        )}

        {/* Persistent threats */}
        {threats.map((th) => {
          const n = paths[th.track] && paths[th.track][th.pos];
          const pos = n || { x: 0.5, y: 0.12 }; // roaming threats with no node sit up top
          return (
            <div key={th.id} className="fade-in"
                 style={{ position: "absolute", left: `${pos.x * 100}%`, top: `${pos.y * 100}%`, transform: "translate(-50%,-160%)", zIndex: 6, width: 92, textAlign: "center" }}>
              <div style={{ width: 46, height: 46, margin: "0 auto", borderRadius: 12, display: "grid", placeItems: "center",
                background: "radial-gradient(circle at 40% 30%, #3a1414, #1a0808)", border: "2px solid var(--vermilion)",
                boxShadow: "0 0 16px rgba(225,83,61,.5)", fontSize: 24 }}>☠</div>
              <div style={{ fontSize: 10, color: "#f0a99d", marginTop: 2, whiteSpace: "nowrap", textShadow: "0 1px 3px #000", fontFamily: "Cinzel,serif" }}>{th.name}</div>
              <div style={{ height: 5, background: "rgba(0,0,0,.6)", borderRadius: 3, marginTop: 2, overflow: "hidden", border: "1px solid rgba(225,83,61,.4)" }}>
                <div style={{ height: "100%", width: `${(th.hp / th.maxHp) * 100}%`, background: "var(--vermilion)" }} />
              </div>
              <div className="mono" style={{ fontSize: 9, color: "var(--muted)" }}>{th.hp}/{th.maxHp}</div>
              <div style={{ display: "flex", gap: 3, justifyContent: "center", marginTop: 3 }}>
                {onAttackThreat && <button className="btn sm danger" style={{ padding: "0 .4rem", fontSize: 10 }} onClick={() => onAttackThreat(th.id)}>Attack</button>}
                {isGM && onDamageThreat && <button className="btn sm ghost" style={{ padding: "0 .3rem", fontSize: 10 }} title="−2 HP" onClick={() => onDamageThreat(th.id, -2)}>−2</button>}
                {isGM && onRemoveThreat && <button className="btn sm ghost" style={{ padding: "0 .3rem", fontSize: 10 }} title="Banish" onClick={() => onRemoveThreat(th.id)}>×</button>}
              </div>
            </div>
          );
        })}

        {atmosphere && map && <div className="torch-vignette" />}
      </div>
    </div>
  );
}

/* ========================================================================== */
/*  Party roster + effects                                                    */
/* ========================================================================== */
function EffectChip({ e, onRemove }) {
  const c = e.kind === "buff" ? "var(--jade)" : "var(--vermilion)";
  return (
    <span className="pill" style={{ borderColor: c + "55", color: c }}>
      {e.kind === "buff" ? "✦" : "☾"} {e.label}
      {onRemove && <button className="btn sm ghost" style={{ padding: "0 .2rem", color: c }} onClick={() => onRemove(e.id)}>×</button>}
    </span>
  );
}
function PlayerRow({ p, isCurrent, isGM, isFirst, isLast, maxPos, onEffect, onRemoveEffect, onHp, onMove, onKick, onRename, onSetPos, onSetHp, onReorder, onTrack, onSheet, onUseItem }) {
  const [open, setOpen] = useState(false);
  const [name, setName] = useState(p.name);
  const [pos, setPos] = useState(p.pos);
  const [hp, setHp] = useState(p.hp);
  useEffect(() => { setName(p.name); setPos(p.pos); setHp(p.hp); }, [p.name, p.pos, p.hp]);

  return (
    <div className="fade-in" style={{
      padding: "10px 12px", borderRadius: 10,
      background: isCurrent ? "linear-gradient(90deg,rgba(232,161,58,.14),transparent)" : "var(--panel-2)",
      border: `1px solid ${isCurrent ? "rgba(232,161,58,.4)" : "var(--line)"}`,
    }}>
      <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
        {isGM && (
          <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
            <button className="btn sm ghost" style={{ padding: "0 .3rem", lineHeight: 1.1 }} disabled={isFirst} title="Move up in turn order" onClick={() => onReorder(p.id, -1)}>▲</button>
            <button className="btn sm ghost" style={{ padding: "0 .3rem", lineHeight: 1.1 }} disabled={isLast} title="Move down in turn order" onClick={() => onReorder(p.id, 1)}>▼</button>
          </div>
        )}
        <div style={{ width: 40, height: 40, borderRadius: "50%", overflow: "hidden", border: `2px solid ${p.color}`, flexShrink: 0, background: p.color, display: "grid", placeItems: "center" }}>
          {p.artKey ? <img src={imgUrl(p.artKey)} style={{ width: "100%", height: "100%", objectFit: "cover" }} /> : <span className="display" style={{ color: "#12151b", fontWeight: 700 }}>{p.name.slice(0, 1).toUpperCase()}</span>}
        </div>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
            <strong style={{ fontFamily: "Cinzel,serif" }}>{p.name}</strong>
            {isCurrent && <span className="pill" style={{ borderColor: "var(--brass)", color: "var(--brass)" }}>on turn</span>}
            {!p.connected && <span className="pill" style={{ color: "var(--muted)" }}>away</span>}
            <span className="mono" style={{ fontSize: 12, color: "var(--muted)" }}>♥ {p.hp}/{p.maxHp} · <span style={{ color: TRACK_COLORS[p.track || 0] }}>path {TRACK_LABEL[p.track || 0]}</span> · #{p.pos}</span>
          </div>
          {p.effects.length > 0 && (
            <div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginTop: 6 }}>
              {p.effects.map((e) => <EffectChip key={e.id} e={e} onRemove={isGM ? (id) => onRemoveEffect(p.id, id) : null} />)}
            </div>
          )}
          {p.items && p.items.length > 0 && (
            <div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginTop: 6 }}>
              {p.items.map((it) => (
                <span key={it.id} className="pill" title={it.body} style={{ display: "inline-flex", alignItems: "center", gap: 6, borderColor: "var(--jade)", color: "var(--jade)" }}>
                  ✦ {it.title}
                  {isGM && onUseItem && <button className="btn sm primary" style={{ padding: "0 .4rem", fontSize: 10 }} onClick={() => onUseItem(p.id, it.id)}>play</button>}
                </span>
              ))}
            </div>
          )}
        </div>
        {isGM && (
          <div style={{ display: "flex", gap: 4, flexShrink: 0, flexWrap: "wrap", justifyContent: "flex-end", maxWidth: 250 }}>
            <button className="btn sm ghost" title="Move to pathway A" style={{ color: TRACK_COLORS[0], borderColor: (p.track === 0 ? TRACK_COLORS[0] : "var(--line-strong)") }} onClick={() => onTrack(p.id, 0)}>→A</button>
            <button className="btn sm ghost" title="Move to pathway B" style={{ color: TRACK_COLORS[1], borderColor: (p.track === 1 ? TRACK_COLORS[1] : "var(--line-strong)") }} onClick={() => onTrack(p.id, 1)}>→B</button>
            <button className="btn sm ghost" title="Move back 1" onClick={() => onMove(p.id, -1)}>◀</button>
            <button className="btn sm ghost" title="Move forward 1" onClick={() => onMove(p.id, 1)}>▶</button>
            <button className="btn sm ghost" title="Heal 1" onClick={() => onHp(p.id, 1)}>+♥</button>
            <button className="btn sm ghost" title="Damage 1" onClick={() => onHp(p.id, -1)}>−♥</button>
            <button className="btn sm ghost" style={{ color: "var(--jade)" }} onClick={() => onEffect(p.id, "buff")}>+Buff</button>
            <button className="btn sm ghost" style={{ color: "var(--vermilion)" }} onClick={() => onEffect(p.id, "debuff")}>+Debuff</button>
            <button className="btn sm ghost" title="Character sheet" onClick={() => onSheet(p.id)}>🗒</button>
            <button className={"btn sm " + (open ? "primary" : "ghost")} title="Manual edit" onClick={() => setOpen((v) => !v)}>✎</button>
            <button className="btn sm danger" title="Remove from party" onClick={() => onKick(p.id)}>×</button>
          </div>
        )}
      </div>

      {isGM && open && (
        <div className="fade-in" style={{ marginTop: 10, paddingTop: 10, borderTop: "1px solid var(--line)", display: "grid", gridTemplateColumns: "1fr auto auto", gap: 8, alignItems: "end" }}>
          <div>
            <label className="fld">Name</label>
            <div style={{ display: "flex", gap: 6 }}>
              <input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onRename(p.id, name)} />
              <button className="btn sm" onClick={() => onRename(p.id, name)}>Set</button>
            </div>
          </div>
          <div style={{ width: 96 }}>
            <label className="fld">Space {maxPos != null ? `(0–${maxPos})` : ""}</label>
            <div style={{ display: "flex", gap: 6 }}>
              <input className="mono" type="number" min={0} max={maxPos ?? undefined} value={pos} onChange={(e) => setPos(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onSetPos(p.id, +pos)} style={{ padding: ".45rem" }} />
              <button className="btn sm" onClick={() => onSetPos(p.id, +pos)}>Go</button>
            </div>
          </div>
          <div style={{ width: 96 }}>
            <label className="fld">Health</label>
            <div style={{ display: "flex", gap: 6 }}>
              <input className="mono" type="number" min={0} max={p.maxHp} value={hp} onChange={(e) => setHp(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onSetHp(p.id, +hp)} style={{ padding: ".45rem" }} />
              <button className="btn sm" onClick={() => onSetHp(p.id, +hp)}>Set</button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

/* ========================================================================== */
/*  Character sheet + stats (mini-VTT)                                        */
/* ========================================================================== */
function StatRow({ stat, canEdit, onCommit, onRemove }) {
  const [label, setLabel] = useState(stat.label);
  const [value, setValue] = useState(stat.value);
  useEffect(() => { setLabel(stat.label); setValue(stat.value); }, [stat.label, stat.value]);
  if (!canEdit) {
    return (
      <div style={{ display: "flex", justifyContent: "space-between", gap: 10, padding: "6px 10px", borderRadius: 8, background: "var(--panel-2)" }}>
        <span style={{ color: "var(--muted)", fontSize: 13 }}>{stat.label}</span>
        <span className="mono" style={{ fontWeight: 700 }}>{stat.value || "—"}</span>
      </div>
    );
  }
  const commit = () => onCommit(stat.id, label, value);
  return (
    <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
      <input value={label} onChange={(e) => setLabel(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} placeholder="Stat" style={{ flex: 1, padding: ".4rem .5rem" }} />
      <input value={value} onChange={(e) => setValue(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} placeholder="Value" className="mono" style={{ width: 84, padding: ".4rem .5rem" }} />
      <button className="btn sm danger" onClick={() => onRemove(stat.id)}>×</button>
    </div>
  );
}

function CharacterSheet({ player, room, canEdit, send, onClose }) {
  const stats = player.stats || [];
  const commitStat = (statId, label, value) => send({ type: "setStat", playerId: player.id, statId, label, value });
  const addStat = () => send({ type: "setStat", playerId: player.id, label: "New stat", value: "" });
  const addSpecial = () => {
    const set = [["Level", "1"], ["AC", "10"], ["S", "5"], ["P", "5"], ["E", "5"], ["C", "5"], ["I", "5"], ["A", "5"], ["L", "5"]];
    set.forEach(([label, value]) => send({ type: "setStat", playerId: player.id, label, value }));
  };

  return (
    <div style={{ position: "fixed", inset: 0, background: "rgba(6,8,12,.72)", backdropFilter: "blur(3px)", display: "grid", placeItems: "center", zIndex: 55, padding: 16 }} onClick={onClose}>
      <div className="pop-in card-surface" style={{ maxWidth: 720, width: "100%", maxHeight: "88vh", overflow: "auto", padding: 0 }} onClick={(e) => e.stopPropagation()}>
        <div style={{ display: "flex", alignItems: "center", gap: 12, padding: "16px 20px", borderBottom: "1px solid var(--line)", position: "sticky", top: 0, background: "var(--panel)", zIndex: 2 }}>
          <div style={{ width: 44, height: 44, borderRadius: "50%", overflow: "hidden", border: `2px solid ${player.color}`, background: player.color, display: "grid", placeItems: "center" }}>
            {player.artKey ? <img src={imgUrl(player.artKey)} style={{ width: "100%", height: "100%", objectFit: "cover" }} /> : <span className="display" style={{ color: "#12151b", fontWeight: 700 }}>{player.name.slice(0, 1).toUpperCase()}</span>}
          </div>
          <div style={{ flex: 1 }}>
            <div className="eyebrow">Character sheet</div>
            <h2 style={{ margin: "2px 0 0", fontSize: 22 }}>{player.name}</h2>
          </div>
          <span className="mono pill">♥ {player.hp}/{player.maxHp}</span>
          <button className="btn sm ghost" onClick={onClose}>Close</button>
        </div>

        <div style={{ display: "grid", gridTemplateColumns: "1fr 260px", gap: 16, padding: 20 }}>
          {/* Sheet document */}
          <div>
            <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
              <div className="eyebrow">Sheet document</div>
              {canEdit && <UploadButton className="btn sm" label={player.sheetKey ? "Replace" : "Upload image / PDF"} kind="sheet" room={room} id={player.id} maxDim={1600} accept="image/*,application/pdf" onKey={(key) => send({ type: "setSheet", playerId: player.id, key })} />}
            </div>
            {player.sheetKey ? (
              <div>
                <iframe title="sheet" src={imgUrl(player.sheetKey)} style={{ width: "100%", height: 440, border: "1px solid var(--line)", borderRadius: 10, background: "#fff" }} />
                <a className="btn sm ghost" href={imgUrl(player.sheetKey)} target="_blank" rel="noreferrer" style={{ marginTop: 8 }}>Open full size ↗</a>
              </div>
            ) : (
              <div style={{ height: 240, borderRadius: 10, border: "1px dashed var(--line-strong)", display: "grid", placeItems: "center", color: "var(--muted)", textAlign: "center", padding: 16 }}>
                <div>
                  <div style={{ fontSize: 30, opacity: .5 }}>🗒</div>
                  <div style={{ marginTop: 6 }}>{canEdit ? "Upload a character sheet — an image or a PDF." : "No sheet uploaded yet."}</div>
                </div>
              </div>
            )}
          </div>

          {/* Stats */}
          <div>
            <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
              <div className="eyebrow">Stats</div>
              {canEdit && <button className="btn sm ghost" onClick={addStat}>＋ Add</button>}
            </div>
            <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
              {stats.length === 0 && <div style={{ fontSize: 13, color: "var(--muted)" }}>{canEdit ? "No stats yet." : "—"}</div>}
              {stats.map((s) => (
                <StatRow key={s.id} stat={s} canEdit={canEdit} onCommit={commitStat} onRemove={(id) => send({ type: "removeStat", playerId: player.id, statId: id })} />
              ))}
            </div>
            {canEdit && stats.length === 0 && (
              <button className="btn sm ghost" style={{ marginTop: 8, width: "100%" }} onClick={addSpecial}>Add S.P.E.C.I.A.L. set</button>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

/* ========================================================================== */
/*  Held cards (a player's hand)                                              */
/* ========================================================================== */
function HandCard({ item, onUse, canUse = true }) {
  return (
    <div className="pop-in" style={{ background: "linear-gradient(180deg, rgba(95,191,155,.16), var(--panel-2))", border: "1px solid var(--jade)", borderRadius: 12, padding: "10px 12px", minWidth: 168, maxWidth: 220, display: "flex", flexDirection: "column", gap: 4 }}>
      <div className="eyebrow" style={{ color: "var(--jade)" }}>✦ held card</div>
      <div style={{ fontFamily: "Cinzel,serif", fontWeight: 600, fontSize: 15 }}>{item.title}</div>
      <div style={{ fontSize: 11.5, color: "var(--muted)", lineHeight: 1.35 }}>{item.body}</div>
      <div style={{ display: "flex", gap: 6, marginTop: 4, flexWrap: "wrap" }}>
        {item.move ? <span className="pill" style={{ color: "var(--jade)" }}>▶ {item.move > 0 ? "+" : ""}{item.move}</span> : null}
        {item.hp ? <span className="pill" style={{ color: "var(--jade)" }}>♥ +{item.hp}</span> : null}
        {item.effect ? <span className="pill">{item.effect.label}</span> : null}
      </div>
      {canUse && <button className="btn sm primary" style={{ marginTop: 6 }} onClick={onUse}>Play card</button>}
    </div>
  );
}

function Hand({ items, onUse, title = "Your hand", canUse = true }) {
  if (!items || !items.length) return null;
  return (
    <div className="card-surface fade-in" style={{ padding: 12 }}>
      <div className="eyebrow" style={{ marginBottom: 8, color: "var(--jade)" }}>{title} · {items.length}</div>
      <div style={{ display: "flex", gap: 10, overflowX: "auto", paddingBottom: 2 }}>
        {items.map((it) => <HandCard key={it.id} item={it} canUse={canUse} onUse={() => onUse(it.id)} />)}
      </div>
    </div>
  );
}

/* ========================================================================== */
/*  Card modal — prophecy + skill check                                       */
/* ========================================================================== */
function Typewriter({ text, speed = 26 }) {
  const [n, setN] = useState(0);
  useEffect(() => { setN(0); if (!text) return; const id = setInterval(() => setN((v) => { if (v >= text.length) { clearInterval(id); return v; } return v + 1; }), speed); return () => clearInterval(id); }, [text, speed]);
  return <span style={{ whiteSpace: "pre-wrap" }}>{text ? text.slice(0, n) : ""}</span>;
}

function Prophecy({ card, accent }) {
  return (
    <div className="prophecy" style={{ padding: "14px 18px", borderBottom: `1px solid ${accent}33`, background: "linear-gradient(180deg, rgba(10,8,14,.6), transparent)" }}>
      <div className="eyebrow" style={{ color: accent, marginBottom: 4 }}>◈ the board speaks</div>
      {card.narration
        ? <div className="display" style={{ fontSize: 16, lineHeight: 1.5, color: "var(--ink)", fontStyle: "italic" }}><Typewriter text={card.narration} /></div>
        : <div className="display" style={{ fontSize: 15, color: "var(--muted)", fontStyle: "italic", letterSpacing: ".08em" }}>the drums stir, the vision forms…</div>}
    </div>
  );
}

function CardModal({ card, players, isGM, pid, onResolve, onRollCheck }) {
  if (!card) return null;
  const accent = CARD_ACCENT[card.type] || "var(--brass)";
  const target = players[card.target];
  const cr = card.checkResult;
  const canRoll = card.check && !cr && (isGM || pid === card.target);
  const outcome = cr ? (cr.success ? card.onSuccess : card.onFail) : null;

  return (
    <div style={{ position: "fixed", inset: 0, background: "rgba(6,8,12,.75)", backdropFilter: "blur(3px)", display: "grid", placeItems: "center", zIndex: 50, padding: 16 }}>
      <div className="pop-in card-surface" style={{ maxWidth: 480, width: "100%", padding: 0, overflow: "hidden", borderColor: accent + "66" }}>
        <Prophecy card={card} accent={accent} />
        <div style={{ background: `linear-gradient(180deg,${accent}22,transparent)`, borderBottom: `1px solid ${accent}44`, padding: "14px 20px" }}>
          <div className="eyebrow" style={{ color: accent }}>{card.type} card{card.tag ? ` · ${card.tag}` : ""}</div>
          <h2 style={{ margin: "6px 0 0", fontSize: 23 }}>{card.title}</h2>
          {target && <div style={{ fontSize: 13, color: "var(--muted)", marginTop: 4 }}>Drawn for <Dot color={target.color} size={8} /> {target.name}</div>}
        </div>
        <div style={{ padding: 20 }}>
          <p style={{ fontSize: 15.5, lineHeight: 1.5, margin: 0 }}>{card.body}</p>
          {isGM && card.gm && (
            <div style={{ marginTop: 14, padding: 12, borderRadius: 10, background: "rgba(154,123,214,.12)", border: "1px solid rgba(154,123,214,.3)" }}>
              <div className="eyebrow" style={{ color: "var(--violet)" }}>GM only · answer</div>
              <div style={{ marginTop: 4 }}>{card.gm}</div>
            </div>
          )}

          {/* Skill check */}
          {card.check && (
            <div style={{ marginTop: 14, padding: 14, borderRadius: 10, border: `1px solid ${accent}44`, background: "var(--panel-2)" }}>
              <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10 }}>
                <div>
                  <div className="eyebrow" style={{ color: accent }}>Skill check</div>
                  <div style={{ fontSize: 15, marginTop: 2 }}>Roll <b>2d6 + {card.check.stat}</b> vs DC <b className="mono">{card.check.dc}</b></div>
                </div>
                {canRoll && <button className="btn primary" onClick={onRollCheck}>Roll the check</button>}
              </div>
              {cr && (
                <div className="pop-in" style={{ marginTop: 12 }}>
                  <div className="mono" style={{ fontSize: 15 }}>
                    {cr.d1}+{cr.d2}{cr.mod >= 0 ? "+" : ""}{cr.mod} = <b style={{ color: cr.success ? "var(--jade)" : "var(--vermilion)" }}>{cr.total}</b> vs {cr.dc}
                    &nbsp;→&nbsp;<b style={{ color: cr.success ? "var(--jade)" : "var(--vermilion)" }}>{cr.success ? "SUCCESS" : "FAILURE"}</b>
                  </div>
                  {outcome && outcome.text && <div style={{ marginTop: 6, fontStyle: "italic", color: "var(--ink-dim)" }}>{outcome.text}</div>}
                  {outcome && (
                    <div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 8 }}>
                      {outcome.move ? <span className="pill" style={{ color: outcome.move > 0 ? "var(--jade)" : "var(--vermilion)" }}>{outcome.move > 0 ? "▶" : "◀"} move {outcome.move > 0 ? "+" : ""}{outcome.move}</span> : null}
                      {outcome.hp ? <span className="pill" style={{ color: outcome.hp > 0 ? "var(--jade)" : "var(--vermilion)" }}>♥ {outcome.hp > 0 ? "+" : ""}{outcome.hp}</span> : null}
                      {outcome.skip ? <span className="pill" style={{ color: "var(--vermilion)" }}>skip next turn</span> : null}
                      {outcome.effect ? <span className="pill">✦ {outcome.effect.label}</span> : null}
                    </div>
                  )}
                </div>
              )}
            </div>
          )}

          {/* Flat outcome (non-check cards) */}
          {card.type === "buff" && (
            <div style={{ marginTop: 12, fontSize: 12.5, color: "var(--jade)" }}>✦ When resolved, this becomes a card in {target ? target.name + "'s" : "the player's"} hand — to play whenever they choose.</div>
          )}
          {!card.check && card.type !== "buff" && (card.move || card.hp || card.effect || card.skip) && (
            <div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 14 }}>
              {card.move ? <span className="pill" style={{ color: card.move > 0 ? "var(--jade)" : "var(--vermilion)" }}>{card.move > 0 ? "▶" : "◀"} move {card.move > 0 ? "+" : ""}{card.move}</span> : null}
              {card.hp ? <span className="pill" style={{ color: card.hp > 0 ? "var(--jade)" : "var(--vermilion)" }}>♥ {card.hp > 0 ? "+" : ""}{card.hp}</span> : null}
              {card.skip ? <span className="pill" style={{ color: "var(--vermilion)" }}>skip next turn</span> : null}
              {card.effect ? <span className="pill">✦ {card.effect.label}</span> : null}
            </div>
          )}

          <div style={{ display: "flex", gap: 10, marginTop: 20, justifyContent: "flex-end" }}>
            {isGM ? (
              <>
                <button className="btn ghost" onClick={() => onResolve(false)}>Dismiss</button>
                <button className="btn primary" disabled={card.check && !cr} onClick={() => onResolve(true)}>{card.check ? "Apply result" : "Apply outcome"}</button>
              </>
            ) : (
              <div style={{ fontSize: 13, color: "var(--muted)" }}>{canRoll ? "Roll your check above." : "Waiting for the GM to resolve…"}</div>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

/* ========================================================================== */
/*  Doom track + win overlay                                                  */
/* ========================================================================== */
function DoomBar({ game, isGM, send }) {
  const pct = Math.min(100, (game.doom / game.doomMax) * 100);
  const hot = pct >= 66;
  return (
    <div className={"card-surface " + (hot ? "doom-hot" : "")} style={{ padding: "8px 12px", display: "flex", alignItems: "center", gap: 10 }}>
      <span className="eyebrow" style={{ color: hot ? "var(--vermilion)" : "var(--brass)", whiteSpace: "nowrap" }}>〰 The Tremor</span>
      <div style={{ flex: 1, height: 10, borderRadius: 6, background: "rgba(0,0,0,.5)", overflow: "hidden", border: "1px solid var(--line-strong)" }}>
        <div style={{ height: "100%", width: `${pct}%`, transition: "width .4s ease", background: hot ? "linear-gradient(90deg,#e0c24e,#e1533d)" : "linear-gradient(90deg,#5fbf9b,#e0c24e)" }} />
      </div>
      <span className="mono" style={{ fontSize: 12, color: "var(--muted)" }}>{game.doom}/{game.doomMax}</span>
      {isGM && (
        <div style={{ display: "flex", gap: 4 }}>
          <button className="btn sm ghost" title="Ease the tremor" disabled={game.doom <= 0} onClick={() => send({ type: "advanceDoom", delta: -1 })}>−</button>
          <button className="btn sm ghost" title="Build the tremor" onClick={() => send({ type: "advanceDoom", delta: 1 })}>Advance</button>
        </div>
      )}
    </div>
  );
}

function WinOverlay({ game, isGM, send }) {
  if (game.phase !== "won" || !game.winnerId) return null;
  const w = game.players[game.winnerId];
  return (
    <div style={{ position: "fixed", inset: 0, background: "radial-gradient(circle at 50% 40%, rgba(232,161,58,.18), rgba(6,8,12,.9))", backdropFilter: "blur(4px)", display: "grid", placeItems: "center", zIndex: 60, padding: 16 }}>
      <div className="pop-in card-surface" style={{ maxWidth: 460, width: "100%", padding: 28, textAlign: "center", borderColor: "var(--brass)" }}>
        <div style={{ fontSize: 44 }}>★</div>
        <div className="eyebrow" style={{ color: "var(--brass)" }}>The board falls silent</div>
        <h1 style={{ margin: "6px 0 0", fontSize: 34, color: "var(--brass)" }}>{w ? w.name : "A hero"} wins</h1>
        <div className="display prophecy" style={{ marginTop: 14, fontStyle: "italic", fontSize: 16, lineHeight: 1.5, color: "var(--ink)" }}>
          {game.winNarration ? <Typewriter text={game.winNarration} /> : "The drums are done. The way lies open."}
        </div>
        {isGM
          ? <button className="btn primary big" style={{ marginTop: 22 }} onClick={() => send({ type: "newGame", keepMaps: true })}>Play again — same party</button>
          : <div style={{ marginTop: 22, color: "var(--muted)", fontSize: 13 }}>Waiting for the GM to start a new game…</div>}
      </div>
    </div>
  );
}

// A GM-summoned prophecy, shown to the whole table as a top banner.
function OmenBanner({ game, isGM, send }) {
  const o = game.omen;
  if (!o) return null;
  return (
    <div className="pop-in" style={{ position: "fixed", top: 12, left: "50%", transform: "translateX(-50%)", zIndex: 55, width: "min(560px, 94vw)" }}>
      <div className="card-surface" style={{ padding: "14px 18px", borderColor: "var(--violet)", boxShadow: "0 12px 40px rgba(0,0,0,.55), 0 0 22px rgba(154,123,214,.25)" }}>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10 }}>
          <div className="eyebrow" style={{ color: "var(--violet)" }}>🔮 the crystal ball speaks</div>
          {isGM && <button className="btn sm ghost" onClick={() => send({ type: "clearOmen" })}>Dismiss</button>}
        </div>
        <div className="display prophecy" style={{ marginTop: 6, fontStyle: "italic", fontSize: 16, lineHeight: 1.5, color: "var(--ink)" }}>
          {o.text ? <Typewriter text={o.text} /> : <span style={{ color: "var(--muted)", letterSpacing: ".08em" }}>gazing into the mists…</span>}
        </div>
      </div>
    </div>
  );
}

// GM: broadcast a message to the whole table — verbatim, or an AI prophecy.
function CrystalBall({ send }) {
  const [subject, setSubject] = useState("");
  const speak = () => { const t = subject.trim(); if (!t) return; send({ type: "speakOmen", text: t }); setSubject(""); };
  const consult = () => { send({ type: "omen", subject: subject.trim() }); setSubject(""); };
  return (
    <div>
      <div className="eyebrow" style={{ color: "var(--violet)" }}>🔮 Crystal ball</div>
      <p style={{ fontSize: 12, color: "var(--muted)", margin: "4px 0 8px" }}><b>Speak</b> broadcasts your exact words to the table. <b>Consult</b> turns them into an AI prophecy (needs the API key; without it you'll get a themed omen).</p>
      <input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="type a message or a hint…" onKeyDown={(e) => e.key === "Enter" && speak()} style={{ fontSize: 13, width: "100%" }} />
      <div style={{ display: "flex", gap: 6, marginTop: 8 }}>
        <button className="btn sm primary" style={{ flex: 1 }} disabled={!subject.trim()} onClick={speak}>📢 Speak</button>
        <button className="btn sm ghost" style={{ flex: 1, color: "var(--violet)", borderColor: "var(--violet)" }} onClick={consult}>🔮 Consult</button>
      </div>
    </div>
  );
}

// GM: change the narrator's persona (and optionally the model).
const VOICE_PRESETS = [
  ["Jumanji drums", "the living, cursed voice of an ancient board game — the drum-echo menace of Jumanji crossed with a dungeon master; ominous, archaic, rhyming"],
  ["Cryptic oracle", "a serene, cryptic oracle who speaks in short riddling couplets — calm, spare, and quietly unsettling"],
  ["Gravelly DM", "a grizzled tabletop dungeon master with a dark sense of humor — vivid, punchy, and only lightly rhyming"],
  ["Gleeful trickster", "a gleeful trickster spirit delighting in the party's misfortune — playful, sing-song, and wicked"],
  ["Fallout terminal", "a dry, glitching pre-war RobCo terminal AI narrating the wasteland — clipped, ironic, faintly menacing"],
];
function NarratorVoice({ game, send }) {
  const cur = game.narrator || { voice: "", model: "" };
  const [voice, setVoice] = useState(cur.voice || "");
  const [model, setModel] = useState(cur.model || "");
  const [saved, setSaved] = useState(false);
  useEffect(() => { setVoice(cur.voice || ""); }, [cur.voice]);
  useEffect(() => { setModel(cur.model || ""); }, [cur.model]);
  const dirty = voice !== (cur.voice || "") || model !== (cur.model || "");
  const save = () => { send({ type: "setNarrator", voice, model }); setSaved(true); setTimeout(() => setSaved(false), 2000); };
  return (
    <div>
      <div className="eyebrow" style={{ color: "var(--brass)" }}>🎙 Narrator voice</div>
      <p style={{ fontSize: 12, color: "var(--muted)", margin: "4px 0 8px" }}>Sets the persona the AI narrator speaks in for cards, victories, and omens. Test it with the crystal ball's <b>Consult</b>.</p>
      <div style={{ display: "flex", gap: 5, flexWrap: "wrap", marginBottom: 8 }}>
        {VOICE_PRESETS.map(([label, v]) => (
          <button key={label} className="btn sm ghost" style={{ fontSize: 11, padding: ".3rem .5rem", borderColor: voice === v ? "var(--brass)" : "var(--line-strong)", color: voice === v ? "var(--brass)" : "var(--ink-dim)" }} onClick={() => setVoice(v)}>{label}</button>
        ))}
      </div>
      <textarea value={voice} onChange={(e) => setVoice(e.target.value)} rows={3} placeholder="Describe the narrator's voice…"
                style={{ width: "100%", resize: "vertical", fontSize: 13, padding: ".5rem .6rem", lineHeight: 1.4 }} />
      <div style={{ display: "flex", gap: 6, alignItems: "center", marginTop: 8 }}>
        <input value={model} onChange={(e) => setModel(e.target.value)} placeholder="model (optional, e.g. claude-sonnet-5)" className="mono" style={{ fontSize: 12, flex: 1 }} />
        <button className="btn sm primary" disabled={!dirty && !saved} onClick={save} style={saved ? { background: "var(--jade)", borderColor: "var(--jade)" } : {}}>{saved ? "Saved ✓" : "Save"}</button>
      </div>
    </div>
  );
}

/* ========================================================================== */
/*  Effect prompt (GM applies custom buff/debuff)                             */
/* ========================================================================== */
function useEffectPrompt(send) {
  return useCallback((playerId, kind) => {
    const label = window.prompt(`${kind === "buff" ? "Buff" : "Debuff"} name?`, kind === "buff" ? "Blessed" : "Cursed");
    if (label === null) return;
    const note = window.prompt("Short note (optional)?", "") || "";
    const skip = kind === "debuff" ? window.confirm("Should this make them skip their next turn?") : false;
    send({ type: "applyEffect", playerId, kind, label, note, skip });
  }, [send]);
}

/* ========================================================================== */
/*  Upload button                                                             */
/* ========================================================================== */
function UploadButton({ label, className, style, kind, room, id, maxDim, accept, onKey }) {
  const ref = useRef(null);
  const [busy, setBusy] = useState(false);
  const pick = () => ref.current && ref.current.click();
  const onFile = async (e) => {
    const file = e.target.files[0]; if (!file) return;
    setBusy(true);
    try {
      const isImage = file.type.startsWith("image/");
      const blob = isImage ? await downscale(file, maxDim, kind === "map" ? 0.82 : 0.88) : file;
      const key = await uploadImage(blob, { room, kind, id });
      onKey(key);
    } catch (err) { alert("Upload failed: " + err.message); }
    finally { setBusy(false); e.target.value = ""; }
  };
  return (
    <>
      <input ref={ref} type="file" accept={accept || "image/*"} style={{ display: "none" }} onChange={onFile} />
      <button className={className || "btn"} style={style} onClick={pick} disabled={busy}>{busy ? "Uploading…" : label}</button>
    </>
  );
}

/* ========================================================================== */
/*  Turn banner                                                               */
/* ========================================================================== */
function TurnBanner({ game }) {
  if (game.phase !== "playing") return null;
  const cur = game.players[game.order[game.turnIdx]];
  if (!cur) return null;
  return (
    <div className="pill" style={{ borderColor: cur.color + "88", background: cur.color + "18", fontSize: 14, padding: ".4rem .8rem" }}>
      <Dot color={cur.color} /> <strong style={{ fontFamily: "Cinzel,serif" }}>{cur.name}</strong>&nbsp;is on the move
    </div>
  );
}

/* ========================================================================== */
/*  Player screen — board first                                              */
/* ========================================================================== */
function PlayerScreen({ game, pid, send, room, status }) {
  const me = game.players[pid];
  const myTurn = game.phase === "playing" && game.order[game.turnIdx] === pid;
  const canRoll = myTurn && !game.rolledThisTurn && !game.activeCard;
  const [rolling, setRolling] = useState(false);
  const [showSheet, setShowSheet] = useState(false);
  const shakeKey = useGameFx(game);

  const roll = () => { Sfx.ensure(); setRolling(true); send({ type: "roll" }); setTimeout(() => setRolling(false), 350); };

  return (
    <div style={{ maxWidth: 860, margin: "0 auto", padding: "14px 14px 120px" }}>
      <header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, marginBottom: 12, flexWrap: "wrap" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
          <img src="/logo.jpg" alt="" width={28} height={28} style={{ width: 28, height: 28, borderRadius: 7, boxShadow: "0 0 0 1px var(--line-strong)" }} />
          <h1 style={{ margin: 0, fontSize: 20, color: "var(--brass)", letterSpacing: ".04em" }}>DRUTUROS</h1>
          <span className="pill mono">{room}</span>
          <span className="pill">Game {game.gameNumber}</span>
        </div>
        <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
          <TurnBanner game={game} />
          <StatusLamp status={status} />
        </div>
      </header>

      {game.phase !== "lobby" && <div style={{ marginBottom: 12 }}><DoomBar game={game} isGM={false} send={send} /></div>}

      <Board game={game} editMode={false} maxH="min(64vh, 600px)" shakeKey={shakeKey}
             onAttackThreat={(threatId) => send({ type: "attackThreat", threatId })} />

      {game.phase === "lobby" && (
        <div className="card-surface fade-in" style={{ padding: 16, marginTop: 12, textAlign: "center" }}>
          <div className="eyebrow">Waiting room</div>
          <p style={{ margin: "6px 0 0", color: "var(--ink-dim)" }}>The GM hasn't started the run yet. Upload your character art and fill your sheet while you wait.</p>
        </div>
      )}

      {game.lastRoll && (
        <div className="card-surface" style={{ padding: 14, marginTop: 12, display: "flex", justifyContent: "center" }}>
          <DiceReadout roll={game.lastRoll} players={game.players} />
        </div>
      )}

      {me && me.items && me.items.length > 0 && (
        <div style={{ marginTop: 12 }}>
          <Hand items={me.items} onUse={(itemId) => send({ type: "useItem", itemId })} />
        </div>
      )}

      {/* Fixed action bar */}
      <div style={{ position: "fixed", left: 0, right: 0, bottom: 0, padding: "12px 14px calc(12px + env(safe-area-inset-bottom))", background: "linear-gradient(180deg,transparent,var(--bg) 30%)", zIndex: 20 }}>
        <div className="card-surface" style={{ maxWidth: 860, margin: "0 auto", padding: 12, display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
          {me && (
            <div style={{ display: "flex", alignItems: "center", gap: 8, marginRight: "auto" }}>
              <div style={{ width: 38, height: 38, borderRadius: "50%", overflow: "hidden", border: `2px solid ${me.color}`, background: me.color, display: "grid", placeItems: "center" }}>
                {me.artKey ? <img src={imgUrl(me.artKey)} style={{ width: "100%", height: "100%", objectFit: "cover" }} /> : <span className="display" style={{ color: "#12151b", fontWeight: 700 }}>{me.name.slice(0, 1).toUpperCase()}</span>}
              </div>
              <div>
                <div style={{ fontFamily: "Cinzel,serif", fontWeight: 600, lineHeight: 1 }}>{me.name}</div>
                <div className="mono" style={{ fontSize: 11, color: "var(--muted)" }}>♥ {me.hp} · path {TRACK_LABEL[me.track || 0]} · space {me.pos}</div>
              </div>
            </div>
          )}
          <button className="btn sm ghost" onClick={() => setShowSheet(true)}>Character</button>
          <UploadButton className="btn sm ghost" label={me && me.artKey ? "Change art" : "Upload art"} kind="art" room={room} id={pid} maxDim={512} onKey={(key) => send({ type: "setArt", key })} />
          <button className="btn" title="Draw a random card" disabled={!myTurn || !!game.activeCard} onClick={() => { Sfx.ensure(); send({ type: "drawCard" }); }}>🎴 Draw</button>
          <button className="btn big primary" style={{ minWidth: 132 }} disabled={!canRoll} onClick={roll}>
            {rolling ? "Rolling…" : myTurn ? (game.rolledThisTurn ? "Rolled" : "Roll 2d6") : "Not your turn"}
          </button>
          <button className="btn big" disabled={!myTurn} onClick={() => send({ type: "endTurn" })}>End turn</button>
        </div>
      </div>

      <CardModal card={game.activeCard} players={game.players} isGM={false} pid={pid} onRollCheck={() => send({ type: "rollCheck" })} />
      <OmenBanner game={game} isGM={false} send={send} />
      <WinOverlay game={game} isGM={false} send={send} />
      {showSheet && me && <CharacterSheet player={me} room={room} canEdit send={send} onClose={() => setShowSheet(false)} />}
    </div>
  );
}

/* ========================================================================== */
/*  GM screen — control room                                                  */
/* ========================================================================== */
function MapRow({ m, active, send }) {
  const [theme, setTheme] = useState(m.theme || "");
  useEffect(() => { setTheme(m.theme || ""); }, [m.theme]);
  const commit = () => { if (theme !== (m.theme || "")) send({ type: "setMapTheme", mapId: m.id, theme }); };
  return (
    <div style={{ padding: 8, borderRadius: 10, background: active ? "rgba(232,161,58,.12)" : "var(--panel-2)", border: `1px solid ${active ? "rgba(232,161,58,.4)" : "var(--line)"}` }}>
      <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
        <img src={imgUrl(m.key)} style={{ width: 54, height: 40, objectFit: "cover", borderRadius: 6 }} />
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontFamily: "Cinzel,serif", fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.name}</div>
          <div style={{ fontSize: 11, color: "var(--muted)" }}>
            <span style={{ color: TRACK_COLORS[0] }}>A {((m.paths && m.paths[0]) || []).length}</span> · <span style={{ color: TRACK_COLORS[1] }}>B {((m.paths && m.paths[1]) || []).length}</span>{active ? " · active" : ""}
          </div>
        </div>
        {!active && <button className="btn sm" onClick={() => { if (confirm("Switch to this map? Everyone returns to its start.")) send({ type: "setActiveMap", mapId: m.id }); }}>Use</button>}
        <button className="btn sm danger" onClick={() => { if (confirm("Delete this map?")) send({ type: "removeMap", mapId: m.id }); }}>×</button>
      </div>
      <input value={theme} onChange={(e) => setTheme(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()}
             placeholder="Theme — sets the narrator's voice" style={{ marginTop: 8, fontSize: 12, padding: ".4rem .5rem" }} />
    </div>
  );
}

function GMScreen({ game, send, room, status }) {
  const [tab, setTab] = useState("board"); // board|maps|party
  const [editMode, setEditMode] = useState(false);
  const [editTrack, setEditTrack] = useState(0); // which pathway is being edited
  const [sheetFor, setSheetFor] = useState(null); // player id whose sheet is open
  const promptEffect = useEffectPrompt(send);
  const shakeKey = useGameFx(game);
  const map = game.maps.find((m) => m.id === game.activeMapId);
  const cur = game.players[game.order[game.turnIdx]];

  const summonThreat = () => {
    const name = prompt("Name the threat?", "Cave Lurker");
    if (name === null) return;
    const hp = parseInt(prompt("Threat HP?", "12"), 10) || 12;
    const track = cur ? (cur.track || 0) : 0;
    const pos = cur ? cur.pos : 0;
    send({ type: "spawnThreat", name, hp, track, pos });
  };

  // Working copies of the active map's two pathways for the editor.
  const paths = map ? [(map.paths && map.paths[0]) || [], (map.paths && map.paths[1]) || []] : [[], []];
  const editPath = paths[editTrack];
  const maxPos = null; // per-track; manual space input is clamped server-side

  const reorder = (id, dir) => {
    const order = [...game.order];
    const i = order.indexOf(id);
    const j = i + dir;
    if (i < 0 || j < 0 || j >= order.length) return;
    [order[i], order[j]] = [order[j], order[i]];
    send({ type: "setOrder", order });
  };
  const addNode = (pt) => {
    if (!map) return;
    send({ type: "setPath", mapId: map.id, track: editTrack, path: [...editPath, { ...pt, type: "path" }] });
  };
  const setNodeType = (track, i, type) => {
    if (!map) return;
    send({ type: "setNodeType", mapId: map.id, track, index: i, type });
  };
  const removeNode = (track, i) => {
    if (!map) return;
    send({ type: "setPath", mapId: map.id, track, path: paths[track].filter((_, idx) => idx !== i) });
  };

  const wide = useIsWide(1024);
  return (
    <div style={{ display: "grid", gridTemplateColumns: wide ? "minmax(0,1fr) 380px" : "1fr", gap: 14, padding: 14, alignItems: "start", minHeight: "100vh" }}>
      {/* Board column */}
      <div>
        <header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, marginBottom: 12, flexWrap: "wrap" }}>
          <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
            <img src="/logo.jpg" alt="" width={30} height={30} style={{ width: 30, height: 30, borderRadius: 7, boxShadow: "0 0 0 1px var(--line-strong)" }} />
            <h1 style={{ margin: 0, fontSize: 22, color: "var(--brass)", letterSpacing: ".04em" }}>DRUTUROS</h1>
            <span className="pill" style={{ borderColor: "var(--brass)", color: "var(--brass)" }}>GM screen</span>
            <span className="pill mono">{room}</span>
            <span className="pill">Game {game.gameNumber}</span>
          </div>
          <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
            <TurnBanner game={game} />
            <StatusLamp status={status} />
          </div>
        </header>

        {map && (
          <div style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 8, flexWrap: "wrap" }}>
            <button className={"btn sm " + (editMode ? "primary" : "ghost")} onClick={() => setEditMode((v) => !v)}>
              {editMode ? "Done editing" : "Edit paths"}
            </button>
            {editMode && (
              <>
                <div style={{ display: "flex", gap: 4, alignItems: "center" }}>
                  <span style={{ fontSize: 12, color: "var(--muted)" }}>Editing</span>
                  {[0, 1].map((t) => (
                    <button key={t} className={"btn sm " + (editTrack === t ? "primary" : "ghost")}
                      style={editTrack === t ? { background: TRACK_COLORS[t], borderColor: TRACK_COLORS[t], color: "#12151b" } : { borderColor: TRACK_COLORS[t] + "66", color: TRACK_COLORS[t] }}
                      onClick={() => setEditTrack(t)}>Pathway {TRACK_LABEL[t]}</button>
                  ))}
                </div>
                <span style={{ fontSize: 12, color: "var(--muted)" }}>Click map to add points to pathway {TRACK_LABEL[editTrack]} · click a point to mark it a beast / riddle / trap / buff / debuff / finish</span>
                <button className="btn sm danger" onClick={() => send({ type: "setPath", mapId: map.id, track: editTrack, path: [] })}>Clear {TRACK_LABEL[editTrack]}</button>
              </>
            )}
            <span style={{ marginLeft: "auto", fontSize: 12, color: "var(--muted)" }}>
              <span style={{ color: TRACK_COLORS[0] }}>A: {paths[0].length}</span> · <span style={{ color: TRACK_COLORS[1] }}>B: {paths[1].length}</span> on “{map.name}”
            </span>
          </div>
        )}

        <Board game={game} editMode={editMode} editTrack={editTrack} maxH="min(62vh, 560px)" shakeKey={shakeKey} isGM
               onAddNode={addNode} onSetNodeType={setNodeType} onRemoveNode={removeNode}
               onDamageThreat={(id, d) => send({ type: "damageThreat", threatId: id, delta: d })}
               onRemoveThreat={(id) => send({ type: "removeThreat", threatId: id })} />

        {game.phase !== "lobby" && <div style={{ marginTop: 12 }}><DoomBar game={game} isGM send={send} /></div>}

        {game.lastRoll && (
          <div className="card-surface" style={{ padding: 14, marginTop: 12, display: "flex", justifyContent: "center" }}>
            <DiceReadout roll={game.lastRoll} players={game.players} />
          </div>
        )}

        {/* Legend */}
        <div style={{ display: "flex", gap: 12, flexWrap: "wrap", marginTop: 12, fontSize: 12, color: "var(--muted)" }}>
          {Object.entries(TYPE_META).filter(([k]) => k !== "path").map(([k, m]) => (
            <span key={k} style={{ display: "inline-flex", alignItems: "center", gap: 5 }}><Dot color={m.color} size={9} />{m.label}</span>
          ))}
        </div>
      </div>

      {/* Control rail */}
      <aside className="card-surface" style={{ padding: 14, position: wide ? "sticky" : "static", top: 14 }}>
        {/* Run controls */}
        <div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
          {game.phase === "lobby"
            ? <button className="btn primary" style={{ flex: 1 }} disabled={game.order.length === 0} onClick={() => send({ type: "startGame" })}>▶ Start run</button>
            : <button className="btn primary" style={{ flex: 1 }} onClick={() => send({ type: "endTurn" })}>End {cur ? cur.name + "’s" : ""} turn</button>}
          <button className="btn" title="New game, same party" onClick={() => { if (confirm("Start a new game with the same players? Positions and effects reset.")) send({ type: "newGame", keepMaps: true }); }}>↻ New game</button>
        </div>

        {/* Tabs */}
        <div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
          {["board", "maps", "party"].map((t) => (
            <button key={t} className={"btn sm " + (tab === t ? "primary" : "ghost")} style={{ flex: 1, textTransform: "capitalize" }} onClick={() => setTab(t)}>{t === "board" ? "Cards" : t}</button>
          ))}
        </div>

        {tab === "board" && (
          <div className="fade-in">
            <div className="eyebrow">Draw a card</div>
            <p style={{ fontSize: 12, color: "var(--muted)", margin: "4px 0 10px" }}>Cards also auto-draw when a player lands on a typed tile. Drawn cards target the current player.</p>
            <div style={{ display: "grid", gridTemplateColumns: "repeat(2,1fr)", gap: 8 }}>
              {CARD_TYPES.map((t) => (
                <button key={t} className="btn sm" style={{ borderColor: CARD_ACCENT[t] + "66", color: CARD_ACCENT[t], textTransform: "capitalize" }}
                        onClick={() => send({ type: "drawCard", cardType: t })}>{TYPE_META[t].glyph} {t}</button>
              ))}
              <button className="btn sm ghost" onClick={() => send({ type: "drawCard" })}>🎴 Random</button>
            </div>

            <div className="divider" />
            <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
              <div className="eyebrow" style={{ color: "var(--vermilion)" }}>Threats</div>
              <button className="btn sm danger" onClick={summonThreat}>☠ Summon</button>
            </div>
            <p style={{ fontSize: 12, color: "var(--muted)", margin: "4px 0 8px" }}>Persistent monsters the party attacks down (2d6 + Strength). Placed on the current player's tile.</p>
            {(game.threats || []).length === 0
              ? <div style={{ fontSize: 12, color: "var(--muted)" }}>No threats on the board.</div>
              : (game.threats || []).map((th) => (
                <div key={th.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", borderRadius: 8, background: "var(--panel-2)", marginBottom: 6 }}>
                  <span style={{ fontSize: 16 }}>☠</span>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontFamily: "Cinzel,serif", fontSize: 13 }}>{th.name}</div>
                    <div style={{ height: 4, background: "rgba(0,0,0,.5)", borderRadius: 3, marginTop: 2 }}><div style={{ height: "100%", width: `${(th.hp / th.maxHp) * 100}%`, background: "var(--vermilion)", borderRadius: 3 }} /></div>
                  </div>
                  <span className="mono" style={{ fontSize: 11, color: "var(--muted)" }}>{th.hp}/{th.maxHp}</span>
                  <button className="btn sm ghost" onClick={() => send({ type: "damageThreat", threatId: th.id, delta: -2 })}>−2</button>
                  <button className="btn sm danger" onClick={() => send({ type: "removeThreat", threatId: th.id })}>×</button>
                </div>
              ))}

            <div className="divider" />
            <CrystalBall send={send} />
            <div className="divider" />
            <NarratorVoice game={game} send={send} />

            <div className="divider" />
            <div className="eyebrow">Recent events</div>
            <div style={{ maxHeight: 200, overflow: "auto", marginTop: 8, display: "flex", flexDirection: "column-reverse", gap: 6 }}>
              {game.log.slice(-30).map((l) => (
                <div key={l.id} style={{ fontSize: 12.5, color: "var(--ink-dim)", borderLeft: "2px solid var(--line-strong)", paddingLeft: 8 }}>{l.msg}</div>
              ))}
            </div>
          </div>
        )}

        {tab === "maps" && (
          <div className="fade-in">
            <UploadButton className="btn primary" style={{ width: "100%" }} label="＋ Upload a map"
                          kind="map" room={room} id={"m"} maxDim={1600}
                          onKey={(key) => { const name = prompt("Name this map?", "New map") || "New map"; const theme = prompt("One-line theme? (sets the narrator's voice)", "A vine-choked jungle temple, ancient and hungry") || ""; send({ type: "addMap", name, key, theme }); }} />
            <div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 8 }}>
              {game.maps.length === 0 && <div style={{ fontSize: 13, color: "var(--muted)" }}>No maps yet. Each map has two pathways and its own theme — the theme colours the AI narrator's prophecies.</div>}
              {game.maps.map((m) => (
                <MapRow key={m.id} m={m} active={m.id === game.activeMapId} send={send} />
              ))}
            </div>
          </div>
        )}

        {tab === "party" && (
          <div className="fade-in">
            <div className="eyebrow" style={{ marginBottom: 8 }}>Party · {game.order.length} · ▲▼ reorders turns</div>
            <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
              {game.order.length === 0 && <div style={{ fontSize: 13, color: "var(--muted)" }}>No players yet. Share the room code <b className="mono">{room}</b> — players join and pick “Player”.</div>}
              {game.order.map((id, i) => {
                const p = game.players[id]; if (!p) return null;
                return <PlayerRow key={id} p={p} isGM
                                  isCurrent={game.phase === "playing" && game.order[game.turnIdx] === id}
                                  isFirst={i === 0} isLast={i === game.order.length - 1} maxPos={maxPos}
                                  onEffect={promptEffect}
                                  onRemoveEffect={(pid2, eid) => send({ type: "removeEffect", playerId: pid2, effectId: eid })}
                                  onHp={(pid2, d) => send({ type: "adjustHp", playerId: pid2, delta: d })}
                                  onSetHp={(pid2, target) => send({ type: "adjustHp", playerId: pid2, delta: target - game.players[pid2].hp })}
                                  onMove={(pid2, d) => send({ type: "movePlayer", playerId: pid2, delta: d })}
                                  onSetPos={(pid2, target) => send({ type: "movePlayer", playerId: pid2, delta: target - game.players[pid2].pos })}
                                  onRename={(pid2, name) => name.trim() && send({ type: "rename", id: pid2, name: name.trim() })}
                                  onReorder={reorder}
                                  onTrack={(pid2, track) => send({ type: "setTrack", playerId: pid2, track })}
                                  onSheet={(pid2) => setSheetFor(pid2)}
                                  onUseItem={(pid2, itemId) => send({ type: "useItem", playerId: pid2, itemId })}
                                  onKick={(pid2) => { if (confirm("Remove " + p.name + "?")) send({ type: "removePlayer", id: pid2 }); }} />;
              })}
            </div>
          </div>
        )}
      </aside>

      <CardModal card={game.activeCard} players={game.players} isGM pid={null} onRollCheck={() => send({ type: "rollCheck" })} onResolve={(apply) => send({ type: "resolveCard", apply })} />
      <OmenBanner game={game} isGM send={send} />
      <WinOverlay game={game} isGM send={send} />
      {sheetFor && game.players[sheetFor] && <CharacterSheet player={game.players[sheetFor]} room={room} canEdit send={send} onClose={() => setSheetFor(null)} />}
    </div>
  );
}

/* ========================================================================== */
/*  Join screen                                                               */
/* ========================================================================== */
function JoinScreen({ onJoin }) {
  const [room, setRoom] = useState(() => (new URLSearchParams(location.search).get("room") || "").toUpperCase());
  const [name, setName] = useState(localStorage.getItem("exp_name") || "");
  const [role, setRole] = useState("player");
  const randomCode = () => setRoom(Array.from({ length: 4 }, () => "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"[Math.floor(Math.random() * 32)]).join(""));
  const go = () => {
    const r = room.trim().toUpperCase();
    if (!r) return;
    Sfx.ensure();
    if (role === "player" && name.trim()) localStorage.setItem("exp_name", name.trim());
    onJoin({ room: r, name: name.trim(), role });
  };

  return (
    <div style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 20 }}>
      <div className="pop-in card-surface" style={{ maxWidth: 440, width: "100%", padding: 28 }}>
        <div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center" }}>
          <img src="/logo.jpg" alt="Druturos Boardgames" width={132} height={132}
               style={{ width: 132, height: 132, borderRadius: 20, boxShadow: "0 10px 30px rgba(0,0,0,.55), 0 0 0 1px var(--line-strong)" }} />
          <h1 className="display" style={{ margin: "16px 0 0", fontSize: 34, color: "var(--brass)", letterSpacing: ".06em", lineHeight: 1 }}>DRUTUROS</h1>
          <div className="eyebrow" style={{ marginTop: 6, letterSpacing: ".34em", color: "var(--ink-dim)" }}>Boardgames</div>
          <p style={{ color: "var(--ink-dim)", marginTop: 12, fontSize: 14 }}>Roll dice, walk the path, and face what the depths throw at you. One GM, a party of explorers, one living table.</p>
        </div>

        <div className="divider" />

        <label className="fld">Room code</label>
        <div style={{ display: "flex", gap: 8 }}>
          <input value={room} maxLength={8} onChange={(e) => setRoom(e.target.value.toUpperCase())} placeholder="e.g. TOMB" className="mono" style={{ letterSpacing: ".18em" }} />
          <button className="btn ghost" onClick={randomCode} title="Random code">🎲</button>
        </div>

        <div style={{ marginTop: 14 }}>
          <label className="fld">I am the…</label>
          <div style={{ display: "flex", gap: 8 }}>
            <button className={"btn " + (role === "player" ? "primary" : "ghost")} style={{ flex: 1 }} onClick={() => setRole("player")}>Player</button>
            <button className={"btn " + (role === "gm" ? "primary" : "ghost")} style={{ flex: 1 }} onClick={() => setRole("gm")}>Game Master</button>
          </div>
        </div>

        {role === "player" && (
          <div style={{ marginTop: 14 }}>
            <label className="fld">Your name</label>
            <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Explorer" onKeyDown={(e) => e.key === "Enter" && go()} />
          </div>
        )}

        <button className="btn primary big" style={{ width: "100%", marginTop: 20 }} disabled={!room.trim() || (role === "player" && !name.trim())} onClick={go}>
          {role === "gm" ? "Open the GM screen" : "Join the table"}
        </button>
        <p style={{ fontSize: 12, color: "var(--muted)", marginTop: 12, textAlign: "center" }}>Share the room code with your party. Same code, same table.</p>
      </div>
    </div>
  );
}

/* ========================================================================== */
/*  Root                                                                      */
/* ========================================================================== */
function App() {
  const { game, status, pid, connect, disconnect, send } = useGameSocket();
  const [cfg, setCfg] = useState(null);

  const join = (c) => { setCfg(c); connect(c); };
  const leave = () => { disconnect(); setCfg(null); };

  if (!cfg) return <JoinScreen onJoin={join} />;
  if (!game) {
    return (
      <div style={{ minHeight: "100vh", display: "grid", placeItems: "center", color: "var(--muted)" }}>
        <div style={{ textAlign: "center" }}>
          <div className="display" style={{ fontSize: 22, color: "var(--brass)" }}>Lighting the lanterns…</div>
          <div style={{ marginTop: 8 }}><StatusLamp status={status} /></div>
        </div>
      </div>
    );
  }

  return (
    <div>
      {cfg.role === "gm"
        ? <GMScreen game={game} send={send} room={cfg.room} status={status} />
        : <PlayerScreen game={game} pid={pid} send={send} room={cfg.room} status={status} />}
      <button className="btn sm ghost" style={{ position: "fixed", top: 10, right: 10, zIndex: 60, opacity: .6 }} onClick={leave}>Leave</button>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
