/* ---------------------------------------------------------------------
   York Property Exchange — browser client.

   This file draws the game and nothing else. Every rule, price and
   balance lives on the server; the browser sends intents and renders
   whatever comes back. Nothing here can be edited in devtools to gain an
   advantage, because none of it is trusted.
   --------------------------------------------------------------------- */

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

/* ---- catalogue (fetched once at boot) ---- */
let CAT = null;
let REGIONS = {}, CATEGORIES = {}, ANALYTICS_TIERS = [], BANK_TIERS = [], TACTICS = {},
    IMPROVEMENTS = [], NEIGHBOURHOODS = [], LANDMARKS = [], ALL_PROPERTIES = [],
    PROP_INDEX = new Map(), NEIGHBOURHOOD_INDEX = new Map(), YIELD_SCALE = 12;

function adoptCatalogue(c) {
  CAT = c;
  REGIONS = c.regions; CATEGORIES = c.categories; ANALYTICS_TIERS = c.analyticsTiers;
  BANK_TIERS = c.bankTiers; TACTICS = c.tactics; IMPROVEMENTS = c.improvements;
  NEIGHBOURHOODS = c.neighbourhoods; LANDMARKS = c.landmarks; ALL_PROPERTIES = c.properties;
  YIELD_SCALE = c.yieldScale;
  PROP_INDEX = new Map(ALL_PROPERTIES.map((p) => [p.id, p]));
  NEIGHBOURHOOD_INDEX = new Map(NEIGHBOURHOODS.map((n) => [n.code, n]));
}
const metaFor = (id) => PROP_INDEX.get(id);

/* ---- formatting and small maths ---- */
const clamp = (n, min, max) => Math.min(max, Math.max(min, n));
const fmt = (n) => "£" + Math.round(n || 0).toLocaleString("en-GB");
const fmtPct = (n) => (n >= 0 ? "+" : "") + n.toFixed(1) + "%";
const fmtDate = (t) => new Date(t).toLocaleDateString([], { day: "2-digit", month: "short", year: "2-digit" });
const fmtTime = (t) => new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const hashStr = (s) => { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h); };
const streetViewUrl = (lat, lng) => `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${lat},${lng}`;
const yieldPctOf = (meta) => meta.dailyYieldPct * YIELD_SCALE;

/* The game clock comes from the server so both sides agree on what a
   "day" is — cooldowns, rent and overheads all key off it. */
let GAME_DAY_MS = 3600000;
const dayIndex = (t) => Math.floor(t / GAME_DAY_MS);
const HOUR_OF_DAY = () => GAME_DAY_MS / 24;
const SECURITY_MAX_LEVEL = 3;

const OVERHEAD_ITEMS = [
  "gutter clearance", "boiler service", "damp check", "roof tile repair", "buildings insurance",
  "communal cleaning", "window sealing", "drain survey", "grounds upkeep", "electrical certificate",
  "pest control visit", "fire alarm test", "chimney sweep", "yard resurfacing", "signage upkeep",
];
/* Mirrors the server's formula exactly so the button can show the price
   before you press it. The server still recomputes it on the way in. */
function overheadFor(meta, prop, now) {
  const seed = hashStr(meta.id + "|" + dayIndex(now));
  const item = OVERHEAD_ITEMS[seed % OVERHEAD_ITEMS.length];
  const share = 0.2 + ((seed >> 5) % 26) / 100;
  const gross = (yieldPctOf(meta) / 100) * prop.currentPrice;
  return { item, cost: Math.max(50, Math.round(gross * share)) };
}

function categoryCountsFor(game, playerId) {
  const counts = {};
  ALL_PROPERTIES.forEach((m) => {
    const p = game.properties[m.id];
    if (p && p.owner === playerId) counts[m.category] = (counts[m.category] || 0) + 1;
  });
  return counts;
}

function dailyRentFor(game, meta, prop, now = Date.now()) {
  const gross = (yieldPctOf(meta) / 100) * prop.currentPrice;
  if (!prop.owner) return gross;
  if (prop.rentStrikeUntil && now < prop.rentStrikeUntil) return 0;
  const counts = categoryCountsFor(game, prop.owner);
  const synergy = (counts[meta.category] || 0) >= 2 ? 1.1 : 1;
  const improve = 1 + 0.08 * (prop.improvementLevel || 0);
  return gross * synergy * improve;
}

function holdStatus(prop, now) {
  if (!prop.owner) return null;
  const held = now - (prop.acquiredAt || 0);
  if (held < GAME_DAY_MS) {
    const rm = Math.ceil((GAME_DAY_MS - held) / 60000);
    return { earning: false, text: `Rent starts in ${rm} min` };
  }
  if ((prop.overheadPaidDay || 0) < dayIndex(now)) return { earning: false, text: "Overheads due today — pay to keep rent flowing" };
  return { earning: true, text: "Overheads paid today — rent active" };
}

function timeLeft(endsAt) {
  const ms = Math.max(0, endsAt - Date.now());
  const m = Math.floor(ms / 60000);
  const d = Math.floor(m / 1440), h = Math.floor((m % 1440) / 60), mm = m % 60;
  if (d > 0) return `${d}d ${h}h`;
  if (h > 0) return `${h}h ${mm}m`;
  if (m > 0) return `${mm}m`;
  return "under a minute";
}

const changeFromHistory = (history, price) => {
  if (!history || history.length < 2) return 0;
  const old = history[0];
  return old ? ((price - old) / old) * 100 : 0;
};
const hourChange = (prop) => (prop.hourPrice ? ((prop.currentPrice - prop.hourPrice) / prop.hourPrice) * 100 : 0);

/* ---- API ---- */
async function api(path, options = {}) {
  const res = await fetch(path, {
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    ...options,
    body: options.body ? JSON.stringify(options.body) : undefined,
  });
  let data = {};
  try { data = await res.json(); } catch {}
  if (!res.ok) throw new Error(data.error || "Something went wrong.");
  return data;
}
const act = (action, payload = {}) => api("/api/action", { method: "POST", body: { action, payload } });

/* A stable per-browser id, so the server can spot one person running two
   accounts from the same machine. */
function deviceId() {
  try {
    let id = localStorage.getItem("ukpx-device");
    if (!id) { id = (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)); localStorage.setItem("ukpx-device", id); }
    document.cookie = `ukpx_device=${id}; Path=/; Max-Age=31536000; SameSite=Lax`;
    return id;
  } catch { return "no-storage"; }
}

/* ---------------------------- visuals ------------------------------ */

function Sparkline({ history, color, width = 96, height = 30, fill = false }) {
  const h = history.length > 1 ? history : [history[0] || 0, history[0] || 0];
  const min = Math.min(...h), max = Math.max(...h);
  const pts = h.map((v, i) => {
    const x = (i / (h.length - 1)) * (width - 4) + 2;
    const y = height - 2 - ((v - min) / (max - min || 1)) * (height - 4);
    return `${x.toFixed(1)},${y.toFixed(1)}`;
  });
  return (
    <svg viewBox={`0 0 ${width} ${height}`} width={width} height={height}>
      {fill && <polygon points={`2,${height - 2} ${pts.join(" ")} ${width - 2},${height - 2}`} fill={color} opacity="0.14" />}
      <polyline points={pts.join(" ")} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function RegionTag({ region }) {
  return <span className="tag" style={{ "--tag-color": REGIONS[region].color }}>{REGIONS[region].label}</span>;
}
function CategoryTag({ category }) {
  return <span className="tag" style={{ "--tag-color": CATEGORIES[category].color }}>{CATEGORIES[category].label}</span>;
}

/* Richer illustrated tile: brick tones, sky gradient, trees, windows, shadow */
function PropertyArt({ meta, height = 150, facade = null }) {
  const seed = hashStr(meta.id);
  const pal = facade ? FACADE_PALETTES[facade] : null;
  const brickSet = ["#B4674D", "#9C5B46", "#C0796041", "#A96A52", "#8E5540"];
  const brick = pal ? pal.brick : brickSet[seed % brickSet.length].slice(0, 7);
  const roof = pal ? pal.roof : ["#3C3B38", "#4A403A", "#33403F"][seed % 3];
  const gid = "sky" + seed;
  const winLit = (i) => ((seed >> i) & 1) === 1;
  const cat = meta.category;

  return (
    <svg viewBox="0 0 300 150" width="100%" height={height} className="art" preserveAspectRatio="xMidYMid slice">
      <defs>
        <linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor={pal ? pal.sky[0] : "#C7D6DF"} />
          <stop offset="100%" stopColor={pal ? pal.sky[1] : "#E6E2D3"} />
        </linearGradient>
        <linearGradient id={gid + "g"} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#8FA678" />
          <stop offset="100%" stopColor="#6F8A5C" />
        </linearGradient>
      </defs>
      <rect width="300" height="150" fill={`url(#${gid})`} />
      <circle cx={40 + (seed % 30)} cy="30" r="16" fill="#F2EAD0" opacity="0.7" />
      <ellipse cx="90" cy="34" rx="28" ry="9" fill="#fff" opacity="0.5" />
      <ellipse cx="210" cy="26" rx="34" ry="10" fill="#fff" opacity="0.4" />
      <rect y="112" width="300" height="38" fill={pal ? pal.ground : "#BFB59A"} />
      <rect y="112" width="300" height="4" fill="#A89E84" />

      {cat === "residential" && (
        <g>
          <rect x="18" y="66" width="52" height="46" fill="#9E7A63" />
          <rect x="230" y="70" width="52" height="42" fill="#9E7A63" />
          <ellipse cx="150" cy="120" rx="86" ry="7" fill="#000" opacity="0.12" />
          <rect x="86" y="56" width="128" height="56" fill={brick} />
          <polygon points="78,58 150,20 222,58" fill={roof} />
          <polygon points="78,58 150,20 150,58" fill="#000" opacity="0.08" />
          <rect x="176" y="26" width="10" height="20" fill="#7A5A48" />
          <rect x="138" y="80" width="24" height="32" fill="#3E5C56" />
          <circle cx="157" cy="97" r="1.7" fill="#E7C86A" />
          {[100, 122, 178, 200].map((x, i) => (
            <g key={x}>
              <rect x={x} y="68" width="16" height="16" fill={winLit(i) ? "#E7C86A" : "#DCE6E8"} stroke="#F2EFE4" strokeWidth="2" />
            </g>
          ))}
          <rect x="100" y="92" width="16" height="16" fill="#DCE6E8" stroke="#F2EFE4" strokeWidth="2" />
          <rect x="184" y="92" width="16" height="16" fill="#DCE6E8" stroke="#F2EFE4" strokeWidth="2" />
          <rect x="86" y="56" width="128" height="4" fill="#000" opacity="0.12" />
          <g>
            <rect x="252" y="86" width="5" height="26" fill="#6B4B33" />
            <circle cx="254" cy="80" r="16" fill={`url(#${gid}g)`} />
          </g>
          <rect x="120" y="112" width="60" height="6" fill="#A8A08A" />
        </g>
      )}

      {cat === "retail" && (
        <g>
          <ellipse cx="150" cy="120" rx="96" ry="7" fill="#000" opacity="0.12" />
          <rect x="44" y="48" width="212" height="64" fill={brick} />
          <rect x="44" y="42" width="212" height="10" fill={roof} />
          <rect x="44" y="60" width="212" height="14" fill="#A23E27" />
          <path d="M44 74 h212 l-6 12 h-200 z" fill="#8E3320" />
          {[0, 1, 2, 3, 4, 5, 6].map((i) => (
            <rect key={i} x={44 + i * 30.3} y="60" width="15" height="14" fill="#F2EFE4" opacity="0.35" />
          ))}
          <rect x="62" y="86" width="52" height="26" fill="#DCE6E8" stroke="#F2EFE4" strokeWidth="3" />
          <rect x="186" y="86" width="52" height="26" fill="#DCE6E8" stroke="#F2EFE4" strokeWidth="3" />
          <rect x="134" y="82" width="32" height="30" fill="#3E5C56" />
          <rect x="30" y="70" width="6" height="42" fill="#6b6455" />
          <circle cx="33" cy="66" r="5" fill="#E7C86A" />
        </g>
      )}

      {cat === "utility" && (
        <g>
          <ellipse cx="150" cy="118" rx="100" ry="8" fill="#000" opacity="0.1" />
          <rect x="40" y="76" width="180" height="36" fill="#8E8C84" />
          <polygon points="40,76 130,52 220,76" fill="#6E6C66" />
          <rect x="52" y="86" width="26" height="26" fill="#5A5852" />
          <rect x="88" y="86" width="26" height="26" fill="#5A5852" />
          <rect x="124" y="86" width="26" height="26" fill="#5A5852" />
          <g stroke="#4A4842" strokeWidth="3" fill="none">
            <path d="M244 112 V50" />
            <path d="M232 58 h24 M234 70 h20" />
          </g>
          <path d="M244 56 C 210 62, 190 58, 160 64" stroke="#4A4842" strokeWidth="1.6" fill="none" />
          <rect x="170" y="60" width="44" height="16" fill="#C9A227" opacity="0.85" />
        </g>
      )}

      {cat === "recreation" && (
        <g>
          <rect y="88" width="300" height="24" fill={`url(#${gid}g)`} />
          <ellipse cx="70" cy="104" rx="40" ry="8" fill="#000" opacity="0.08" />
          <rect x="66" y="76" width="8" height="30" fill="#6B4B33" />
          <circle cx="70" cy="62" r="26" fill="#6F8A5C" />
          <circle cx="54" cy="70" r="16" fill="#7E9968" />
          <circle cx="88" cy="70" r="14" fill="#5F7A4E" />
          <rect x="150" y="86" width="60" height="7" fill="#8B6A4A" />
          <rect x="152" y="93" width="5" height="16" fill="#6b6455" />
          <rect x="203" y="93" width="5" height="16" fill="#6b6455" />
          <rect x="150" y="72" width="60" height="6" fill="#8B6A4A" />
          <path d="M236 106 q14 -30 28 0" fill="#8FA678" />
          <circle cx="250" cy="66" r="9" fill="#C9A227" opacity="0.7" />
        </g>
      )}

      {cat === "industry" && (
        <g>
          <ellipse cx="150" cy="120" rx="106" ry="7" fill="#000" opacity="0.12" />
          <rect x="40" y="66" width="180" height="46" fill="#7C8189" />
          <g fill="#5F646C">
            <polygon points="40,66 76,44 112,66" /><polygon points="112,66 148,44 184,66" /><polygon points="184,66 220,44 256,66" />
          </g>
          <rect x="220" y="66" width="36" height="46" fill="#7C8189" />
          <rect x="238" y="18" width="14" height="48" fill="#6B6F76" />
          <rect x="262" y="30" width="12" height="36" fill="#6B6F76" />
          <ellipse cx="245" cy="14" rx="14" ry="7" fill="#fff" opacity="0.45" />
          <ellipse cx="262" cy="6" rx="18" ry="8" fill="#fff" opacity="0.3" />
          {[54, 90, 126, 162, 198].map((x) => <rect key={x} x={x} y="80" width="20" height="16" fill="#C3D2D8" stroke="#8E939A" strokeWidth="1.5" />)}
          <rect x="60" y="104" width="120" height="8" fill="#5F646C" />
        </g>
      )}
    </svg>
  );
}

/* ---------------------------- Map ------------------------------ */

const OUSE_PATH = "M 150 10 C 175 70, 130 130, 155 190 C 178 250, 140 320, 165 375";
const WALLS = { cx: 188, cy: 220, r: 52 };

/* One pass over the properties gives every district its value, its
   movement and how much attention it is getting. All three maps read from
   this, so they always agree with each other and with the market. */
function districtStats(game) {
  return NEIGHBOURHOODS.map((n) => {
    const props = ALL_PROPERTIES.filter((m) => m.neighbourhood === n.code);
    let value = 0, interest = 0, owned = 0, moveSum = 0, moveCount = 0;
    for (const m of props) {
      const p = game.properties[m.id];
      if (!p) continue;
      value += p.currentPrice;
      interest += p.interest || 0;
      if (p.owner) owned += 1;
      const ch = hourChange(p);
      if (Number.isFinite(ch)) { moveSum += ch; moveCount += 1; }
    }
    return { ...n, value, interest, owned, count: props.length, move: moveCount ? moveSum / moveCount : 0 };
  });
}

/* ---- 1. Surveyor's Plan: the default, and the one that answers "where" ---- */
function PlanMap({ stats, active, onPick, onOpen }) {
  const teeth = [];
  for (let a = 0; a < 360; a += 9) {
    const rad = (a * Math.PI) / 180;
    teeth.push(
      <line key={a}
        x1={(WALLS.cx + Math.cos(rad) * WALLS.r).toFixed(1)} y1={(WALLS.cy + Math.sin(rad) * WALLS.r).toFixed(1)}
        x2={(WALLS.cx + Math.cos(rad) * (WALLS.r + 3.4)).toFixed(1)} y2={(WALLS.cy + Math.sin(rad) * (WALLS.r + 3.4)).toFixed(1)}
        stroke="#8C7F5F" strokeWidth="0.8" />);
  }
  const grid = [];
  for (let gx = 20; gx <= 320; gx += 20) grid.push(<line key={"v" + gx} x1={gx} y1="14" x2={gx} y2="386" stroke="#D9D0B7" strokeWidth="0.5" />);
  for (let gy = 20; gy <= 380; gy += 20) grid.push(<line key={"h" + gy} x1="14" y1={gy} x2="326" y2={gy} stroke="#D9D0B7" strokeWidth="0.5" />);

  return (
    <svg viewBox="0 0 340 400" width="100%" height="auto" role="img" aria-label="Survey plan of York trading districts">
      <rect x="0" y="0" width="340" height="400" fill="#EDE7D6" />
      {grid}
      <path d={OUSE_PATH} stroke="#AFC4D2" strokeWidth="11" fill="none" strokeLinecap="round" />
      <path d={OUSE_PATH} stroke="#7E9BB0" strokeWidth="0.8" fill="none" strokeDasharray="1 2" />
      <circle cx={WALLS.cx} cy={WALLS.cy} r={WALLS.r} fill="none" stroke="#8C7F5F" strokeWidth="1.4" />
      {teeth}
      <text x={WALLS.cx} y={WALLS.cy - WALLS.r - 7} textAnchor="middle" fontFamily="'IBM Plex Mono', monospace"
        fontSize="6" letterSpacing="1.4" fill="#8C7F5F">BAR WALLS</text>

      {stats.map((n) => {
        const right = n.mapX < 180;
        const lx = right ? n.mapX + 9 : n.mapX - 9;
        return (
          <g key={n.code} className="map-pin" onClick={() => onPick(n.code)} tabIndex={0}
            onKeyDown={(e) => { if (e.key === "Enter") onPick(n.code); }}>
            {active === n.code && <circle cx={n.mapX} cy={n.mapY} r="11" fill="none" stroke="#23395D" strokeWidth="1.6" />}
            <line x1={n.mapX} y1={n.mapY} x2={lx} y2={n.mapY} stroke="#8C7F5F" strokeWidth="0.6" />
            <rect x={n.mapX - 3.2} y={n.mapY - 3.2} width="6.4" height="6.4"
              fill={REGIONS[n.region].color} stroke="#F2EFE4" strokeWidth="1" />
            <text x={right ? lx + 2 : lx - 2} y={n.mapY + 2.6} textAnchor={right ? "start" : "end"}
              fontFamily="'IBM Plex Mono', monospace" fontSize="6.4" letterSpacing="0.6" fill="#3B382C">
              {n.name.toUpperCase()}
            </text>
          </g>
        );
      })}

      {LANDMARKS.map((l) => (
        <g key={l.id} className="map-pin" onClick={() => onOpen(l.id)} tabIndex={0}>
          <path d={`M${l.mapX} ${l.mapY - 5.5} L${l.mapX + 5} ${l.mapY} L${l.mapX} ${l.mapY + 5.5} L${l.mapX - 5} ${l.mapY} Z`}
            fill="#C9A227" stroke="#5C4A1F" strokeWidth="0.9" />
          <circle cx={l.mapX} cy={l.mapY} r="1.3" fill="#5C4A1F" />
        </g>
      ))}

      <g stroke="#3B382C" fill="#3B382C">
        <line x1="22" y1="372" x2="82" y2="372" strokeWidth="1.2" />
        <line x1="22" y1="369" x2="22" y2="375" strokeWidth="1.2" />
        <line x1="52" y1="370" x2="52" y2="374" strokeWidth="1" />
        <line x1="82" y1="369" x2="82" y2="375" strokeWidth="1.2" />
        <text x="22" y="384" fontFamily="'IBM Plex Mono', monospace" fontSize="6" stroke="none">0</text>
        <text x="82" y="384" fontFamily="'IBM Plex Mono', monospace" fontSize="6" stroke="none" textAnchor="middle">1 km</text>
      </g>
      <g transform="translate(306,366)">
        <path d="M0 -12 L4 4 L0 0.5 L-4 4 Z" fill="#3B382C" />
        <text x="0" y="14" textAnchor="middle" fontFamily="'IBM Plex Mono', monospace" fontSize="7" fill="#3B382C">N</text>
      </g>
      <rect x="10" y="10" width="320" height="380" fill="none" stroke="#3B382C" strokeWidth="1.4" />
      <rect x="13.5" y="13.5" width="313" height="373" fill="none" stroke="#3B382C" strokeWidth="0.5" />
      <text x="20" y="26" fontFamily="'IBM Plex Mono', monospace" fontSize="6.5" letterSpacing="1.2" fill="#8C7F5F">
        CITY OF YORK · TRADING DISTRICTS · SHEET 1 OF 1 · 1:12 500
      </text>
    </svg>
  );
}

/* ---- 2. Market Heat: earned with the Broker feed, answers "where now" ---- */
function HeatMap({ stats, active, onPick, onOpen, analyticsLevel }) {
  const maxValue = Math.max(...stats.map((s) => s.value), 1);
  const grid = [];
  for (let gx = 20; gx <= 320; gx += 40) grid.push(<line key={"v" + gx} x1={gx} y1="14" x2={gx} y2="386" stroke="#1B2A22" strokeWidth="0.6" />);
  for (let gy = 20; gy <= 380; gy += 40) grid.push(<line key={"h" + gy} x1="14" y1={gy} x2="326" y2={gy} stroke="#1B2A22" strokeWidth="0.6" />);

  /* Ranked movers are the Full Terminal's edge — the Broker feed sees the
     colours, the terminal gets them ordered. */
  const movers = [...stats].sort((a, b) => b.move - a.move);
  const board = analyticsLevel >= 2 ? [...movers.slice(0, 3), ...movers.slice(-2)] : [];

  return (
    <svg viewBox="0 0 340 400" width="100%" height="auto" role="img" aria-label="Market heat map of York districts">
      <rect x="0" y="0" width="340" height="400" fill="#0E1512" />
      {grid}
      <path d={OUSE_PATH} stroke="#1E3A44" strokeWidth="9" fill="none" strokeLinecap="round" opacity="0.8" />
      <circle cx={WALLS.cx} cy={WALLS.cy} r={WALLS.r} fill="none" stroke="#2A3B32" strokeWidth="1" strokeDasharray="3 4" />

      {stats.map((n) => {
        const up = n.move >= 0;
        const col = up ? "#4FD98A" : "#F2705C";
        const r = 5 + (n.value / maxValue) * 7;
        const ring = 12 + Math.min(9, n.interest) * 1.9;
        return (
          <g key={n.code} className="map-pin" onClick={() => onPick(n.code)} tabIndex={0}
            onKeyDown={(e) => { if (e.key === "Enter") onPick(n.code); }}>
            {n.interest > 0 && <circle cx={n.mapX} cy={n.mapY} r={ring.toFixed(1)} fill={col} opacity="0.1" />}
            {active === n.code && <circle cx={n.mapX} cy={n.mapY} r={(r + 8).toFixed(1)} fill="none" stroke="#E8E3D4" strokeWidth="1" />}
            <circle cx={n.mapX} cy={n.mapY} r={(r + 4).toFixed(1)} fill="none" stroke={col} strokeWidth="0.6" opacity="0.4" />
            <circle cx={n.mapX} cy={n.mapY} r={r.toFixed(1)} fill={col} opacity="0.85" />
            <text x={n.mapX} y={(n.mapY - r - 5).toFixed(1)} textAnchor="middle" fontFamily="'IBM Plex Mono', monospace"
              fontSize="6" letterSpacing="0.7" fill="#9FB3A8">{n.name.toUpperCase()}</text>
            <text x={n.mapX} y={(n.mapY + r + 9).toFixed(1)} textAnchor="middle" fontFamily="'IBM Plex Mono', monospace"
              fontSize="6.4" fontWeight="600" fill={col}>{(up ? "+" : "") + n.move.toFixed(1)}%</text>
          </g>
        );
      })}

      {LANDMARKS.map((l) => (
        <g key={l.id} className="map-pin" onClick={() => onOpen(l.id)} tabIndex={0}>
          <circle cx={l.mapX} cy={l.mapY} r="9" fill="#C9A227" opacity="0.12" />
          <path d={`M${l.mapX} ${l.mapY - 5} L${l.mapX + 4.4} ${l.mapY + 3} L${l.mapX - 4.4} ${l.mapY + 3} Z`} fill="#E0BE45" />
        </g>
      ))}

      {board.length > 0 && (
        <g>
          <text x="22" y="30" fontFamily="'IBM Plex Mono', monospace" fontSize="7" letterSpacing="1.3" fill="#6E8479">
            MOVERS · LAST HOUR
          </text>
          {board.map((n, i) => (
            <text key={n.code} x="22" y={44 + i * 11} fontFamily="'IBM Plex Mono', monospace" fontSize="6.6"
              fill={n.move >= 0 ? "#4FD98A" : "#F2705C"}>
              {(n.move >= 0 ? "▲ " : "▼ ") + n.name + "  " + (n.move >= 0 ? "+" : "") + n.move.toFixed(1) + "%"}
            </text>
          ))}
        </g>
      )}
      <text x="318" y="384" textAnchor="end" fontFamily="'IBM Plex Mono', monospace" fontSize="6" fill="#3E5147">
        RING = TRADER INTEREST · SIZE = DISTRICT VALUE
      </text>
    </svg>
  );
}

/* ---- 3. Illuminated Charter: decoration, bought and worn ---- */
function CharterMap({ stats, active, onPick, onOpen }) {
  const motif = [];
  for (let i = 18; i < 322; i += 13) {
    motif.push(<circle key={"t" + i} cx={i} cy="19" r="2.1" fill="none" stroke="#B8912C" strokeWidth="0.9" />);
    motif.push(<circle key={"b" + i} cx={i} cy="381" r="2.1" fill="none" stroke="#B8912C" strokeWidth="0.9" />);
  }
  for (let i = 32; i < 370; i += 13) {
    motif.push(<circle key={"l" + i} cx="19" cy={i} r="2.1" fill="none" stroke="#B8912C" strokeWidth="0.9" />);
    motif.push(<circle key={"r" + i} cx="321" cy={i} r="2.1" fill="none" stroke="#B8912C" strokeWidth="0.9" />);
  }
  const ripples = [];
  for (let t = 40; t < 370; t += 26) {
    ripples.push(<path key={t} d={`M${146 + Math.sin(t / 32) * 14} ${t} q6 -4 12 0`} stroke="#C9A94E" strokeWidth="0.9" fill="none" opacity="0.75" />);
  }
  const glyph = (n) => {
    const c = REGIONS[n.region].color;
    if (n.region === "CEN") return <g><rect x={n.mapX - 4} y={n.mapY - 3} width="8" height="8" fill={c} /><path d={`M${n.mapX} ${n.mapY - 11} L${n.mapX + 4} ${n.mapY - 3} L${n.mapX - 4} ${n.mapY - 3} Z`} fill="#B8912C" /></g>;
    if (n.region === "OUT" || n.region === "NTH") return <g><ellipse cx={n.mapX} cy={n.mapY - 3} rx="5" ry="4.6" fill={c} /><rect x={n.mapX - 0.9} y={n.mapY + 0.5} width="1.8" height="4.5" fill="#6B4B33" /></g>;
    return <g><rect x={n.mapX - 4.5} y={n.mapY - 2} width="9" height="7" fill={c} /><path d={`M${n.mapX - 5.6} ${n.mapY - 2} L${n.mapX} ${n.mapY - 7.5} L${n.mapX + 5.6} ${n.mapY - 2} Z`} fill="#B8912C" /></g>;
  };

  return (
    <svg viewBox="0 0 340 400" width="100%" height="auto" role="img" aria-label="Illuminated charter map of York">
      <rect x="0" y="0" width="340" height="400" fill="#E4D7B6" />
      <ellipse cx="90" cy="120" rx="120" ry="90" fill="#DCCDA6" opacity="0.5" />
      <ellipse cx="250" cy="300" rx="130" ry="100" fill="#DCCDA6" opacity="0.42" />
      <path d={OUSE_PATH} stroke="#2C4B8C" strokeWidth="10" fill="none" strokeLinecap="round" opacity="0.9" />
      {ripples}
      <circle cx={WALLS.cx} cy={WALLS.cy} r={WALLS.r} fill="none" stroke="#8C6A18" strokeWidth="1.6" />
      <circle cx={WALLS.cx} cy={WALLS.cy} r={WALLS.r - 3} fill="none" stroke="#B8912C" strokeWidth="0.7" strokeDasharray="5 3" />
      {stats.map((n) => (
        <g key={n.code} className="map-pin" onClick={() => onPick(n.code)} tabIndex={0}
          onKeyDown={(e) => { if (e.key === "Enter") onPick(n.code); }}>
          {active === n.code && <circle cx={n.mapX} cy={n.mapY} r="13" fill="none" stroke="#7A2E2E" strokeWidth="1.4" />}
          {glyph(n)}
          <text x={n.mapX} y={n.mapY + 14} textAnchor="middle" fontFamily="'Cormorant Garamond', Georgia, serif"
            fontSize="9" fontWeight="600" fill="#4A3A20">{n.name}</text>
        </g>
      ))}
      {LANDMARKS.map((l) => (
        <g key={l.id} className="map-pin" onClick={() => onOpen(l.id)} tabIndex={0}>
          <circle cx={l.mapX} cy={l.mapY} r="9.5" fill="#D8B449" stroke="#8C6A18" strokeWidth="1" />
          <circle cx={l.mapX} cy={l.mapY} r="6" fill="none" stroke="#8C6A18" strokeWidth="0.6" />
          <path d={`M${l.mapX} ${l.mapY - 4.5} L${l.mapX + 2} ${l.mapY + 1} L${l.mapX} ${l.mapY + 4} L${l.mapX - 2} ${l.mapY + 1} Z`} fill="#7A2E2E" />
        </g>
      ))}
      <rect x="14" y="14" width="312" height="372" fill="none" stroke="#8C6A18" strokeWidth="2.4" />
      <rect x="24" y="24" width="292" height="352" fill="none" stroke="#7A2E2E" strokeWidth="0.9" />
      {motif}
      <g transform="translate(170,352)">
        <rect x="-62" y="-13" width="124" height="24" fill="#D8B449" opacity="0.34" />
        <text x="0" y="4" textAnchor="middle" fontFamily="'Cormorant Garamond', Georgia, serif" fontSize="15"
          fontWeight="700" letterSpacing="3" fill="#5A3A14">EBORACVM</text>
      </g>
      <text x="42" y="52" fontFamily="'Cormorant Garamond', Georgia, serif" fontSize="30" fontWeight="700" fill="#7A2E2E">Y</text>
    </svg>
  );
}

function MapTab({ game, me, onOpen, onEquip, flash }) {
  const [activeCluster, setActiveCluster] = useState(null);
  const analyticsLevel = me.analyticsLevel || 0;
  const stats = useMemo(() => districtStats(game), [game.properties]);
  const styles = (me.shop && me.shop.maps) || [{ id: "plan", name: "Surveyor's Plan", available: true }];
  const chosen = (me.cosmetics && me.cosmetics.map) || "plan";
  const style = styles.find((s) => s.id === chosen && s.available) ? chosen : "plan";

  const info = activeCluster ? NEIGHBOURHOOD_INDEX.get(activeCluster) : null;
  const clusterProps = activeCluster ? ALL_PROPERTIES.filter((m) => m.neighbourhood === activeCluster) : [];
  const activeStat = activeCluster ? stats.find((s) => s.code === activeCluster) : null;
  const hot = [...stats].filter((s) => s.interest > 0).sort((a, b) => b.interest - a.interest).slice(0, 3);

  const intro = {
    plan: "The city as a survey sheet — river, bar walls, the fifteen trading districts and the landmarks. Click a district to see what is in it.",
    heat: "The same city read as a market: each district sized by what it is worth and coloured by which way it moved in the last hour. The ring around it is how much attention traders are paying.",
    charter: "York as a scribe would have drawn it. Every district is here, painted rather than plotted — no more informative than the plan, and far better looking.",
  }[style];

  return (
    <div>
      <p className="intro">{intro}</p>

      <div className="map-switch">
        {styles.map((s) => (
          <button key={s.id}
            className={"map-switch-btn" + (style === s.id ? " active" : "") + (s.available ? "" : " locked")}
            onClick={() => (s.available ? onEquip("map", s.id) : flash(s.locked))}
            title={s.available ? s.blurb : s.locked}>
            {s.name}{!s.available && <span className="lock-pip">locked</span>}
          </button>
        ))}
      </div>

      <div className="map-layout">
        <div className="map-svg-wrap" style={style === "heat" ? { background: "#0E1512", borderColor: "#26332C" } : style === "charter" ? { background: "#E4D7B6", borderColor: "#B9A579" } : null}>
          {style === "plan" && <PlanMap stats={stats} active={activeCluster} onPick={setActiveCluster} onOpen={onOpen} />}
          {style === "heat" && <HeatMap stats={stats} active={activeCluster} onPick={setActiveCluster} onOpen={onOpen} analyticsLevel={analyticsLevel} />}
          {style === "charter" && <CharterMap stats={stats} active={activeCluster} onPick={setActiveCluster} onOpen={onOpen} />}

          {style !== "heat" && (
            <div className="legend-wrap">
              {Object.entries(REGIONS).map(([code, r]) => (
                <div className="legend-row" key={code}><span className="legend-swatch" style={{ background: r.color }} />{r.label}</div>
              ))}
              <div className="legend-row"><span className="legend-swatch" style={{ background: "#C9A227" }} />Landmark (£2m net worth)</div>
            </div>
          )}

          {style === "heat" ? (
            analyticsLevel >= 2
              ? hot.length > 0 && <div className="muted" style={{ marginTop: 8 }}>Interest hotspots: {hot.map((h) => h.name).join(", ")}</div>
              : <div className="muted" style={{ marginTop: 8 }}>The Full terminal ranks movers and hotspots on this view.</div>
          ) : analyticsLevel < 1 ? (
            <div className="muted" style={{ marginTop: 8 }}>The Broker feed unlocks the Market Heat view — price movement and trader interest, district by district.</div>
          ) : null}
        </div>

        <div className="map-side">
          {activeCluster && info ? (
            <div className="card">
              <h3>{info.name}</h3>
              <div className="meta"><RegionTag region={info.region} /> · {clusterProps.length} properties · {activeStat.owned} owned</div>
              {analyticsLevel >= 1 && (
                <div className="portfolio-total" style={{ marginBottom: 10 }}>
                  <div className="block"><div className="label">District value</div><div className="value">{fmt(activeStat.value)}</div></div>
                  <div className="block"><div className="label">Last hour</div>
                    <div className="value" style={{ color: activeStat.move >= 0 ? "var(--gain)" : "var(--loss)" }}>{fmtPct(activeStat.move)}</div></div>
                </div>
              )}
              {clusterProps.map((m) => {
                const p = game.properties[m.id];
                const owner = p && p.owner ? game.players[p.owner] : null;
                return (
                  <div className="cluster-row" key={m.id} onClick={() => onOpen(m.id)}>
                    <span><CategoryTag category={m.category} /> {m.name}<br />
                      <span className="muted">{owner ? <>owned by <TraderName player={owner} size={12} /></> : "available"}</span></span>
                    <span className="price">{fmt(p ? p.currentPrice : 0)}</span>
                  </div>
                );
              })}
            </div>
          ) : <div className="empty">Click a district to see what is there.</div>}
        </div>
      </div>
    </div>
  );
}

/* ---------------------------- shared bits ------------------------------ */

function StreetViewLink({ meta }) {
  return (
    <a className="sv-link" href={streetViewUrl(meta.lat, meta.lng)} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
      View on Google Street View ↗
    </a>
  );
}

function ownerFacade(game, prop) {
  const owner = prop.owner ? game.players[prop.owner] : null;
  return owner ? owner.facade || null : null;
}

function PropertyTile({ meta, prop, game, me, history, onOpen, onBuy }) {
  const change = changeFromHistory(history, prop.currentPrice);
  const locked = meta.exclusive && me.netWorth < meta.minNetWorth;
  const rent = dailyRentFor(game, meta, prop);
  return (
    <div className="ptile" onClick={() => onOpen(meta.id)}>
      <PropertyArt meta={meta} facade={ownerFacade(game, prop)} />
      <div className="ptile-body">
        <div className="ptile-name">{meta.name}</div>
        <div style={{ marginBottom: 6 }}><RegionTag region={meta.region} /><CategoryTag category={meta.category} /></div>
        <div className="price" style={{ fontSize: 16 }}>{fmt(prop.currentPrice)}</div>
        <div className={change >= 0 ? "chg-up" : "chg-down"}>{fmtPct(change)} this session</div>
        <div className="rent-line">Daily rent {fmt(rent)} · {yieldPctOf(meta).toFixed(2)}% yield</div>
        <StreetViewLink meta={meta} />
        <div style={{ marginTop: 8 }}>
          {prop.inAuction ? <span className="auction-badge">In auction</span>
            : locked ? <span className="lock-badge">Needs {fmt(meta.minNetWorth)} net worth</span>
            : <button className="btn small" onClick={(e) => { e.stopPropagation(); onBuy(meta.id); }} disabled={me.cash < prop.currentPrice}>Buy</button>}
        </div>
      </div>
    </div>
  );
}

/* ---------------------------- Detail modal ------------------------------ */

function DetailModal({ meta, prop, game, me, now, history, onClose, onAct }) {
  const owner = prop.owner ? game.players[prop.owner] : null;
  const isMine = prop.owner === me.id;
  const locked = meta.exclusive && me.netWorth < meta.minNetWorth;
  const hold = isMine ? holdStatus(prop, now) : null;
  const change = changeFromHistory(history, prop.currentPrice);
  const rent = dailyRentFor(game, meta, prop, now);
  const lvl = prop.improvementLevel || 0;
  const improvement = IMPROVEMENTS[Math.min(lvl, 2)];
  const improveCost = Math.round(prop.currentPrice * 0.03 * (lvl + 1));
  const cooling = prop.lastImprovedAt && now - prop.lastImprovedAt < 30 * GAME_DAY_MS;
  const coolDays = cooling ? Math.ceil((30 * GAME_DAY_MS - (now - prop.lastImprovedAt)) / GAME_DAY_MS) : 0;
  const oh = overheadFor(meta, prop, now);
  const paidToday = (prop.overheadPaidDay || 0) >= dayIndex(now);
  const secLvl = prop.securityLevel || 0;
  const securityCost = Math.round(prop.currentPrice * 0.025 * (secLvl + 1));
  const securityCooling = prop.lastSecurityAt && now - prop.lastSecurityAt < 7 * GAME_DAY_MS;
  const securityCoolDays = securityCooling ? Math.ceil((7 * GAME_DAY_MS - (now - prop.lastSecurityAt)) / GAME_DAY_MS) : 0;
  const onStrike = prop.rentStrikeUntil > now;
  const tacticOnCooldown = prop.lastTacticAt && now - prop.lastTacticAt < 2 * GAME_DAY_MS;
  const rumourCost = Math.round(prop.currentPrice * TACTICS.rumour.costPct);
  const strikeCost = Math.round(prop.currentPrice * TACTICS.strike.costPct);
  const linked = owner && (me.linkedTo || []).includes(prop.owner);

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal-box" onClick={(e) => e.stopPropagation()}>
        <button className="modal-close" onClick={onClose} aria-label="Close">×</button>
        <PropertyArt meta={meta} height={180} facade={ownerFacade(game, prop)} />
        <div className="modal-inner">
          <h3 style={{ fontFamily: "'Fraunces', serif", fontSize: 20, margin: "0 0 6px" }}>{meta.name}</h3>
          <div style={{ marginBottom: 8 }}>
            <RegionTag region={meta.region} /><CategoryTag category={meta.category} />
            {meta.exclusive && <span className="lock-badge">Exclusive site</span>}
          </div>
          <div style={{ fontSize: 25, fontFamily: "'IBM Plex Mono', monospace", fontWeight: 600 }}>{fmt(prop.currentPrice)}</div>
          <div className={change >= 0 ? "chg-up" : "chg-down"} style={{ marginBottom: 8 }}>{fmtPct(change)} this session</div>
          <Sparkline history={history} color={change >= 0 ? "#2F6B45" : "#B0342A"} width={220} height={48} fill />
          <div className="rent-line" style={{ marginTop: 8 }}>
            <strong>Daily rent income: {fmt(rent)}</strong> · {yieldPctOf(meta).toFixed(2)}% daily rental yield
          </div>
          <StreetViewLink meta={meta} />

          <div style={{ marginTop: 12 }}>
            {locked ? <div className="hold-note warn">Requires {fmt(meta.minNetWorth)} net worth. Yours: {fmt(me.netWorth)}.</div>
              : prop.inAuction ? <span className="auction-badge">Under auction — see Auctions</span>
              : !prop.owner ? <button className="btn" onClick={() => onAct("buy", { propertyId: meta.id })} disabled={me.cash < prop.currentPrice}>Buy for {fmt(prop.currentPrice)}</button>
              : isMine ? (
                <div>
                  <div className={"hold-note " + (hold.earning ? "ok" : "warn")}>{hold.text}</div>
                  {onStrike && <div className="hold-note warn">Tenants are withholding rent until {fmtTime(prop.rentStrikeUntil)}.</div>}
                  {prop.lastTacticAt > 0 && now - prop.lastTacticAt < 7 * GAME_DAY_MS && (
                    <div className="muted" style={{ margin: "3px 0" }}>
                      {prop.lastTacticSuccess ? "This property was targeted" : "An attempted tactic against this property failed"} recently
                      {prop.lastTacticAttackerName ? ` — full terminal traces this to ${prop.lastTacticAttackerName}.` : "."}
                    </div>
                  )}
                  <button className="btn small" onClick={() => onAct("overheads", { propertyId: meta.id })} disabled={paidToday}>
                    {paidToday ? "Overheads paid today" : `Pay ${oh.item} — ${fmt(oh.cost)}`}
                  </button>
                  <div className="improve-box">
                    <div style={{ fontWeight: 600, marginBottom: 3 }}>{lvl >= 3 ? "Fully improved" : improvement.name}</div>
                    {lvl < 3 && (
                      <>
                        <div className="muted" style={{ marginBottom: 5 }}>{improvement.note}</div>
                        <div>Cost {fmt(improveCost)} → rent rises about 8% ({fmt(rent)} → {fmt(rent * 1.08)} per day)</div>
                        <div className="muted" style={{ margin: "4px 0 8px" }}>
                          Roughly 1 in 10 jobs overrun and deliver no uplift, and the cost is still spent. One improvement per property per month.
                        </div>
                        <button className="btn small" onClick={() => onAct("improve", { propertyId: meta.id })} disabled={cooling}>
                          {cooling ? `Available in ${coolDays}d` : `Improve to level ${lvl + 1}/3`}
                        </button>
                      </>
                    )}
                  </div>
                  <div className="improve-box">
                    <div style={{ fontWeight: 600, marginBottom: 3 }}>{secLvl >= SECURITY_MAX_LEVEL ? "Maximum security" : `Security level ${secLvl}/${SECURITY_MAX_LEVEL}`}</div>
                    {secLvl < SECURITY_MAX_LEVEL && (
                      <>
                        <div className="muted" style={{ marginBottom: 5 }}>Fences, cameras and a watchful neighbour — lowers the odds and severity of tactics run against this property.</div>
                        <div>Cost {fmt(securityCost)} → tactic success chance against you drops further</div>
                        <button className="btn small" onClick={() => onAct("security", { propertyId: meta.id })} disabled={securityCooling} style={{ marginTop: 6 }}>
                          {securityCooling ? `Available in ${securityCoolDays}d` : `Upgrade to level ${secLvl + 1}/${SECURITY_MAX_LEVEL}`}
                        </button>
                      </>
                    )}
                  </div>
                </div>
              ) : (
                <div>
                  <div className="muted" style={{ marginBottom: 8 }}>
                    Owned by <TraderName player={owner} /> · security {prop.securityLevel || 0}/{SECURITY_MAX_LEVEL}
                    {owner && owner.firmName && <div className="firm-line">{owner.firmName}</div>}
                  </div>
                  {meta.exclusive ? <div className="muted">Landmarks are protected from tactics.</div> : linked ? (
                    <div className="muted">You can't run tactics against this trader.</div>
                  ) : !me.canInteract ? (
                    <div className="gate-note">{me.interactionBlock}</div>
                  ) : (
                    <div className="improve-box">
                      <div style={{ fontWeight: 600, marginBottom: 3 }}>Run a tactic</div>
                      <div className="muted" style={{ marginBottom: 8 }}>Costs you either way, and you stay anonymous unless {owner ? owner.name : "the owner"} holds a Full terminal subscription.</div>
                      <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
                        <button className="btn small secondary" onClick={() => onAct("tactic", { propertyId: meta.id, type: "rumour" })} disabled={tacticOnCooldown || me.cash < rumourCost}>Rumours — {fmt(rumourCost)}</button>
                        <button className="btn small secondary" onClick={() => onAct("tactic", { propertyId: meta.id, type: "strike" })} disabled={tacticOnCooldown || me.cash < strikeCost}>Rent strike — {fmt(strikeCost)}</button>
                      </div>
                      {tacticOnCooldown && <div className="muted" style={{ marginTop: 6 }}>Recently targeted — cooling down.</div>}
                    </div>
                  )}
                </div>
              )}
          </div>

          <h3 style={{ fontFamily: "'Fraunces', serif", fontSize: 14, margin: "16px 0 4px" }}>Ownership history</h3>
          {!prop.history || !prop.history.length ? <div className="hist-row">No recorded transactions yet.</div>
            : [...prop.history].reverse().slice(0, 5).map((h, i) => (
              <div className="hist-row" key={i}>
                {h.type === "bought" ? "Bought" : h.type === "auction" ? "Won at auction" : "Acquired via trade"} by {h.ownerName} for {fmt(h.price)} — {fmtDate(h.time)}
              </div>
            ))}
        </div>
      </div>
    </div>
  );
}

/* ---------------------------- Market ------------------------------ */

function MarketTab({ game, me, histories, onAct, onOpen }) {
  const [search, setSearch] = useState("");
  const [catFilter, setCatFilter] = useState("all");
  const [cluster, setCluster] = useState("none");

  let rows = ALL_PROPERTIES.filter((m) => game.properties[m.id] && !game.properties[m.id].owner && !game.properties[m.id].inAuction);
  if (search) rows = rows.filter((m) => m.name.toLowerCase().includes(search.toLowerCase()));
  if (catFilter !== "all") rows = rows.filter((m) => m.category === catFilter);

  let groups;
  if (cluster === "area") {
    groups = Object.entries(REGIONS).map(([code, r]) => ({ key: code, title: r.label, items: rows.filter((m) => m.region === code) })).filter((g) => g.items.length);
  } else if (cluster === "movers") {
    const risers = rows.filter((m) => hourChange(game.properties[m.id]) >= 1).sort((a, b) => hourChange(game.properties[b.id]) - hourChange(game.properties[a.id]));
    const fallers = rows.filter((m) => hourChange(game.properties[m.id]) <= -1).sort((a, b) => hourChange(game.properties[a.id]) - hourChange(game.properties[b.id]));
    const steady = rows.filter((m) => Math.abs(hourChange(game.properties[m.id])) < 1);
    groups = [
      { key: "up", title: "Rising fast", items: risers },
      { key: "down", title: "Falling", items: fallers },
      { key: "flat", title: "Steady", items: steady },
    ].filter((g) => g.items.length);
  } else {
    groups = [{ key: "all", title: null, items: [...rows].sort((a, b) => game.properties[a.id].currentPrice - game.properties[b.id].currentPrice) }];
  }

  return (
    <div>
      <p className="intro">Everything currently available to buy. Properties owned by other traders drop off this list — find those on the Map, in Auctions, or through a Trade.</p>
      <div className="filter-bar">
        <input placeholder="Search properties" value={search} onChange={(e) => setSearch(e.target.value)} />
        <select value={catFilter} onChange={(e) => setCatFilter(e.target.value)}>
          <option value="all">All categories</option>
          {Object.entries(CATEGORIES).map(([k, c]) => <option key={k} value={k}>{c.label}</option>)}
        </select>
        <select value={cluster} onChange={(e) => setCluster(e.target.value)}>
          <option value="none">No clustering — price low to high</option>
          <option value="area">Cluster by area</option>
          <option value="movers">Cluster by movers</option>
        </select>
      </div>
      {!rows.length && <div className="empty">Nothing matches — every property here may already be owned.</div>}
      {groups.map((g) => (
        <div key={g.key}>
          {g.title && <div className="cluster-head">{g.title} <span className="muted">({g.items.length})</span></div>}
          <div className="grid">
            {g.items.map((meta) => (
              <PropertyTile key={meta.id} meta={meta} prop={game.properties[meta.id]} game={game} me={me}
                history={histories[meta.id] || []} onOpen={onOpen} onBuy={(id) => onAct("buy", { propertyId: id })} />
            ))}
          </div>
        </div>
      ))}
    </div>
  );
}

/* ---------------------------- Portfolio ------------------------------ */

function PortfolioTab({ game, me, now, histories, onAct, onOpen }) {
  const [auctionOpen, setAuctionOpen] = useState(null);
  const [startPrice, setStartPrice] = useState("");
  const [duration, setDuration] = useState("3");
  const mine = ALL_PROPERTIES.filter((m) => game.properties[m.id] && game.properties[m.id].owner === me.id);
  const dailyTotal = mine.reduce((s, m) => s + dailyRentFor(game, m, game.properties[m.id], now), 0);

  return (
    <div>
      <div className="portfolio-total">
        <div className="block"><div className="label">Cash</div><div className="value">{fmt(me.cash)}</div></div>
        <div className="block"><div className="label">Property value</div><div className="value">{fmt(me.portfolioValue)}</div></div>
        <div className="block"><div className="label">Net worth</div><div className="value">{fmt(me.netWorth)}</div></div>
        <div className="block"><div className="label">Daily rent potential</div><div className="value">{fmt(dailyTotal)}</div></div>
        <div className="block"><div className="label">Income earned</div><div className="value">{fmt(me.totalIncome)}</div></div>
      </div>

      {!mine.length ? <div className="empty">You don't own any properties yet.</div> : mine.map((meta) => {
        const p = game.properties[meta.id];
        const hold = holdStatus(p, now);
        const history = histories[meta.id] || [];
        const change = changeFromHistory(history, p.currentPrice);
        const oh = overheadFor(meta, p, now);
        const paidToday = (p.overheadPaidDay || 0) >= dayIndex(now);
        return (
          <div className="card" key={meta.id}>
            <div className="card-row">
              <div style={{ minWidth: 200, flex: 1 }}>
                <h3 style={{ cursor: "pointer" }} onClick={() => onOpen(meta.id)}>{meta.name}</h3>
                <div className="meta"><RegionTag region={meta.region} /><CategoryTag category={meta.category} /> {fmt(p.currentPrice)}</div>
                <div className={change >= 0 ? "chg-up" : "chg-down"}>{fmtPct(change)} this session</div>
                <Sparkline history={history} color={change >= 0 ? "#2F6B45" : "#B0342A"} width={200} height={44} fill />
                <div className="rent-line">Daily rent {fmt(dailyRentFor(game, meta, p, now))} · {yieldPctOf(meta).toFixed(2)}% daily rental yield</div>
                <div className={"hold-note " + (hold.earning ? "ok" : "warn")}>{hold.text}</div>
                {p.rentStrikeUntil > now && <div className="hold-note warn">Tenants are withholding rent until {fmtTime(p.rentStrikeUntil)}.</div>}
                <div className="muted">Improvement {p.improvementLevel || 0}/3 · Security {p.securityLevel || 0}/{SECURITY_MAX_LEVEL}</div>
                <StreetViewLink meta={meta} />
              </div>
              <div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "flex-start" }}>
                {p.inAuction ? <span className="auction-badge">Under auction</span> : (
                  <>
                    <button className="btn small" onClick={() => onAct("overheads", { propertyId: meta.id })} disabled={paidToday}>
                      {paidToday ? "Overheads paid today" : `${oh.item} — ${fmt(oh.cost)}`}
                    </button>
                    <button className="btn small secondary" onClick={() => onOpen(meta.id)}>Improve / secure</button>
                    <button className="btn small secondary" onClick={() => onAct("sell", { propertyId: meta.id })}>Sell {fmt(Math.round(p.currentPrice * 0.97))}</button>
                    <button className="btn small" onClick={() => { setAuctionOpen(meta.id); setStartPrice(Math.round(p.currentPrice * 1.05)); }}>Auction</button>
                  </>
                )}
              </div>
            </div>
            {auctionOpen === meta.id && (
              <div className="field-row">
                <label>Starting bid</label><input type="number" value={startPrice} onChange={(e) => setStartPrice(e.target.value)} />
                <label>Duration</label>
                <select value={duration} onChange={(e) => setDuration(e.target.value)}>
                  <option value="1">1 day</option><option value="3">3 days</option><option value="7">7 days</option><option value="14">14 days</option>
                </select>
                <button className="btn" onClick={() => { onAct("auction.start", { propertyId: meta.id, startPrice: Number(startPrice), days: Number(duration) }); setAuctionOpen(null); }}>List it</button>
                <button className="btn secondary" onClick={() => setAuctionOpen(null)}>Cancel</button>
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
}

/* ---------------------------- Auctions ------------------------------ */

function AuctionsTab({ game, me, onAct }) {
  const [bids, setBids] = useState({});
  const active = game.auctions.filter((a) => a.status === "active").slice().sort((x, y) => x.endsAt - y.endsAt);
  const closed = game.auctions.filter((a) => a.status !== "active").slice(0, 4);

  return (
    <div>
      <p className="intro">List from Portfolio for 1–14 days, or bid on what other traders have put up. Foreclosures open at half value and close fast. A bid in the last minutes extends the close, so nothing can be sniped.</p>
      {!me.canInteract && <div className="gate-note">{me.interactionBlock}</div>}
      {!active.length ? <div className="empty">No auctions running right now.</div> : active.map((a) => {
        const meta = metaFor(a.propertyId);
        const isMine = a.seller === me.id;
        const bidder = a.currentBidder ? game.players[a.currentBidder] : null;
        const linked = (me.linkedTo || []).includes(a.seller);
        const val = bids[a.id] ?? a.currentBid + Math.max(1000, Math.round(a.currentBid * 0.02));
        return (
          <div className="card" key={a.id}>
            <div className="card-row">
              <div>
                <h3>{meta.name} {a.foreclosure && <span className="foreclosure-badge">Foreclosure</span>}</h3>
                <div className="meta">
                  <RegionTag region={meta.region} />
                  {a.foreclosure
                    ? <>seized from {isMine ? "you" : <TraderName player={game.players[a.seller]} />} · opened at half value · ends in {timeLeft(a.endsAt)}</>
                    : <>listed by {isMine ? "you" : <TraderName player={game.players[a.seller]} />} · ends in {timeLeft(a.endsAt)}</>}
                </div>
              </div>
              <div style={{ textAlign: "right" }}>
                <div className="stat-label">Current bid</div>
                <div className="price" style={{ fontSize: 18 }}>{fmt(a.currentBid)}</div>
                <div className="muted">{bidder ? `by ${bidder.name}` : "no bids yet"}</div>
              </div>
            </div>
            {!isMine && !linked && me.canInteract && (
              <div className="field-row">
                <input type="number" value={val} onChange={(e) => setBids({ ...bids, [a.id]: e.target.value })} />
                <button className="btn" onClick={() => onAct("auction.bid", { auctionId: a.id, amount: Number(val) })}>Place bid</button>
              </div>
            )}
            {linked && <div className="muted" style={{ marginTop: 6 }}>You can't bid on this lot.</div>}
          </div>
        );
      })}
      {closed.length > 0 && (
        <>
          <div className="cluster-head">Recently closed</div>
          {closed.map((a) => {
            const bidder = a.currentBidder ? game.players[a.currentBidder] : null;
            return <div key={a.id} className="muted" style={{ padding: "4px 0" }}>{metaFor(a.propertyId).name} — {bidder ? `sold to ${bidder.name} for ${fmt(a.currentBid)}` : "no winning bid"}</div>;
          })}
        </>
      )}
    </div>
  );
}

/* ---------------------------- Bank ------------------------------ */

function BankTab({ me, now, onAct }) {
  const [depositField, setDepositField] = useState("");
  const [withdrawField, setWithdrawField] = useState("");
  const [borrowField, setBorrowField] = useState("");
  const [repayField, setRepayField] = useState("");

  const level = me.bankLevel || 0;
  const tier = BANK_TIERS[Math.min(level, BANK_TIERS.length - 1)];
  const nextTier = BANK_TIERS[level + 1];
  const inArrears = me.loan > 0 && me.arrearsSince > 0;
  const graceLeft = inArrears ? Math.max(0, tier.graceDays * GAME_DAY_MS - (now - me.arrearsSince)) : 0;
  const underWater = me.netWorth < 0;
  const negativeLeft = underWater && me.negativeSince ? Math.max(0, tier.negativeDays * GAME_DAY_MS - (now - me.negativeSince)) : 0;
  const mins = (ms) => `${Math.ceil(ms / 60000)} min`;

  return (
    <div>
      <p className="intro">The exchange's bank turns idle cash into a small, safe yield — and lends against your portfolio when you want to move faster than your cash allows. Interest on both sides settles once a game day, alongside the rents.</p>

      {(inArrears || underWater) && (
        <div className="card" style={{ borderLeft: "3px solid var(--loss)" }}>
          <h3 style={{ color: "var(--loss)" }}>The bank wants a word</h3>
          {inArrears && <div style={{ fontSize: 13, marginBottom: 4 }}>You missed interest on your loan. {tier.graceDays > 0 && graceLeft > 0 ? `About ${mins(graceLeft)} of grace before the bank seizes a property.` : "Your least valuable property is seized at the next settlement."}</div>}
          {underWater && <div style={{ fontSize: 13 }}>Your net worth is below zero at {fmt(me.netWorth)}. {tier.negativeDays > 0 && negativeLeft > 0 ? `You can trade under water for about ${mins(negativeLeft)} more.` : "Trading under water triggers a foreclosure at the next settlement."}</div>}
        </div>
      )}

      <div className="card">
        <h3>{tier.name}</h3>
        <div className="meta">Banking tier {level} of {BANK_TIERS.length - 1}</div>
        <div style={{ fontSize: 13, marginBottom: 10 }}>{tier.desc}</div>
        {nextTier ? (
          <>
            <div style={{ fontSize: 13, marginBottom: 4 }}><strong>{nextTier.name}</strong> — {nextTier.desc}</div>
            <div className="muted" style={{ marginBottom: 8 }}>
              Deposits {(nextTier.depositRate * 100).toFixed(2)}%/day · {nextTier.loanRate ? `loans ${(nextTier.loanRate * 100).toFixed(2)}%/day` : "no lending"} ·
              {" "}{nextTier.graceDays ? `${nextTier.graceDays} day grace` : "no grace on arrears"}
            </div>
            <button className="btn" onClick={() => onAct("bank.upgrade")} disabled={me.cash < nextTier.cost}>Open for {fmt(nextTier.cost)}</button>
          </>
        ) : <div className="muted">You hold the highest tier the bank offers.</div>}
      </div>

      <div className="card">
        <h3>Deposit account</h3>
        {level < 1 ? <div className="muted">Locked — open a deposit account above to start earning on idle cash.</div> : (
          <>
            <div className="portfolio-total" style={{ marginBottom: 12 }}>
              <div className="block"><div className="label">On deposit</div><div className="value">{fmt(me.deposit)}</div></div>
              <div className="block"><div className="label">Rate</div><div className="value">{(tier.depositRate * 100).toFixed(2)}%/day</div></div>
              <div className="block"><div className="label">Earns next settlement</div><div className="value">{fmt(me.deposit * tier.depositRate)}</div></div>
              <div className="block"><div className="label">Cash to hand</div><div className="value">{fmt(me.cash)}</div></div>
            </div>
            <div className="field-row">
              <label>Deposit</label>
              <input type="number" value={depositField} onChange={(e) => setDepositField(e.target.value)} placeholder="0" />
              <button className="btn small secondary" onClick={() => setDepositField(String(Math.floor(me.cash)))}>Max</button>
              <button className="btn small" onClick={() => { onAct("bank.deposit", { amount: depositField }); setDepositField(""); }}>Pay in</button>
            </div>
            <div className="field-row">
              <label>Withdraw</label>
              <input type="number" value={withdrawField} onChange={(e) => setWithdrawField(e.target.value)} placeholder="0" />
              <button className="btn small secondary" onClick={() => setWithdrawField(String(Math.floor(me.deposit)))}>Max</button>
              <button className="btn small" onClick={() => { onAct("bank.withdraw", { amount: withdrawField }); setWithdrawField(""); }}>Take out</button>
            </div>
            <div className="muted" style={{ marginTop: 8 }}>Deposits are safe from tactics, but they're also the first thing the bank takes if you can't service a loan.</div>
          </>
        )}
      </div>

      <div className="card">
        <h3>Credit line</h3>
        {level < 2 ? <div className="muted">Locked — reach the credit line tier to borrow against your portfolio.</div> : (
          <>
            <div className="portfolio-total" style={{ marginBottom: 12 }}>
              <div className="block"><div className="label">Outstanding</div><div className="value">{fmt(me.loan)}</div></div>
              <div className="block"><div className="label">Rate</div><div className="value">{(tier.loanRate * 100).toFixed(2)}%/day</div></div>
              <div className="block"><div className="label">Interest next settlement</div><div className="value">{fmt(me.loan * tier.loanRate)}</div></div>
              <div className="block"><div className="label">Still available</div><div className="value">{fmt(me.borrowCapacity)}</div></div>
            </div>
            <div className="field-row">
              <label>Borrow</label>
              <input type="number" value={borrowField} onChange={(e) => setBorrowField(e.target.value)} placeholder="0" />
              <button className="btn small secondary" onClick={() => setBorrowField(String(Math.floor(me.borrowCapacity)))}>Max</button>
              <button className="btn small" onClick={() => { onAct("bank.borrow", { amount: borrowField }); setBorrowField(""); }} disabled={me.borrowCapacity <= 0}>Draw down</button>
            </div>
            <div className="field-row">
              <label>Repay</label>
              <input type="number" value={repayField} onChange={(e) => setRepayField(e.target.value)} placeholder="0" />
              <button className="btn small secondary" onClick={() => setRepayField(String(Math.floor(Math.min(me.loan, me.cash))))}>Max</button>
              <button className="btn small" onClick={() => { onAct("bank.repay", { amount: repayField }); setRepayField(""); }} disabled={me.loan <= 0}>Pay back</button>
            </div>
            <div className="muted" style={{ marginTop: 8 }}>You can borrow up to your net worth, so the most you can ever be is twice-geared.</div>
          </>
        )}
      </div>

      <div className="card">
        <h3>What happens if you default</h3>
        <div style={{ fontSize: 13, lineHeight: 1.6 }}>
          Each settlement the bank takes its interest from your cash, then from your deposits. If neither covers it, the shortfall is added to your loan and you fall into arrears.
          Once grace runs out the bank seizes your <strong>least valuable</strong> property and lists it as a one-hour foreclosure auction at half its market value. Every trader sees the alert.
          Whatever it fetches goes straight against your debt; if nobody bids, the bank repossesses it, writes the debt down by the opening price, and the property returns to the open market.
        </div>
      </div>
    </div>
  );
}

/* ---------------------------- Tactics ------------------------------ */

function TacticsTab({ game, me, onAct }) {
  const linked = new Set(me.linkedTo || []);
  const others = Object.values(game.players).filter((p) => p.id !== me.id && !linked.has(p.id));
  const [search, setSearch] = useState("");
  const [toId, setToId] = useState(others[0] ? others[0].id : "");
  const filtered = others.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()));
  const targets = toId ? ALL_PROPERTIES.filter((m) => game.properties[m.id] && game.properties[m.id].owner === Number(toId) && !m.exclusive) : [];
  const now = Date.now();
  const myTactics = game.tactics.filter((t) => t.mine).slice(0, 12);
  const againstMe = game.tactics.filter((t) => !t.mine).slice(0, 12);

  return (
    <div>
      <p className="intro">Pay to run a covert tactic against another trader's owned property — rumours dent its value, a rent strike stops the tenant paying for a while. Every attempt costs you whether it lands or not, landmarks can't be targeted, and owners can defend with Security. Attackers stay anonymous unless the target holds a Full terminal subscription.</p>
      {!me.canInteract && <div className="gate-note">{me.interactionBlock}</div>}
      {!others.length ? <div className="empty">No other traders are available to you yet.</div> : (
        <div className="card">
          <h3>Choose a target</h3>
          <div className="field-row">
            <label>Find trader</label><input className="search-input" placeholder="Search by name" value={search} onChange={(e) => setSearch(e.target.value)} />
            <label>Target</label>
            <select value={toId} onChange={(e) => setToId(e.target.value)} style={{ width: 160 }}>
              {filtered.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
            </select>
          </div>
          {!targets.length ? <div className="empty" style={{ marginTop: 10 }}>This trader owns no eligible property.</div> : targets.map((meta) => {
            const prop = game.properties[meta.id];
            const onCooldown = prop.lastTacticAt && now - prop.lastTacticAt < 2 * GAME_DAY_MS;
            const rumourCost = Math.round(prop.currentPrice * TACTICS.rumour.costPct);
            const strikeCost = Math.round(prop.currentPrice * TACTICS.strike.costPct);
            return (
              <div className="cluster-row" key={meta.id} style={{ flexWrap: "wrap" }}>
                <span><CategoryTag category={meta.category} /> {meta.name} <span className="muted">{fmt(prop.currentPrice)} · security {prop.securityLevel || 0}/{SECURITY_MAX_LEVEL}</span></span>
                <span style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
                  <button className="btn small secondary" disabled={!me.canInteract || onCooldown || me.cash < rumourCost} onClick={() => onAct("tactic", { propertyId: meta.id, type: "rumour" })}>Rumours — {fmt(rumourCost)}</button>
                  <button className="btn small secondary" disabled={!me.canInteract || onCooldown || me.cash < strikeCost} onClick={() => onAct("tactic", { propertyId: meta.id, type: "strike" })}>Rent strike — {fmt(strikeCost)}</button>
                </span>
                {onCooldown && <div className="muted" style={{ width: "100%" }}>Recently targeted — cooling down.</div>}
              </div>
            );
          })}
        </div>
      )}

      <div className="card">
        <h3>Your recent tactics</h3>
        {!myTactics.length ? <div className="empty">You haven't run any tactics yet.</div> : myTactics.map((t) => (
          <div className="event-row" key={t.id}>
            <div className="event-time">{fmtTime(t.time)}</div>
            <div>{TACTICS[t.type] ? TACTICS[t.type].label : t.type} against {metaFor(t.targetProperty).name} ({t.targetOwnerName || "a trader"}) — {t.success ? "landed" : "failed"}, cost {fmt(t.cost)}</div>
          </div>
        ))}
      </div>

      <div className="card">
        <h3>Activity on your properties</h3>
        {!againstMe.length ? <div className="empty">No tactics have targeted your properties.</div> : againstMe.map((t) => (
          <div className="event-row" key={t.id}>
            <div className="event-time">{fmtTime(t.time)}</div>
            <div>
              {TACTICS[t.type] ? TACTICS[t.type].label : t.type} {t.success ? "hit" : "was attempted against"} {metaFor(t.targetProperty).name}
              {t.attackerName ? ` — full terminal traces this to ${t.attackerName}.` : "."}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

/* ---------------------------- Trade ------------------------------ */

function TradeTab({ game, me, onAct }) {
  const linked = new Set(me.linkedTo || []);
  const others = Object.values(game.players).filter((p) => p.id !== me.id && !linked.has(p.id));
  const [search, setSearch] = useState("");
  const [toId, setToId] = useState(others[0] ? others[0].id : "");
  const [offerProps, setOfferProps] = useState([]);
  const [offerCash, setOfferCash] = useState(0);
  const [requestProps, setRequestProps] = useState([]);
  const [requestCash, setRequestCash] = useState(0);
  const filtered = others.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()));
  const mine = ALL_PROPERTIES.filter((m) => game.properties[m.id] && game.properties[m.id].owner === me.id && !game.properties[m.id].inAuction);
  const theirs = toId ? ALL_PROPERTIES.filter((m) => game.properties[m.id] && game.properties[m.id].owner === Number(toId) && !game.properties[m.id].inAuction) : [];
  const toggle = (l, s, id) => s(l.includes(id) ? l.filter((x) => x !== id) : [...l, id]);
  const incoming = game.trades.filter((o) => o.to === me.id);
  const outgoing = game.trades.filter((o) => o.from === me.id);

  const give = offerProps.reduce((s, id) => s + game.properties[id].currentPrice, 0) + (Number(offerCash) || 0);
  const get = requestProps.reduce((s, id) => s + game.properties[id].currentPrice, 0) + (Number(requestCash) || 0);
  const duty = Math.round((give + get) * (game.rules.stampDutyPct || 0) / 2);
  const balanced = give > 0 && get > 0 && get >= give * game.rules.fairTradeFloor && give >= get * game.rules.fairTradeFloor;

  return (
    <div>
      <p className="intro">
        Both sides of a trade must be within {Math.round((1 - game.rules.fairTradeFloor) * 100)}% of fair value, and stamp duty of {Math.round(game.rules.stampDutyPct * 100)}% is
        split between you and burned. Only money you've earned can go to another trader — your opening grant stays with you.
      </p>
      {!me.canInteract && <div className="gate-note">{me.interactionBlock}</div>}
      {!others.length ? <div className="empty">No other traders are available to you yet.</div> : (
        <div className="card">
          <h3>Propose a trade</h3>
          <div className="field-row">
            <label>Find trader</label><input className="search-input" placeholder="Search by name" value={search} onChange={(e) => setSearch(e.target.value)} />
            <label>Trade with</label>
            <select value={toId} onChange={(e) => { setToId(e.target.value); setRequestProps([]); }} style={{ width: 160 }}>
              {filtered.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
            </select>
          </div>
          <div className="trade-cols" style={{ marginTop: 12 }}>
            <div className="trade-col">
              <div style={{ fontSize: 12.5, fontWeight: 600, marginBottom: 6 }}>You offer</div>
              <div className="check-list">
                {!mine.length && <div className="muted">You own no properties.</div>}
                {mine.map((m) => (
                  <label className="check-row" key={m.id}>
                    <input type="checkbox" checked={offerProps.includes(m.id)} onChange={() => toggle(offerProps, setOfferProps, m.id)} />
                    {m.name} ({fmt(game.properties[m.id].currentPrice)})
                  </label>
                ))}
              </div>
              <div className="field-row"><label>+ cash</label><input type="number" value={offerCash} onChange={(e) => setOfferCash(e.target.value)} /></div>
              <div className="hint">You can commit {fmt(me.transferable)} of earned money.</div>
            </div>
            <div className="trade-col">
              <div style={{ fontSize: 12.5, fontWeight: 600, marginBottom: 6 }}>You request</div>
              <div className="check-list">
                {!theirs.length && <div className="muted">They own no properties.</div>}
                {theirs.map((m) => (
                  <label className="check-row" key={m.id}>
                    <input type="checkbox" checked={requestProps.includes(m.id)} onChange={() => toggle(requestProps, setRequestProps, m.id)} />
                    {m.name} ({fmt(game.properties[m.id].currentPrice)})
                  </label>
                ))}
              </div>
              <div className="field-row"><label>+ cash</label><input type="number" value={requestCash} onChange={(e) => setRequestCash(e.target.value)} /></div>
            </div>
          </div>
          <div style={{ marginTop: 12 }}>
            <div className="muted" style={{ marginBottom: 8 }}>
              You give {fmt(give)} · you get {fmt(get)} · stamp duty {fmt(duty)} each
              {give > 0 && get > 0 && !balanced && <span style={{ color: "var(--loss)" }}> — too lopsided to submit</span>}
            </div>
            <button className="btn" disabled={!me.canInteract || !balanced} onClick={() => {
              onAct("trade.propose", { toId: Number(toId), offerProps, offerCash: Number(offerCash) || 0, requestProps, requestCash: Number(requestCash) || 0 });
              setOfferProps([]); setOfferCash(0); setRequestProps([]); setRequestCash(0);
            }}>Send offer</button>
          </div>
        </div>
      )}

      {incoming.length > 0 && <><div className="cluster-head">Offers for you</div>
        {incoming.map((o) => (
          <div className="offer-block" key={o.id}>
            <div className="muted">From {game.players[o.from] ? game.players[o.from].name : "a trader"}</div>
            <div style={{ fontSize: 13, margin: "6px 0 8px" }}>
              They give: {o.offerProps.map((id) => metaFor(id).name).join(", ") || "—"}{o.offerCash > 0 ? ` + ${fmt(o.offerCash)}` : ""}<br />
              They want: {o.requestProps.map((id) => metaFor(id).name).join(", ") || "—"}{o.requestCash > 0 ? ` + ${fmt(o.requestCash)}` : ""}
            </div>
            <button className="btn" onClick={() => onAct("trade.respond", { tradeId: o.id, accept: true })} style={{ marginRight: 8 }}>Accept</button>
            <button className="btn secondary" onClick={() => onAct("trade.respond", { tradeId: o.id, accept: false })}>Decline</button>
          </div>
        ))}</>}

      {outgoing.length > 0 && <><div className="cluster-head">Your pending offers</div>
        {outgoing.map((o) => (
          <div className="offer-block" key={o.id}>
            <div className="muted">To {game.players[o.to] ? game.players[o.to].name : "a trader"}</div>
            <div style={{ fontSize: 13, margin: "6px 0 8px" }}>
              You give: {o.offerProps.map((id) => metaFor(id).name).join(", ") || "—"}{o.offerCash > 0 ? ` + ${fmt(o.offerCash)}` : ""}<br />
              You want: {o.requestProps.map((id) => metaFor(id).name).join(", ") || "—"}{o.requestCash > 0 ? ` + ${fmt(o.requestCash)}` : ""}
            </div>
            <button className="btn secondary" onClick={() => onAct("trade.cancel", { tradeId: o.id })}>Cancel</button>
          </div>
        ))}</>}
    </div>
  );
}

/* ---------------------------- Leaderboard ------------------------------ */

function snapshotChange(player, current, ms) {
  const snaps = player.snapshots || [];
  if (!snaps.length) return null;
  const cutoff = Date.now() - ms;
  const past = [...snaps].reverse().find((s) => s.t <= cutoff);
  if (!past || !past.v) return null;
  return ((current - past.v) / past.v) * 100;
}

function LeaderboardTab({ game, me }) {
  const [sortBy, setSortBy] = useState("net");
  const rows = Object.values(game.players).map((p) => ({
    id: p.id, p, net: p.netWorth,
    day: snapshotChange(p, p.netWorth, GAME_DAY_MS),
    week: snapshotChange(p, p.netWorth, 7 * GAME_DAY_MS),
  }));
  const sorted = [...rows].sort((a, b) => {
    if (sortBy === "day") return (b.day ?? -Infinity) - (a.day ?? -Infinity);
    if (sortBy === "week") return (b.week ?? -Infinity) - (a.week ?? -Infinity);
    return b.net - a.net;
  }).slice(0, 10);
  const myRank = [...rows].sort((a, b) => b.net - a.net).findIndex((r) => r.id === me.id) + 1;
  const pct = (v) => v === null ? <span className="muted">—</span> : <span className={v >= 0 ? "chg-up" : "chg-down"}>{fmtPct(v)}</span>;

  return (
    <div>
      <p className="intro">Top ten traders by net worth. Percentages compare against saved snapshots, so new traders show a dash until enough history builds up.</p>
      <div className="filter-bar">
        <select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
          <option value="net">Rank by net worth</option>
          <option value="day">Rank by one-day growth</option>
          <option value="week">Rank by seven-day growth</option>
        </select>
        <span className="muted" style={{ alignSelf: "center" }}>Your overall rank: #{myRank || "—"} of {rows.length}</span>
      </div>
      <div className="lb-wrap">
        <table className="lb">
          <thead><tr><th></th><th>Trader</th><th>Net worth</th><th>1d</th><th>7d</th><th>Properties</th></tr></thead>
          <tbody>
            {sorted.map((r, i) => (
              <tr key={r.id} className={r.id === me.id ? "you-row" : ""}>
                <td className="rank">{i + 1}</td>
                <td><TraderName player={r.p} />{r.id === me.id && <span className="muted"> (you)</span>}
                  {r.p.firmName && <div className="firm-line">{r.p.firmName}</div>}</td>
                <td className="price">{fmt(r.net)}</td>
                <td>{pct(r.day)}</td>
                <td>{pct(r.week)}</td>
                <td>{r.p.propertyCount}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

/* ---------------------------- Market report ------------------------------ */

function EventsTab({ game, me, onAct }) {
  const level = me.analyticsLevel || 0;
  const nextTier = ANALYTICS_TIERS[level + 1];
  const currentTier = ANALYTICS_TIERS[level];

  return (
    <div>
      <div className="card">
        <h3>Your desk</h3>
        <div className="meta">Everything that happened to you, whether or not it made the news.</div>
        {!game.notifications.length ? <div className="empty">Nothing yet — buy something and the rents will start arriving.</div>
          : game.notifications.map((n) => (
            <div className="notif-row" key={n.id}>
              <span className="notif-dot" />
              <div style={{ flex: 1 }}>{n.text}</div>
              <div className="event-time">{fmtTime(n.t)}</div>
            </div>
          ))}
      </div>

      <div className="card">
        <h3>Analytics subscription</h3>
        <div className="meta">Current plan: {currentTier.name}</div>
        <div style={{ fontSize: 13, marginBottom: 8 }}>{currentTier.desc}</div>
        {nextTier ? (
          <>
            <div style={{ fontSize: 13, marginBottom: 8 }}><strong>{nextTier.name}</strong> — {nextTier.desc}</div>
            <button className="btn" onClick={() => onAct("analytics")} disabled={me.cash < nextTier.cost}>Upgrade for {fmt(nextTier.cost)}</button>
          </>
        ) : <div className="muted">You have the highest tier available.</div>}
        {game.hiddenNews > 0 && <div className="muted" style={{ marginTop: 8 }}>{game.hiddenNews} trader action{game.hiddenNews === 1 ? "" : "s"} currently below your reporting threshold.</div>}
      </div>

      <div className="card">
        <h3>Market sentiment</h3>
        <div className="meta">Drives the drift behind every property's price.</div>
        <div className="gauge-row">
          <div className="gauge-label">Bank rate outlook</div>
          <div className="gauge-track">
            <div className={"gauge-fill" + (game.market.economicIndex < 0 ? " neg" : "")}
              style={{ left: game.market.economicIndex >= 0 ? "50%" : `${50 + game.market.economicIndex * 50}%`, width: `${Math.abs(game.market.economicIndex) * 50}%` }} />
          </div>
          <div style={{ width: 40, textAlign: "right", fontFamily: "'IBM Plex Mono', monospace" }}>{game.market.economicIndex >= 0 ? "+" : ""}{(game.market.economicIndex * 100).toFixed(0)}</div>
        </div>
        {Object.entries(REGIONS).map(([code, r]) => {
          const v = game.market.regionTrend[code] || 0;
          return (
            <div className="gauge-row" key={code}>
              <div className="gauge-label">{r.label}</div>
              <div className="gauge-track"><div className={"gauge-fill" + (v < 0 ? " neg" : "")} style={{ left: v >= 0 ? "50%" : `${50 + v * 50}%`, width: `${Math.abs(v) * 50}%` }} /></div>
              <div style={{ width: 40, textAlign: "right", fontFamily: "'IBM Plex Mono', monospace" }}>{v >= 0 ? "+" : ""}{(v * 100).toFixed(0)}</div>
            </div>
          );
        })}
      </div>

      <div className="card">
        <h3>Market report</h3>
        {!game.news.length && <div className="empty">No market activity reported yet.</div>}
        {game.news.map((e) => (
          <div className="event-row" key={e.id}>
            <div className="event-time">{fmtTime(e.t)}</div>
            <div>{e.text}</div>
          </div>
        ))}
      </div>
    </div>
  );
}

/* ---------------------------- Personalise ------------------------------ */

/* Paddle is loaded lazily, only when someone opens the shop, and only if
   the server says payments are configured. */
let paddleReady = null;
function loadPaddle(shop) {
  if (paddleReady) return paddleReady;
  paddleReady = new Promise((resolve, reject) => {
    if (!shop.configured || !shop.clientToken) return reject(new Error("Payments aren't set up yet."));
    if (window.Paddle) return resolve(window.Paddle);
    const s = document.createElement("script");
    s.src = "https://cdn.paddle.com/paddle/v2/paddle.js";
    s.onload = () => {
      try {
        if (shop.environment !== "production") window.Paddle.Environment.set("sandbox");
        window.Paddle.Initialize({ token: shop.clientToken });
        resolve(window.Paddle);
      } catch (e) { reject(e); }
    };
    s.onerror = () => reject(new Error("Couldn't reach the payment provider."));
    document.head.appendChild(s);
  });
  return paddleReady;
}

function PersonaliseTab({ me, onAct, onEquip, onFirmName, flash }) {
  const shop = me.shop || { items: [], configured: false };
  const worn = me.cosmetics || {};
  const [firm, setFirm] = useState(worn.firmName || "");
  const ownsFirm = shop.owned.includes("firm.name");

  async function buy(item) {
    try {
      const Paddle = await loadPaddle(shop);
      Paddle.Checkout.open({
        items: [{ priceId: item.priceId, quantity: 1 }],
        customer: me.email ? { email: me.email } : undefined,
        customData: { userId: String(me.id), sku: item.sku },
        settings: { successUrl: location.origin + "/?purchased=" + encodeURIComponent(item.sku) },
      });
    } catch (e) { flash(e.message); }
  }

  const themeSwatch = (sku) => {
    const map = {
      "theme.night-desk": ["#1E1F1C", "#7FA08C", "#D9B45A"],
      "theme.blueprint": ["#12314F", "#7FB0D8", "#EBD9A6"],
      "theme.terminal": ["#080C08", "#2C8C43", "#B7F5C0"],
    };
    return map[sku] || ["#E7E1D2", "#1F4B3F", "#C9A227"];
  };

  return (
    <div>
      <p className="intro">
        Everything here is decoration. None of it moves a price, earns a penny, speeds anything up, or tells you
        something another trader can't see — the market treats a founding trader exactly like somebody who joined this morning.
      </p>

      <div className="card">
        <h3>Your appearance</h3>
        <div className="meta">How you look to everyone else on the exchange.</div>

        <div style={{ marginBottom: 14 }}>
          <div className="stat-label">Exchange theme</div>
          <div className="swatch-row">
            <button className={"swatch" + (!worn.theme ? " active" : "")} title="Ledger (default)"
              onClick={() => onEquip("theme", null)}
              style={{ background: "linear-gradient(135deg,#E7E1D2 60%,#1F4B3F 60%)" }} />
            {["theme.night-desk", "theme.blueprint", "theme.terminal"].map((sku) => {
              const [a, b, c] = themeSwatch(sku);
              const owned = shop.themes.includes(sku);
              return (
                <button key={sku} className={"swatch" + (worn.theme === sku ? " active" : "")}
                  disabled={!owned} title={owned ? sku : "Not owned yet"}
                  onClick={() => onEquip("theme", sku)}
                  style={{ background: `linear-gradient(135deg, ${a} 50%, ${b} 50%, ${b} 78%, ${c} 78%)` }} />
              );
            })}
          </div>
        </div>

        <div style={{ marginBottom: 14 }}>
          <div className="stat-label">House crest</div>
          <div className="swatch-row">
            <button className={"crest-btn" + (!worn.crest ? " active" : "")} onClick={() => onEquip("crest", null)} title="None">—</button>
            {Object.keys(CREST_SHAPES).map((sku) => {
              const owned = shop.crests.includes(sku);
              return (
                <button key={sku} className={"crest-btn" + (worn.crest === sku ? " active" : "")}
                  disabled={!owned} onClick={() => onEquip("crest", sku)} title={owned ? sku.split(".")[1] : "Not owned yet"}>
                  <svg width="100%" height="100%" viewBox="0 0 24 24">{CREST_SHAPES[sku]("currentColor")}</svg>
                </button>
              );
            })}
          </div>
        </div>

        <div style={{ marginBottom: 14 }}>
          <div className="stat-label">Property façades</div>
          <div className="swatch-row">
            <button className={"swatch" + (!worn.facade ? " active" : "")} onClick={() => onEquip("facade", null)}
              title="As built" style={{ background: "linear-gradient(135deg,#B4674D 50%,#C7D6DF 50%)" }} />
            {Object.entries(FACADE_PALETTES).map(([sku, pal]) => {
              const owned = shop.facades.includes(sku);
              return (
                <button key={sku} className={"swatch" + (worn.facade === sku ? " active" : "")}
                  disabled={!owned} onClick={() => onEquip("facade", sku)} title={owned ? sku.split(".")[1] : "Not owned yet"}
                  style={{ background: `linear-gradient(135deg, ${pal.brick} 50%, ${pal.sky[0]} 50%)` }} />
              );
            })}
          </div>
          <div className="hint">Applies to every property you own, for everyone who looks at them.</div>
        </div>

        <div>
          <div className="stat-label">Trading house</div>
          {ownsFirm ? (
            <div className="field-row">
              <input className="search-input" style={{ width: 220 }} maxLength={28} value={firm}
                placeholder="Koshy &amp; Co Estates" onChange={(e) => setFirm(e.target.value)} />
              <button className="btn small" onClick={() => onFirmName(firm)}>Save</button>
              {worn.firmName && <button className="btn small secondary" onClick={() => { setFirm(""); onFirmName(""); }}>Clear</button>}
            </div>
          ) : <div className="muted">Locked — a firm name appears under yours wherever you trade.</div>}
        </div>
      </div>

      <div className="card">
        <h3>Shop</h3>
        {!shop.configured ? (
          <div className="muted">
            The shop isn't switched on yet. Add your Paddle client token and webhook secret to <code>.env</code> to open it.
          </div>
        ) : (
          <>
            <div className="meta">One-off purchases. Paddle handles the payment and the VAT; no card details reach this server.</div>
            <div className="shop-grid">
              {shop.items.map((item) => (
                <div className="shop-item" key={item.sku}>
                  <h4>{item.name}</h4>
                  <div className="blurb">{item.blurb}</div>
                  {item.owned ? <span className="owned-tag">Owned</span> : (
                    <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10 }}>
                      <span className="shop-price">{item.price}</span>
                      <button className="btn small" disabled={!item.purchasable} onClick={() => buy(item)}>
                        {item.priceId ? "Buy" : "Not on sale"}
                      </button>
                    </div>
                  )}
                </div>
              ))}
            </div>
          </>
        )}
      </div>
    </div>
  );
}

/* ---------------------------- cosmetics ------------------------------ */

/* Crests are drawn, not loaded — no image files to host, and they inherit
   the current theme's ink colour wherever they appear. */
const CREST_SHAPES = {
  "crest.rose": (c) => <g><circle cx="12" cy="12" r="6" fill={c} /><circle cx="12" cy="12" r="3" fill="none" stroke="#fff" strokeWidth="1.2" /><path d="M12 3 L14 7 L10 7 Z" fill={c} /></g>,
  "crest.keys": (c) => <g stroke={c} strokeWidth="1.8" fill="none"><circle cx="8" cy="8" r="3" /><path d="M10 10 L17 17 M15 17 l2 0 M17 15 l0 2" /></g>,
  "crest.tower": (c) => <g fill={c}><rect x="8" y="9" width="8" height="11" /><path d="M7 9 h10 l-1 -3 h-2 v2 h-1 v-2 h-2 v2 h-1 v-2 h-2 z" /></g>,
  "crest.lion": (c) => <g fill={c}><circle cx="12" cy="11" r="5" /><path d="M12 4 l3 3 -3 1 -3 -1 z" /><rect x="10" y="15" width="4" height="6" /></g>,
  "crest.ouse": (c) => <g stroke={c} strokeWidth="2" fill="none" strokeLinecap="round"><path d="M4 9 q4 -3 8 0 t8 0" /><path d="M4 14 q4 -3 8 0 t8 0" /><path d="M4 19 q4 -3 8 0 t8 0" /></g>,
  "crest.oak": (c) => <g fill={c}><ellipse cx="12" cy="9" rx="7" ry="6" /><rect x="11" y="14" width="2" height="7" /></g>,
  "crest.compass": (c) => <g><circle cx="12" cy="12" r="8" fill="none" stroke={c} strokeWidth="1.6" /><path d="M12 5 L14 12 L12 19 L10 12 Z" fill={c} /></g>,
  "crest.anchor": (c) => <g stroke={c} strokeWidth="1.8" fill="none" strokeLinecap="round"><circle cx="12" cy="5" r="2" /><path d="M12 7 v13 M7 12 h10 M5 16 q7 6 14 0" /></g>,
};

function Crest({ id, size = 16, color }) {
  const draw = CREST_SHAPES[id];
  if (!draw) return null;
  return (
    <svg className="crest-inline" width={size} height={size} viewBox="0 0 24 24" aria-hidden="true">
      {draw(color || "currentColor")}
    </svg>
  );
}

/* A player's name as it appears anywhere in the game: crest, name, and a
   founder's mark. One component so identity is consistent everywhere. */
function TraderName({ player, size = 16 }) {
  if (!player) return <span>a trader</span>;
  return (
    <span>
      {player.crest && <Crest id={player.crest} size={size} />}
      {player.name}
      {player.founder && <span className="founder-mark" title="Founding trader">✦</span>}
    </span>
  );
}

const FACADE_PALETTES = {
  "facade.georgian": { brick: "#D8CFC0", roof: "#4A4A46", sky: ["#CBD9E2", "#EDE7DA"], ground: "#C6BCA4" },
  "facade.victorian": { brick: "#8E4436", roof: "#33302C", sky: ["#C2CFD8", "#E4DFD2"], ground: "#BAB19A" },
  "facade.winter": { brick: "#A9A29B", roof: "#E8EDF0", sky: ["#D6E1EA", "#F2F3F1"], ground: "#E9ECEC" },
};

/* ---------------------------- accounts ------------------------------ */

function AuthScreen({ onAuthed }) {
  const [mode, setMode] = useState("login");
  const [email, setEmail] = useState("");
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [notice, setNotice] = useState("");
  const [busy, setBusy] = useState(false);

  /* Arriving from a reset email: the token is in the URL. */
  const resetToken = useMemo(() => {
    try { return new URL(location.href).searchParams.get("token"); } catch { return null; }
  }, []);
  const isResetLink = typeof location !== "undefined" && location.pathname === "/reset" && resetToken;
  const [resetValid, setResetValid] = useState(null);

  useEffect(() => {
    if (!isResetLink) return;
    setMode("reset");
    api(`/api/reset-check?token=${encodeURIComponent(resetToken)}`)
      .then((r) => { setResetValid(r.valid); if (!r.valid) setError(r.error); })
      .catch(() => setResetValid(false));
  }, [isResetLink, resetToken]);

  async function submit(e) {
    if (e) e.preventDefault();
    setError(""); setNotice(""); setBusy(true);
    try {
      if (mode === "register") {
        const res = await api("/api/register", { method: "POST", body: { email, username, password, deviceId: deviceId() } });
        if (res.needsVerification) {
          setNotice("Check your inbox — we've sent you a link to confirm the address before you can trade.");
          setMode("login");
        } else { await onAuthed(); }
      } else if (mode === "forgot") {
        const res = await api("/api/forgot", { method: "POST", body: { email } });
        setNotice(res.message);
      } else if (mode === "reset") {
        await api("/api/reset", { method: "POST", body: { token: resetToken, password } });
        history.replaceState({}, "", "/");
        await onAuthed();
      } else {
        await api("/api/login", { method: "POST", body: { emailOrName: email, password, deviceId: deviceId() } });
        await onAuthed();
      }
    } catch (err) { setError(err.message); }
    setBusy(false);
  }

  /* Set a new password from an emailed link. */
  if (mode === "reset") {
    return (
      <div className="ukpx-root">
        <div className="auth-wrap">
          <form className="auth-card" onSubmit={submit}>
            <h1>Set a new password</h1>
            {resetValid === false ? (
              <>
                <p className="lede">{error || "That reset link is no longer valid."}</p>
                <button className="btn" type="button" style={{ width: "100%" }}
                  onClick={() => { history.replaceState({}, "", "/"); setMode("forgot"); setError(""); }}>
                  Request a new link
                </button>
              </>
            ) : resetValid === null ? <p className="lede">Checking that link…</p> : (
              <>
                <p className="lede">Choose something you'll remember. Setting it signs out anyone currently using your account.</p>
                <label htmlFor="newpw">New password</label>
                <input id="newpw" type="password" value={password} autoComplete="new-password"
                  onChange={(ev) => setPassword(ev.target.value)} required />
                <div className="hint">At least 10 characters.</div>
                {error && <div className="error-text" style={{ marginTop: 10 }}>{error}</div>}
                <button className="btn" type="submit" disabled={busy} style={{ width: "100%", marginTop: 14 }}>
                  {busy ? "One moment…" : "Set password and sign in"}
                </button>
              </>
            )}
          </form>
        </div>
      </div>
    );
  }

  /* Ask for a reset email. */
  if (mode === "forgot") {
    return (
      <div className="ukpx-root">
        <div className="auth-wrap">
          <form className="auth-card" onSubmit={submit}>
            <h1>Forgotten password</h1>
            <p className="lede">Tell us the address you registered with and we'll send a link to set a new password. The link works once and expires in an hour.</p>
            <label htmlFor="fpemail">Email address</label>
            <input id="fpemail" type="email" value={email} autoComplete="username" onChange={(ev) => setEmail(ev.target.value)} required />
            {error && <div className="error-text" style={{ marginTop: 10 }}>{error}</div>}
            {notice && <div className="gate-note">{notice}</div>}
            <button className="btn" type="submit" disabled={busy} style={{ width: "100%", marginTop: 14 }}>
              {busy ? "Sending…" : "Send the link"}
            </button>
            <div className="auth-switch">
              <button type="button" onClick={() => { setMode("login"); setError(""); setNotice(""); }}>Back to sign in</button>
            </div>
          </form>
        </div>
      </div>
    );
  }

  return (
    <div className="ukpx-root">
      <div className="auth-wrap">
        <form className="auth-card" onSubmit={submit}>
          <h1>York Property Exchange</h1>
          <p className="lede">
            A live property market for York: 155 properties across fifteen neighbourhoods, plus landmarks you can only touch once
            you're worth a couple of million. Everyone trades in the same market, in real time.
          </p>

          <div className="mode-toggle">
            <button type="button" className={"mode-btn" + (mode === "login" ? " active" : "")} onClick={() => { setMode("login"); setError(""); }}>Sign in</button>
            <button type="button" className={"mode-btn" + (mode === "register" ? " active" : "")} onClick={() => { setMode("register"); setError(""); }}>Create an account</button>
          </div>

          <label htmlFor="email">{mode === "register" ? "Email address" : "Email or trading name"}</label>
          <input id="email" type={mode === "register" ? "email" : "text"} value={email} autoComplete="username"
            onChange={(e) => setEmail(e.target.value)} required />

          {mode === "register" && (
            <>
              <label htmlFor="username">Trading name</label>
              <input id="username" value={username} maxLength={18} onChange={(e) => setUsername(e.target.value)} required />
              <div className="hint">This is the name other traders see on the leaderboard and in the market report.</div>
            </>
          )}

          <label htmlFor="password">Password</label>
          <input id="password" type="password" value={password} autoComplete={mode === "register" ? "new-password" : "current-password"}
            onChange={(e) => setPassword(e.target.value)} required />
          {mode === "register" && <div className="hint">At least 10 characters. A short phrase you'll remember beats a jumble you won't.</div>}

          {error && <div className="error-text" style={{ marginTop: 10 }}>{error}</div>}
          {notice && <div className="gate-note">{notice}</div>}

          <button className="btn" type="submit" disabled={busy} style={{ width: "100%", marginTop: 14 }}>
            {busy ? "One moment…" : mode === "register" ? `Open an account with ${fmt(500000)}` : "Sign in"}
          </button>

          {mode === "register" && (
            <div className="gate-note">
              One account per person. New accounts trade the open market straight away, but can only deal with other traders after
              they've been going a while and earned their own money — and your opening grant never leaves your account.
            </div>
          )}
          <div className="auth-switch">
            {mode === "login"
              ? <>
                  No account yet? <button type="button" onClick={() => setMode("register")}>Create one</button>
                  <br />
                  <button type="button" onClick={() => { setMode("forgot"); setError(""); }}>Forgotten your password?</button>
                </>
              : <>Already trading? <button type="button" onClick={() => setMode("login")}>Sign in</button></>}
          </div>
        </form>
      </div>
    </div>
  );
}

/* ---------------------------- app root ------------------------------ */

function Exchange() {
  const [booted, setBooted] = useState(false);
  const [state, setState] = useState(null);
  const [authed, setAuthed] = useState(false);
  const [tab, setTab] = useState("market");
  const [notice, setNotice] = useState("");
  const [now, setNow] = useState(Date.now());
  const [detailId, setDetailId] = useState(null);
  const [connected, setConnected] = useState(true);
  const histories = useRef({});
  const esRef = useRef(null);

  const flash = useCallback((msg) => {
    setNotice(msg);
    setTimeout(() => setNotice((c) => (c === msg ? "" : c)), 3600);
  }, []);

  /* Prices arrive as a compact map every tick; we keep our own short
     history locally so the server never has to resend a chart. */
  const recordPrices = useCallback((props) => {
    const h = histories.current;
    for (const id of Object.keys(props)) {
      const price = typeof props[id] === "number" ? props[id] : props[id].currentPrice;
      if (!h[id]) h[id] = [price];
      else { h[id].push(price); if (h[id].length > 40) h[id].shift(); }
    }
  }, []);

  const applyState = useCallback((s) => {
    if (!s) return;
    GAME_DAY_MS = (s.rules && s.rules.gameDayMs) || GAME_DAY_MS;
    recordPrices(s.properties);
    setState(s);
    setAuthed(true);
  }, [recordPrices]);

  const boot = useCallback(async () => {
    const cat = await api("/api/catalogue");
    adoptCatalogue(cat);
    const me = await api("/api/me");
    if (me.authenticated) applyState(me.state);
    else setAuthed(false);
    setBooted(true);
  }, [applyState]);

  useEffect(() => { boot().catch(() => setBooted(true)); }, [boot]);

  useEffect(() => {
    const t = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(t);
  }, []);

  /* One event stream carries everything: a full state after any action by
     anyone, and a small price message on each tick. */
  useEffect(() => {
    if (!authed) return;
    const es = new EventSource("/api/stream");
    esRef.current = es;
    es.addEventListener("state", (ev) => { setConnected(true); applyState(JSON.parse(ev.data)); });
    es.addEventListener("tick", (ev) => {
      setConnected(true);
      const payload = JSON.parse(ev.data);
      recordPrices(payload.prices);
      setState((prev) => {
        if (!prev) return prev;
        const properties = { ...prev.properties };
        for (const id of Object.keys(payload.prices)) {
          if (properties[id]) properties[id] = { ...properties[id], currentPrice: payload.prices[id] };
        }
        return { ...prev, properties, market: { ...prev.market, economicIndex: payload.economicIndex, regionTrend: payload.regionTrend } };
      });
    });
    es.onerror = () => setConnected(false);
    return () => { es.close(); esRef.current = null; };
  }, [authed, applyState, recordPrices]);

  const onAct = useCallback(async (action, payload) => {
    try {
      const res = await act(action, payload);
      if (res.message) flash(res.message);
      if (res.state) applyState(res.state);
    } catch (e) { flash(e.message); }
  }, [applyState, flash]);

  const onEquip = useCallback(async (slot, value) => {
    try {
      const res = await api("/api/shop/equip", { method: "POST", body: { slot, value } });
      if (res.message) flash(res.message);
      if (res.state) applyState(res.state);
    } catch (e) { flash(e.message); }
  }, [applyState, flash]);

  const onFirmName = useCallback(async (name) => {
    try {
      const res = await api("/api/shop/firm-name", { method: "POST", body: { name } });
      if (res.message) flash(res.message);
      if (res.state) applyState(res.state);
    } catch (e) { flash(e.message); }
  }, [applyState, flash]);

  async function signOut() {
    try { await api("/api/logout", { method: "POST" }); } catch {}
    if (esRef.current) esRef.current.close();
    setAuthed(false); setState(null);
  }

  if (!booted) {
    return <div className="ukpx-root"><div className="center-screen"><div style={{ fontFamily: "'Fraunces', serif", fontSize: 18 }}><span className="conn-dot" />Opening the exchange…</div></div></div>;
  }
  if (!authed || !state) return <AuthScreen onAuthed={boot} />;

  const me = state.self;
  const game = {
    players: state.players, properties: state.properties, auctions: state.auctions,
    trades: state.trades, tactics: state.tactics, news: state.news,
    notifications: state.notifications, hiddenNews: state.hiddenNews,
    market: state.market, rules: state.rules,
  };

  const tickerItems = state.news.filter((e) => e.kind === "market").slice(0, 8);
  const activeAuctions = state.auctions.filter((a) => a.status === "active");
  const incoming = state.trades.filter((o) => o.to === me.id);
  const detailMeta = detailId ? metaFor(detailId) : null;
  const bankAlert = me.loan > 0 && (me.arrearsSince > 0 || me.netWorth < 0);

  const openDetail = (id) => setDetailId(id);

  const themeClass = me.cosmetics && me.cosmetics.theme ? " " + me.cosmetics.theme.replace("theme.", "theme-") : "";

  return (
    <div className={"ukpx-root" + themeClass}>
      <div className="topbar">
        <div><h1>York Property Exchange</h1><div className="sub">A live market for York, open to traders everywhere</div></div>
        <div className="sub">
          {Object.keys(state.players).length} trader{Object.keys(state.players).length === 1 ? "" : "s"} registered ·{" "}
          <button className="switch-link" onClick={signOut} style={{ color: "#c9d6cb" }}>sign out</button>
        </div>
      </div>

      {!connected && <div className="offline-bar">Reconnecting to the exchange…</div>}

      <div className="ticker-wrap">
        <div className="ticker-track">
          {tickerItems.length ? tickerItems.map((e) => <span className="ticker-item" key={e.id}>{e.text}</span>)
            : <span className="ticker-item">Market open — no major moves reported</span>}
        </div>
      </div>

      <div className="playerbar">
        <div>
          <span className="stat-label">Trading as</span>
          <span className="stat-value" style={{ fontFamily: "'Fraunces', serif" }}>
            {me.cosmetics && me.cosmetics.crest && <Crest id={me.cosmetics.crest} size={15} />}
            {me.name}
          </span>
          {me.cosmetics && me.cosmetics.firmName && <span className="firm-line">{me.cosmetics.firmName}</span>}
        </div>
        <div><span className="stat-label">Cash</span><span className="stat-value">{fmt(me.cash)}</span></div>
        <div><span className="stat-label">Portfolio</span><span className="stat-value">{fmt(me.portfolioValue)}</span></div>
        {me.deposit > 0 && <div><span className="stat-label">On deposit</span><span className="stat-value">{fmt(me.deposit)}</span></div>}
        {me.loan > 0 && <div><span className="stat-label">Owed to bank</span><span className="stat-value" style={{ color: "var(--loss)" }}>{fmt(me.loan)}</span></div>}
        <div><span className="stat-label">Net worth</span><span className="stat-value" style={me.netWorth < 0 ? { color: "var(--loss)" } : null}>{fmt(me.netWorth)}</span></div>
        <div><span className="stat-label">Income earned</span><span className="stat-value">{fmt(me.totalIncome)}</span></div>
      </div>

      <nav className="tabs">
        {[["market", "Market"], ["map", "Map"], ["portfolio", "Portfolio"],
          ["auctions", `Auctions${activeAuctions.length ? ` (${activeAuctions.length})` : ""}`],
          ["bank", bankAlert ? "Bank ●" : "Bank"],
          ["tactics", "Tactics"],
          ["trade", `Trade${incoming.length ? ` (${incoming.length})` : ""}`],
          ["leaderboard", "Leaderboard"], ["events", "Market report"], ["personalise", "Personalise"]].map(([id, label]) => (
          <button key={id} className={"tab-btn" + (tab === id ? " active" : "")} onClick={() => setTab(id)}>{label}</button>
        ))}
      </nav>

      <div className="content">
        {tab === "market" && <MarketTab game={game} me={me} histories={histories.current} onAct={onAct} onOpen={openDetail} />}
        {tab === "map" && <MapTab game={game} me={me} onOpen={openDetail} onEquip={onEquip} flash={flash} />}
        {tab === "portfolio" && <PortfolioTab game={game} me={me} now={now} histories={histories.current} onAct={onAct} onOpen={openDetail} />}
        {tab === "auctions" && <AuctionsTab game={game} me={me} onAct={onAct} />}
        {tab === "bank" && <BankTab me={me} now={now} onAct={onAct} />}
        {tab === "tactics" && <TacticsTab game={game} me={me} onAct={onAct} />}
        {tab === "trade" && <TradeTab game={game} me={me} onAct={onAct} />}
        {tab === "leaderboard" && <LeaderboardTab game={game} me={me} />}
        {tab === "events" && <EventsTab game={game} me={me} onAct={onAct} />}
        {tab === "personalise" && <PersonaliseTab me={me} onAct={onAct} onEquip={onEquip} onFirmName={onFirmName} flash={flash} />}
      </div>

      {notice && <div className="notice-toast">{notice}</div>}

      {detailId && detailMeta && state.properties[detailId] && (
        <DetailModal meta={detailMeta} prop={state.properties[detailId]} game={game} me={me} now={now}
          history={histories.current[detailId] || []} onClose={() => setDetailId(null)}
          onAct={(a, p) => { onAct(a, p); }} />
      )}
    </div>
  );
}

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