/* ---------------------------------------------------------------------
   Civexio — 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, useContext } = React;

/* Nothing in this file had a React error boundary before, which meant an
   uncaught exception in ANY single tab's render — one bad record in a
   list, a lookup that came back empty — took the whole app down to a
   blank white screen, header and all, instead of just that tab. Error
   boundaries must be class components; this is the only one in the file
   for exactly that reason. Wrapped once, around the tab content switch,
   keyed by the active tab so switching away and back gives it a fresh
   attempt rather than staying stuck once tripped. */
class TabErrorBoundary extends React.Component {
  constructor(props) { super(props); this.state = { error: null }; }
  static getDerivedStateFromError(error) { return { error }; }
  componentDidCatch(error, info) {
    console.error("[tab crashed]", error, info);
    this.setState({ where: String((info && info.componentStack) || "").trim().split("\n").slice(0, 4).join("\n") });
  }
  render() {
    if (this.state.error) {
      /* The reason is shown, not just logged. A boundary that hides the
         error costs more than it saves: the first version of this said only
         "something went wrong", which meant a real crash on the Tactics tab
         could be reported in full by a player and still tell nobody
         anything. The message is the fastest route from "it broke" to a
         fix, and this is a game about property, not a bank — there is
         nothing sensitive in a React error string. */
      return (
        <div className="card">
          <h3>This section hit a snag</h3>
          <p className="muted">
            Something went wrong loading this tab. Your account and everything else on the page are fine.
            Try another tab and come back, or reload the page.
          </p>
          <details style={{ marginTop: 10 }}>
            <summary className="muted" style={{ cursor: "pointer" }}>What went wrong</summary>
            <pre style={{
              marginTop: 8, padding: 10, overflowX: "auto", whiteSpace: "pre-wrap",
              background: "var(--paper-dark)", border: "1px solid var(--line)",
              fontFamily: "'IBM Plex Mono', monospace", fontSize: 11.5, lineHeight: 1.5,
            }}>
              {String(this.state.error && this.state.error.message || this.state.error)}
              {this.state.where ? `\n\nin:${this.state.where}` : ""}
            </pre>
            <p className="muted" style={{ fontSize: 12 }}>Copying this text into a bug report is the whole diagnosis.</p>
          </details>
        </div>
      );
    }
    return this.props.children;
  }
}

/* ---- catalogue (fetched once at boot) ---- */
let CAT = null;
/* The district leader's rewards, replaced from the catalogue on load. */
let LEADER_RULES = { groundRentPct: 0.05, ownRentPct: 0.10, skillPerEvening: 8, securityLevels: 1, changeDays: 7 };
let REGIONS = {}, CATEGORIES = {}, ANALYTICS_TIERS = [], BANK_TIERS = [], TACTICS = {},
    IMPROVEMENTS = [], NEIGHBOURHOODS = [], LANDMARKS = [], ALL_PROPERTIES = [],
    PROP_INDEX = new Map(), NEIGHBOURHOOD_INDEX = new Map(), YIELD_SCALE = 12,
    RARITIES = {}, ABILITIES = {}, DECK = { cards: {}, rarityColours: {} },
    /* What is built and what is coming, for the landing page and the
       account panel. It arrives in the catalogue because the landing page
       renders before anyone has signed in. */
    ROADMAP = [], OPENING_REWARDS = [],
    /* Every honour there is, earned or not (lib/badges.js). */
    HONOURS = [];
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; RARITIES = c.rarities || {}; ABILITIES = c.abilities || {};
  /* The tactics deck: thirty-one cards of static text, sent once at boot so a
     card in hand and the same card in the Combat Library cannot disagree. */
  DECK = c.deck || { cards: {}, rarityColours: {} };
  OPENING_REWARDS = (c.openingWeek && c.openingWeek.rewards) || [];
  ROADMAP = c.roadmap || [];
  HONOURS = c.honours || [];
  LEADER_RULES = c.leaderRewards || LEADER_RULES;
  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);

/* HOUSES PLAYERS HAVE BUILT. The catalogue above is fetched once at boot and
   never changes; a finished house arrives later, in the shared state, and is
   added to the same list and index so everything that looks a property up
   finds it. Replaced whole each time, so an underpinned house loses its flaw
   here as it does on the server. */
function adoptBuilt(list) {
  if (!Array.isArray(list)) return;
  const byId = new Map(list.map((m) => [m.id, m]));
  /* The buildings of a new district (lib/zones.js) arrive the same way. */
  const late = (m) => m.built || m.zone;
  ALL_PROPERTIES = ALL_PROPERTIES.filter((m) => !late(m) || byId.has(m.id))
    .map((m) => (late(m) ? byId.get(m.id) : m));
  for (const m of list) if (!ALL_PROPERTIES.some((x) => x.id === m.id)) ALL_PROPERTIES.push(m);
  PROP_INDEX = new Map(ALL_PROPERTIES.map((p) => [p.id, p]));
}

/* THE NEW DISTRICTS. York Central and the rest are opened by the Council
   as the city fills (lib/zones.js), long after the page loaded its
   catalogue, so they arrive in the shared `city` key and join the district
   list here. Each carries its own outline: it is not a Voronoi cell. */
function adoptCity(city) {
  if (!city || !Array.isArray(city.zones)) return;
  for (const z of city.zones) {
    const rec = { code: z.code, name: z.name, region: z.region, lat: z.lat, lng: z.lng,
      zone: true, outline: z.outline, openedAt: z.openedAt };
    const i = NEIGHBOURHOODS.findIndex((n) => n.code === z.code);
    if (i >= 0) NEIGHBOURHOODS[i] = { ...NEIGHBOURHOODS[i], ...rec }; else NEIGHBOURHOODS.push(rec);
  }
  NEIGHBOURHOOD_INDEX = new Map(NEIGHBOURHOODS.map((n) => [n.code, n]));
}

/* ---- 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 and rent all key off it. Not the weekly upkeep
   window, which is deliberately in real time: see lib/config.js. */
let GAME_HOUR_MS = 3600000;
let GAME_DAY_MS = 24 * GAME_HOUR_MS;
const SECURITY_MAX_LEVEL = 3;
/* A security level's natural resistance, as a percentage: each level turns
   away one attack in ten and takes a tenth off one that lands (landChance
   and damageMultiplier in lib/cards.js, SECURITY_STEP 0.10). */
const securityPct = (level) => Math.round(Math.min(level || 0, 4) * 10);

/* KEEPING A PROPERTY EARNING.

   Rent used to be gated on a bill that appeared every single hour — and paying it always returned two to five times what it cost, so
   it was never a decision, only a click to remember, a dozen times an hour.
   Rent now runs on its own for a real week at a time. An improvement pushes
   the week out; at the top level, where there is nothing left to improve, a
   small payment does instead. The server decides both — `canPayUpkeep` and
   `upkeepCost` arrive on the property — so the button and the rule behind
   it cannot drift apart. */
function earningState(prop, now) {
  if (!prop.owner) return null;
  const held = now - (prop.acquiredAt || 0);
  if (held < GAME_HOUR_MS) {
    const rm = Math.ceil((GAME_HOUR_MS - held) / 60000);
    return { earning: false, tone: "ok", text: `Rent starts in ${rm} min` };
  }
  const left = (prop.earningUntil || 0) - now;
  if (left <= 0) {
    return { earning: false, tone: "warn",
      text: prop.canPayUpkeep
        ? "Not earning. Cover the upkeep to start the rent again"
        : "Not earning. Improve it to start the rent again" };
  }
  if (left < 24 * 3600 * 1000) {
    return { earning: true, tone: "warn", text: `Earning, stops in ${spanOf(left)}` };
  }
  return { earning: true, tone: "ok", text: `Earning, ${spanOf(left)} left this week` };
}

/* COVER, AND WHEN TO OFFER IT (Aaron, 1 Oct 2026: "the property was
   earning rent despite the cover not paid", and then "the cover button
   should only show up when relevant, when the rent has stopped").

   A building earns for a week at a time. At the top improvement level
   there is nothing left to improve, so paying the cover is how its rent is
   restarted. The button used to show the whole week through, so it read as
   a bill already overdue on a building that was plainly earning. It now
   appears only once the rent has stopped. The day before, the card already
   says "Earning, stops in ..." and the owner is told by notice. */
function coverOffer(prop, now) {
  if (!prop || !prop.canPayUpkeep) return null;
  if ((prop.earningUntil || 0) > now) return null;
  return { label: `Restart the rent for ${fmt(prop.upkeepCost || 0)}`, note: "The rent has stopped. Paying restarts it for a week." };
}

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;
}

/* Mirrors rentMultiplier() on the server. The browser only ever has its own
   effects, so this is right for your properties and neutral for everyone
   else's — which is all the UI shows. */
function abilityRentMult(game, meta, prop) {
  const e = game && game.self && game.self.effects;
  if (!e || !prop || prop.owner !== game.self.id) return 1;
  const m = (1 + (e.globalRentPct || 0))
    * (1 + ((e.regionRentPct || {})[meta.region] || 0))
    * (1 + ((e.neighbourhoodRentPct || {})[meta.neighbourhood] || 0))
    * (1 + ((e.stockRentPct || {})[meta.stock] || 0))
    * (1 + ((e.categoryRentPct || {})[meta.category] || 0))
    * (1 + ((e.selfRentPct || {})[meta.id] || 0));
  return Math.min(m, 1.6);
}

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 * abilityRentMult(game, meta, prop);
}

/* A small badge in the property's rarity colour. The catalogue carries the
   colours so the server stays the single source of truth for them. */
/* An ability that needs a set says how far along you are, because a
   condition you cannot see your progress against may as well not exist. */
const SET_NEEDS = { pair2: 2, stock3: 3, region3: 3, region4: 4, cat3: 3 };
const SET_FIELD = { pair2: "neighbourhood", stock3: "stock", cat3: "category" };

/* Whether a property's ability is actually paying, and how far off it is if
   not. Lifted out of AbilityLine so the portfolio masthead can count live
   bonuses without a second, quietly different implementation of the rule. */
function abilityProgress(meta, game, me) {
  const need = SET_NEEDS[meta.abilityId];
  if (!need || !game || !me) return null;
  const held = ALL_PROPERTIES.filter((m) => game.properties[m.id] && game.properties[m.id].owner === me.id);
  const field = SET_FIELD[meta.abilityId] || "region";
  const have = held.filter((m) => m[field] === meta[field]).length;
  return { have, need, on: have >= need };
}

/* ONE AWAY, AND WHO HOLDS THE PIECE.

   The game has always known you are 3/4 in Micklegate — every tile says so.
   It has never once said WHICH property closes it or WHO has that property,
   and the Trade tab opened with "search by name", so you had to already know
   who you wanted to deal with before the tab helped at all. There was no
   path from "I need one more in Micklegate" to "here is who holds it".

   This is not a new mechanic. It is the same `abilityProgress` calculation
   read the other way round: instead of asking "is this one on", ask "what
   would turn it on, and is it gettable".

   Only genuine near misses are listed — exactly one short. Two short is not
   a deal, it is a plan, and a list of everything you might one day want is
   the same as no list. */
function setGaps(game, me) {
  if (!game || !me || !game.properties) return [];
  const mine = ALL_PROPERTIES.filter(
    (m) => game.properties[m.id] && game.properties[m.id].owner === me.id);
  const seen = new Map();

  for (const meta of mine) {
    const need = SET_NEEDS[meta.abilityId];
    if (!need) continue;
    const field = SET_FIELD[meta.abilityId] || "region";
    const value = meta[field];
    const key = `${meta.abilityId}|${field}|${value}`;
    if (seen.has(key)) continue;
    const have = mine.filter((m) => m[field] === value).length;
    if (have !== need - 1) continue;          /* one short, and only one */

    /* What would close it: anything with the same field value that is not
       already yours. Held by a trader means a deal; on the market means a
       purchase, and the two are worth saying differently. */
    const candidates = ALL_PROPERTIES
      .filter((m) => m[field] === value)
      .filter((m) => !(game.properties[m.id] && game.properties[m.id].owner === me.id))
      .map((m) => {
        const row = game.properties[m.id] || {};
        const owner = row.owner ? game.players[row.owner] : null;
        return { id: m.id, meta: m, price: row.currentPrice,
                 inAuction: !!row.inAuction,
                 ownerId: row.owner || null, ownerName: owner ? owner.name : null };
      })
      .sort((a, b) => (b.ownerId ? 1 : 0) - (a.ownerId ? 1 : 0) || (a.price || 0) - (b.price || 0));
    if (!candidates.length) continue;

    seen.set(key, {
      key, field, value, have, need,
      abilityName: meta.abilityName,
      abilityText: meta.abilityText,
      /* The properties of yours that are waiting on it — the thing that
         actually switches on, named. */
      waiting: mine.filter((m) => m.abilityId === meta.abilityId && m[field] === value),
      candidates,
    });
  }
  return [...seen.values()];
}

/* The panel. Property first, person second — the inversion the tab needed. */
function OneAway({ game, me, onPick }) {
  const gaps = useMemo(() => setGaps(game, me), [game, me]);
  if (!gaps.length) return null;
  return (
    <section className="card one-away">
      <h3>One away</h3>
      <p className="one-away-intro">
        Sets you are a single property short of. The game already counts these on
        every tile; here is what would close them, and who holds it.
      </p>
      {gaps.map((g) => {
        const held = g.candidates.filter((c) => c.ownerId && !c.inAuction);
        const open = g.candidates.filter((c) => !c.ownerId);
        return (
          <div key={g.key} className="gap-row">
            <div className="gap-head">
              <b>{g.abilityName}</b>
              <span className="gap-count">{g.have} of {g.need} in {g.value}</span>
            </div>
            <p className="gap-what">
              {g.waiting.length === 1
                ? <>One more turns it on for <b>{g.waiting[0].name}</b>.</>
                : <>One more turns it on for <b>{g.waiting.length} of your properties</b>.</>}
            </p>
            {held.length > 0 && (
              <div className="gap-holders">
                {held.slice(0, 6).map((c) => (
                  <button key={c.id} className="gap-holder"
                          onClick={() => onPick && onPick(c)}>
                    <span className="gap-prop">{c.meta.name}</span>
                    <span className="gap-owner">{c.ownerName}</span>
                  </button>
                ))}
                {held.length > 6 && (
                  <span className="gap-more">and {held.length - 6} more</span>
                )}
              </div>
            )}
            {!held.length && (
              <p className="gap-none">
                {open.length
                  ? `Nobody holds one. ${open.length} ${open.length === 1 ? "is" : "are"} still on the market.`
                  : "Every one of them is in an auction right now."}
              </p>
            )}
          </div>
        );
      })}
    </section>
  );
}

/* DIMMING ONLY MEANS SOMETHING ON A PROPERTY YOU OWN.

   This dimmed any ability whose SET was unmet and left every other ability
   at full strength — and abilityProgress returns null for anything that is
   not one of the five set abilities. So on the market, where you own none
   of it and nothing is live, the self-scope abilities read as active and
   the portfolio ones read as switched off. Half the page looked emphasised
   for a reason that had nothing to do with the property.

   Now the state is about ownership first: on an unowned property nothing is
   dimmed and no counter is shown, because a count of how many you hold
   towards a set you cannot start is noise. The set condition is still said,
   in words, because that is the thing worth knowing before you buy. */
function AbilityLine({ meta, game, me }) {
  const mine = !!(game && me && game.properties[meta.id] && game.properties[meta.id].owner === me.id);
  const progress = abilityProgress(meta, game, me);
  return (
    <div className={"ability-line" + (mine && progress && !progress.on ? " off" : "")}>
      <strong>{meta.abilityName}
        {mine && progress
          ? <span className="set-count"> {progress.have}/{progress.need}{progress.on ? " ✓" : ""}</span>
          : progress
            ? <span className="set-need"> needs {progress.need}</span>
            : null}
      </strong>
      <span className="ability-text">{meta.abilityText}</span>
    </div>
  );
}

function RarityMark({ meta, showName = false }) {
  const r = RARITIES[meta.rarity];
  if (!r) return null;
  /* Common has no colour of its own, so it falls back to the page's own
     rule rather than painting a swatch of nothing. */
  const styled = r.colour === "transparent" ? {} : { borderColor: r.colour, color: r.colour };
  return (
    <span className={"rarity-mark q-" + meta.rarity} style={styled}
          title={`${r.label}: ${meta.abilityName}`}>
      <span className="rarity-dot" style={r.colour === "transparent" ? {} : { background: r.colour }} />
      {showName ? r.label : null}
    </span>
  );
}

/* ONE SPAN FORMATTER, AND EVERY DURATION IN THE GAME GOES THROUGH IT.

   Anything past a day used to be quoted a different way in every panel it
   appeared in: "30h" on an improvement cooldown, "1.5 days" on a hostile
   bid, "3 days left" on the rent week, "2d 4h" on a lease. A reader had to
   do arithmetic to compare two of them, and "30h" is the worst of the set —
   nobody counts in thirty hours.

   The rule, once, here: past 23h 59m it reads in days, hours and minutes.
   Below that the days are noise and drop out. Below an hour so do the
   hours. `under` is what nought reads as, because "0m" looks broken. */
function spanOf(ms, under = "under a minute") {
  const total = Math.max(0, Math.floor(ms / 60000));
  const d = Math.floor(total / 1440);
  const h = Math.floor((total % 1440) / 60);
  const m = total % 60;
  if (d > 0) return `${d}d ${h}h ${m}m`;
  if (h > 0) return `${h}h ${m}m`;
  if (total > 0) return `${m}m`;
  return under;
}

function timeLeft(endsAt) {
  return spanOf(endsAt - Date.now());
}

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 } });

/* ONE PAGE LOAD, FOR THE ANALYTICS (30 Sep 2026; lib/analytics.js). The
   referring site, any campaign tags, whether this is the sign up screen,
   and the civexio.com visit that sent them here, if one did. Sent once per
   tab: a reload is not a new visit. What comes back is kept in this tab's
   session storage only, so a sign up can say where it came from, and is
   gone when the tab closes. No cookie. */
function readAcq() {
  try { return JSON.parse(sessionStorage.getItem("civexio.acq") || "null") || {}; } catch { return {}; }
}
(function pageVisit() {
  try {
    if (typeof window === "undefined" || readAcq().sent) return;
    const u = new URL(location.href);
    const p = u.searchParams;
    const lv = Number(p.get("lv")) || 0;
    const utm = { source: p.get("utm_source") || "", medium: p.get("utm_medium") || "", campaign: p.get("utm_campaign") || "" };
    fetch("/api/pv", {
      method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ path: u.pathname, ref: document.referrer || "", utm, lv, join: p.has("join") }),
    }).then((r) => (r.ok ? r.json() : null)).catch(() => null).then((r) => {
      /* The civexio.com visit is kept even if this page load was not
         recorded, so the sign up can still be credited to it. */
      try { sessionStorage.setItem("civexio.acq", JSON.stringify({ sent: 1, pv: (r && r.v) || null, lv: lv || null })); } catch {}
    });
  } catch {}
})();

/* 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>;
}

/* THE DISTRICT, WHEREVER A PROPERTY IS NAMED.

   A property's tag used to read "East York" — the compass quadrant, which
   is the same three words on a third of the board and tells a player
   nothing they can act on. The district is the unit freeholds, territory
   and the map's field report are all counted in.

   The quadrant survives as the tag's COLOUR, so the at-a-glance grouping is
   still there while the word does useful work. Region tags are still right
   for a district itself (on the map, where the heading is already its
   name), which is why RegionTag stays. */
function DistrictTag({ meta }) {
  if (!meta) return null;
  const region = REGIONS[meta.region] || {};
  return (
    <span className="tag" style={{ "--tag-color": region.color }}
          title={region.label ? `${meta.neighbourhoodName}, ${region.label}` : meta.neighbourhoodName}>
      {meta.neighbourhoodName}
    </span>
  );
}
function CategoryTag({ category }) {
  return <span className="tag" style={{ "--tag-color": CATEGORIES[category].color }}>{CATEGORIES[category].label}</span>;
}

/* An elevation drawn the way a surveyor would draw it for a valuation:
   front-on, to scale, in real materials. Everything is derived from the
   property's own id, so all 155 differ and none of them is an image file.

   Realism here comes from four things, in this order of payoff: brick and
   tile courses so surfaces read as material rather than fill; a consistent
   light from the left, so every sill, lintel and eaves throws a shadow;
   openings with a recess rather than a flat pane; and the small true
   details of a British street — downpipes, chimney pots, air bricks, a
   fanlight over the door. */
function elevationArt(meta, pal) {
  const seed = hashStr(meta.id);
  const rnd = (n) => ((seed >> (n * 3)) & 255) / 255;
  const pick = (arr, n) => arr[(seed >> (n * 2)) % arr.length];

  const W = 300, H = 150, GROUND = 128;

  /* Materials. A cosmetic facade overrides the brick, which is the whole
     point of buying one. */
  const BRICKS = ["#A8674E", "#96594A", "#B0745A", "#8E5744", "#B98063", "#7E5A4C"];
  const STONES = ["#D8CDBA", "#CFC3AE", "#E0D6C4"];
  const ROOFS = ["#4A4744", "#57504B", "#3E4547", "#6B5F57"];
  const isLandmark = meta.neighbourhood === "landmark" || meta.exclusive;
  const brick = pal ? pal.brick : (isLandmark ? "#D5CAB4" : pick(BRICKS, 0));
  const stone = isLandmark ? "#E4DBC8" : pick(STONES, 1);
  const roofCol = pal ? pal.roof : pick(ROOFS, 2);
  const sky0 = pal ? pal.sky[0] : "#C6D4DC";
  const sky1 = pal ? pal.sky[1] : "#E9E2D2";

  const cat = meta.category;
  const storeys = meta.basePrice > 900000 ? 4 : meta.basePrice > 420000 ? 3 : meta.basePrice > 220000 ? 2 : 1;
  const bw = 150 + Math.round(rnd(3) * 56);
  const x0 = Math.round((W - bw) / 2);
  const x1 = x0 + bw;
  /* Reserve headroom for the roof BEFORE sizing the storeys, or a
     four-storey building pushes its own chimney off the top of the frame.
     Landmarks need more, because they get a tower. */
  const roofRoom = isLandmark ? 66 : 40;
  const floorH = Math.min(30, (GROUND - roofRoom) / storeys);
  const bodyH = storeys * floorH;
  const y0 = GROUND - bodyH;
  const bays = bw > 190 ? 4 : bw > 165 ? 3 : 2;
  const uid = "e" + seed.toString(36);

  const S = [];
  const p = (...a) => S.push(...a);

  /* ---- sky, distance, ground ---- */
  p(`<defs>
      <linearGradient id="${uid}s" x1="0" y1="0" x2="0" y2="1">
        <stop offset="0" stop-color="${sky0}"/><stop offset="1" stop-color="${sky1}"/>
      </linearGradient>
      <linearGradient id="${uid}p" x1="0" y1="0" x2="0" y2="1">
        <stop offset="0" stop-color="#B9B09C"/><stop offset="1" stop-color="#A79E8A"/>
      </linearGradient>
      <clipPath id="${uid}c"><rect x="${x0}" y="${y0}" width="${bw}" height="${bodyH}"/></clipPath>
    </defs>`);
  p(`<rect width="${W}" height="${H}" fill="url(#${uid}s)"/>`);

  /* Neighbours, so the building sits in a terrace rather than a field. */
  const nL = 34 + Math.round(rnd(4) * 22), nR = 30 + Math.round(rnd(5) * 26);
  p(`<rect x="0" y="${GROUND - bodyH + nL * 0.35}" width="${x0 + 4}" height="${H}" fill="#8E8578" opacity=".30"/>`);
  p(`<rect x="${x1 - 4}" y="${GROUND - bodyH + nR * 0.4}" width="${W - x1 + 4}" height="${H}" fill="#8E8578" opacity=".24"/>`);

  p(`<rect y="${GROUND}" width="${W}" height="${H - GROUND}" fill="url(#${uid}p)"/>`);
  p(`<rect y="${GROUND}" width="${W}" height="1.5" fill="#7E7767" opacity=".7"/>`);
  for (let i = 0; i < 7; i++) {
    p(`<line x1="${i * 44 + 10}" y1="${GROUND}" x2="${i * 44 + 4}" y2="${H}" stroke="#8C8474" stroke-width="1" opacity=".5"/>`);
  }
  /* Cast shadow, light from the left. */
  p(`<path d="M${x1} ${GROUND} l26 ${H - GROUND} h${-26} Z" fill="#000" opacity=".10"/>`);

  /* ---- roof ---- */
  const eaves = y0;
  if (isLandmark) {
    /* Landmarks get a parapet and a tower rather than a domestic roof —
       they are meant to look like the reason the city is on the map. */
    p(`<rect x="${x0 - 8}" y="${eaves - 16}" width="${bw + 16}" height="16" fill="${stone}"/>`);
    p(`<rect x="${x0 - 10}" y="${eaves - 16}" width="${bw + 20}" height="5" fill="${stone}"/>`);
    for (let i = 0; i * 12 < bw + 16; i++) {
      p(`<rect x="${x0 - 8 + i * 12}" y="${eaves - 22}" width="7" height="6" fill="${stone}"/>`);
    }
    const tw = 34, tx = x0 + bw / 2 - tw / 2, th = 34;
    p(`<rect x="${tx}" y="${eaves - 22 - th}" width="${tw}" height="${th}" fill="${stone}"/>`);
    p(`<rect x="${tx + tw * 0.62}" y="${eaves - 22 - th}" width="${tw * 0.38}" height="${th}" fill="#000" opacity=".09"/>`);
    for (let i = 0; i * 9 < tw; i++) p(`<rect x="${tx + i * 9}" y="${eaves - 28 - th}" width="5" height="6" fill="${stone}"/>`);
    p(`<path d="M${tx + 8} ${eaves - 22 - th + 12} a6 6 0 0 1 12 0 v10 h-12 Z" fill="#3B4A55"/>`);
  } else if (cat === "residential" || cat === "recreation") {
    /* A hipped roof, not a pyramid: the ridge is a line across the middle
       third, which is how a terrace or a semi actually sits. */
    const ridge = eaves - (15 + Math.round(rnd(6) * 8));
    const rL = x0 + bw * 0.28, rR = x1 - bw * 0.28;
    /* The stack is drawn first so the roof laps over its base and it reads
       as rising through the tiles rather than resting on them. */
    const cx = Math.round(x0 + bw * (rnd(7) > 0.5 ? 0.66 : 0.2));
    p(`<rect x="${cx}" y="${ridge - 16}" width="16" height="${eaves - ridge + 22}" fill="${brick}"/>`);
    p(`<rect x="${cx + 11}" y="${ridge - 16}" width="5" height="${eaves - ridge + 22}" fill="#000" opacity=".18"/>`);

    p(`<path d="M${x0 - 7} ${eaves} L${rL} ${ridge} L${rR} ${ridge} L${x1 + 7} ${eaves} Z" fill="${roofCol}"/>`);
    for (let i = 1; i < 6; i++) {
      const t = i / 6, yy = eaves - (eaves - ridge) * t;
      p(`<line x1="${x0 - 7 + (rL - x0 + 7) * t}" y1="${yy}" x2="${x1 + 7 - (x1 + 7 - rR) * t}" y2="${yy}" stroke="#000" stroke-width=".8" opacity=".14"/>`);
    }
    p(`<path d="M${rR} ${ridge} L${x1 + 7} ${eaves} L${(rR + x1 + 7) / 2} ${eaves} Z" fill="#000" opacity=".14"/>`);
    p(`<rect x="${rL}" y="${ridge - 1.4}" width="${rR - rL}" height="2.4" fill="#000" opacity=".18"/>`);

    p(`<rect x="${cx - 1.5}" y="${ridge - 19}" width="19" height="4" fill="${stone}"/>`);
    const pots = 1 + Math.round(rnd(8));
    for (let i = 0; i < pots && i < 2; i++) {
      p(`<rect x="${cx + 2 + i * 8}" y="${ridge - 26}" width="5.5" height="8" rx="1.2" fill="#9C6A50"/>`);
      p(`<rect x="${cx + 2 + i * 8}" y="${ridge - 26}" width="5.5" height="1.6" fill="#000" opacity=".22"/>`);
    }
  } else if (cat === "retail") {
    p(`<rect x="${x0 - 5}" y="${eaves - 13}" width="${bw + 10}" height="13" fill="${stone}"/>`);
    p(`<rect x="${x0 - 7}" y="${eaves - 13}" width="${bw + 14}" height="4" fill="${stone}"/>`);
    p(`<rect x="${x0 - 7}" y="${eaves - 9.5}" width="${bw + 14}" height="1.6" fill="#000" opacity=".18"/>`);
  } else {
    /* Utility and industry: a shallow pitch with a ridge vent. */
    const ridge = eaves - 11;
    p(`<path d="M${x0 - 6} ${eaves} L${x0 + bw / 2} ${ridge} L${x1 + 6} ${eaves} Z" fill="${roofCol}"/>`);
    p(`<rect x="${x0 + bw / 2 - 16}" y="${ridge - 4}" width="32" height="4" rx="1" fill="#6E7176"/>`);
    p(`<path d="M${x0 + bw / 2} ${ridge} L${x1 + 6} ${eaves} L${x0 + bw / 2} ${eaves} Z" fill="#000" opacity=".12"/>`);
  }

  /* ---- brickwork ---- */
  p(`<rect x="${x0}" y="${y0}" width="${bw}" height="${bodyH}" fill="${brick}"/>`);
  p(`<g clip-path="url(#${uid}c)">`);
  for (let y = y0 + 4; y < GROUND; y += 4.5) {
    p(`<line x1="${x0}" y1="${y}" x2="${x1}" y2="${y}" stroke="#000" stroke-width=".55" opacity=".10"/>`);
  }
  /* A scatter of off-tone bricks stops the wall reading as flat colour. */
  for (let i = 0; i < 26; i++) {
    const bx = x0 + ((seed >> i) % bw), by = y0 + 4 + (((seed >> (i + 5)) % Math.floor(bodyH / 4.5)) * 4.5);
    p(`<rect x="${bx}" y="${by}" width="7" height="3.4" fill="#000" opacity="${i % 3 ? ".05" : ".09"}"/>`);
  }
  /* Light from the left: the right third sits in shade. */
  p(`<rect x="${x0 + bw * 0.66}" y="${y0}" width="${bw * 0.34}" height="${bodyH}" fill="#000" opacity=".07"/>`);
  p(`</g>`);

  /* Stone quoins on the corners of the better addresses. */
  if (rnd(9) > 0.45) {
    for (let i = 0; i * 11 < bodyH; i++) {
      const qy = y0 + i * 11, qh = Math.min(11, GROUND - qy) - 1;
      if (qh <= 2) break;
      const qw = i % 2 ? 7 : 11;
      p(`<rect x="${x0}" y="${qy}" width="${qw}" height="${qh}" fill="${stone}"/>`);
      p(`<rect x="${x1 - qw}" y="${qy}" width="${qw}" height="${qh}" fill="${stone}" opacity=".85"/>`);
    }
  }
  /* String course between the ground and first floors. */
  if (storeys > 1 && rnd(10) > 0.4) {
    p(`<rect x="${x0}" y="${GROUND - floorH - 3}" width="${bw}" height="4" fill="${stone}"/>`);
    p(`<rect x="${x0}" y="${GROUND - floorH + 0.6}" width="${bw}" height="1.4" fill="#000" opacity=".16"/>`);
  }

  /* ---- openings ---- */
  const winW = Math.min(26, (bw - 26) / bays - 10);
  const winH = Math.max(11, Math.min(19, floorH * 0.62));
  const bayX = (b) => x0 + 14 + b * ((bw - 28) / bays) + ((bw - 28) / bays - winW) / 2;
  const lit = (i) => ((seed >> i) & 3) !== 0;

  const sash = (wx, wy, ww, wh, isLit) => {
    p(`<rect x="${wx - 2.5}" y="${wy - 3.5}" width="${ww + 5}" height="3.5" fill="${stone}"/>`);
    p(`<rect x="${wx - 2.5}" y="${wy - 0.6}" width="${ww + 5}" height="1.4" fill="#000" opacity=".22"/>`);
    p(`<rect x="${wx}" y="${wy}" width="${ww}" height="${wh}" fill="#2B3138"/>`);
    p(`<rect x="${wx}" y="${wy}" width="${ww}" height="${wh}" fill="${isLit ? "#EBC97E" : "#8FA4B2"}" opacity="${isLit ? ".92" : ".55"}"/>`);
    p(`<path d="M${wx} ${wy} h${ww} l${-ww * 0.55} ${wh * 0.62} Z" fill="#fff" opacity=".16"/>`);
    p(`<rect x="${wx}" y="${wy + wh / 2 - 1}" width="${ww}" height="2" fill="#F2EFE8"/>`);
    p(`<rect x="${wx + ww / 2 - 1}" y="${wy}" width="2" height="${wh}" fill="#F2EFE8"/>`);
    p(`<rect x="${wx - 1.5}" y="${wy - 1.5}" width="${ww + 3}" height="${wh + 3}" fill="none" stroke="#F2EFE8" stroke-width="2.2"/>`);
    p(`<rect x="${wx - 4}" y="${wy + wh + 1.5}" width="${ww + 8}" height="3" fill="${stone}"/>`);
    p(`<rect x="${wx - 4}" y="${wy + wh + 4.5}" width="${ww + 8}" height="2" fill="#000" opacity=".20"/>`);
  };

  for (let f = 0; f < storeys; f++) {
    const wy = GROUND - (f + 1) * floorH + (floorH - winH) * 0.42;
    const groundFloor = f === 0;
    for (let b = 0; b < bays; b++) {
      const wx = bayX(b);
      if (groundFloor && cat === "retail") continue;
      if (groundFloor && b === Math.floor(bays / 2) && cat !== "retail") continue;
      sash(wx, wy, winW, winH, lit(f * 4 + b));
    }
  }

  if (cat === "retail") {
    /* A shopfront: stallriser, fascia, pilasters, deep glazing. */
    const sy = GROUND - floorH + Math.max(3, floorH * 0.13);
    p(`<rect x="${x0 + 6}" y="${sy}" width="${bw - 12}" height="${GROUND - sy}" fill="#2E3A3A"/>`);
    p(`<rect x="${x0 + 6}" y="${sy}" width="${bw - 12}" height="${GROUND - sy - 7}" fill="#9FB6BE" opacity=".8"/>`);
    p(`<path d="M${x0 + 6} ${sy} h${bw - 12} l${-(bw - 12) * 0.5} ${GROUND - sy - 7} Z" fill="#fff" opacity=".18"/>`);
    p(`<rect x="${x0 + 6}" y="${GROUND - 7}" width="${bw - 12}" height="7" fill="#5A4B3C"/>`);
    p(`<rect x="${x0 + 2}" y="${sy - 9}" width="${bw - 4}" height="9" fill="#3E4F45"/>`);
    p(`<rect x="${x0 + 2}" y="${sy - 9}" width="${bw - 4}" height="2" fill="#fff" opacity=".12"/>`);
    for (let i = 0; i < 5; i++) {
      p(`<rect x="${x0 + 10 + i * ((bw - 20) / 5)}" y="${sy - 2}" width="${(bw - 20) / 10}" height="2" fill="#C4432F"/>`);
    }
    p(`<rect x="${x0 + 6}" y="${sy}" width="5" height="${GROUND - sy}" fill="${stone}"/>`);
    p(`<rect x="${x1 - 11}" y="${sy}" width="5" height="${GROUND - sy}" fill="${stone}" opacity=".85"/>`);
  } else {
    /* Front door: panelled, with a fanlight and a step. */
    const dw = 20, dx = bayX(Math.floor(bays / 2)) + (winW - dw) / 2;
    const dh = Math.min(27, floorH * 0.92), dy = GROUND - dh;
    p(`<rect x="${dx - 4}" y="${dy - 9}" width="${dw + 8}" height="${dh + 9}" fill="${stone}"/>`);
    p(`<rect x="${dx - 4}" y="${dy - 9}" width="${dw + 8}" height="3" fill="#000" opacity=".16"/>`);
    p(`<path d="M${dx} ${dy} a${dw / 2} ${dw / 2} 0 0 1 ${dw} 0 Z" fill="${lit(11) ? "#EBC97E" : "#9FB6BE"}"/>`);
    p(`<rect x="${dx}" y="${dy}" width="${dw}" height="${dh}" fill="${pick(["#2F4A3C", "#3A2E28", "#25384C", "#6B2C2C"], 3)}"/>`);
    p(`<rect x="${dx + 2.5}" y="${dy + 4}" width="${dw - 5}" height="8" fill="#000" opacity=".18"/>`);
    p(`<rect x="${dx + 2.5}" y="${dy + 15}" width="${dw - 5}" height="8" fill="#000" opacity=".18"/>`);
    p(`<circle cx="${dx + dw - 4}" cy="${dy + 15}" r="1.4" fill="#D9C07A"/>`);
    p(`<rect x="${dx - 6}" y="${GROUND - 3}" width="${dw + 12}" height="3" fill="${stone}"/>`);
  }

  /* ---- guttering, downpipe, small true details ---- */
  p(`<rect x="${x0 - 7}" y="${y0 - 3}" width="${bw + 14}" height="3.2" fill="#5D5B56"/>`);
  p(`<rect x="${x0 - 7}" y="${y0 - 0.2}" width="${bw + 14}" height="1.4" fill="#000" opacity=".2"/>`);
  const pipeX = rnd(12) > 0.5 ? x0 + 5 : x1 - 8;
  p(`<rect x="${pipeX}" y="${y0}" width="3.2" height="${GROUND - y0}" fill="#4F4D49"/>`);
  p(`<rect x="${pipeX}" y="${y0}" width="1.1" height="${GROUND - y0}" fill="#fff" opacity=".18"/>`);
  p(`<rect x="${pipeX - 1}" y="${GROUND - 5}" width="5.2" height="2" fill="#4F4D49"/>`);
  p(`<rect x="${x0 + 20}" y="${GROUND - 6}" width="7" height="4" fill="#3A3733" opacity=".5"/>`);

  if (rnd(13) > 0.62) {
    /* Railings and a basement area, the way the older streets have. */
    for (let i = 0; i < 9; i++) p(`<rect x="${x0 + 8 + i * 8}" y="${GROUND - 11}" width="1.4" height="11" fill="#33312E"/>`);
    p(`<rect x="${x0 + 6}" y="${GROUND - 12.5}" width="74" height="1.8" fill="#33312E"/>`);
  }
  if (rnd(14) > 0.7) {
    const tx = rnd(15) > 0.5 ? 26 : W - 26;
    p(`<rect x="${tx - 1.6}" y="${GROUND - 26}" width="3.2" height="26" fill="#5E4A38"/>`);
    p(`<circle cx="${tx}" cy="${GROUND - 33}" r="13" fill="#6F8A5C"/>`);
    p(`<circle cx="${tx - 8}" cy="${GROUND - 27}" r="8" fill="#7C9767"/>`);
    p(`<circle cx="${tx + 8}" cy="${GROUND - 28}" r="7" fill="#5F7C50"/>`);
  }
  return S.join("");
}

/* ---- THE BUILT HOUSE, DRAWN FROM ITS DESIGN -------------------------------
   Ported from preview/build-tab.html, the builder's bench, where Aaron saw it
   go up. Same spirit as elevationArt(): brick courses, light from the left,
   recessed openings; but driven by the design a player chose rather than a
   hash of the property id, and able to draw each stage of the build.
   `spec` is the design ({ type, brick, roof, door, windows, spec: [...] })
   plus `progress`, 0 to 4. Every value is a key looked up in the tables
   below, so nothing a player typed reaches the markup. */
const BUILT_BRICKS = {
  red:    { name: "York red",      face: "#A24E35", dark: "#7E3A2B", mortar: "#8E4430" },
  white:  { name: "Limewashed",    face: "#DED4BE", dark: "#BCB09A", mortar: "#C8BCA6" },
  buff:   { name: "Buff stock",    face: "#C09A6B", dark: "#9E7B52", mortar: "#AA8A62" },
  grey:   { name: "Grey gault",    face: "#9AA0A0", dark: "#7C8384", mortar: "#878D8D" },
};
const BUILT_ROOFS = {
  slate:   { name: "Welsh slate",  a: "#4E545A", b: "#6A7074" },
  pantile: { name: "Clay pantile", a: "#93502F", b: "#B06B44" },
  stone:   { name: "Stone flag",   a: "#6E6A5E", b: "#8A8576" },
};
const BUILT_DOORS = {
  forest: { name: "Forest green", a: "#1F4A3C", b: "#12302A" },
  claret: { name: "Claret",       a: "#7E1F33", b: "#54121F" },
  navy:   { name: "Navy",         a: "#243C58", b: "#16283C" },
  black:  { name: "Black",        a: "#26282A", b: "#161818" },
  ochre:  { name: "Ochre",        a: "#A9762A", b: "#7E561B" },
  ivory:  { name: "Ivory",        a: "#DCD2B8", b: "#BCB198" },
};
const BUILT_STONE = { a: "#E6DEC9", b: "#CBC0A4" };


/* spec: { type, brick, roof, door, windows, bay, cellar, railings, storeys }
   plus `progress` 0–4, because the whole point of a build that takes days is
   watching it go up:
     0  the empty plot — hoarding, site board, materials, skip
     1  groundworks — footings poured, trenches open
     2  shell — brick to eaves, openings still voids, scaffold up
     3  roof and glazing — watertight, scaffold still up, hoarding still on
     4  fit-out — door, railings, bay glazed, scaffold struck, hoarding down */
function builtElevation(spec) {
  const W = 300, H = 150, GROUND = 132;
  const pr = spec.progress === undefined ? 4 : spec.progress;
  const br = BUILT_BRICKS[spec.brick] || BUILT_BRICKS.red;
  const rf = BUILT_ROOFS[spec.roof] || BUILT_ROOFS.slate;
  const dr = BUILT_DOORS[spec.door] || BUILT_DOORS.forest;
  const uid = "b" + Math.random().toString(36).slice(2, 8);

  const storeys = spec.storeys || (spec.type === "detached" ? 3 : 2);
  const bw = spec.type === "terraced" ? 118 : spec.type === "semi" ? 150 : 188;
  const x0 = Math.round((W - bw) / 2), x1 = x0 + bw;
  const upper = storeys - 1;
  const floorH = Math.min(27, (GROUND - 46) / (storeys + 0.34));
  const groundH = floorH * 1.34;
  const bodyH = upper * floorH + groundH;
  const y0 = GROUND - bodyH;
  const WALL = 9;

  const S = [];
  const p = (...a) => S.push(...a);

  p(`<defs>
    <linearGradient id="${uid}sky" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0" stop-color="#C2C9C6"/><stop offset="1" stop-color="#E9E0CB"/></linearGradient>
    <linearGradient id="${uid}rf" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0" stop-color="${rf.a}"/><stop offset="1" stop-color="${rf.b}"/></linearGradient>
    <linearGradient id="${uid}st" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0" stop-color="${BUILT_STONE.a}"/><stop offset="1" stop-color="${BUILT_STONE.b}"/></linearGradient>
    <linearGradient id="${uid}dr" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0" stop-color="${dr.a}"/><stop offset="1" stop-color="${dr.b}"/></linearGradient>
    <linearGradient id="${uid}gl" x1="0.05" y1="0" x2="0.95" y2="1">
      <stop offset="0" stop-color="#44515B"/><stop offset="0.4" stop-color="#6E7D85"/>
      <stop offset="0.41" stop-color="#3A464F"/><stop offset="1" stop-color="#262F36"/></linearGradient>
    <pattern id="${uid}bk" width="12" height="6" patternUnits="userSpaceOnUse">
      <rect width="12" height="6" fill="${br.mortar}"/>
      <rect x="0.4" y="0.4" width="5.2" height="2.3" fill="${br.face}"/>
      <rect x="6.4" y="0.4" width="5.2" height="2.3" fill="${br.dark}"/>
      <rect x="-2.6" y="3.3" width="5.2" height="2.3" fill="${br.dark}"/>
      <rect x="3.4" y="3.3" width="5.2" height="2.3" fill="${br.face}"/>
      <rect x="9.4" y="3.3" width="5.2" height="2.3" fill="${br.face}"/></pattern>
  </defs>`);

  /* sky, the street, the ground */
  p(`<rect width="${W}" height="${H}" fill="url(#${uid}sky)"/>`);
  p(`<rect y="${GROUND - 26}" width="${W}" height="26" fill="#B6AE9C" opacity=".45"/>`);
  p(`<rect y="${GROUND}" width="${W}" height="${H - GROUND}" fill="#B4AC99"/>`);
  p(`<rect y="${GROUND}" width="${W}" height="2" fill="#9C9481"/>`);
  p(`<rect y="${GROUND + 11}" width="${W}" height="${H - GROUND - 11}" fill="#6F6C66"/>`);

  /* ---- 0: the bare plot ------------------------------------------------ */
  if (pr === 0) {
    p(`<rect x="${x0 - 6}" y="${GROUND - 13}" width="${bw + 12}" height="13" fill="#9A8E72"/>`);
    p(`<rect x="${x0 - 6}" y="${GROUND - 13}" width="${bw + 12}" height="1.6" fill="#7E735B"/>`);
    p(`<g class="grow"><rect x="${x0 + 12}" y="${GROUND - 8}" width="24" height="8" fill="#8A6A45"/>
       <rect x="${x0 + 12}" y="${GROUND - 11}" width="24" height="3" fill="#9E7C52"/></g>`);
    p(`<rect x="${x1 - 40}" y="${GROUND - 9}" width="30" height="9" fill="#5E6B52"/>`);
  }

  /* ---- 1: footings ------------------------------------------------------
     The slab itself sits below the hoarding, which is where a slab belongs
     and also means you would see nothing at all. What you actually see from
     the street at this stage is spoil, props and an arm over the boards. */
  if (pr === 1) {
    p(`<g class="grow">`);
    p(`<rect x="${x0}" y="${GROUND - 9}" width="${bw}" height="9" fill="#9E9686"/>`);
    p(`<rect x="${x0 + 3}" y="${GROUND - 7}" width="${bw - 6}" height="5" fill="#7E7768"/>`);
    /* spoil heaps over the hoarding line */
    p(`<path d="M${x0 + 14} ${GROUND - 15} q 13 -13 27 0 Z" fill="#7C6B4E"/>`);
    p(`<path d="M${x0 + 18} ${GROUND - 15} q 8 -8 17 0 Z" fill="#8E7B5A"/>`);
    p(`<path d="M${x1 - 62} ${GROUND - 15} q 10 -9 21 0 Z" fill="#7C6B4E"/>`);
    /* trench props standing proud of the boards */
    for (const px of [x0 + 52, x0 + 62, x0 + 72]) {
      p(`<rect x="${px}" y="${GROUND - 24}" width="1.8" height="10" fill="#8A7A5E"/>`);
      p(`<rect x="${px - 2}" y="${GROUND - 25}" width="6" height="1.6" fill="#A08D6A"/>`);
    }
    /* an excavator arm, which is the one shape that says "groundworks" */
    p(`<g stroke="#B2861F" stroke-width="3.4" stroke-linecap="round" fill="none">
         <path d="M${x1 - 44} ${GROUND - 14} L${x1 - 34} ${GROUND - 33}"/>
         <path d="M${x1 - 34} ${GROUND - 33} L${x1 - 18} ${GROUND - 24}"/></g>`);
    p(`<path d="M${x1 - 21} ${GROUND - 26} l 9 5 -2 7 -9 -5 Z" fill="#6E6148"/>`);
    p(`</g>`);
  }
  /* footings stay under everything after that */
  if (pr >= 2) p(`<rect x="${x0 - 2}" y="${GROUND - 3}" width="${bw + 4}" height="3" fill="#9E9686"/>`);

  /* ---- 2+: the shell --------------------------------------------------- */
  if (pr >= 2) {
    const wallTop = pr === 2 ? y0 + 3 : y0 + 3;
    p(`<g class="grow"><rect x="${x0}" y="${wallTop}" width="${bw}" height="${GROUND - wallTop}" fill="url(#${uid}bk)"/>
      <rect x="${x1 - bw * 0.3}" y="${wallTop}" width="${bw * 0.3}" height="${GROUND - wallTop}" fill="#000" opacity=".07"/>
      ${upper >= 1 ? `<rect x="${x0}" y="${y0 + upper * floorH + 1}" width="${bw}" height="2" fill="url(#${uid}st)"/>` : ""}</g>`);
  }

  /* window and door geometry, shared by the void stage and the glazed one */
  const bays = spec.type === "terraced" ? 2 : spec.type === "semi" ? 2 : 3;
  const winW = spec.type === "detached" ? 24 : 26, winH = floorH * 0.52;
  const gap = (bw - bays * winW) / (bays + 1);
  const gy = y0 + 6 + upper * floorH;
  const FLOOR = GROUND - (spec.railings && pr >= 4 ? WALL : 1);
  const doorW = 18, doorH = FLOOR - gy;
  const doorX = spec.type === "terraced" ? x1 - gap - doorW : x0 + gap;

  const sash = (wx, wy) => {
    const g = [];
    g.push(`<rect x="${wx - 2.5}" y="${wy - 3}" width="${winW + 5}" height="3.4" fill="url(#${uid}st)"/>`);
    g.push(`<rect x="${wx}" y="${wy}" width="${winW}" height="${winH}" fill="#EDE7D7"/>`);
    g.push(`<rect x="${wx + 1.6}" y="${wy + 1.6}" width="${winW - 3.2}" height="${winH - 3.2}" fill="url(#${uid}gl)"/>`);
    if (spec.windows === "sash") {
      g.push(`<rect x="${wx + winW / 2 - 0.6}" y="${wy + 1.6}" width="1.2" height="${winH - 3.2}" fill="#EDE7D7"/>`);
      g.push(`<rect x="${wx + 1.6}" y="${wy + winH / 2 - 0.8}" width="${winW - 3.2}" height="1.6" fill="#EDE7D7"/>`);
      g.push(`<rect x="${wx + winW * 0.28}" y="${wy + 1.6}" width=".8" height="${winH - 3.2}" fill="#EDE7D7" opacity=".85"/>`);
      g.push(`<rect x="${wx + winW * 0.72}" y="${wy + 1.6}" width=".8" height="${winH - 3.2}" fill="#EDE7D7" opacity=".85"/>`);
    } else {
      g.push(`<rect x="${wx + winW / 2 - 0.7}" y="${wy + 1.6}" width="1.4" height="${winH - 3.2}" fill="#EDE7D7"/>`);
    }
    g.push(`<rect x="${wx - 3.5}" y="${wy + winH}" width="${winW + 7}" height="2.6" fill="url(#${uid}st)"/>`);
    g.push(`<rect x="${wx - 3.5}" y="${wy + winH + 2.6}" width="${winW + 7}" height="1.2" fill="#000" opacity=".16"/>`);
    return g.join("");
  };
  /* THE REST OF THE GROUND FLOOR. The bench drew a door and one window or
     bay on the ground floor whatever the width, which left a detached
     house with three windows upstairs and a blank wall below. Every upper
     bay that does not sit over the door or the front window gets one. */
  const frontX = spec.type === "terraced" ? x0 + gap : x0 + gap + doorW + gap;
  const frontW = spec.bay ? Math.min(52, bw - doorW - gap * 3) : winW;
  const groundSlots = Array.from({ length: bays }, (_, b) => x0 + gap + b * (winW + gap)).filter((wx) =>
    wx + winW < doorX - 4 || wx > doorX + doorW + 4)
    .filter((wx) => wx + winW < frontX - 4 || wx > frontX + frontW + 4);
  const voidOpening = (wx, wy, ww, wh) =>
    `<rect x="${wx}" y="${wy}" width="${ww}" height="${wh}" fill="#2A2E31"/>` +
    `<rect x="${wx}" y="${wy}" width="${ww}" height="2" fill="#7A6F55"/>`;

  /* ---- 2: openings are voids; 3+: they are glazed ---------------------- */
  if (pr === 2) {
    for (let f = 0; f < upper; f++) {
      const wy = y0 + 8 + f * floorH;
      for (let b = 0; b < bays; b++) p(voidOpening(x0 + gap + b * (winW + gap), wy, winW, winH));
    }
    p(voidOpening(doorX, gy + 4, doorW, doorH - 4));
    for (const wx of groundSlots) p(voidOpening(wx, gy + 5, winW, winH));
    if (!spec.bay) p(voidOpening(spec.type === "terraced" ? x0 + gap : x0 + gap + doorW + gap, gy + 5, winW, winH));
    else p(voidOpening(spec.type === "terraced" ? x0 + gap : x0 + gap + doorW + gap, gy + 5, 52, FLOOR - gy - 9));
  }

  /* ---- 3+: roof, chimney, glazing ------------------------------------- */
  if (pr >= 3) {
    const cx = spec.type === "terraced" ? x1 - 14 : x0 + 16;
    p(`<g class="grow"><rect x="${cx}" y="${y0 - 34}" width="13" height="30" fill="url(#${uid}bk)"/>
      <rect x="${cx - 2}" y="${y0 - 36}" width="17" height="3" fill="${BUILT_STONE.b}"/>
      <rect x="${cx + 1}" y="${y0 - 43}" width="4.5" height="8" rx="1" fill="#8C6A50"/>
      <rect x="${cx + 7.5}" y="${y0 - 43}" width="4.5" height="8" rx="1" fill="#7C5C46"/>`);
    if (spec.type === "terraced") {
      p(`<path d="M${x0 - 8} ${y0} L${x0 + 10} ${y0 - 17} L${x1 - 10} ${y0 - 17} L${x1 + 8} ${y0} Z" fill="url(#${uid}rf)"/>`);
    } else {
      p(`<path d="M${x0 - 8} ${y0} L${x0 + bw * 0.3} ${y0 - 21} L${x1 - bw * 0.3} ${y0 - 21} L${x1 + 8} ${y0} Z" fill="url(#${uid}rf)"/>`);
    }
    for (let i = 1; i < 4; i++) {
      p(`<path d="M${x0 - 8 + i * 5} ${y0 - i * 4.4} H${x1 + 8 - i * 5}" stroke="#1E2427" stroke-width=".5" opacity=".18"/>`);
    }
    p(`<rect x="${x0 - 10}" y="${y0 - 1}" width="${bw + 20}" height="3.5" fill="${BUILT_STONE.a}"/>`);
    p(`<rect x="${x0 - 10}" y="${y0 + 2.5}" width="${bw + 20}" height="1.4" fill="#A79B80"/></g>`);

    p(`<g class="grow">`);
    for (let f = 0; f < upper; f++) {
      const wy = y0 + 8 + f * floorH;
      for (let b = 0; b < bays; b++) p(sash(x0 + gap + b * (winW + gap), wy));
    }
    for (const wx of groundSlots) p(sash(wx, gy + 5));
    p(`</g>`);
  }

  /* ---- 4: fit-out ------------------------------------------------------ */
  if (pr >= 4) {
    p(`<g class="grow">`);
    if (spec.bay) {
      const byW = Math.min(52, bw - doorW - gap * 3);
      const byX = spec.type === "terraced" ? x0 + gap : x0 + gap + doorW + gap;
      /* The bay starts level with the head of the door and keeps a slim
         roof, so its glazing reads as a bay window rather than a strip
         under an awning. The bench's version left about four pixels of
         glass once railings raised the floor line. */
      const byY = gy - 2;
      p(`<path d="M${byX} ${FLOOR} L${byX} ${byY + 5} L${byX + 5} ${byY} L${byX + byW - 5} ${byY} L${byX + byW} ${byY + 5} L${byX + byW} ${FLOOR} Z" fill="url(#${uid}bk)"/>`);
      p(`<path d="M${byX - 2.5} ${byY + 5.5} L${byX + 4} ${byY - 1.5} L${byX + byW - 4} ${byY - 1.5} L${byX + byW + 2.5} ${byY + 5.5} Z" fill="${rf.a}"/>`);
      p(`<path d="M${byX - 2.5} ${byY + 5.5} L${byX + byW + 2.5} ${byY + 5.5} L${byX + byW + 2.5} ${byY + 7} L${byX - 2.5} ${byY + 7} Z" fill="${rf.b}"/>`);
      const gt = byY + 8, gh = FLOOR - 4.5 - gt;
      for (let i = 0; i < 3; i++) {
        const pw = (byW - 7) / 3;
        p(`<rect x="${byX + 3.5 + i * pw}" y="${gt}" width="${pw - 2}" height="${gh}" fill="#EDE7D7"/>`);
        p(`<rect x="${byX + 4.7 + i * pw}" y="${gt + 1.2}" width="${pw - 4.4}" height="${gh - 2.4}" fill="url(#${uid}gl)"/>`);
      }
      p(`<rect x="${byX - 2.5}" y="${FLOOR - 4.5}" width="${byW + 5}" height="2.8" fill="url(#${uid}st)"/>`);
      p(`<rect x="${byX - 2.5}" y="${FLOOR - 1.7}" width="${byW + 5}" height="1.2" fill="#000" opacity=".16"/>`);
    } else {
      p(sash(spec.type === "terraced" ? x0 + gap : x0 + gap + doorW + gap, gy + 5));
    }

    const fan = Math.max(6, doorH * 0.2);
    p(`<rect x="${doorX - 3.2}" y="${gy}" width="${doorW + 6.4}" height="${doorH}" fill="url(#${uid}st)"/>`);
    p(`<rect x="${doorX - 5}" y="${gy - 2.8}" width="${doorW + 10}" height="3.2" fill="url(#${uid}st)"/>`);
    p(`<rect x="${doorX - 5}" y="${gy + 0.4}" width="${doorW + 10}" height="1.2" fill="#000" opacity=".14"/>`);
    p(`<rect x="${doorX}" y="${gy + 3}" width="${doorW}" height="${fan}" fill="#8EA0A8"/>`);
    p(`<rect x="${doorX + doorW / 2 - 0.5}" y="${gy + 3}" width="1" height="${fan}" fill="#EDE7D7"/>`);
    p(`<rect x="${doorX}" y="${gy + 3 + fan}" width="${doorW}" height="${doorH - 3 - fan}" fill="url(#${uid}dr)"/>`);
    const pnH = (doorH - 6 - fan) / 2 - 1.5;
    for (const py of [gy + 5 + fan, gy + 6.5 + fan + pnH]) {
      p(`<rect x="${doorX + 2.2}" y="${py}" width="${doorW / 2 - 3.4}" height="${pnH}" fill="#000" opacity=".17"/>`);
      p(`<rect x="${doorX + doorW / 2 + 1.2}" y="${py}" width="${doorW / 2 - 3.4}" height="${pnH}" fill="#000" opacity=".17"/>`);
    }
    p(`<circle cx="${doorX + doorW - 3.4}" cy="${gy + 3 + fan + (doorH - fan) * 0.45}" r="1.2" fill="#C6A567"/>`);
    p(`<rect x="${doorX - 5.5}" y="${FLOOR}" width="${doorW + 11}" height="${GROUND - FLOOR}" fill="#D8CDB2"/>`);

    if (spec.cellar) for (let i = 0; i < Math.floor(bw / 34); i++)
      p(`<rect x="${x0 + 12 + i * 34}" y="${FLOOR - 4}" width="6.5" height="3.5" fill="#4A443C"/>`);

    if (spec.railings) {
      const wy = GROUND - WALL;
      p(`<rect x="${x0 - 14}" y="${wy + 4.5}" width="${bw + 28}" height="4.5" fill="${br.dark}"/>`);
      p(`<rect x="${x0 - 15}" y="${wy + 3.2}" width="${bw + 30}" height="1.6" fill="${BUILT_STONE.b}"/>`);
      p(`<rect x="${x0 - 14}" y="${wy - 1.6}" width="${bw + 28}" height="1.5" fill="#2A312F"/>`);
      for (let i = 0; i * 6.5 < bw + 28; i++)
        p(`<rect x="${x0 - 14 + i * 6.5}" y="${wy - 0.5}" width="1.3" height="4.5" fill="#2A312F"/>`);
    }
    p(`<rect x="${x1 - 3}" y="${y0 + 4}" width="1.8" height="${FLOOR - y0 - 4}" fill="#3E4440" opacity=".8"/>`);
    p(`</g>`);
  }

  /* ---- scaffolding and hoarding, struck at fit-out --------------------- */
  if (pr >= 1 && pr <= 3) {
    const top = pr === 1 ? GROUND - 18 : y0 - (pr >= 3 ? 26 : 8);
    p(`<g class="scaff" opacity=".92">`);
    for (let sx = x0 - 9; sx <= x1 + 9; sx += 17)
      p(`<rect x="${sx}" y="${top}" width="1.6" height="${GROUND - top}" fill="#8A7A5E"/>`);
    for (let ly = top + 6; ly < GROUND - 4; ly += 17) {
      p(`<rect x="${x0 - 10}" y="${ly}" width="${bw + 20}" height="1.5" fill="#8A7A5E"/>`);
      p(`<rect x="${x0 - 10}" y="${ly - 2.6}" width="${bw + 20}" height="2.6" fill="#B49A6E"/>`);
      p(`<rect x="${x0 - 10}" y="${ly - 3.2}" width="${bw + 20}" height=".7" fill="#CBB588"/>`);
    }
    /* a ladder, because a site without one does not read as a site */
    p(`<g stroke="#6E6148" stroke-width="1.1"><path d="M${x0 + 6} ${GROUND} L${x0 + 12} ${top + 8}"/><path d="M${x0 + 13} ${GROUND} L${x0 + 19} ${top + 8}"/></g>`);
    for (let r = 0; r < 7; r++) {
      const t = r / 7, ry = GROUND - t * (GROUND - top - 8);
      p(`<path d="M${x0 + 6 + t * 6} ${ry} H${x0 + 13 + t * 6}" stroke="#6E6148" stroke-width=".9"/>`);
    }
    p(`</g>`);
    /* the hoarding, and the developer's board on it */
    p(`<rect x="${x0 - 16}" y="${GROUND - 15}" width="${bw + 32}" height="15" fill="#8E7F63"/>`);
    for (let hx = x0 - 16; hx < x1 + 16; hx += 7)
      p(`<rect x="${hx}" y="${GROUND - 15}" width=".9" height="15" fill="#7A6C52" opacity=".7"/>`);
    p(`<rect x="${x0 - 16}" y="${GROUND - 16}" width="${bw + 32}" height="1.6" fill="#6E6148"/>`);
    p(`<rect x="${x1 - 46}" y="${GROUND - 13}" width="42" height="11" fill="#1F4B3F"/>`);
    p(`<rect x="${x1 - 44.5}" y="${GROUND - 11.5}" width="39" height="8" fill="none" stroke="#D8CDB2" stroke-width=".6"/>`);
    p(`<rect x="${x1 - 42}" y="${GROUND - 9.5}" width="22" height="1.5" fill="#D8CDB2" opacity=".9"/>`);
    p(`<rect x="${x1 - 42}" y="${GROUND - 6.8}" width="30" height="1.1" fill="#D8CDB2" opacity=".55"/>`);
  }

  return S.join("");
}
/* The design as stored carries its specification as a list; the drawing
   reads each item as a flag. */
function builtArt(design, progress = 4) {
  const sp = new Set(design.spec || []);
  return builtElevation({ ...design, bay: sp.has("bay"), cellar: sp.has("cellar"),
    railings: sp.has("railings"), cavity: sp.has("cavity"), progress });
}

/* ---- A PLOT, DRAWN BEFORE ANYTHING IS BUILT ON IT -------------------------
   Aaron, 26 Sep 2026: "a randomised graphic for each plot, perhaps showing
   what can be built on it". The street scene is seeded from the plot's id,
   so every player sees the same street for the same plot and it never
   changes on a refresh: the neighbours either side (their brick, height and
   roof), a tree or a wall, the sky, the hoarding and the agent's board.

   Over the empty ground stands a dashed outline of every house the frontage
   will take, drawn to the same scale as the finished houses, so a plot that
   takes a semi or a detached shows both and how much bigger the one is than
   the other. Nothing here reads the ground or the tier: those are the
   survey's to reveal. Every value is a number or a key from fixed tables. */
const PLOT_SKIES = [
  ["#C2C9C6", "#E9E0CB"], ["#B9C4CC", "#E6E1D2"], ["#CFC8BC", "#EDE3CC"], ["#BCC6BE", "#E3DFCB"],
  ["#A9BDD0", "#E4E6DF"], ["#D6C3B0", "#EFE2CC"], ["#B4C2C8", "#DCE3DC"], ["#C9BFC8", "#ECE2D6"],
];
const PLOT_H = 164;
const GHOST_W = { terraced: 118, semi: 150, detached: 188 };
const GHOST_STOREYS = { terraced: 2, semi: 2, detached: 3 };
const GHOST_NAME = { terraced: "Terraced", semi: "Semi detached", detached: "Detached" };
/* The street around a plot: brick and painted render in the colours York's
   terraces actually wear, roofs, doors, shop fascias, trees, cars and buses.
   Only ever drawn here; they are scenery, not choices a player makes. */
const STREET_FACES = [
  { face: "#A4452F", dark: "#6E2A1C" }, { face: "#8C3B2B", dark: "#5E2419" }, { face: "#B8743F", dark: "#86522B" },
  { face: "#C9A56E", dark: "#9C7C4E" }, { face: "#D8C7A0", dark: "#AE9C78" }, { face: "#EFE8D8", dark: "#C9BFA8" },
  { face: "#F4F2EC", dark: "#CFCBC0" }, { face: "#A9BFCB", dark: "#7F96A3" }, { face: "#B7C4A3", dark: "#8C9A7A" },
  { face: "#E3B9A8", dark: "#B98E7C" }, { face: "#E6CB7E", dark: "#B99E58" }, { face: "#8F959A", dark: "#686E73" },
  { face: "#5E4A3C", dark: "#3E3027" }, { face: "#C7D3D6", dark: "#98A6AA" },
];
const STREET_ROOFS = ["#4B5058", "#3A3E44", "#9A4A32", "#6E5A4B", "#5F6B6E", "#7A4636"];
const STREET_DOORS = ["#7E1F33", "#243C58", "#2F5B45", "#1E1E1E", "#C9A23A", "#3F7C8C", "#B0452F", "#5B3A5E"];
const STREET_SHOPS = ["#1F4B3F", "#7E1F33", "#243C58", "#2A2A2A", "#8A5A2B", "#3F7C8C"];
const STREET_CARS = ["#B23A2E", "#2E4F7A", "#E0DCD2", "#3A3A3A", "#6E8A5A", "#C9A23A", "#8A8F96", "#5B3A5E", "#D9D4C4", "#1F4B3F"];
const STREET_BUSES = [["#7E1F33", "#E8DCC4"], ["#2A6E8C", "#F0EBDD"], ["#C23B2B", "#F2E7D0"], ["#3D6B45", "#EDE6CF"], ["#4A3F7A", "#EDE6D6"]];
const STREET_LEAVES = [
  ["#5E7352", "#6B8160", "#546A4A"], ["#4F6B45", "#5E7C52", "#445C3C"], ["#7A8B4E", "#8C9C5C", "#6A7A42"],
  ["#D2A1AE", "#E0B6C0", "#C08E9B"], ["#C27B3A", "#D28F4C", "#A9672E"], ["#6D7F5A", "#7E9068", "#5D6E4C"],
];

function plotArt(plot) {
  const W = 300, H = PLOT_H, GROUND = 132, KERB = GROUND + 11;
  const seed = (k) => hashStr(`${plot.id}:${k}`);
  const pick = (k, list) => list[seed(k) % list.length];
  const uid = "p" + (seed("uid") % 1e6).toString(36);
  const sky = pick("sky", PLOT_SKIES);
  const S = [];
  const p = (...a) => S.push(...a);
  const fits = ["terraced", "semi", "detached"].filter((t) => (plot.fits || []).includes(t));
  const widest = fits.length ? Math.max(...fits.map((t) => GHOST_W[t])) : 150;
  const gap = widest + 18;
  const px0 = Math.round((W - gap) / 2), px1 = px0 + gap;

  p(`<defs><linearGradient id="${uid}sky" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0" stop-color="${sky[0]}"/><stop offset="1" stop-color="${sky[1]}"/></linearGradient></defs>`);
  p(`<rect width="${W}" height="${H}" fill="url(#${uid}sky)"/>`);

  /* THE SKYLINE: what the city looks like behind the street, in haze. Kept
     below the outlines' names so nothing reads over them. */
  const haze = "#8E989A";
  const base = GROUND - 18;
  const roofline = (k, lo, hi) => {
    let x = -(seed(`${k}:o`) % 20);
    let i = 0;
    while (x < W) {
      const w = 16 + (seed(`${k}:w${i}`) % 26), h = lo + (seed(`${k}:h${i}`) % (hi - lo));
      p(`<rect x="${x}" y="${base - h}" width="${w + 1}" height="${h + 20}" fill="${haze}" opacity=".32"/>`);
      if (seed(`${k}:c${i}`) % 3 === 0) p(`<rect x="${x + w * 0.3}" y="${base - h - 5}" width="4" height="5" fill="${haze}" opacity=".32"/>`);
      x += w; i++;
    }
  };
  const line = seed("skyline") % 8;
  const at = 40 + (seed("skyline:x") % (W - 80));
  if (line === 0) roofline("r", 6, 22);
  else if (line === 1) {
    /* a cathedral: two west towers, a crossing tower, the long nave */
    roofline("r", 4, 12);
    const c = (d) => p(`<path d="${d}" fill="${haze}" opacity=".42"/>`);
    c(`M${at - 34} ${base} V${base - 22} H${at + 30} V${base} Z`);
    for (const tx of [at - 40, at - 26]) {
      c(`M${tx} ${base} V${base - 48} h2 v-4 h2 v4 h4 v-4 h2 v4 h2 V${base} Z`);
    }
    c(`M${at + 6} ${base} V${base - 44} h2 v-3 h3 v3 h4 v-3 h3 v3 h2 V${base} Z`);
  } else if (line === 2) {
    /* a parish church spire */
    roofline("r", 4, 14);
    p(`<path d="M${at - 6} ${base} V${base - 30} H${at + 6} V${base} Z M${at - 6} ${base - 30} L${at} ${base - 62} L${at + 6} ${base - 30} Z" fill="${haze}" opacity=".42"/>`);
  } else if (line === 3) {
    /* wooded hills beyond the edge of town */
    const h1 = 14 + (seed("hill1") % 16), h2 = 10 + (seed("hill2") % 14);
    p(`<path d="M0 ${base - h1 * 0.4} Q ${W * 0.25} ${base - h1 * 1.5} ${W * 0.5} ${base - h2} T ${W} ${base - h1 * 0.6} V${base + 20} H0 Z" fill="#8FA08A" opacity=".45"/>`);
    for (let i = 0; i < 14; i++) {
      const tx = (seed(`ht${i}`) % W);
      p(`<circle cx="${tx}" cy="${base - 4 - (seed(`hy${i}`) % 8)}" r="${4 + (seed(`hr${i}`) % 4)}" fill="#7A8C74" opacity=".45"/>`);
    }
  } else if (line === 4) {
    /* the city walls and a gatehouse */
    roofline("r", 10, 20);
    p(`<rect x="0" y="${base - 8}" width="${W}" height="30" fill="#A49C8A" opacity=".55"/>`);
    for (let x = 0; x < W; x += 8) p(`<rect x="${x}" y="${base - 12}" width="4" height="4" fill="#A49C8A" opacity=".55"/>`);
    p(`<path d="M${at - 14} ${base} V${base - 30} h4 v-4 h4 v4 h4 v-4 h4 v4 h4 v-4 h4 v4 h4 V${base} Z" fill="#A49C8A" opacity=".6"/>`);
  } else if (line === 5) {
    /* an old mill chimney over the roofs */
    roofline("r", 6, 18);
    p(`<path d="M${at - 4} ${base} L${at - 2.5} ${base - 58} H${at + 2.5} L${at + 4} ${base} Z" fill="${haze}" opacity=".45"/>`);
  } else if (line === 6) {
    /* trees in a park behind */
    for (let i = 0; i < 18; i++) {
      const tx = (seed(`pt${i}`) % (W + 20)) - 10;
      p(`<circle cx="${tx}" cy="${base - 6 - (seed(`py${i}`) % 12)}" r="${8 + (seed(`pr${i}`) % 7)}" fill="#8A9A80" opacity=".4"/>`);
    }
  }
  /* line 7: an open sky */
  p(`<rect y="${GROUND - 26}" width="${W}" height="26" fill="#B6AE9C" opacity=".45"/>`);

  /* THE NEIGHBOURS: brick or painted render, a door, now and then a shop. */
  const neighbour = (side) => {
    const kind = seed(`${side}:kind`) % 10;
    const edge = side === "l" ? px0 : px1;
    const x0 = side === "l" ? 0 : edge, x1 = side === "l" ? edge : W;
    if (kind >= 8) {
      /* a tree and a garden wall instead of a house */
      const cx = side === "l" ? edge - 30 : edge + 30;
      const lv = pick(`${side}:gleaf`, STREET_LEAVES);
      p(`<rect x="${x0}" y="${GROUND - 10}" width="${x1 - x0}" height="10" fill="#9A8466"/>`);
      p(`<rect x="${x0}" y="${GROUND - 11}" width="${x1 - x0}" height="1.6" fill="#B8A988"/>`);
      p(`<rect x="${cx - 2.5}" y="${GROUND - 40}" width="5" height="30" fill="#6A5A45"/>`);
      p(`<circle cx="${cx}" cy="${GROUND - 52}" r="22" fill="${lv[0]}"/><circle cx="${cx - 11}" cy="${GROUND - 44}" r="14" fill="${lv[1]}"/><circle cx="${cx + 12}" cy="${GROUND - 46}" r="15" fill="${lv[2]}"/>`);
      return;
    }
    const br = pick(`${side}:face`, STREET_FACES);
    const rf = pick(`${side}:roof`, STREET_ROOFS);
    const light = parseInt(br.face.slice(1, 3), 16) > 0xC8;
    const frame = light && seed(`${side}:frame`) % 2 ? "#2E3236" : "#EDE7D7";
    const storeys = 2 + (seed(`${side}:st`) % 2);
    const floorH = 24, top = GROUND - storeys * floorH - 6;
    p(`<rect x="${x0}" y="${top}" width="${x1 - x0}" height="${GROUND - top}" fill="${br.face}"/>`);
    p(`<rect x="${x0}" y="${top}" width="${x1 - x0}" height="${GROUND - top}" fill="${br.dark}" opacity=".16"/>`);
    p(`<rect x="${x0}" y="${top - 12}" width="${x1 - x0}" height="12" fill="${rf}"/>`);
    if (seed(`${side}:chim`) % 2) {
      const cx = side === "l" ? x0 + 12 + (seed(`${side}:cx`) % Math.max(1, x1 - x0 - 34)) : x0 + 12 + (seed(`${side}:cx`) % Math.max(1, x1 - x0 - 34));
      p(`<rect x="${cx}" y="${top - 22}" width="12" height="11" fill="${br.dark}"/><rect x="${cx - 1}" y="${top - 23}" width="14" height="2" fill="${BUILT_STONE.b}"/>`);
      p(`<rect x="${cx + 2}" y="${top - 27}" width="3" height="4" fill="#9A5A3C"/><rect x="${cx + 7}" y="${top - 27}" width="3" height="4" fill="#9A5A3C"/>`);
    }
    p(`<rect x="${x0}" y="${top - 1}" width="${x1 - x0}" height="2.5" fill="${BUILT_STONE.a}"/>`);
    const gx = side === "l" ? x1 - 6 : x0;
    p(`<rect x="${gx}" y="${top}" width="6" height="${GROUND - top}" fill="#000" opacity=".12"/>`);
    const slots = [];
    for (let wx = x0 + 10; wx + 16 < x1 - 8; wx += 28) slots.push(wx);
    const shop = kind >= 6 && slots.length >= 1;
    for (let f = 0; f < storeys; f++) {
      const wy = top + 7 + f * floorH;
      const groundFloor = f === storeys - 1;
      if (groundFloor && shop) continue;
      slots.forEach((wx, i) => {
        if (groundFloor && i === seed(`${side}:door`) % slots.length) {
          const dc = pick(`${side}:dc`, STREET_DOORS);
          p(`<rect x="${wx + 2}" y="${GROUND - 21}" width="12" height="21" fill="${frame}"/><rect x="${wx + 3.4}" y="${GROUND - 19.6}" width="9.2" height="19.6" fill="${dc}"/>`);
          p(`<circle cx="${wx + 10.6}" cy="${GROUND - 9}" r=".9" fill="#D8C38A"/>`);
          return;
        }
        p(`<rect x="${wx}" y="${wy}" width="16" height="12" fill="${frame}"/><rect x="${wx + 1.4}" y="${wy + 1.4}" width="13.2" height="9.2" fill="#46535C"/>`);
      });
    }
    if (shop) {
      /* a corner shop: fascia, a striped awning, a wide window */
      const sc = pick(`${side}:shop`, STREET_SHOPS);
      const sx0 = x0 + 4, sx1 = x1 - 4, fy = GROUND - 26;
      p(`<rect x="${sx0}" y="${fy}" width="${sx1 - sx0}" height="7" fill="${sc}"/>`);
      p(`<rect x="${sx0 + 3}" y="${fy + 7}" width="${sx1 - sx0 - 6}" height="${GROUND - fy - 7}" fill="#3E4A52"/>`);
      p(`<rect x="${sx0 + 5}" y="${fy + 9}" width="${sx1 - sx0 - 10}" height="${GROUND - fy - 11}" fill="#DCD6C4" opacity=".22"/>`);
      for (let ax = sx0; ax < sx1; ax += 6) {
        p(`<path d="M${ax} ${fy + 7} h6 l-1 5 h-6 Z" fill="${(ax - sx0) / 6 % 2 ? "#EFE8D8" : sc}"/>`);
      }
    }
  };
  neighbour("l");
  neighbour("r");

  /* the pavement, the kerb and the road */
  p(`<rect y="${GROUND}" width="${W}" height="${KERB - GROUND}" fill="#B4AC99"/>`);
  p(`<rect y="${GROUND}" width="${W}" height="2" fill="#9C9481"/>`);
  p(`<rect y="${KERB}" width="${W}" height="${H - KERB}" fill="#6F6C66"/>`);
  p(`<rect y="${KERB}" width="${W}" height="1.6" fill="#D2CBB8"/>`);
  if (seed("lines") % 2) p(`<rect y="${KERB + 2.6}" width="${W}" height=".9" fill="#D9B43A"/><rect y="${KERB + 4.3}" width="${W}" height=".9" fill="#D9B43A"/>`);
  else for (let x = 8; x < W; x += 26) p(`<rect x="${x}" y="${H - 9}" width="12" height="1.2" fill="#E8E2D2" opacity=".55"/>`);

  /* the plot: rough ground behind a hoarding */
  p(`<rect x="${px0}" y="${GROUND - 6}" width="${gap}" height="6" fill="#8E8A64"/>`);
  for (let i = 0; i < 9; i++) {
    const gx = px0 + 6 + (seed(`tuft${i}`) % Math.max(1, gap - 12));
    p(`<path d="M${gx} ${GROUND - 5} l2 -5 l2 5 Z" fill="#6E7A4E" opacity=".8"/>`);
  }

  /* STREET TREES AND LAMPS on the pavement, only beside the plot, never over
     its outlines. */
  const sideSpots = [];
  for (let x = 10; x < px0 - 14; x += 22) sideSpots.push(x);
  for (let x = px1 + 14; x < W - 8; x += 22) sideSpots.push(x);
  const leaves = pick("leaves", STREET_LEAVES);
  const nTrees = seed("trees") % 4;
  const used = new Set();
  for (let i = 0; i < nTrees && sideSpots.length; i++) {
    const k = seed(`tree${i}`) % sideSpots.length;
    if (used.has(k)) continue;
    used.add(k);
    const tx = sideSpots[k];
    const tall = seed(`tall${i}`) % 3 === 0;
    p(`<rect x="${tx - 1.5}" y="${GROUND - 20}" width="3" height="${KERB - 3 - (GROUND - 20)}" fill="#5E4E3C"/>`);
    if (tall) p(`<ellipse cx="${tx}" cy="${GROUND - 34}" rx="8" ry="20" fill="${leaves[0]}"/><ellipse cx="${tx + 2}" cy="${GROUND - 30}" rx="5" ry="14" fill="${leaves[2]}"/>`);
    else p(`<circle cx="${tx}" cy="${GROUND - 28}" r="11" fill="${leaves[0]}"/><circle cx="${tx - 6}" cy="${GROUND - 23}" r="7" fill="${leaves[1]}"/><circle cx="${tx + 6}" cy="${GROUND - 24}" r="7.5" fill="${leaves[2]}"/>`);
  }
  if (seed("lamp") % 2 && sideSpots.length > used.size) {
    const k = [...sideSpots.keys()].find((j) => !used.has(j));
    const lx = sideSpots[k] + 6;
    p(`<rect x="${lx - .8}" y="${GROUND - 34}" width="1.6" height="${KERB - 2 - (GROUND - 34)}" fill="#2E3236"/><path d="M${lx - 3.5} ${GROUND - 34} h7 l-1.5 -5 h-4 Z" fill="#2E3236"/><rect x="${lx - 2}" y="${GROUND - 33}" width="4" height="1.6" fill="#F1D98A"/>`);
  }

  /* THE OUTLINES: what the frontage will take, widest at the back. Dashed
     and unfilled, so they read as a possibility rather than a building. */
  const drawn = fits.slice().sort((a, b) => GHOST_W[b] - GHOST_W[a]);
  drawn.forEach((t, i) => {
    const bw = GHOST_W[t], storeys = GHOST_STOREYS[t];
    const floorH = Math.min(27, (GROUND - 46) / (storeys + 0.34));
    const bodyH = (storeys - 1) * floorH + floorH * 1.34;
    const x0 = Math.round((W - bw) / 2), x1 = x0 + bw, y0 = GROUND - bodyH;
    const ridge = t === "terraced" ? 17 : 21;
    const inset = t === "terraced" ? 18 : bw * 0.3;
    const colour = i === 0 ? "#243C58" : "#7E1F33";
    p(`<path d="M${x0} ${GROUND} V${y0} L${x0 + inset} ${y0 - ridge} H${x1 - inset} L${x1} ${y0} V${GROUND}"
         fill="${colour}" fill-opacity=".05" stroke="${colour}" stroke-width="1.4" stroke-dasharray="4 3" stroke-linejoin="round"/>`);
    /* The widest is named above its roof; any smaller one inside its own
       roof line, since a semi and a terraced house share a height and
       their names would otherwise print over each other. */
    const ly = i === 0 ? y0 - ridge - 5 : y0 - ridge + 12;
    p(`<text x="${W / 2}" y="${ly}" text-anchor="middle" font-family="IBM Plex Mono, ui-monospace, monospace" font-size="8.5" font-weight="600" letter-spacing=".6" fill="${colour}">${GHOST_NAME[t].toUpperCase()}</text>`);
  });
  if (!drawn.length) {
    p(`<text x="${W / 2}" y="${GROUND - 40}" text-anchor="middle" font-family="IBM Plex Mono, ui-monospace, monospace" font-size="8.5" fill="#6F6C66">NOTHING FITS THIS FRONTAGE</text>`);
  }

  /* the hoarding along the front, and the agent's board */
  p(`<rect x="${px0}" y="${GROUND - 13}" width="${gap}" height="13" fill="#8E7F63" opacity=".92"/>`);
  for (let hx = px0; hx < px1; hx += 7) p(`<rect x="${hx}" y="${GROUND - 13}" width=".9" height="13" fill="#7A6C52" opacity=".7"/>`);
  p(`<rect x="${px0}" y="${GROUND - 14}" width="${gap}" height="1.4" fill="#6E6148"/>`);
  /* Low on the hoarding and to one side, so it never crosses an outline's
     roof or its name. */
  const bw2 = 58;
  const bx = (seed("board") % 2) ? px0 + 6 : px1 - bw2 - 6;
  p(`<rect x="${bx + bw2 / 2 - 1}" y="${GROUND - 20}" width="2" height="8" fill="#5A4E3A"/>`);
  p(`<rect class="plot-board" x="${bx}" y="${GROUND - 31}" width="${bw2}" height="13" fill="#1F4B3F"/>`);
  p(`<rect x="${bx + 1.5}" y="${GROUND - 29.5}" width="${bw2 - 3}" height="10" fill="none" stroke="#D8CDB2" stroke-width=".6"/>`);
  p(`<text x="${bx + bw2 / 2}" y="${GROUND - 22.2}" text-anchor="middle" font-family="IBM Plex Mono, ui-monospace, monospace" font-size="5.4" font-weight="600" letter-spacing=".3" fill="#EDE3CC">BUILDING PLOT</text>`);

  /* the frontage, measured along the kerb */
  const my = GROUND + 5;
  p(`<path d="M${px0} ${my} H${px1} M${px0} ${my - 3} V${my + 3} M${px1} ${my - 3} V${my + 3}" stroke="#4A4538" stroke-width=".8"/>`);
  p(`<rect x="${W / 2 - 20}" y="${my - 4.5}" width="40" height="9" fill="#B4AC99"/>`);
  p(`<text x="${W / 2}" y="${my + 2.6}" text-anchor="middle" font-family="IBM Plex Mono, ui-monospace, monospace" font-size="7.5" fill="#2E2A22">${Number(plot.frontage).toFixed(1)} m</text>`);

  /* TRAFFIC in the road below the kerb: parked cars, now and then a van,
     and on some streets a bus pulling in. All below the measured line. */
  const taken = [];
  const clear = (a, b) => taken.every(([c, d]) => b < c - 4 || a > d + 4);
  const wheel = (x) => `<circle cx="${x}" cy="${H - 4.5}" r="3.4" fill="#1E1E1E"/><circle cx="${x}" cy="${H - 4.5}" r="1.3" fill="#9A9A96"/>`;
  if (seed("bus") % 5 === 0) {
    const [body, band] = pick("buscol", STREET_BUSES);
    const left = seed("bus:side") % 2 === 0;
    const len = 96;
    const bx0 = left ? px0 - 6 - len : px1 + 6;
    taken.push([bx0, bx0 + len]);
    const top = H - 30;
    let s = `<g class="veh"><rect x="${bx0}" y="${top}" width="${len}" height="24" rx="3" fill="${body}"/>`;
    s += `<rect x="${bx0}" y="${top + 16}" width="${len}" height="3" fill="${band}"/>`;
    for (let wx = bx0 + 6; wx < bx0 + len - 12; wx += 13) s += `<rect x="${wx}" y="${top + 3}" width="10" height="10" rx="1" fill="#3E4A52"/><rect x="${wx + 1}" y="${top + 4}" width="3" height="8" fill="#DCD6C4" opacity=".25"/>`;
    const dx = left ? bx0 + len - 12 : bx0 + 3;
    s += `<rect x="${dx}" y="${top + 3}" width="9" height="16" fill="#2E3236"/>`;
    s += wheel(bx0 + 16) + wheel(bx0 + len - 16) + `</g>`;
    p(s);
  }
  const nCars = 1 + (seed("cars") % 3);
  for (let i = 0; i < nCars; i++) {
    const van = seed(`van${i}`) % 5 === 0;
    const len = van ? 42 : 36;
    let cx = 4 + (seed(`car${i}`) % (W - len - 8));
    for (let tries = 0; tries < 8 && !clear(cx, cx + len); tries++) cx = (cx + 47) % (W - len - 8) + 4;
    if (!clear(cx, cx + len)) continue;
    taken.push([cx, cx + len]);
    const col = pick(`carcol${i}`, STREET_CARS);
    const facingLeft = seed(`face${i}`) % 2;
    let s = `<g class="veh">`;
    if (van) {
      const cab = facingLeft ? cx : cx + len - 11;
      const box = facingLeft ? cx + 11 : cx;
      s += `<rect x="${box}" y="${H - 20}" width="${len - 11}" height="14" rx="1.5" fill="${col}"/>`;
      s += `<path d="M${cab} ${H - 6} V${H - 14} ${facingLeft ? `L${cab + 4} ${H - 19} H${cab + 11}` : `H${cab} L${cab + 7} ${H - 19} H${cab}`} V${H - 6} Z" fill="${col}"/>`;
      s += `<rect x="${cab + (facingLeft ? 3 : 2)}" y="${H - 17}" width="6" height="4" fill="#3E4A52"/>`;
    } else {
      s += `<rect x="${cx}" y="${H - 14}" width="${len}" height="8.5" rx="2.5" fill="${col}"/>`;
      s += `<path d="M${cx + 7} ${H - 14} L${cx + 11} ${H - 20} H${cx + 25} L${cx + 30} ${H - 14} Z" fill="${col}"/>`;
      s += `<path d="M${cx + 9.5} ${H - 14} L${cx + 12.4} ${H - 18.6} H${cx + 17.4} V${H - 14} Z M${cx + 18.8} ${H - 14} V${H - 18.6} H${cx + 24.4} L${cx + 27.6} ${H - 14} Z" fill="#46535C"/>`;
      s += `<rect x="${facingLeft ? cx : cx + len - 2}" y="${H - 12.5}" width="2" height="2" fill="#F1D98A"/>`;
    }
    s += wheel(cx + 8) + wheel(cx + len - 8) + `</g>`;
    p(s);
  }
  return S.join("");
}

function PlotArt({ plot }) {
  const inner = useMemo(() => plotArt(plot), [plot.id, (plot.fits || []).join()]);
  return (
    <svg viewBox={`0 0 300 ${PLOT_H}`} width="100%" className="art bld-plot-art" role="img"
         aria-label={`The plot on ${plot.address}. ${plot.fits && plot.fits.length ? "It can take a " + plot.fits.map((t) => GHOST_NAME[t] || t).join(" or ") + " house." : ""}`}
         dangerouslySetInnerHTML={{ __html: inner }} />
  );
}

function PropertyArt({ meta, height = 150, facade = null }) {
  const pal = facade ? FACADE_PALETTES[facade] : null;
  /* The drawing is generated as markup rather than as elements: it is a few
     hundred shapes and building them as React nodes costs more than it is
     worth for something that never changes once drawn. Nothing in it comes
     from user input — every value is a number or a colour from the tables
     above. */
  /* A house a player built is drawn from its own design. */
  const inner = useMemo(() => (meta.built ? builtArt(meta.built) : elevationArt(meta, pal)), [meta.id, facade]);
  return (
    <svg viewBox="0 0 300 150" width="100%" {...(height ? { height } : {})} className="art"
         preserveAspectRatio="xMidYMid slice" role="img"
         aria-label={`Elevation of ${meta.name}`}
         dangerouslySetInnerHTML={{ __html: inner }} />
  );
}

/* ---------------------------- Map ------------------------------ */
/* MAP V3 — the real city.

   V2 drew York from hand-written SVG: a bezier for the Ouse, a circle for
   the bar walls, and fifteen district pins at made-up coordinates. It was
   honest about being a diagram, and that was the problem. Players could not
   find themselves on it, so "my properties" never became "my part of town",
   and a territory you cannot picture is not one worth fighting for.

   V3 draws the actual place. `npm run mapdata` pulls York's roads, water,
   parkland, railways, the bar walls and 21,479 building footprints from
   OpenStreetMap into public/geo/york.json; `npm run geocode` puts every one
   of the 150 tradeable properties on a real building. It is all vector, so
   it holds up from the whole city down to a single terrace at thirty times
   in.

   WHY NOT A TILE PROVIDER. Esri's free world imagery is the obvious
   shortcut and we got as far as reading the terms: free commercial use
   requires zero revenue from the app, which the Paddle shop makes false the
   day it takes a pound. MapLibre or Leaflet over an open tile source solves
   the licence but not the look — the point was never a map of York, it was
   a map of York that reads as a board you can own. So the geometry is ours,
   drawn our way, and costs 561 KB gzipped once per player per day.

   THE SKINS ARE PAPER, NOT MAPS. Surveyor's Plan, Market Heat and
   Illuminated Charter were three separate renderers, which is why only one
   of them ever got the work. They are now three palettes over this one
   renderer, so a Charter buyer gets everything the plan does — every road,
   every building, every clickable deed — painted differently. Heat keeps
   its own job: it colours districts by where the money moved rather than by
   who holds them. */

const MAP_W = 340, MAP_H = 400;
const MAP_MAX_K = 30;

/* What appears at which zoom. Each is a legibility threshold, not a
   performance one: minor roads at 1x are a grey haze, and 21k building
   footprints under a whole-city view are a texture rather than buildings. */
const K_MINOR = 1.3;      /* side streets */
const K_BUILD = 2.2;      /* the city's buildings as ground texture */
const K_FOOT  = 4;        /* a property drawn as its own footprint */
const K_LABEL = 5;        /* above this, district labels are in the way */
/* WAS 8, AND LEFT A DEAD BAND.

   District labels come off at K_LABEL (5) because by then they are in the
   way; property names arrived at 8. Between the two the map showed a page
   of unlabelled footprints — zoomed in far enough to have lost the
   district names, not far enough to have gained the building ones, which
   is the one zoom where you cannot tell what you are looking at.

   Names can come in the moment the labels go because PropertyNames already
   places them by collision and drops what does not fit, most valuable
   first. At this zoom that means a handful of well-spaced names on the
   biggest buildings, filling in as you go further. */
const K_NAME  = K_LABEL;  /* property names, from the moment district labels go */

function clamp01(v) { return Math.max(0, Math.min(1, v)); }

/* Rarity rides on the outline, not a pip: common has no colour of its own,
   so ordinary property recedes to a plain edge and only the finds light up.
   The widths sit deliberately close together — this is a serif-weight
   difference, not a highlighter. */
const RARITY_EDGE = {
  common:   { edge: null,      w: 0.55 },
  uncommon: { edge: "#8E979F", w: 0.75 },
  rare:     { edge: "#B08C1E", w: 1.0 },
  ultra:    { edge: "#B4342E", w: 1.25 },
  secret:   { edge: "#7A5AA8", w: 1.5 },
};

/* Five answers, whoever is playing.

   A colour per trader does not survive contact with a real game: at fifty
   players the map is confetti and none of it means anything. Only the two
   traders you are actually feuding with get a colour of their own; everyone
   else is "another trader", in one muted violet. */
/* OWNERSHIP AND RARITY MUST NOT SHARE A COLOUR.

   The map already splits them correctly — rarity is the building's OUTLINE,
   ownership is its FILL, the same division the property and tactic cards
   use. What was wrong was the palette. Measured as CIE76 distance against
   lib/rarity.js's five colours, the old set collided head-on:

     contested #B08C1E  vs  rare      #B08C1E   ΔE  0.0   the same colour
     rival two #6357B0  vs  historic  #7A5AA8   ΔE  9.4   both violet
     rival one #C1503C  vs  ultra     #B4342E   ΔE  9.9   both red

   Anything under about 15 is confusable at the size a building is drawn.

   Rarity owns parchment, grey-blue, gold, red and violet, so ownership now
   takes only hues rarity never uses: green, orange, blue and a deep slate.
   Worst case against any rarity colour is now ΔE 28.9, and the four are
   40.6 apart from each other.

   CONTESTED IS NOT A COLOUR ANY MORE. It is a state rather than an owner —
   nobody holds the district — so it is drawn as a hatch. That removes the
   last collision by removing the hue entirely, and says something truer than
   a fifth colour could: contested ground is striped, not painted.

   OPEN IS WHITE. "Available" is the single most useful thing this map can
   tell a player, and the old pale cream all but vanished against a district
   its owner had coloured in. Nothing else on the map is pure white. */
const TERR = {
  mine: "#1F8A4C",
  rivals: ["#E2761B", "#1C6FA8"],
  other: "#3F5A52",
  contested: "url(#terr-contested)",
  contestedCss: "repeating-linear-gradient(45deg, #3F5A52 0 3px, #C9C2AC 3px 6px)",
  open: "#FFFFFF",
  /* ONE COLOUR FOR A ROLE, NOT AN IDENTITY. Every district's leader is
     written in the same colour whoever they are, so it needs a hue nothing
     else on this map claims. Blue was the obvious pick and is already a
     rival's; teal and cyan both sit close enough to the Ouse to be read as
     water. Magenta is nowhere in the rarity scheme, nowhere in the ownership
     scheme, and nowhere in nature — measured at ΔE 34.0 from its nearest
     neighbour, which is rarity's violet. */
  leader: "#C2439B",
  /* THE MAGNATE, who owns every building in a district (Aaron, 30 Sep
     2026). Indigo. On the map it is written on the leader's dark pill, so
     it is the light indigo (8:1 on the pill, ΔE 29 from rarity's violet,
     its nearest neighbour); on paper, in the tables, the same hue is set
     deep, #2A2896 (styles.css). */
  magnate: "#A9A6FF",
};
const RIVAL_LIMIT = 2;

/* The two map inks go to the traders you have declared as rivals (27 Sep
   2026), in the order you named them. */
function rivalPalette(rivalries) {
  const out = new Map();
  ((rivalries && rivalries.mine) || []).slice(0, RIVAL_LIMIT).forEach((r, i) => {
    out.set(r.id, { colour: TERR.rivals[i], name: r.name || "A rival" });
  });
  return out;
}

/* Who holds a district, for colouring it. A clear majority takes it;
   otherwise it is contested, or still open ground. This is about how the map
   READS and is deliberately separate from the freehold rule, which is about
   who collects the rent. */
/* WHO LEADS A DISTRICT IS THE SERVER'S ANSWER, NOT THE MAP'S.

   This used to work it out for itself and require a MAJORITY — more than
   five of a district's ten — while lib/freehold.js awards the freehold to
   whoever owns the MOST, ties going to the incumbent. Three of ten is a
   plurality, so the server made Crunkle district leader of Tang Hall, wrote
   it to the freeholds table, announced it in the news feed and put it on
   his profile, and the map quietly called the district contested and
   printed no name at all. Two rules for one fact, and the one nobody could
   see won.

   `lead` is the row from game.freeholds, which is the same table the rest
   of the game reads. The counts are still used for the contested/open
   shading underneath, because that genuinely is a question about spread
   rather than about who holds the deed. */
function holderOf(counts, total, meId, palette, lead) {
  const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]);
  const owned = entries.reduce((s, e) => s + e[1], 0);
  if (lead && lead.user_id) {
    const id = Number(lead.user_id);
    if (id === meId) return { state: "mine", holder: id };
    const known = palette.get(id);
    return known
      ? { state: "rival", holder: id, colour: known.colour, name: known.name }
      : { state: "other", holder: id };
  }
  if (!entries.length || !total || !owned) return { state: "open" };
  return { state: owned / total > 0.25 ? "contested" : "open" };
}

const holderColour = (h) =>
  h.state === "mine" ? TERR.mine
    : h.state === "rival" ? h.colour
    : h.state === "other" ? TERR.other
    : h.state === "contested" ? TERR.contested
    : TERR.open;

/* The SVG fill and the legend swatch are not interchangeable: a <pattern>
   reference works as an SVG fill and means nothing to CSS background. */
const swatchFor = (h) => (h.state === "contested" ? TERR.contestedCss : holderColour(h));

const holderLabel = (h) =>
  h.state === "mine" ? "You hold this district"
    : h.state === "rival" ? `${h.name} holds this district`
    : h.state === "other" ? "Another trader holds this district"
    : h.state === "contested" ? "Contested: nobody has a majority"
    : "Open ground";

/* ---- the papers ------------------------------------------------------- */
/* Same city, same geometry, same clicks. Only the ink changes. */
const MAP_PAPERS = {
  plan: {
    id: "plan", frame: "#0D1411", edge: "#B08C1E",
    groundTop: "#DEDCC4", groundBottom: "#C8CAAE",
    green: "#93B074", greenOp: 0.85,
    water: "#77AEC8", waterEdge: "#5C93AE",
    build: "#C6C0A8", buildEdge: "#A39C84",
    major: "#FBF7E6", minor: "#F5F3E5",
    rail: "#7C7768", wall: "#8A6E3E",
    label: "#F2EFE0", labelBg: "#0E1C15", name: "#2A3A30",
  },
  /* Night imagery: the city as the market sees it after hours. The ground
     goes dark so the only bright things on it are money and ownership. */
  heat: {
    id: "heat", frame: "#080C0A", edge: "#2F6B45", byMove: true,
    /* LIFTED OFF THE FLOOR. The ground was #18231E, which made the city
       almost unreadable and — worse — left nothing for a hover state to
       show against: the cursor moved and nothing happened. Raising it two
       steps costs the night look nothing, because what makes this paper
       dark is that most districts now take NO fill at all. */
    groundTop: "#22302A", groundBottom: "#16201B",
    green: "#27412F", greenOp: 0.9,
    water: "#143A50", waterEdge: "#215574",
    build: "#2E3931", buildEdge: "#404C43", trade: "#B7C4B2",
    major: "#6E7D6B", minor: "#4A5548",
    rail: "#3A423A", wall: "#6E5A32",
    label: "#DDE7DC", labelBg: "#0A100C", name: "#9FB3A2",
  },
  charter: {
    id: "charter", frame: "#3A2E18", edge: "#B8912C",
    groundTop: "#EADDBC", groundBottom: "#D9C79C",
    green: "#B4BE86", greenOp: 0.8,
    water: "#9FC0CE", waterEdge: "#7FA3B4",
    build: "#DBCEAA", buildEdge: "#B7A67C",
    major: "#FCF7E6", minor: "#F3EBD4",
    rail: "#8A7A58", wall: "#8C6A18",
    label: "#F6EFD9", labelBg: "#4A3A1C", name: "#4A3A1C",
  },
};

/* ---- projection ------------------------------------------------------- */
/* One projection for every layer, or nothing lines up. Equirectangular with
   a cosine correction at York's latitude, and the SAME scale on both axes —
   using the bounding box's own aspect on each would stretch the city to fit
   the sheet and put every building wrong relative to its street. */
function makeProjection(b) {
  const midLat = (b.n + b.s) / 2;
  const kx = 111320 * Math.cos((midLat * Math.PI) / 180), ky = 111320;
  const spanX = (b.e - b.w) * kx, spanY = (b.n - b.s) * ky;
  const sc = Math.min((MAP_W - 24) / spanX, (MAP_H - 24) / spanY);
  const ox = (MAP_W - spanX * sc) / 2, oy = (MAP_H - spanY * sc) / 2;
  const f = (lng, lat) => [ox + (lng - b.w) * kx * sc, oy + (b.n - lat) * ky * sc];
  f.metresPerUnit = 1 / sc;
  return f;
}

/* ---- territory: the sheet cut by perpendicular bisectors --------------- */
function clipHalf(poly, a, b) {
  const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
  const nx = b.x - a.x, ny = b.y - a.y;
  const side = (p) => (p[0] - mx) * nx + (p[1] - my) * ny;
  const out = [];
  for (let i = 0; i < poly.length; i++) {
    const cur = poly[i], nxt = poly[(i + 1) % poly.length];
    const dc = side(cur), dn = side(nxt);
    if (dc <= 0) out.push(cur);
    if ((dc < 0 && dn > 0) || (dc > 0 && dn < 0)) {
      const t = dc / (dc - dn);
      out.push([cur[0] + (nxt[0] - cur[0]) * t, cur[1] + (nxt[1] - cur[1]) * t]);
    }
  }
  return out;
}

function territoryCells(sites) {
  const box = [[0, 0], [MAP_W, 0], [MAP_W, MAP_H], [0, MAP_H]];
  return sites.map((s) => {
    let poly = box;
    for (const o of sites) {
      if (o === s) continue;
      poly = clipHalf(poly, s, o);
      if (poly.length < 3) break;
    }
    return { ...s, poly };
  });
}

/* Corners rounded, so a district reads as a piece on a board rather than a
   cell in a diagram. */
function roundedPath(poly, r) {
  if (!poly || poly.length < 3) return "";
  const n = poly.length, parts = [];
  for (let i = 0; i < n; i++) {
    const prev = poly[(i - 1 + n) % n], cur = poly[i], next = poly[(i + 1) % n];
    const tp = [prev[0] - cur[0], prev[1] - cur[1]], tn = [next[0] - cur[0], next[1] - cur[1]];
    const lp = Math.hypot(tp[0], tp[1]) || 1, ln = Math.hypot(tn[0], tn[1]) || 1;
    const rr = Math.min(r, lp / 2, ln / 2);
    const p1 = [cur[0] + (tp[0] / lp) * rr, cur[1] + (tp[1] / lp) * rr];
    const p2 = [cur[0] + (tn[0] / ln) * rr, cur[1] + (tn[1] / ln) * rr];
    parts.push((i ? "L" : "M") + p1[0].toFixed(1) + " " + p1[1].toFixed(1));
    parts.push("Q" + cur[0].toFixed(1) + " " + cur[1].toFixed(1) + " " + p2[0].toFixed(1) + " " + p2[1].toFixed(1));
  }
  return parts.join(" ") + " Z";
}

function centroidOf(poly) {
  let a = 0, cx = 0, cy = 0;
  for (let i = 0; i < poly.length; i++) {
    const p = poly[i], q = poly[(i + 1) % poly.length];
    const f = p[0] * q[1] - q[0] * p[1];
    a += f; cx += (p[0] + q[0]) * f; cy += (p[1] + q[1]) * f;
  }
  a /= 2;
  return a ? [cx / (6 * a), cy / (6 * a)] : [poly[0][0], poly[0][1]];
}

/* ---- the city, prepared once ------------------------------------------ */
/* Every path string in here is built one time and then never again. The
   draw loop's job is to choose which of them to hand React, not to turn
   coordinates into text sixty times a second. */
const TILE_COLS = 16, TILE_ROWS = 19;
const TILE_W = MAP_W / TILE_COLS, TILE_H = MAP_H / TILE_ROWS;

/* EVERY LAYER IS CULLED, NOT JUST THE BUILDINGS.

   The first version drew roads, water and parkland as one path element each,
   holding the whole city, and culled only the 21,479 buildings. Measured in
   Chromium that panned at 61 ms a frame inside a district and 81 ms at
   street level — about thirteen frames a second. The buildings were never
   the problem: 4,970 minor roads and 3,397 pieces of parkland in two path
   elements still have to be walked and re-stroked on every frame, however
   little of them is on screen, and `vector-effect: non-scaling-stroke` makes
   the stroking part worse because it is recomputed in device space each
   time.

   So every layer is diced into the same grid. A way is assigned to every
   tile its bounding box touches and drawn exactly once — a Set of indices
   rather than one copy per tile, because drawing a filled shape twice under
   `fill-rule: evenodd` would punch a hole in it rather than double it.

   The join only runs when the visible tile RANGE changes, so panning inside
   one screenful costs nothing at all, and the wide view short-circuits to a
   prebuilt string because at 1x every tile is visible anyway. */
function tiledLayer(paths, boxes) {
  const tiles = [];
  for (let i = 0; i < TILE_COLS * TILE_ROWS; i++) tiles.push([]);
  for (let i = 0; i < paths.length; i++) {
    const b = boxes[i];
    const c0 = Math.max(0, Math.min(TILE_COLS - 1, Math.floor(b[0] / TILE_W)));
    const c1 = Math.max(0, Math.min(TILE_COLS - 1, Math.floor(b[2] / TILE_W)));
    const r0 = Math.max(0, Math.min(TILE_ROWS - 1, Math.floor(b[1] / TILE_H)));
    const r1 = Math.max(0, Math.min(TILE_ROWS - 1, Math.floor(b[3] / TILE_H)));
    for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) tiles[r * TILE_COLS + c].push(i);
  }
  return { paths, tiles, all: paths.join(" ") };
}

function cullLayer(layer, c0, c1, r0, r1) {
  if (!layer) return "";
  if (c0 <= 0 && r0 <= 0 && c1 >= TILE_COLS - 1 && r1 >= TILE_ROWS - 1) return layer.all;
  const seen = new Set();
  for (let r = r0; r <= r1; r++) {
    for (let c = c0; c <= c1; c++) {
      const list = layer.tiles[r * TILE_COLS + c];
      for (let i = 0; i < list.length; i++) seen.add(list[i]);
    }
  }
  if (!seen.size) return "";
  const out = [];
  seen.forEach((i) => out.push(layer.paths[i]));
  return out.join(" ");
}

function buildCityModel(geo) {
  const P = makeProjection(geo.bbox);
  /* One pass per way: project it, write its path string, and record the
     bounding box the culler will test. Doing these together is what keeps
     the whole build to a few hundred milliseconds. */
  const project = (coords, close) => {
    if (!coords || coords.length < 2) return null;
    let d = "";
    let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
    for (let i = 0; i < coords.length; i++) {
      const p = P(coords[i][0], coords[i][1]);
      if (p[0] < minx) minx = p[0];
      if (p[0] > maxx) maxx = p[0];
      if (p[1] < miny) miny = p[1];
      if (p[1] > maxy) maxy = p[1];
      d += (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1);
    }
    return { d: close ? d + "Z" : d, box: [minx, miny, maxx, maxy] };
  };
  const layerOf = (list, close) => {
    const paths = [], boxes = [];
    for (const w of list) {
      const r = project(w.c, close || w.a);
      if (!r) continue;
      paths.push(r.d); boxes.push(r.box);
    }
    return tiledLayer(paths, boxes);
  };

  const bPaths = [], bBoxes = [];
  for (const b of geo.city_buildings) {
    const r = project(b, true);
    if (!r) continue;
    bPaths.push(r.d); bBoxes.push(r.box);
  }

  const cells = territoryCells(NEIGHBOURHOODS.filter((n) => !n.zone).map((n) => {
    const p = P(n.lng, n.lat);
    return { code: n.code, name: n.name, region: n.region, x: p[0], y: p[1] };
  })).map((c) => {
    const cen = centroidOf(c.poly);
    return { ...c, d: roundedPath(c.poly, 8), cx: cen[0], cy: cen[1] };
  });
  /* A new district is drawn on its own outline, over the cells around it,
     rather than as one more Voronoi site that would carve up its
     neighbours. */
  for (const n of NEIGHBOURHOODS) {
    if (!n.zone || !Array.isArray(n.outline) || n.outline.length < 3) continue;
    const poly = n.outline.map(([lng, lat]) => P(lng, lat));
    const cen = centroidOf(poly);
    const p = P(n.lng, n.lat);
    cells.push({ code: n.code, name: n.name, region: n.region, x: p[0], y: p[1], poly,
      d: roundedPath(poly, 4), cx: cen[0], cy: cen[1], zone: true });
  }

  const props = ALL_PROPERTIES.map((m) => {
    const xy = P(m.lng, m.lat);
    /* A built house carries its own footprint: it stands on a building the
       catalogue never claimed. */
    const foot = m.footprint || (m.osm && geo.buildings[m.osm]);
    const r = foot ? project(foot, true) : null;
    return {
      id: m.id, meta: m, district: m.neighbourhood,
      x: xy[0], y: xy[1], foot: r ? r.d : null,
      /* The footprint's own middle and size, so a small building can be
         drawn larger than life around its own centre (29 Sep 2026). */
      fx: r ? (r.box[0] + r.box[2]) / 2 : xy[0], fy: r ? (r.box[1] + r.box[3]) / 2 : xy[1],
      span: r ? Math.max(r.box[2] - r.box[0], r.box[3] - r.box[1]) : 0,
    };
  });

  return {
    P, cells, props,
    buildings: tiledLayer(bPaths, bBoxes),
    byDistrict: new Map(NEIGHBOURHOODS.map((n) =>
      [n.code, props.filter((p) => p.district === n.code)])),
    layers: {
      green: layerOf(geo.layers.green, true),
      water: layerOf(geo.layers.water, true),
      major: layerOf(geo.layers.major, false),
      minor: layerOf(geo.layers.minor, false),
      rail: layerOf(geo.layers.rail, false),
      wall: layerOf(geo.layers.wall, false),
    },
  };
}

/* ---- loading the city ------------------------------------------------- */
/* Fetched on the first map open rather than at boot: a player who never
   opens the map never pays for it, and one who does pays once — the server
   gzips it to 561 KB and marks it cacheable for a day. */
let GEO_CACHE = null, GEO_PROMISE = null;

function useCityGeo(active) {
  const [state, setState] = useState(() => (GEO_CACHE ? { geo: GEO_CACHE } : {}));
  useEffect(() => {
    if (!active || state.geo || state.error) return undefined;
    let alive = true;
    if (!GEO_PROMISE) {
      GEO_PROMISE = fetch("/geo/york.json")
        .then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
        .then((g) => { GEO_CACHE = g; return g; })
        .catch((e) => { GEO_PROMISE = null; throw e; });
    }
    GEO_PROMISE.then((g) => { if (alive) setState({ geo: g }); })
      .catch((e) => { if (alive) setState({ error: e.message || "could not load" }); });
    return () => { alive = false; };
  }, [active, state.geo, state.error]);
  return state;
}

/* ---- pan, zoom, and the way back -------------------------------------- */
/* The transform is translate-then-scale, so a point p on the sheet lands at
   x + k*p on screen; the clamp below is the algebra of "the sheet must still
   cover the frame" solved for x. Zooming holds whatever is under the cursor
   still, which is the difference between a map you can read and one that
   throws you across the city every time you scroll. */
/* THE CITY OPENS WITH A MARGIN ROUND IT.

   At k = 1 the sheet fills the frame exactly, so every piece of floating
   furniture — the key, the breadcrumbs, the zoom buttons — sits on top of a
   district somebody wants to click. Backing off a touch costs nothing at
   this scale (the whole city is still legible) and buys a band of empty
   ground at the edges for the furniture to live on. */
const CITY_K = 0.9;
const cityView = () => ({
  k: CITY_K,
  x: (MAP_W * (1 - CITY_K)) / 2,
  y: (MAP_H * (1 - CITY_K)) / 2,
});

function useMapView() {
  const [view, setView] = useState(cityView);
  /* Where you were before each fly-in. Clicking the wrong district should
     cost one click to undo, not a trip back out to the whole city and a hunt
     for where you actually were. */
  const [depth, setDepth] = useState(0);
  const history = useRef([]);
  /* A callback ref rather than a plain one, and the node kept in STATE.

     The wheel listener has to be registered by hand, because React attaches
     wheel handlers passively and a passive listener cannot preventDefault —
     without which the page scrolls or zooms away underneath the map. But a
     useRef does not re-render, so the effect that attaches it only ran once,
     against whichever <svg> existed at mount. Switch map skin and a new
     element mounts while the listener stays bound to the discarded one, and
     wheel zoom silently stops working. Holding the node in state makes the
     effect re-run for each new element, which is the whole fix. */
  const [svgEl, setSvgEl] = useState(null);
  const svgNode = useRef(null);
  const svgRef = useCallback((node) => { svgNode.current = node; setSvgEl(node); }, []);
  const pointers = useRef(new Map());
  const pinch = useRef(null);
  const drag = useRef(null);
  const anim = useRef(0);
  const justDragged = useRef(false);
  /* The live view, for handlers that need to read it without being a state
     updater. Kept in step on every render. */
  const viewRef = useRef(view);
  viewRef.current = view;

  const fit = useCallback((v) => {
    /* The floor is CITY_K, not 1. It was 1, which would have quietly undone
       the opening margin the moment anything went through here — zoom out
       twice and the city snapped back to full-bleed with the key sitting on
       Dringhouses again, and only the Back button could reach the view the
       game actually opens with. */
    const k = Math.max(CITY_K, Math.min(MAP_MAX_K, v.k));
    if (k <= 1) {
      /* Smaller than the frame: there is nothing to pan to, so it is centred
         rather than clamped against an edge that is no longer there. */
      return { k, x: (MAP_W * (1 - k)) / 2, y: (MAP_H * (1 - k)) / 2 };
    }
    return {
      k,
      x: Math.min(0, Math.max(MAP_W - MAP_W * k, v.x)),
      y: Math.min(0, Math.max(MAP_H - MAP_H * k, v.y)),
    };
  }, []);

  /* Client pixels to sheet units. The SVG scales to its container, so this
     cannot assume one is the other. */
  const toSheet = useCallback((cx, cy) => {
    const el = svgNode.current;
    if (!el) return { x: MAP_W / 2, y: MAP_H / 2 };
    const r = el.getBoundingClientRect();
    return { x: ((cx - r.left) / r.width) * MAP_W, y: ((cy - r.top) / r.height) * MAP_H };
  }, []);

  const zoomAt = useCallback((factor, sheetPt) => {
    setView((v) => {
      const k = Math.max(1, Math.min(MAP_MAX_K, v.k * factor));
      if (k === v.k) return v;
      const ratio = k / v.k;
      return fit({ k, x: sheetPt.x - (sheetPt.x - v.x) * ratio, y: sheetPt.y - (sheetPt.y - v.y) * ratio });
    });
  }, [fit]);

  /* Eased travel to an arbitrary view. Everything that moves the map without
     a finger on it goes through here, so a fly-in, a zoom button and a step
     back all feel like the same object moving. */
  const glide = useCallback((target, ms = 420) => {
    cancelAnimationFrame(anim.current);
    const from = viewRef.current;
    const to = fit(target);
    if (typeof window !== "undefined"
        && window.matchMedia
        && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setView(to);
      return;
    }
    const t0 = performance.now();
    const step = (now) => {
      const p = Math.min(1, (now - t0) / ms);
      const e = 1 - Math.pow(1 - p, 3);
      setView({
        k: from.k + (to.k - from.k) * e,
        x: from.x + (to.x - from.x) * e,
        y: from.y + (to.y - from.y) * e,
      });
      if (p < 1) anim.current = requestAnimationFrame(step);
    };
    anim.current = requestAnimationFrame(step);
  }, [fit]);

  /* The buttons used to jump about the CENTRE OF THE SHEET, which is not
     where you are looking once you have panned — so every press threw the
     view sideways as well as in. They now zoom about the middle of what is
     actually on screen. */
  const glideBy = useCallback((factor) => {
    const v0 = viewRef.current;
    const centre = { x: (MAP_W / 2 - v0.x) / v0.k, y: (MAP_H / 2 - v0.y) / v0.k };
    const k = Math.max(1, Math.min(MAP_MAX_K, v0.k * factor));
    if (k === v0.k) return;
    const ratio = k / v0.k;
    glide({ k, x: centre.x - (centre.x - v0.x) * ratio, y: centre.y - (centre.y - v0.y) * ratio }, 240);
  }, [glide]);

  useEffect(() => () => cancelAnimationFrame(anim.current), []);

  useEffect(() => {
    const el = svgEl;
    if (!el) return undefined;
    /* A trackpad pinch arrives as a wheel event with ctrlKey set, and a
       trackpad scroll as one without. Both are ours, and both must be
       prevented or the browser zooms the whole page instead — which is
       exactly what was happening on the laptop. The step is taken from
       deltaY rather than fixed, so a pinch is continuous instead of leaping
       in fixed increments. */
    const onWheel = (e) => {
      e.preventDefault();
      const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1;
      const d = e.deltaY * unit;
      const factor = Math.exp(-d * (e.ctrlKey ? 0.012 : 0.0035));
      zoomAt(Math.max(0.4, Math.min(2.5, factor)), toSheet(e.clientX, e.clientY));
    };
    /* Safari on macOS does not send ctrl+wheel for a pinch; it sends these
       instead, and ignoring them means the page zooms there too. */
    const onGesture = (e) => { e.preventDefault(); };
    const onGestureChange = (e) => {
      e.preventDefault();
      zoomAt(Math.max(0.5, Math.min(2, e.scale || 1)), toSheet(e.clientX, e.clientY));
    };
    el.addEventListener("wheel", onWheel, { passive: false });
    el.addEventListener("gesturestart", onGesture, { passive: false });
    el.addEventListener("gesturechange", onGestureChange, { passive: false });
    el.addEventListener("gestureend", onGesture, { passive: false });
    return () => {
      el.removeEventListener("wheel", onWheel);
      el.removeEventListener("gesturestart", onGesture);
      el.removeEventListener("gesturechange", onGestureChange);
      el.removeEventListener("gestureend", onGesture);
    };
  }, [svgEl, zoomAt, toSheet]);

  /* Capture is claimed only once a drag is really under way.

     Capturing on pointerdown, as this used to, redirects every later pointer
     event to the SVG — and with it the click. So clicking a district or a
     property did nothing at all with a mouse, while touch, which routes
     differently, worked perfectly. That is why it looked like a desktop-only
     fault. Four pixels of movement is the line between a click and a drag. */
  const DRAG_SLOP = 4;

  const onPointerDown = (e) => {
    if (e.pointerType === "mouse" && e.button !== 0) return;
    pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
    if (pointers.current.size === 2) {
      const two = [...pointers.current.values()];
      pinch.current = { dist: Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y) };
      drag.current = null;
    } else {
      drag.current = { id: e.pointerId, x0: e.clientX, y0: e.clientY, moving: false };
    }
    justDragged.current = false;
  };

  const onPointerMove = (e) => {
    const prev = pointers.current.get(e.pointerId);
    if (!prev) return;
    pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });

    if (pointers.current.size === 2 && pinch.current) {
      const two = [...pointers.current.values()];
      const dist = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
      if (pinch.current.dist > 0) {
        zoomAt(dist / pinch.current.dist, toSheet((two[0].x + two[1].x) / 2, (two[0].y + two[1].y) / 2));
      }
      pinch.current.dist = dist;
      return;
    }

    const d = drag.current;
    if (!d || d.id !== e.pointerId) return;
    if (!d.moving) {
      if (Math.hypot(e.clientX - d.x0, e.clientY - d.y0) < DRAG_SLOP) return;
      d.moving = true;
      try { e.currentTarget.setPointerCapture(e.pointerId); } catch {}
    }
    const el = svgNode.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const dx = ((e.clientX - prev.x) / r.width) * MAP_W;
    const dy = ((e.clientY - prev.y) / r.height) * MAP_H;
    setView((v) => (v.k <= 1 ? v : fit({ ...v, x: v.x + dx, y: v.y + dy })));
  };

  const onPointerUp = (e) => {
    pointers.current.delete(e.pointerId);
    const d = drag.current;
    if (d && d.id === e.pointerId) {
      if (d.moving) {
        try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
        justDragged.current = true;
      }
      drag.current = null;
    }
    if (pointers.current.size < 2) pinch.current = null;
  };

  /* Remember where we are, so Back has somewhere to go. Callers push before
     they move, and carry whatever selection state belongs with the view. */
  const push = useCallback((extra) => {
    history.current.push({ ...viewRef.current, extra });
    if (history.current.length > 20) history.current.shift();
    setDepth(history.current.length);
  }, []);

  const back = useCallback(() => {
    const prev = history.current.pop();
    setDepth(history.current.length);
    if (!prev) return null;
    glide({ k: prev.k, x: prev.x, y: prev.y }, 380);
    return prev.extra;
  }, [glide]);

  /* Fly to a box on the sheet, with a little air around it.

     The air used to be 55% and the floor 1.6x, which landed a district just
     short of K_FOOT — so choosing somewhere zoomed in far enough to lose the
     overview and not far enough to gain the buildings, which is the one
     place on the scale worth nothing to anybody. 20% air and a floor above
     K_FOOT means picking a district always arrives somewhere its buildings
     are drawn as buildings. */
  const flyToBox = useCallback((minx, miny, maxx, maxy, maxK = 14, ms = 420) => {
    const w = Math.max(12, (maxx - minx) * 1.2), h = Math.max(14, (maxy - miny) * 1.2);
    const k = Math.max(K_FOOT + 0.6, Math.min(maxK, Math.min(MAP_W / w, MAP_H / h)));
    const cx = (minx + maxx) / 2, cy = (miny + maxy) / 2;
    glide({ k, x: MAP_W / 2 - cx * k, y: MAP_H / 2 - cy * k }, ms);
  }, [glide]);

  const toCity = useCallback(() => { glide(cityView(), 420); }, [glide]);

  const clearHistory = useCallback(() => { history.current = []; setDepth(0); }, []);

  /* The visible rectangle in sheet units — what culling asks for. */
  const viewport = useMemo(() => ({
    x0: -view.x / view.k, y0: -view.y / view.k,
    x1: (MAP_W - view.x) / view.k, y1: (MAP_H - view.y) / view.k,
  }), [view]);

  return {
    view, viewport, svgRef, glide, flyToBox, toCity, push, back, clearHistory,
    canGoBack: depth > 0,
    zoomIn: () => glideBy(1.7),
    zoomOut: () => glideBy(1 / 1.7),
    transform: `translate(${view.x} ${view.y}) scale(${view.k})`,
    handlers: {
      onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp,
      /* Letting capture go on pointerup means a drag that happens to finish
         over a district would otherwise select it. Caught here, above the
         markers, so none of them need to know about dragging. */
      onClickCapture: (e) => {
        if (!justDragged.current) return;
        justDragged.current = false;
        e.stopPropagation();
        e.preventDefault();
      },
      style: { touchAction: "none", cursor: view.k > 1 ? "grab" : "default" },
    },
  };
}

/* ---- the drawn layers -------------------------------------------------- */
/* Each of these is memoised on the things that actually change it, because
   the view transform changes sixty times a second during a drag and none of
   this does. Panning becomes one attribute write on one <g>. */

const NS = { vectorEffect: "non-scaling-stroke" };

const CityGround = React.memo(function CityGround({ d, paper }) {
  return (
    <>
      <rect x="-80" y="-80" width={MAP_W + 160} height={MAP_H + 160} fill={`url(#ground-${paper.id})`} />
      <path d={d.green} fill={paper.green} fillOpacity={paper.greenOp} fillRule="evenodd" />
      <path d={d.water} fill={paper.water} stroke={paper.waterEdge}
            strokeWidth="0.5" fillRule="evenodd" {...NS} />
    </>
  );
});

const CityBuildings = React.memo(function CityBuildings({ d, paper, compact }) {
  if (!d) return null;
  /* ON A PHONE THE CITY STEPS BACK (Aaron, 30 Sep 2026: the tradeable
     buildings were "difficult to make out compared to the surrounding map
     buildings"). The buildings nobody can buy fade into the ground, so the
     ones you can are the only solid shapes on the street. */
  return <path d={d} fill={paper.build} stroke={paper.buildEdge} strokeWidth="0.4"
               opacity={compact ? 0.5 : 1} fillRule="evenodd" {...NS} />;
});

const CityRoads = React.memo(function CityRoads({ d, paper, showMinor, wide }) {
  return (
    <>
      {showMinor && (
        <path d={d.minor} fill="none" stroke={paper.minor}
              strokeWidth={wide ? 7 : 3} strokeLinecap="round" {...NS} />
      )}
      <path d={d.major} fill="none" stroke={paper.major}
            strokeWidth={wide ? 12 : 5} strokeLinecap="round" {...NS} />
      <path d={d.rail} fill="none" stroke={paper.rail}
            strokeWidth="1.2" strokeDasharray="5 4" {...NS} />
      <path d={d.wall} fill="none" stroke={paper.wall}
            strokeWidth="2.4" strokeLinecap="round" {...NS} />
    </>
  );
});

/* A territory wash is for reading the city at a glance; once you are inside
   a district it is the streets you came to look at, so it thins out
   continuously rather than at one hard step.

   Emphasis is relative, never absolute. An earlier version forced the
   selected district's fill to half opacity, which looked right across the
   whole city and was a disaster once you flew in — the wash came back to
   full strength exactly when you had arrived to look underneath it. Hover
   and selection now multiply the drawn opacity, so both respect the fade
   instead of fighting it. */
const TerritoryLayer = React.memo(function TerritoryLayer({ cells, selected, k, onPick }) {
  const fade = Math.max(0.14, Math.min(1, 1.3 - k * 0.26));
  return (
    <g>
      {cells.map((c) => {
        /* ON THE HEAT PAPER THE COLOUR IS THE MESSAGE, NOT A TINT.

           Territory is a wash: a hint of who owns what, under buildings
           that are the real subject. Heat is the opposite — the band IS
           what the map is for, so it is painted at strength, and a district
           in no band is painted not at all. Drawing the old ramp at the
           territory wash over a near-black ground is what made this paper
           unreadable. */
        /* AND IT STILL FADES WITH THE ZOOM. A floor under this was the first
           attempt and it painted the whole screen gold at street level,
           where the district is no longer the subject — the buildings and
           what has been done to them are. The band belongs to the city
           view; down among the houses it should get out of the way. */
        const op = c.heat !== undefined
          ? BAND_OP[c.heat] * fade
          : (c.state === "open" ? 0.13 : 0.3) * fade;
        /* HOVER IS A STEP ABOVE THE WASH, NOT A MULTIPLE OF IT.

           A multiple was the first attempt and it fails at both ends. The
           wash fades hard as you zoom in — by district level it is around
           0.02, deliberately, because down there the buildings are the
           subject and the territory is only a tint. Multiplying that by two
           is invisible; adding a fixed floor to it, which was the next
           attempt, washes the entire screen in colour the moment the cursor
           rests anywhere. A fixed step scaled by the same fade reads the
           same at every zoom: obvious over the whole city, a whisper when
           you are stood inside one district.

           AND THE DISTRICT YOU HAVE ALREADY CHOSEN DOES NOT RESPOND AT ALL.
           After clicking in, the cursor is inside that shape the entire time
           you are reading its buildings, so a hover state there is a
           permanent tint over the thing you came to look at. The selected
           outline already says which one it is; it does not need to shout it
           every time the mouse moves. */
        /* The hover, on the other hand, keeps a floor. It is a reply to the
           cursor, not a layer of information, and a reply you cannot see is
           not a reply — this is what "I can barely tell which district I am
           hovering" was. */
        const step = Math.max(0.24, 0.22 * fade);
        const hovered = selected === c.code ? op
          /* An unbanded district has nothing to brighten, so its hover is a
             pale wash of its own — otherwise the cursor crosses half the
             city without the map admitting it. */
          : c.heat === 0 ? step
          : Math.min(0.92, op + step);
        return (
        <path key={c.code} data-code={c.code}
              className={"terr" + (selected === c.code ? " sel" : "") + (c.zone ? " is-zone" : "")}
              d={c.d} fill={c.colour} stroke={c.edge || c.colour}
              strokeDasharray={c.zone ? "6 4" : undefined}
              strokeOpacity={c.heat !== undefined ? 0.5 : 0.9}
              strokeWidth="1.6" tabIndex={0} role="button" aria-label={c.name}
              style={{ "--op": op.toFixed(3), "--hop": hovered.toFixed(3) }}
              onClick={(e) => { e.stopPropagation(); onPick(c.code); }}
              onKeyDown={(e) => {
                if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPick(c.code); }
              }}
              {...NS} />
        );
      })}
    </g>
  );
});

/* Properties sit above territory, so they take the click first.

   A property is the thing the whole game is about, so it has to be clickable
   wherever it is drawn — as a footprint when there is room, and as a marker
   when there is not. The invisible hit pad is what makes that true: a
   terrace at low zoom is two units across, which is a target no finger and
   few mice can hit reliably. It is sized for whatever is pointing at it,
   generous on a touch screen and tighter on a desktop where a big pad would
   only steal clicks from its neighbours. */
const K_PROP_CLICK = 2;   /* below this the properties are decoration */

/* A gold ring over one property, sized in screen terms so it reads the same
   from the whole city as it does from one street. */
const SpotlightRing = React.memo(function SpotlightRing({ marks, id, k }) {
  if (!id) return null;
  const m = marks.find((x) => x.id === id);
  if (!m) return null;
  const r = Math.max(1.8, 11 / k);
  return (
    <g className="spot-ring" pointerEvents="none">
      <circle cx={m.x.toFixed(1)} cy={m.y.toFixed(1)} r={(r * 1.9).toFixed(2)}
              fill="none" stroke="#B08C1E" strokeWidth={(0.5 / k).toFixed(3)} opacity=".5" />
      <circle className="spot-pulse" cx={m.x.toFixed(1)} cy={m.y.toFixed(1)} r={r.toFixed(2)}
              fill="none" stroke="#B08C1E" strokeWidth={(1.3 / k).toFixed(3)} />
      <circle cx={m.x.toFixed(1)} cy={m.y.toFixed(1)} r={(r * 0.14).toFixed(2)} fill="#B08C1E" />
    </g>
  );
});

/* EVERY BUILDING YOU CAN OWN STANDS OFF THE MAP (Aaron, 29 Sep 2026: "purchasable
   buildings on the map need to be more obvious, with better contrast and
   sizing, compared to the rest of the map that is not purchasable. This
   includes the owned buildings").

   Three things, none of them a new colour, because ownership is the fill and
   rarity is the outline and neither scheme may gain a hue:

   A DARK CASING. Every tradeable building is drawn twice, first as a wide
   ink stroke and then as itself on top, the way a printed map cases a road.
   An open building is white and so are the main roads; the casing is what
   stops the one reading as the other, and it lifts the owned colours off
   the beige city just as much.

   A MINIMUM SIZE. A real footprint can be a few metres across, which at
   district zoom is a speck beside the warehouses around it. Anything
   smaller on screen than MIN_FOOT is drawn larger than life around its own
   middle, up to two and a half times, so the smallest deed is still a
   thing you can see and tap. The city around it is drawn true.

   BIGGER AND SOLID FROM FURTHER OUT. The city view markers were small and
   faded to 55% on purpose, so the territory wash did the talking; they are
   now a little larger and nearly solid. They still take no clicks out
   there, so the districts keep them. */
const MARK_CASE = "#10201A";
const MIN_FOOT = 7;                       /* screen size, in sheet units at 1x */
const PropertyMarks = React.memo(function PropertyMarks({ marks, k, coarse, compact, halo, footprints, openId, onOpen }) {
  const half = Math.max(0.9, 2.6 / Math.sqrt(k));
  const hit = Math.max(half * (coarse ? 2.4 : 1.6), (coarse ? 7 : 4.2) / k);
  /* AT CITY ZOOM THE PROPERTIES MUST NOT TAKE THE CLICK.

     Each hit pad is about five sheet units across, there are 155 of them,
     and they cluster tightly around the fifteen district centres, which is
     exactly where you aim when you want to click a district. So the pads
     switch off until you are far enough in for a property to be a thing
     rather than a speck. Far out, the ground takes the click and the answer
     is "which district"; close in, the buildings take it and the answer is
     "which deed". */
  const live = k >= K_PROP_CLICK;
  /* THE TAP PAD, AND WHY IT IS DRAWN UNDERNEATH (Aaron, 1 Oct 2026: "clicking
     a building is not always bringing up the correct building").

     Each building used to carry an invisible circle on top of its own
     shape, sized in sheet units with a floor (`half` never goes below 0.9).
     Sheet units grow with the zoom, so at street level that circle was
     forty or more pixels across, sat on the building's map point rather
     than on its footprint, and was drawn after every earlier building. A
     click on one building's roof landed in a neighbour's circle and opened
     the neighbour.

     Now the pad is a fixed size ON SCREEN, centred on the footprint the
     player can see, and every pad is drawn in a layer BELOW every shape.
     A click on a drawn building always opens that building; the pad only
     catches a near miss on open ground, for a building too small to hit. */
  const pad = footprints ? (coarse ? 9 : 5) / k : hit;
  /* THE CASING THINS AS YOU GO IN. Close up the buildings are large and a
     heavy outline turns the street into a cartoon (Aaron, 29 Sep 2026), so
     it runs from 3.2 at district zoom down to 1.4 on the street. */
  const caseW = footprints ? Math.max(1.4, Math.min(3.2, 3.2 - (k - 6) * 0.09)) : 2.8;
  return (
    <g pointerEvents={live ? "auto" : "none"} opacity={live ? 1 : 0.9}>
      <g className="prop-pads">
        {marks.map((m) => (
          <circle key={m.id} cx={(footprints && m.foot ? m.fx : m.x).toFixed(2)} cy={(footprints && m.foot ? m.fy : m.y).toFixed(2)}
                  r={pad.toFixed(3)} fill="transparent" data-prop={m.id}
                  onClick={(e) => { e.stopPropagation(); onOpen(m.id); }} />
        ))}
      </g>
      {marks.map((m) => {
        const r = RARITY_EDGE[m.rarity] || RARITY_EDGE.common;
        const w = Math.max(1, r.w * (k >= 20 ? 1.2 : 1.5));
        const edge = r.edge || MARK_CASE;
        const foot = footprints && m.foot;
        /* Larger than life only when it would otherwise be a speck. */
        /* On a phone the smallest deed is held bigger still. */
        const minFoot = compact ? MIN_FOOT * 1.6 : MIN_FOOT;
        const grow = foot && m.span > 0 ? Math.min(compact ? 3.5 : 2.5, Math.max(1, (minFoot / k) / m.span)) : 1;
        const tf = grow > 1.01
          ? `translate(${m.fx.toFixed(2)} ${m.fy.toFixed(2)}) scale(${grow.toFixed(3)}) translate(${(-m.fx).toFixed(2)} ${(-m.fy).toFixed(2)})`
          : undefined;
        const draw = (props) => foot
          ? <path d={m.foot} strokeLinejoin="round" {...props} {...NS} />
          : <rect x={(m.x - half).toFixed(1)} y={(m.y - half).toFixed(1)}
                  width={(half * 2).toFixed(1)} height={(half * 2).toFixed(1)}
                  rx={(half * 0.35).toFixed(1)} {...props} {...NS} />;
        return (
          <g key={m.id} className={"prop" + (openId === m.id ? " open" : "") + (m.idle ? " is-idle" : "")}
             data-prop={m.id} tabIndex={live ? 0 : -1} role="button" aria-label={m.name}
             onClick={(e) => { e.stopPropagation(); onOpen(m.id); }}
             onKeyDown={(e) => {
               if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(m.id); }
             }}>
            <g transform={tf}>
              {/* On a phone, a pale halo outside the casing cuts each
                  building out of the street around it. */}
              {compact && halo && foot && draw({ className: "prop-halo", fill: "none", stroke: halo, strokeOpacity: 0.9, strokeWidth: caseW + w + 3.2 })}
              {draw({ className: "prop-case", fill: MARK_CASE, stroke: MARK_CASE, strokeWidth: caseW + w })}
              {/* An owned building whose rent has stopped is drawn faded,
                  so it does not read as a busy one (1 Oct 2026). */}
              {draw({ className: "prop-shape", fill: m.fill, fillOpacity: m.idle ? 0.42 : 1, stroke: edge, strokeWidth: w })}
            </g>
            {m.seal && (
              /* The Council's seal: a city task names this building. Sized
                 on screen, like the casing, so it reads at every zoom. */
              <g transform={`translate(${m.x.toFixed(1)} ${m.y.toFixed(1)}) scale(${(1 / Math.max(1, k)).toFixed(4)})`} className="task-seal" pointerEvents="none">
                <circle r="5.2" fill="#F4E7C5" stroke="#6E1423" strokeWidth="1.6" />
                <circle r="2.2" fill="#6E1423" />
              </g>
            )}
          </g>
        );
      })}
    </g>
  );
});

/* Labels, placed greedily and skipped when they would collide.

   Fifteen district names on a 340-unit sheet overlap around the centre,
   where the districts are smallest and most numerous — exactly where the
   names matter most. Sorting by how much room a label has and dropping the
   ones that cannot fit puts a readable subset on the map instead of an
   unreadable complete set; zooming in gives the dropped ones their room
   back, which is the natural place to recover them. */
/* x and y are WHERE THE LABEL IS DRAWN, not where its subject is. The two
   are the same thing for a property and emphatically not for a district:
   the label sits on the centroid of the territory polygon, while the cell
   also carries the site point it was grown from, and an outlying district's
   polygon sprawls to the edge of the sheet miles from its site.

   DistrictLabels used to spread the whole cell into these items, so the
   site x/y came along for the ride and this test culled on them — two
   districts whose sites happen to sit close together (Bishopthorpe and
   Fulford, both south of the city) knocked each other out over an overlap
   that existed at coordinates neither label occupied. Bishopthorpe's name
   was simply missing from the city view, and reappeared on zooming in only
   because the boxes shrink with k. Callers must now pass the drawn
   position as x/y. */
function placeWithoutOverlap(items) {
  const placed = [];
  const out = [];
  for (const it of items) {
    const clash = placed.some((p) =>
      Math.abs(p.x - it.x) * 2 < p.w + it.w && Math.abs(p.y - it.y) * 2 < p.h + it.h);
    if (clash) continue;
    placed.push(it);
    out.push(it);
  }
  return out;
}

/* Same collision test, but a clash moves the label instead of deleting it.
   The offsets are in sheet units scaled by k, so the ring is the same size
   on screen whatever the zoom. */
const NUDGES = [[0, 0], [0, -1], [0, 1], [-1.6, 0], [1.6, 0], [-1.4, -0.9], [1.4, -0.9],
  [-1.4, 0.9], [1.4, 0.9], [0, -2], [0, 2], [-2.6, 0], [2.6, 0], [0, -3], [0, 3]];

function placeWithNudge(items, k) {
  const placed = [];
  const out = [];
  /* `below` is anything drawn under the label, the leader's pill since
     27 Sep 2026, so the collision box covers it too. */
  const clashes = (x, cy, w, ch) => placed.some((p) =>
    Math.abs(p.x - x) * 2 < p.w + w && Math.abs(p.cy - cy) * 2 < p.ch + ch);
  for (const it of items) {
    let put = null;
    const below = it.below || 0;
    for (const [dx, dy] of NUDGES) {
      const x = Math.max(8 + it.w / 2, Math.min(MAP_W - 8 - it.w / 2, it.x + (dx * 11) / k));
      const y = Math.max(8 + it.h / 2, Math.min(MAP_H - 8 - it.h / 2, it.y + (dy * 11) / k));
      if (!clashes(x, y + below / 2, Math.max(it.w, it.bw || 0), it.h + below)) {
        put = { ...it, x, y, cy: y + below / 2, ch: it.h + below, w: it.w }; break;
      }
    }
    if (!put) continue;
    placed.push(put);
    out.push(put);
  }
  return out;
}

const DistrictLabels = React.memo(function DistrictLabels({ cells, k, paper }) {
  const fs = 5.4 / k, h = 9.6 / k;
  /* Nudged back inside the frame. A district whose centroid sits near the
     edge of the sheet had half its name clipped by the border — which looked
     like a rendering bug and, for Osbaldwick, hid the district's name
     entirely on a narrow screen. */
  /* NUDGE, DON'T DROP.

     Fifteen districts, and at the city view their centroids cluster hard
     around the middle of the sheet — so a pure keep-or-drop test threw
     three or four names away every time, and which ones it threw depended
     on polygon area, which favours the outlying districts with nothing near
     them. The city view is exactly where a player needs the names most.

     So a label that clashes is moved before it is abandoned: the centroid
     first, then a ring of offsets around it. Only a label with nowhere at
     all to go is dropped, which at this scale is none of them. */
  const laid = placeWithNudge(cells.map((c) => {
    const w = (c.name.length * 3.6 + 15) / k;
    /* Built field by field rather than spread, so the cell's site point
       cannot ride along and be mistaken for the label's position. */
    return {
      code: c.code, name: c.name, colour: c.colour, w, h, area: c.area,
      /* Named explicitly, for the reason the comment above gives: anything
         not copied here does not exist by the time the label is drawn. The
         holder line silently drew nothing until these three were added. */
      holderName: c.holderName || null,
      /* The leader's pill hangs under the name; see DistrictLabels. */
      below: c.holderName ? h * 0.94 : 0,
      bw: c.holderName ? Math.max(h * 2.6, c.holderName.length * fs * 0.98 * 0.56 + h * 1.6) : 0,
      holderMine: !!c.holderMine,
      holderCrest: c.holderCrest || null,
      holderMagnate: !!c.holderMagnate,
      holderColour: c.holder ? holderColour(c.holder) : c.colour,
      x: Math.max(8 + w / 2, Math.min(MAP_W - 8 - w / 2, c.cx)),
      y: Math.max(8 + h / 2, Math.min(MAP_H - 8 - h / 2, c.cy)),
    };
  }).sort((a, b) => b.area - a.area), k);
  return (
    <g pointerEvents="none">
      {laid.map((c) => (
        <g key={c.code} className="dlabel" data-code={c.code}
           transform={`translate(${c.x.toFixed(1)} ${c.y.toFixed(1)})`}>
          <rect x={-c.w / 2} y={-h / 2} width={c.w} height={h} rx={h / 2}
                fill={paper.labelBg} fillOpacity="0.88" stroke={c.colour}
                strokeWidth="0.9" {...NS} />
          <circle cx={-c.w / 2 + h * 0.55} cy="0" r={h * 0.23} fill={c.colour} />
          {/* textLength rather than a width guess. Estimating uppercase
              Georgia at 3.1 units a character was wrong by half — OSBALDWICK
              wanted 68 units and got a 45-unit pill, so the name ran out
              through the frame border and the collision test was measuring a
              box the label did not occupy. Declaring the width and making
              the text fit it means the pill, the text and the thing the
              collision test compares are all the same rectangle. */}
          <text x={-c.w / 2 + h * 0.95} y={fs * 0.36} fontFamily="Georgia,serif"
                fontSize={fs} fill={paper.label}
                textLength={Math.max(1, c.w - h * 1.45)} lengthAdjust="spacingAndGlyphs">
            {c.name.toUpperCase()}</text>

          {/* WHO HOLDS IT, under the name. The dashed freehold ring already
              says a district IS held; it has never said by whom, so the one
              fact a player most wants off this map — am I looking at my own
              ground or somebody else's — was the one it withheld.

              A crown rather than a colour swatch: the colour is already
              carrying the same information on the district itself, and a
              second dot of it would be repetition where a glyph is a word. */}
          {c.holderName && (() => {
            /* SIZED TO THE NAME, NOT TO THE PILL ABOVE IT. The district name
               is deliberately stretched to fill its pill — declaring the
               width is what stopped OSBALDWICK running out through the frame.
               Applying the same trick to a holder makes "You" three letters
               spread across sixty units, which looks like a rendering fault.
               So this pill takes its width from its own content. */
            /* LARGER SINCE 27 SEP 2026 (Aaron: "make the district leader
               text larger"). Near the size of the district's own name now,
               and sized in map units like it, so it holds at every zoom. */
            const lf = fs * 0.98;
            const cw = c.holderName.length * lf * 0.56 + h * 1.75;
            const hw = Math.max(h * 2.6, cw);
            return (
              <g transform={`translate(0 ${(h * 1.02).toFixed(2)})`}>
                <rect x={-hw / 2} y={-h * 0.42} width={hw} height={h * 0.84}
                      rx={h * 0.42} fill={paper.labelBg} fillOpacity="0.9"
                      stroke={c.holderColour || c.colour} strokeWidth="0.5" {...NS} />
                <g transform={`translate(${(-hw / 2 + h * 0.56).toFixed(2)} 0)`}>
                  {CREST_SHAPES[c.holderCrest] ? (
                    /* Drawn on a 24x24 grid like every other crest in the
                       game, so one definition serves the shop, the trader
                       name and the map. */
                    <g transform={`scale(${(h * 0.034).toFixed(4)}) translate(-12 -12)`}>
                      {CREST_SHAPES[c.holderCrest](c.holderMagnate ? TERR.magnate : TERR.leader)}
                    </g>
                  ) : (
                    <g transform={`scale(${(h * 0.028).toFixed(3)})`}>
                      <path d={CROWN_PATH} fill={c.holderMagnate ? TERR.magnate : TERR.leader} />
                    </g>
                  )}
                </g>
                <text x={(-hw / 2 + h * 1.16).toFixed(2)} y={lf * 0.34} fontFamily="Georgia,serif"
                      fontSize={lf} fill={c.holderMagnate ? TERR.magnate : TERR.leader}
                      fontWeight={c.holderMine ? "700" : "400"}>
                  {c.holderName}</text>
              </g>
            );
          })()}
        </g>
      ))}
    </g>
  );
});

/* Property names, once there is room for them. The most valuable get first
   refusal on the space, which is the right tie-break: at street level you
   are looking for the thing worth buying. */
const PropertyNames = React.memo(function PropertyNames({ marks, k, paper, viewport }) {
  const fs = 3.5 / k;
  const visible = marks.filter((m) =>
    m.x > viewport.x0 && m.x < viewport.x1 && m.y > viewport.y0 && m.y < viewport.y1);
  const laid = placeWithoutOverlap(visible
    .map((m) => {
      const w = (m.stock.length * 1.95 + 2) / k;
      return { id: m.id, name: m.stock, price: m.price, w, h: 4.8 / k,
        x: Math.max(viewport.x0 + w / 2, Math.min(viewport.x1 - w / 2, m.x)),
        y: m.y + 6.8 / k };
    })
    .sort((a, b) => b.price - a.price));
  return (
    <g pointerEvents="none">
      {laid.map((l) => (
        <text key={l.id} x={l.x.toFixed(1)} y={l.y.toFixed(1)} textAnchor="middle"
              fontFamily="'IBM Plex Sans',system-ui,sans-serif" fontSize={fs} fontWeight="600"
              fill={paper.name} stroke={paper.groundTop} strokeWidth={1.1 / k}
              paintOrder="stroke" strokeLinejoin="round">{l.name}</text>
      ))}
    </g>
  );
});

/* A freehold is the whole district, so it is marked on the district itself:
   a second line just inside the border, the way a deed plan hatches the
   parcel it covers. Nothing else on the map uses a dashed inner edge. */
const FreeholdRings = React.memo(function FreeholdRings({ rings }) {
  if (!rings.length) return null;
  return (
    <g pointerEvents="none">
      {rings.map((r) => (
        <path key={r.code} d={r.d} fill="none" stroke={r.colour} strokeWidth="2.6"
              strokeDasharray="6 3" strokeOpacity="0.9" {...NS} />
      ))}
    </g>
  );
});

/* ---- the map itself ---------------------------------------------------- */
function CityMap({ model, paper, mv, cells, marks, rings, selected, openId, onPickDistrict, onOpenProperty }) {
  const k = mv.view.k;
  const vp = mv.viewport;
  const coarse = useMemo(() =>
    typeof window !== "undefined" && window.matchMedia
      ? window.matchMedia("(pointer: coarse)").matches : false, []);
  /* A phone, by touch or by width: the map draws the tradeable buildings
     louder and the rest of the city quieter there. */
  const [narrow, setNarrow] = useState(() =>
    typeof window !== "undefined" && window.matchMedia ? window.matchMedia("(max-width: 700px)").matches : false);
  useEffect(() => {
    if (typeof window === "undefined" || !window.matchMedia) return;
    const mq = window.matchMedia("(max-width: 700px)");
    const on = () => setNarrow(mq.matches);
    mq.addEventListener ? mq.addEventListener("change", on) : mq.addListener(on);
    return () => { mq.removeEventListener ? mq.removeEventListener("change", on) : mq.removeListener(on); };
  }, []);
  const compact = coarse || narrow;

  /* Quantised so that a slow zoom does not rebuild 155 markers on every
     frame for a change nobody can see. Panning does not touch it at all. */
  const kq = Math.round(k * 8) / 8;

  /* What is on screen, as a tile RANGE. The memo below holds while you pan
     within the same tiles and only rebuilds when a new column or row comes
     into view — which is why a drag costs one attribute write rather than a
     re-join of the city. */
  const c0 = Math.max(0, Math.floor(vp.x0 / TILE_W));
  const c1 = Math.min(TILE_COLS - 1, Math.floor(vp.x1 / TILE_W));
  const r0 = Math.max(0, Math.floor(vp.y0 / TILE_H));
  const r1 = Math.min(TILE_ROWS - 1, Math.floor(vp.y1 / TILE_H));
  const showBuild = k >= K_BUILD;
  const showMinor = k >= K_MINOR;
  const tileKey = `${c0},${c1},${r0},${r1}|${showBuild}|${showMinor}`;
  const drawn = useMemo(() => ({
    green: cullLayer(model.layers.green, c0, c1, r0, r1),
    water: cullLayer(model.layers.water, c0, c1, r0, r1),
    major: cullLayer(model.layers.major, c0, c1, r0, r1),
    minor: showMinor ? cullLayer(model.layers.minor, c0, c1, r0, r1) : "",
    rail: cullLayer(model.layers.rail, c0, c1, r0, r1),
    wall: cullLayer(model.layers.wall, c0, c1, r0, r1),
    buildings: showBuild ? cullLayer(model.buildings, c0, c1, r0, r1) : "",
  }),
  /* eslint-disable-next-line react-hooks/exhaustive-deps */
  [model, tileKey]);

  const mPerUnit = model.P.metresPerUnit / k;
  const across = Math.round(MAP_W * mPerUnit);

  return (
    <svg viewBox={`0 0 ${MAP_W} ${MAP_H}`} ref={mv.svgRef} {...mv.handlers}
         className="city-map" role="img" aria-label="Map of York"
         data-scale={`${across.toLocaleString("en-GB")} m across · ${k.toFixed(1)}×`}>
      <defs>
        <linearGradient id={`ground-${paper.id}`} x1="0" y1="0" x2="0.3" y2="1">
          <stop offset="0%" stopColor={paper.groundTop} />
          <stop offset="100%" stopColor={paper.groundBottom} />
        </linearGradient>
        <clipPath id="map-frame">
          <rect x="6" y="6" width={MAP_W - 12} height={MAP_H - 12} rx="3" />
        </clipPath>
        {/* Contested ground is striped rather than painted. It is a state,
            not an owner — nobody holds it — and a stripe says that where a
            fifth colour would only have added another thing to learn. */}
        <pattern id="terr-contested" width="8" height="8" patternUnits="userSpaceOnUse"
                 patternTransform="rotate(45)">
          <rect width="8" height="8" fill="#C9C2AC" />
          <rect width="4" height="8" fill="#3F5A52" />
        </pattern>
      </defs>
      <rect width={MAP_W} height={MAP_H} fill={paper.frame} />
      <g clipPath="url(#map-frame)">
        <g transform={mv.transform}>
          <CityGround d={drawn} paper={paper} />
          {showBuild && <CityBuildings d={drawn.buildings} paper={paper} compact={compact} />}
          <CityRoads d={drawn} paper={paper} showMinor={showMinor} wide={k >= 14} />
          <TerritoryLayer cells={cells} selected={selected} k={kq} onPick={onPickDistrict} />
          <FreeholdRings rings={rings} />
          <PropertyMarks marks={marks} k={kq} coarse={coarse} compact={compact}
                         halo={paper.byMove ? null : paper.major}
                         footprints={k >= K_FOOT} openId={openId} onOpen={onOpenProperty} />
          {k <= K_LABEL && <DistrictLabels cells={cells} k={kq} paper={paper} />}
          {k >= K_NAME && <PropertyNames marks={marks} k={kq} paper={paper} viewport={vp} />}
          {/* THE BEACON.

              openId used to be styling only, and .prop.open had no rule
              behind it — so a map opened from a card arrived over the right
              roof and gave no sign which of the thirty roofs it was. The
              ring is drawn last, outside the marks group (which fades back
              at city zoom), so it is visible from the very first frame:
              during the fly-in it is the thing you are flying at. */}
          <SpotlightRing marks={marks} id={openId} k={kq} />
        </g>
      </g>
      <rect x="6" y="6" width={MAP_W - 12} height={MAP_H - 12} rx="3" fill="none"
            stroke={paper.edge} strokeWidth="1.2" />
    </svg>
  );
}

/* One pass over the properties gives every district its value, its movement
   and how much attention it is getting. The map and the field report both
   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;
    let bought24 = 0, attacked24 = 0;
    const since = Date.now() - 24 * 3600 * 1000;
    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;
      if (p.acquiredAt && p.acquiredAt > since) bought24 += 1;
      if (p.lastTacticAt && p.lastTacticAt > since) attacked24 += 1;
      const ch = hourChange(p);
      if (Number.isFinite(ch)) { moveSum += ch; moveCount += 1; }
    }
    return { ...n, value, interest, owned, count: props.length, bought24, attacked24,
             move: moveCount ? moveSum / moveCount : 0 };
  });
}

const polyArea = (poly) => {
  let a = 0;
  for (let i = 0; i < poly.length; i++) {
    const p = poly[i], q = poly[(i + 1) % poly.length];
    a += p[0] * q[1] - q[0] * p[1];
  }
  return Math.abs(a / 2);
};

/* Market Heat colours by where the money went rather than by who holds the
   ground — the one thing that view is for. */
/* MARKET HEAT IS ACTIVITY, NOT DRIFT.

   It used to colour a district by the average hourly price change of the
   properties in it, against thresholds that tripped at a quarter of one per
   cent. In a market that drifts upward at all — which this one does, by
   design — that is green almost everywhere almost all the time, and a map
   that is always the same colour is telling you nothing.

   What a trader actually wants to know at a glance is where things are
   HAPPENING: what changed hands last night, and where the fighting is. Both
   are already on the property record — acquiredAt and lastTacticAt — so this
   needs no new server data at all.

   One hue, dark to hot, because this is a quantity rather than a set of
   categories: a sequential ramp says "more" where a rainbow would only say
   "different". Attacks are NOT on the ramp — they are marked separately,
   because "six sales" and "six raids" are different news and averaging them
   into one colour would hide exactly the thing worth seeing. */
/* The mark beside a district leader's name is THEIR OWN HOUSE CREST, the one
   they chose on the Personalise page — not a generic crown. A crown says
   "somebody holds this"; a crest says who, and it is the same mark they wear
   everywhere else in the game, so it is recognised rather than read.

   The crown stays as the fallback for a leader who has not chosen one. */
const CROWN_PATH = "M-16 7 L-16 -7 L-9 0 L-4.5 -11 L0 -2 L4.5 -11 L9 0 L16 -7 L16 7 Z";

/* TERTILES, NOT AN ABSOLUTE RAMP, AND NOTHING FOR NOTHING.

   The first cut coloured a district by its raw count against fixed steps,
   which failed in both directions: on a quiet day the whole city sat on
   step one and the map said nothing, and the six steps were close enough
   together that the key was unreadable anyway. Worse, "no activity at all"
   had a colour of its own, so a map of a sleeping city was still a map
   covered in paint.

   Three bands, ranked against the rest of today: busiest third, middle
   third, quietest third. A district nothing has happened in takes NO FILL,
   so the ground shows through and the eye goes straight to what moved.
   Relative bands also mean the map always says something — there is always
   a busiest third — without ever implying a quiet day was a busy one,
   because the field report still carries the actual number.

   THE INKS ARE PIGMENTS, NOT DATA-VIZ DEFAULTS. The first attempt ran blue
   to gold to a pale cream, which separated beautifully and looked awful —
   a cream wash over half the city, in a game that is otherwise printed in
   greens, brass and oxblood. These are the colours a Victorian printer had
   in the drawer: slate for the quiet end, antique brass for the middle,
   gold for where the money is. Nothing pale, because a pale band covering
   a large district is the thing that made the map unsightly.

   Money runs to gold; attacks run to vermilion. Two different questions
   should not arrive in the same colours even though they never share a
   screen, or a player learns the wrong lesson at one zoom and carries it
   to the other.

   WEIGHT FOLLOWS THE DATA. The three bands are not painted at the same
   strength — a third, a half, three-quarters. Painting them equally put
   the loudest colour on the map over the districts where the LEAST was
   happening, which is the opposite of what a heat map is for. Now the eye
   is pulled to the busy end and the quiet third is a whisper, one step up
   from the districts where nothing happened at all.

   Nothing is ever painted at full strength: the city underneath still has
   to be legible, or the heat map stops being a map. Measured composited
   over the ground: adjacent bands ΔE 33.2 and 26.0 for money, 29.8 and
   31.6 for attacks, with the faintest band ΔE 15.4 clear of the paper —
   quiet, but not silent. */
const HEAT_BANDS = [null, "#7E97A8", "#A06E20", "#C9922B"];
const RAID_BANDS = [null, "#7E97A8", "#8E3E22", "#BE3528"];
const BAND_OP = [0, 0.32, 0.54, 0.76];
/* What the legend paints. A key swatch is a flat colour and the map is a
   wash over a dark ground, so the swatch has to be the COMPOSITE or the
   key shows colours the map never draws. These are measured, not guessed. */
const HEAT_KEY = ["#22302A", "#3F5152", "#665125", "#A17A2B"];
/* The buildings are painted at full strength — they are small, and a wash
   on a house is invisible — so THEIR key swatches are the raw inks, not the
   composite the districts use. A key that does not match what is on the
   page is worse than no key. */
const RAID_KEY = ["#22302A", "#7E97A8", "#8E3E22", "#BE3528"];
/* Untouched has no colour, so hovering it needs one of its own — a plain
   pale wash that says "this shape is under the cursor" and nothing else. */
const BAND_NONE = "#DDE7DC";

/* Rank into thirds. Ties always share a band, because two districts with
   three sales each cannot honestly be in different thirds.

   With fewer than three distinct values there are no thirds to speak of,
   so: one distinct value puts everything in the middle band (they are all
   equally busy, and calling that "the busiest" would be a lie), two
   distinct values take the outer bands. */
function tertileBands(entries) {
  const out = new Map();
  const live = entries.filter((e) => e.n > 0);
  if (!live.length) return out;
  const uniq = [...new Set(live.map((e) => e.n))].sort((a, b) => b - a);
  if (uniq.length === 1) { for (const e of live) out.set(e.key, 2); return out; }
  if (uniq.length === 2) {
    for (const e of live) out.set(e.key, e.n === uniq[0] ? 3 : 1);
    return out;
  }
  const sorted = [...live].sort((a, b) => b.n - a.n);
  const cut1 = sorted[Math.ceil(sorted.length / 3) - 1].n;
  const cut2 = sorted[Math.ceil((2 * sorted.length) / 3) - 1].n;
  for (const e of live) out.set(e.key, e.n >= cut1 ? 3 : e.n >= cut2 ? 2 : 1);
  return out;
}

/* ONE QUESTION PER ZOOM LEVEL.

   Pulled back, the heat map answers "where is the money moving" — districts
   by what changed hands in the last day. Zoomed in, it answers a different
   question that only makes sense at that scale: "where is the fighting" —
   individual buildings by how recently a tactic card landed on them.

   Two questions on one screen would have meant averaging sales and raids
   into a single colour, which hides the half worth seeing. Splitting them by
   zoom gives each a whole map, and matches what a player is doing at each
   scale: choosing a district, then choosing a building.

   Recency rather than a count, because lastTacticAt is what the property
   record actually holds — an honest "struck an hour ago" beats a total this
   client would have to invent. It is banded the same way the districts are:
   of the buildings hit in the last day, the most recent third, the middle
   third and the rest. Everything else is untouched and takes no fill —
   which on a normal day is most of the street, and should be. */
const raidBand = (at, now) => {
  if (!at) return 0;
  const hours = (now - at) / 3600000;
  return hours > 24 ? 0 : 24 - hours;     /* bigger = more recent */
};

/* THE WAY BACK IS A TRAIL, NOT A BUTTON.

   FIT did this job and nobody clicked it: a two-letter chip in a stack of
   identical chips announces nothing, and a control you have to be told about
   has failed. A breadcrumb answers "where am I" and "how do I get back" in
   one object, in the place every map and file browser has trained people to
   look — and the Back button inside it is gold and labelled, because the
   control it replaces went unclicked for exactly the opposite reason. */
function MapCrumbs({ here, onCity, onBack, canGoBack, scale }) {
  return (
    <nav className="map-crumbs" aria-label="Where you are">
      <button className="crumb-back" onClick={onBack} disabled={!canGoBack}
              aria-label="Back to the previous view">
        <span aria-hidden="true">&#8592;</span><span>Back</span>
      </button>
      <ol>
        {here
          ? <>
              <li><button className="crumb" onClick={onCity}>York</button></li>
              <li><span className="here">{here}</span></li>
            </>
          : <li><span className="here">York</span></li>}
      </ol>
      <span className="map-scale">{scale}</span>
    </nav>
  );
}

/* Offered only where the browser will honour it — iOS Safari has never
   supported the Fullscreen API on a plain element, and a button that does
   nothing is worse than no button. Read once, at module load. */
const FULLSCREEN_OK = typeof document !== "undefined"
  && !!(document.fullscreenEnabled || document.webkitFullscreenEnabled);

/* HOW FULL YORK IS (Aaron, 1 Oct 2026). The share of the city's
   buildings in traders' hands, against the milestones at which the Council
   opens a new district. A bar, not a dial: the ticks are where the city
   grows, and the gap to the next one is the thing worth reading. */
function CityGauge({ city, now }) {
  if (!city || !city.total) return null;
  const ms = city.milestones || [];
  const next = ms.find((m) => !m.reached);
  const waiting = ms.find((m) => m.reached && !m.opened);
  return (
    <section className="city-gauge card" aria-label="How much of York is owned">
      <div className="city-gauge-head">
        <span className="city-gauge-l">York occupied</span>
        <b className="city-gauge-v">{Math.round(city.pct)}%</b>
      </div>
      <div className="city-gauge-bar" role="img"
           aria-label={`${city.owned} of ${city.total} buildings owned${next ? `; ${next.name} opens at ${next.pct}%` : ""}`}>
        <i style={{ width: `${Math.min(100, city.pct)}%` }} />
        {ms.map((m) => (
          <span key={m.pct} className={"city-gauge-tick" + (m.reached ? " is-reached" : "")}
                style={{ left: `${m.pct}%` }} title={`${m.name} at ${m.pct}%`} />
        ))}
      </div>
      <p className="city-gauge-note">
        {waiting
          ? <><b>{waiting.name}</b> opens at this evening's turnover.</>
          : next
            ? <><b>{next.name}</b> opens when {next.pct}% of York is owned: {Math.max(1, Math.ceil((next.pct / 100) * city.total) - city.owned)} more buildings.</>
            : <>Every new district the Council planned is open.</>}
      </p>
    </section>
  );
}

function MapTab({ game, me, onOpen, onEquip, onAct, flash, spotlight = null }) {
  const fullRef = useRef(null);
  const [full, setFull] = useState(false);
  /* The browser owns this state, not us. Escape, F11 and the system chrome
     can all leave full screen without going near our button, so the flag is
     read back off the document rather than toggled optimistically. */
  useEffect(() => {
    const sync = () => setFull(!!(document.fullscreenElement || document.webkitFullscreenElement));
    document.addEventListener("fullscreenchange", sync);
    document.addEventListener("webkitfullscreenchange", sync);
    return () => {
      document.removeEventListener("fullscreenchange", sync);
      document.removeEventListener("webkitfullscreenchange", sync);
    };
  }, []);
  const toggleFull = useCallback(() => {
    const el = fullRef.current;
    if (!el) return;
    const active = document.fullscreenElement || document.webkitFullscreenElement;
    const go = active
      ? (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)
      : (el.requestFullscreen || el.webkitRequestFullscreen || (() => {})).call(el);
    /* requestFullscreen rejects if the gesture is not trusted, which is not
       worth an error in the console — the button simply does nothing and
       the map is exactly where it was. */
    if (go && typeof go.catch === "function") go.catch(() => {});
  }, []);
  const [selected, setSelected] = useState(null);
  const [showAll, setShowAll] = useState(false);
  const [keysOpen, setKeysOpen] = useState(
    typeof window === "undefined" || window.innerWidth > 760);
  const mv = useMapView();
  const { geo, error } = useCityGeo(true);
  const analyticsLevel = me.analyticsLevel || 0;

  const styles = (me.shop && me.shop.maps) || [{ id: "plan", name: "Surveyor's Plan", available: true }];
  const chosen = (me.cosmetics && me.cosmetics.map) || "plan";
  const styleId = styles.find((s) => s.id === chosen && s.available) ? chosen : "plan";
  const paper = MAP_PAPERS[styleId] || MAP_PAPERS.plan;

  const model = useMemo(() => (geo ? buildCityModel(geo) : null),
    [geo, (game.built || []).length, ((game.city && game.city.zones) || []).length]);
  const stats = useMemo(() => districtStats(game), [game.properties]);
  const palette = useMemo(() => rivalPalette(me.rivalries), [me.rivalries]);
  /* The bands are worked out across the WHOLE city at once — a third is a
     third of something, so no district can decide its own band. */
  const heatBandOf = useMemo(
    () => tertileBands(stats.map((st) => ({ key: st.code, n: st.bought24 }))),
    [stats]);

  useEffect(() => { setShowAll(false); }, [selected]);

  /* Ownership, once, for the whole map. */
  const cells = useMemo(() => {
    if (!model) return [];
    const statOf = new Map(stats.map((s) => [s.code, s]));
    /* The freeholds table, keyed by district — the game's own record of who
       leads what, rather than a second opinion derived from the deeds. */
    const leadBy = new Map((game.freeholds || []).map((f) => [f.neighbourhood, f]));
    return model.cells.map((c) => {
      const list = model.byDistrict.get(c.code) || [];
      const counts = {};
      let mine = 0, openN = 0;
      for (const p of list) {
        const rec = game.properties[p.id];
        if (!rec || !rec.owner) { openN += 1; continue; }
        counts[rec.owner] = (counts[rec.owner] || 0) + 1;
        if (rec.owner === me.id) mine += 1;
      }
      const holder = holderOf(counts, list.length, me.id, palette, leadBy.get(c.code));
      const st = statOf.get(c.code);
      /* A district with no sales sits in the quietest band (Aaron, 27 Sep
         2026: "ok to have no transactions sitting in the quietest third"). */
      const band = paper.byMove ? (heatBandOf.get(c.code) || 1) : undefined;
      return {
        ...c,
        area: polyArea(c.poly),
        state: holder.state,
        holder,
        heat: band,
        colour: paper.byMove ? (HEAT_BANDS[band] || BAND_NONE) : holderColour(holder),
        /* THE OUTLINE IS NOT THE FILL. A district in no band is filled with
           nothing and outlined in the pale hover colour, which drew bright
           white borders round half the city — the loudest thing on a map
           whose whole point was that quiet districts stay quiet. */
        edge: paper.byMove ? (HEAT_BANDS[band] || "#46564D") : undefined,
        raided: paper.byMove ? (st ? st.attacked24 : 0) : 0,
        /* Who holds the district, by name, so the label can say it. A
           freehold is already drawn as a dashed inner ring — that says a
           district IS held without ever saying by whom. */
        holderName: holder.holder
          ? (holder.holder === me.id ? "You" : ((game.players[holder.holder] || {}).name || "A trader"))
            + ((leadBy.get(c.code) || {}).magnate ? " (magnate)" : "")
          : null,
        holderMagnate: !!(leadBy.get(c.code) || {}).magnate,
        holderMine: holder.holder === me.id,
        holderCrest: holder.holder ? (game.players[holder.holder] || {}).crest || null : null,
        mine, open: openN, total: list.length, stat: st,
        /* The district leader, for the field report (27 Sep 2026). */
        leaderId: (leadBy.get(c.code) || {}).user_id || null,
        leaderCount: (leadBy.get(c.code) || {}).count || 0,
      };
    });
  }, [model, game.properties, game.freeholds, me.id, palette, paper, stats, heatBandOf]);

  const nowMs = Date.now();
  /* Same idea one scale down: of everything struck in the last day, which
     third was struck most recently. Buildings nobody has touched are not in
     the reckoning at all — they are not the quietest third, they are not on
     the scale. */
  const raidBandOf = useMemo(() => {
    if (!model || !paper.byMove) return new Map();
    return tertileBands(model.props.map((p) => {
      const rec = game.properties[p.id];
      return { key: p.id, n: raidBand(rec ? rec.lastTacticAt : 0, nowMs) };
    }));
  }, [model, game.properties, paper, Math.floor(nowMs / 300000)]);

  const sealKey = ((me.cityTasks && me.cityTasks.board) || []).map((t) => t.property || "").join(",")
    + "|" + ((me.cityTasks && me.cityTasks.mine && me.cityTasks.mine.property) || "");
  const sealIds = useMemo(() => new Set(sealKey.split(/[,|]/).filter(Boolean)), [sealKey]);
  const marks = useMemo(() => {
    if (!model) return [];
    return model.props.map((p) => {
      const rec = game.properties[p.id];
      const owner = rec && rec.owner;
      const known = owner ? palette.get(owner) : null;
      /* On the heat paper a building is coloured by how recently it was hit,
         not by who owns it — ownership is what the surveyor's plan is for. */
      const band = paper.byMove ? (raidBandOf.get(p.id) || 0) : 0;
      /* An untouched building is not the bottom of a scale it is not on,
         but it is not one of the city's other buildings either. It was
         drawn in this paper's ordinary building fill, and on the dark
         ground a tradeable building then looked exactly like the ones
         nobody can buy (Aaron, 30 Sep 2026). Pale sage now: lighter than
         anything else on this paper and on none of its scales. */
      const fill = paper.byMove
        ? (RAID_BANDS[band] || paper.trade || paper.build)
        : !owner ? TERR.open
        : owner === me.id ? TERR.mine
        : known ? known.colour : TERR.other;
      return {
        id: p.id, x: p.x, y: p.y, foot: p.foot, fx: p.fx, fy: p.fy, span: p.span,
        fill, heat: paper.byMove ? band : undefined,
        rarity: p.meta.rarity, name: p.meta.name, stock: p.meta.stock,
        price: rec ? rec.currentPrice : p.meta.basePrice, open: !owner,
        idle: !!owner && !paper.byMove && (rec.earningUntil || 0) < Date.now()
          && Date.now() - (rec.acquiredAt || 0) > GAME_HOUR_MS,
        /* A city task names this building (lib/tasks.js): the Council's
           seal, shown only to traders who can take city tasks. */
        seal: sealIds.has(p.id),
      };
    });
  }, [model, game.properties, me.id, palette, paper, raidBandOf, sealIds]);

  const rings = useMemo(() => {
    if (!model) return [];
    const byCode = new Map(model.cells.map((c) => [c.code, c]));
    return (game.freeholds || []).map((f) => {
      const c = byCode.get(f.neighbourhood);
      if (!c) return null;
      return { code: f.neighbourhood, d: c.d, colour: f.user_id === me.id ? TERR.mine : "#8C6A18" };
    }).filter(Boolean);
  }, [model, game.freeholds, me.id]);

  const cell = selected ? cells.find((c) => c.code === selected) : null;

  const pickDistrict = useCallback((code) => {
    mv.push({ selected });
    setSelected(code);
    const list = model ? model.byDistrict.get(code) || [] : [];
    /* Fit the district's PROPERTIES, not its territory polygon. An outlying
       district's cell runs to the edge of the sheet, so fitting the polygon
       barely zoomed at all and landed you looking at open country. The
       properties are what you clicked to see. */
    const src = list.length ? list : [];
    if (!src.length) return;
    const xs = src.map((p) => p.x), ys = src.map((p) => p.y);
    mv.flyToBox(Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys));
  }, [mv, model, selected]);

  /* THE SPOTLIGHT.

     Opened from a property card, the map has one job: say where in York
     this thing is. So it deliberately does NOT cut straight to the
     building — it draws the whole city first, waits a beat, and then flies
     down, because the journey is the answer. Arriving pre-zoomed would show
     a roof and tell nobody anything. */
  const flownTo = useRef(null);
  /* useMapView() hands back a FRESH object every render, so depending on
     `mv` here meant this effect re-ran on every one — and since the map
     re-renders sixty times a second while it glides, the cleanup cancelled
     the pending flight before it could fire while the guard below refused
     to schedule another. The board opened on the city and simply stayed
     there. flyToBox itself is a stable useCallback; that is what to depend
     on. */
  const flyToBox = mv.flyToBox;
  useEffect(() => {
    if (!spotlight || !model || flownTo.current === spotlight) return undefined;
    const p = model.props.find((x) => x.id === spotlight);
    if (!p) return undefined;
    flownTo.current = spotlight;
    setSelected(p.district || null);
    const id = setTimeout(() => {
      /* A box, not a point: flyToBox pads what it is given, and a zero-width
         box would pin the scale to the maximum and land on a single roof
         with no street around it. Eight units is about a block. */
      /* Slow on purpose. The default 420ms is right for a click you made on
         the map itself, where you already know where you are; here the
         descent from the whole city IS the answer, and at four-tenths of a
         second it was over before it had said anything. */
      flyToBox(p.x - 8, p.y - 8, p.x + 8, p.y + 8, MAP_MAX_K, 1400);
    }, 620);
    return () => clearTimeout(id);
  }, [spotlight, model, flyToBox]);

  const goCity = useCallback(() => {
    mv.push({ selected });
    setSelected(null);
    mv.toCity();
  }, [mv, selected]);

  const goBack = useCallback(() => {
    const prev = mv.back();
    if (prev && "selected" in prev) setSelected(prev.selected);
  }, [mv]);

  const k = mv.view.k;
  const scale = model
    ? `${Math.round(MAP_W * (model.P.metresPerUnit / k)).toLocaleString("en-GB")} m · ${k.toFixed(1)}×`
    : "";

  const intro = {
    plan: "York as it actually is to this day in a 1:1 ratio: every road, the Ouse, the bar walls and twenty one thousand buildings, with the fifteen trading districts washed over the top. Click a district to fly in; keep going and the properties become the buildings they stand on.",
    heat: "The same city after hours: districts lit by where the money moved in the last 24 hours rather than by who holds them. Free to everyone. The Full terminal subscription ranks the movers underneath.",
    charter: "York as a scribe would have drawn it: the same streets, the same deeds, the same clicks, in ink and gold leaf.",
  }[styleId];

  const listed = cell && model ? (model.byDistrict.get(cell.code) || [])
    .map((p) => ({ p, rec: game.properties[p.id] }))
    .sort((a, b) => (b.rec ? b.rec.currentPrice : 0) - (a.rec ? a.rec.currentPrice : 0)) : [];

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

      <div className="map-switch">
        {styles.map((s) => (
          <button key={s.id}
            className={"map-switch-btn" + (styleId === 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" ref={fullRef}
             style={{ background: paper.frame, borderColor: paper.edge }}>
          {model ? (
            <>
              <MapCrumbs here={cell ? cell.name : null} onCity={goCity} onBack={goBack}
                         canGoBack={mv.canGoBack} scale={scale} />
              <CityMap model={model} paper={paper} mv={mv} cells={cells} marks={marks}
                       rings={rings} selected={selected} openId={spotlight}
                       onPickDistrict={pickDistrict} onOpenProperty={onOpen} />
              <div className="map-tools">
                <button onClick={mv.zoomIn} aria-label="Zoom in" title="Zoom in">+</button>
                <button onClick={mv.zoomOut} aria-label="Zoom out" title="Zoom out">&minus;</button>
                {/* FULL SCREEN, VIA THE BROWSER, NOT A CSS IMPOSTOR.

                    A div stretched to 100vw/100vh looks like full screen and
                    is not: the topbar, the tab strip and the browser chrome
                    all stay, which on a laptop is most of the height the map
                    was asking for. The Fullscreen API takes the actual
                    screen. Safari on iOS has never supported it for a plain
                    element, so the button is only offered where it works —
                    a control that does nothing is worse than no control. */}
                {FULLSCREEN_OK && (
                  <button onClick={toggleFull}
                          aria-label={full ? "Leave full screen" : "Full screen"}
                          title={full ? "Leave full screen" : "Full screen"}>
                    {full ? "\u2921" : "\u2922"}
                  </button>
                )}
              </div>
              <button className="map-keys-toggle" onClick={() => setKeysOpen(!keysOpen)}
                      aria-expanded={keysOpen}>{keysOpen ? "Hide key" : "Key"}</button>
              {keysOpen && (
                <div className="map-key" aria-label="Map key">
                  {paper.byMove ? (
                    /* THE KEY HAS TO MATCH THE PAPER, AND THE ZOOM. It was
                       hard-coded to territory, so Market Heat — which has
                       never coloured a district by who owns it — came with a
                       legend for a different map entirely. It now says
                       whichever of the two questions the map is currently
                       answering. */
                    k < K_FOOT ? (
                      <>
                        <b>CHANGED HANDS · 24H</b>
                        <span><i className="key-mark" style={{ background: HEAT_KEY[3] }} />Busiest third</span>
                        <span><i className="key-mark" style={{ background: HEAT_KEY[2] }} />Middle third</span>
                        <span><i className="key-mark" style={{ background: HEAT_KEY[1] }} />Quietest third</span>
                        <span className="key-note">A district with no sales counts as quietest</span>
                        <span className="key-note">Zoom in for where the fighting is</span>
                      </>
                    ) : (
                      <>
                        <b>STRUCK · 24H</b>
                        <span><i className="key-mark" style={{ background: RAID_KEY[3] }} />Most recent third</span>
                        <span><i className="key-mark" style={{ background: RAID_KEY[2] }} />Middle third</span>
                        <span><i className="key-mark" style={{ background: RAID_KEY[1] }} />Longest ago</span>
                        <span><i className="key-mark key-none" />Untouched</span>
                        <span className="key-note">Pull back for what changed hands</span>
                      </>
                    )
                  ) : k < K_FOOT ? (
                    <>
                      <b>TERRITORY</b>
                      <span><i className="key-mark" style={{ background: TERR.mine }} />Yours</span>
                      <span><i className="key-mark" style={{ background: TERR.rivals[0] }} />Rival</span>
                      <span><i className="key-mark" style={{ background: TERR.other }} />Another trader</span>
                      <span><i className="key-mark" style={{ background: TERR.contestedCss }} />Contested</span>
                      {/* Aaron, 27 Sep 2026: the leader's colour belongs in
                          the key, and "Open" did not need to be there. */}
                      <span><i className="key-mark" style={{ background: TERR.leader }} />District leader</span>
                      <span><i className="key-mark" style={{ background: TERR.magnate }} />Magnate: owns all of it</span>
                    </>
                  ) : (
                    <>
                      {/* WHO OWNS IT, BY FILL (Aaron, 1 Oct 2026: "the colours
                          should be shown on the key"). Down among the
                          buildings the fill is ownership and the outline is
                          rarity, and the key said only the second. */}
                      <b>OWNER · FILL</b>
                      <span><i className="key-mark sq" style={{ background: TERR.mine }} />Yours</span>
                      {[...palette.values()].map((r) => (
                        <span key={r.colour}><i className="key-mark sq" style={{ background: r.colour }} />{r.name}, your rival</span>
                      ))}
                      <span><i className="key-mark sq" style={{ background: TERR.other }} />Another trader</span>
                      <span><i className="key-mark sq" style={{ background: TERR.open }} />For sale</span>
                      <span><i className="key-mark sq" style={{ background: "#989F8F" }} />Faded: owned, rent stopped</span>
                      <b>RARITY · OUTLINE</b>
                      {Object.entries(RARITY_EDGE).map(([key, r]) => (
                        <span key={key}>
                          <i className="key-mark outline"
                             style={{ borderColor: r.edge || "#6E7268", borderWidth: Math.max(1, r.w) }} />
                          {(RARITIES[key] && RARITIES[key].label) || key}
                        </span>
                      ))}
                    </>
                  )}
                </div>
              )}
            </>
          ) : error ? (
            <div className="map-loading">
              <b>The city map did not load.</b>
              <span className="muted">{error}. Everything else still works; try the tab again in a moment.</span>
            </div>
          ) : (
            <div className="map-loading"><b>Surveying York…</b>
              <span className="muted">Roads, river, walls and twenty one thousand buildings.</span></div>
          )}
        </div>

        <div className="map-side">
          <CityGauge city={game.city} now={Date.now()} />
          {cell ? (
            <div className="card">
              <h3>{cell.name}</h3>
              <div className="meta"><RegionTag region={cell.region} /> · {cell.total} properties
                {(() => {
                  const d = game.city && (game.city.districts || []).find((x) => x.code === cell.code);
                  return d ? <> · {Math.round(d.pct)}% occupied</> : null;
                })()}</div>
              {(() => {
                const z = game.city && (game.city.zones || []).find((x) => x.code === cell.code);
                if (!z) return null;
                return (
                  <p className="zone-note">
                    Opened by the Council on {new Date(z.openedAt).toLocaleDateString("en-GB", { timeZone: "Europe/London", day: "numeric", month: "long" })} as York filled.
                    {z.refusalUntil > Date.now()
                      ? <> Until {ukWhen(z.refusalUntil)}, only traders below the median net worth may buy here.</>
                      : null}
                  </p>
                );
              })()}
              {/* WHO LEADS IT, BY NAME, LINKED TO THEIR PROFILE. Aaron, 27 Sep
                  2026: flying into a district, the field report should say
                  who its district leader is, and say it once. With a leader
                  that is the only line; without one, the old note says how
                  the district stands (a majority, contested, or open). */}
              {cell.leaderId && game.players[cell.leaderId] ? (
                <div className="field-leader">
                  <span className="field-leader-l">District leader</span>
                  <span><TraderName player={game.players[cell.leaderId]} size={14} />
                    {cell.leaderId === me.id && <span className="muted"> (you)</span>}
                    <span className="muted"> · {cell.leaderCount} of {cell.total}</span></span>
                </div>
              ) : (
                <div className="holder-note">
                  <i style={{ background: swatchFor(cell.holder) }} />{holderLabel(cell.holder)}
                </div>
              )}
              <div className="stat-row">
                <div className="stat"><span className="l">Yours</span><span className="v">{cell.mine}</span></div>
                <div className="stat"><span className="l">Open</span><span className="v">{cell.open}</span></div>
                <div className="stat"><span className="l">Last hour</span>
                  <span className="v" style={{ color: cell.stat && cell.stat.move >= 0 ? "var(--gain)" : "var(--loss)" }}>
                    {cell.stat ? fmtPct(cell.stat.move) : "—"}</span></div>
              </div>
              {analyticsLevel >= 1 && cell.stat && (
                <div className="meta" style={{ marginBottom: 8 }}>District value {fmt(cell.stat.value)}</div>
              )}
              {(showAll ? listed : listed.slice(0, 5)).map(({ p, rec }) => {
                const owner = rec && rec.owner ? game.players[rec.owner] : null;
                return (
                  <div className="cluster-row" key={p.id} onClick={() => onOpen(p.id)}>
                    <FavStar id={p.id} me={me} onAct={onAct} size={14} />
                    <span><CategoryTag category={p.meta.category} /> {p.meta.name}<br />
                      <span className="muted">{owner ? <>owned by <TraderName player={owner} size={12} /></> : "available"}</span></span>
                    <span className="price">{fmt(rec ? rec.currentPrice : 0)}</span>
                  </div>
                );
              })}
              {listed.length > 5 && (
                <button className="btn small secondary" style={{ marginTop: 10 }}
                        onClick={() => setShowAll(!showAll)}>
                  {showAll ? "Show fewer" : `Show all ${listed.length}`}
                </button>
              )}
            </div>
          ) : (
            <div className="empty">
              <b>Pick a district.</b><br />
              Fifteen of them, drawn as the ground closer to one centre than to any other
              {game.city && (game.city.zones || []).length
                ? <>, and {game.city.zones.map((z) => z.name).join(" and ")}, opened by the Council as the city filled and drawn on {game.city.zones.length === 1 ? "its" : "their"} own boundary</>
                : null}.
              Click one to fly in, or click any building to open its deed.
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

/* --------------------------- the Gaming Council ------------------------- */
/* SURVEYOR'S BLUFF, and the skill it pays.

   This is the part of Civexio that is not Civexio. Everything else on these
   tabs rewards one faculty — arithmetic about money over time — and once the
   opening stake is spent there is nothing to exercise it on for hours. Here
   the question is whether you can write a convincing lie, write a true thing
   that sounds like one, and tell which is which when somebody else does it.

   The screen has three states and they are deliberately not tabs: what you
   filed, what the city filed, and what last night settled. A player who
   opens this once a day should be able to take it all in without choosing
   anything first. */

const BLUFF_CALL = { certify: "Certified", condemn: "Condemned" };

function CountdownTo({ at }) {
  const [now, setNow] = useState(Date.now());
  useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), 30000);
    return () => clearInterval(id);
  }, []);
  return <span>{spanOf(Math.max(0, at - now))}</span>;
}

/* A filer's record, which is the only signal on the board besides the prose
   — and the thing that makes a reputation worth spending. Ten honest filings
   buy the credibility that one big fraud cashes in. */
function FilerRecord({ record }) {
  if (!record || !record.filings) {
    return <span className="filer-record new">no record yet</span>;
  }
  const pct = Math.round((record.truthful / record.filings) * 100);
  return (
    <span className="filer-record" title={`${record.truthful} honest and ${record.invented} invented, of ${record.filings} settled`}>
      {record.filings} filed · {pct}% held up
    </span>
  );
}

function FileDossier({ game, me, onAct }) {
  const b = me.bluff;
  /* Only what the server would actually accept. The estate is three
     buildings; once all three slots are used the rest of a portfolio is no
     longer surveyable, and offering them here would mean picking a property
     and being refused at the counter. */
  const est = me.bluff.estate || { ids: null, slots: 3, used: [], free: 3 };
  const allowed = est.ids ? new Set(est.ids) : null;
  const owned = ALL_PROPERTIES.filter((m) => {
    const p = game.properties[m.id];
    return p && p.owner === me.id && (!allowed || allowed.has(m.id));
  });
  const [propertyId, setPropertyId] = useState(owned.length ? owned[0].id : "");
  const [claim, setClaim] = useState("none");
  const [condition, setCondition] = useState("sound");
  const [note, setNote] = useState("");

  /* What the building actually has. The owner is entitled to this and
     nobody else is; the server only ever sends the register for properties
     this player owns. */
  const truth = (b.surveys || {})[propertyId] || null;

  /* The drop-downs start on the truth, and follow it when the property
     changes. Two reasons, and the second is the important one. A player who
     files without touching anything files an honest survey, which is the
     safe default for somebody still working out what this screen is. And
     starting truthful makes the register mean something: every lie in the
     game is now a deliberate move away from what is printed above. */
  useEffect(() => {
    if (!truth) return;
    setClaim(truth.feature);
    setCondition(truth.condition);
  }, [propertyId, truth && truth.feature, truth && truth.condition]);
  const labelOf = (list, id) => ((list || []).find((x) => x.id === id) || {}).label || "";

  /* Whichever way the two drop-downs are set, this is what the filing will
     be. Shown plainly, and never as a question — the player is choosing what
     to claim about their building, not confessing to a category. */
  const wouldLie = truth && (claim !== truth.feature || condition !== truth.condition);

  if (b.mine) {
    return (
      <div className="council-card bare">
        <OrnateFrame tone="ivory" className="survey-board filed">
        <FrameHead kicker="CITY SURVEYOR'S OFFICE" title="Filed Today"
                   sub="Lodged, and beyond changing" />
        <div className="filed-line"><b>{b.mine.property}</b></div>
        <div className="filed-claims">
          <span>{b.mine.claim}</span>
          <span>{b.mine.condition}</span>
        </div>
        {b.mine.note && <p className="filed-note">“{b.mine.note}”</p>}
        <div className={"filed-flag " + (b.mine.honest ? "true" : "false")}>
          {b.mine.honest
            ? "Everything you claimed is true of the building."
            : "Some of what you claimed is not true of the building."}
          {" "}Nobody else can see which.
        </div>
        <div className="muted" style={{ marginTop: 8 }}>
          {b.mine.audits} {b.mine.audits === 1 ? "trader has" : "traders have"} called it.
          {b.mine.audits < b.minAudits
            && ` It needs ${b.minAudits} to settle at all. Below that, nothing is won or lost.`}
        </div>
        </OrnateFrame>
      </div>
    );
  }

  if (!owned.length) {
    return (
      <div className="council-card bare">
        <OrnateFrame tone="ivory" className="survey-board">
          <FrameHead kicker="CITY SURVEYOR'S OFFICE" title="File a Survey"
                     sub="One a day, on a building you hold" />
          <div className="empty">You need a property to survey. Buy one, or take your draw on the Market.</div>
        </OrnateFrame>
      </div>
    );
  }

  const meta = ALL_PROPERTIES.find((m) => m.id === propertyId);

  return (
    <div className="council-card bare">
      <OrnateFrame tone="ivory" className="survey-board">
        <FrameHead kicker="CITY SURVEYOR'S OFFICE" title="File a Survey"
                   sub="One a day, on a building you hold"
                   status="The claim is yours to make" />
      <div className="board-note">
        Claim whatever you like about it. The city decides whether it believes you.
      </div>
      {/* The cap is the reason the week works: the same buildings have to come
          round often enough for anybody to build a read on them. */}
      <div className="estate-note">
        You survey <b>{est.slots}</b> of your buildings, and the first {est.slots} you file on
        become yours for good.{" "}
        {est.free > 0
          ? `${est.free} slot${est.free === 1 ? "" : "s"} still free.`
          : "All three are taken. Sell one to change that."}
      </div>

      <label className="field">
        <span>Property</span>
        <select value={propertyId} onChange={(e) => setPropertyId(e.target.value)}>
          {owned.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
        </select>
      </label>

      {/* The register. Stated flatly, with no advice attached — what the
          player does with it is the entire game. */}
      {truth && (
        <div className="register">
          <span className="register-kicker">YOUR SURVEYOR'S PRIVATE REPORT</span>
          <div className="register-row">
            <span>Heritage</span><b>{labelOf(b.claims, truth.feature)}</b>
          </div>
          <div className="register-row">
            <span>Condition</span><b>{labelOf(b.conditions, truth.condition)}</b>
          </div>
          <div className="register-row">
            <span>Base value</span><b>{meta ? fmt(meta.basePrice) : "—"}</b>
          </div>
          <div className="register-foot">Only you can see this. It is not sent with your dossier.</div>
        </div>
      )}

      <label className="field">
        <span>Claimed historical feature</span>
        <select value={claim} onChange={(e) => setClaim(e.target.value)}>
          {b.claims.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
        </select>
      </label>

      <label className="field">
        <span>Claimed structural condition</span>
        <select value={condition} onChange={(e) => setCondition(e.target.value)}>
          {(b.conditions || []).map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
        </select>
      </label>

      <label className="field">
        <span>Seller's pitch <em>({b.noteMax || 150} characters, and this is the whole game)</em></span>
        <textarea rows="3" maxLength={b.noteMax || 150} value={note}
                  placeholder="Give them a reason to believe you. Or to doubt you."
                  onChange={(e) => setNote(e.target.value)} />
      </label>

      <div className={"filing-stakes " + (wouldLie ? "false" : "true")}>
        {wouldLie
          ? <span>+{b.payout.fraudCertified} if it passes · {b.payout.fraudCondemned} if it is caught</span>
          : <span>+{b.payout.honestCertified} if believed · +{b.payout.honestCondemned} if wrongly accused</span>}
      </div>

      <button className="btn" style={{ width: "100%", marginTop: 10 }}
              onClick={() => onAct("bluff.file", { propertyId, claim, condition, note })}>
        File with the inspector
      </button>
      <div className="muted" style={{ marginTop: 8, fontSize: 11.5 }}>
        Being disbelieved when you were telling the truth pays more than being believed.
        An honest survey that sounds outrageous is a strategy, not a mistake.
      </div>
      <footer className="board-footer">
        <span>SIGNED AND LODGED WITH THE CITY</span>
      </footer>
      </OrnateFrame>
    </div>
  );
}

/* The building itself, small. Falls back to the drawn facade when there is
   no photograph, which is most of them — the drawing is the game's own and
   reads better at this size than a cropped photo would. */
function DossierThumb({ id }) {
  const meta = id ? metaFor(id) : null;
  if (!meta) return null;
  const photo = artFor(meta, null);
  return (
    <div className="dossier-thumb">
      {photo
        ? <img src={photo} alt="" loading="lazy"
               onError={(e) => { e.target.style.display = "none"; }} />
        : <PropertyArt meta={meta} facade={null} height={null} />}
    </div>
  );
}

function AuditBoard({ me, onAct }) {
  const b = me.bluff;
  const called = b.board.filter((d) => d.yourCall).length;
  const win = b.auditWin || {};
  return (
    <div className="council-card bare">
      <OrnateFrame tone="felt" className="audit-board">
        <FrameHead kicker="THE CITY OF YORK" title="The Audit Board"
                   sub="Surveys lodged today, open for calling"
                   status={`${called} of ${b.maxAudits} calls used`} />
      <div className="board-note">
        {b.auditFee} skill a call, lost if you are wrong. A correct certify wins {win.certify},
        a correct condemn wins {win.condemn}. Catching a fraud is worth four times agreeing with
        an honest one, and guessing at random still breaks even.
      </div>
      {!b.board.length
        ? <div className="empty on-felt">Nothing filed yet today. Come back this evening.</div>
        : b.board.map((d) => (
          <div className={"dossier" + (d.yourCall ? " called" : "")} key={d.id}>
            {/* THE BUILDING, NOT JUST ITS NAME.

                A board of eighteen text rows is a spreadsheet. The same rows
                with the actual house on them are cards you recognise across
                a week — which is the whole point of capping everyone at three
                buildings: you are meant to remember that one. */}
            <DossierThumb id={d.propertyId} />
            <div className="dossier-head">
              <b>{d.property}</b>
              <FilerRecord record={d.record} />
            </div>
            <div className="dossier-by">
              filed by {d.filedBy}
              {d.baseValue != null && <span className="dossier-value"> · base value {fmt(d.baseValue)}</span>}
            </div>
            <div className="dossier-claims">
              <div><span>Heritage</span><b>{d.claim}</b></div>
              <div><span>Condition</span><b>{d.condition}</b></div>
            </div>
            {d.note && <p className="dossier-note">“{d.note}”</p>}
            {d.yourCall
              ? <div className={"dossier-called " + d.yourCall}>
                  You {BLUFF_CALL[d.yourCall].toLowerCase()} this. It settles tonight.
                </div>
              : (
                <div className="dossier-actions">
                  <button className="btn-certify" disabled={me.skill.points < b.auditFee}
                          onClick={() => onAct("bluff.audit", { dossierId: d.id, call: "certify" })}>
                    Certify<small>+{win.certify} if it is true</small>
                  </button>
                  <button className="btn-condemn" disabled={me.skill.points < b.auditFee}
                          onClick={() => onAct("bluff.audit", { dossierId: d.id, call: "condemn" })}>
                    Condemn<small>+{win.condemn} if you catch it</small>
                  </button>
                </div>
              )}
          </div>
        ))}
        <footer className="board-footer">
          <span>A CLAIM IS EITHER TRUE OR IT IS NOT</span><i>•</i>
          <span>THE FILER NEVER SAYS WHICH</span>
        </footer>
      </OrnateFrame>
    </div>
  );
}

function LastNight({ me }) {
  const r = me.bluff.results;
  if (!r || (!r.filings.length && !r.calls.length)) return null;
  return (
    <div className="council-card">
      <h4>What settled</h4>
      {r.filings.map((f, i) => (
        <div className="settled-row" key={"f" + i}>
          <span className={"pip " + (f.outcome === "quiet" ? "quiet" : f.honest === (f.outcome === "certified") ? "good" : "bad")} />
          <span className="settled-what">
            Your survey on <b>{f.property}</b>
            {f.outcome === "quiet"
              ? " drew too few calls to settle."
              : ` was ${f.outcome} ${f.certifies} to ${f.condemns}. It was ${f.honest ? "honest" : "a fraud"}.`}
          </span>
        </div>
      ))}
      {r.calls.map((c, i) => (
        <div className="settled-row" key={"c" + i}>
          <span className={"pip " + (c.quiet ? "quiet" : c.right ? "good" : "bad")} />
          <span className="settled-what">
            You {BLUFF_CALL[c.call].toLowerCase()} <b>{c.property}</b>
            {c.quiet ? ": it never settled, fee returned." : c.right ? ", and you were right." : ", and you were wrong."}
          </span>
        </div>
      ))}
    </div>
  );
}

function SkillShop({ game, me, onAct }) {
  const owned = ALL_PROPERTIES.filter((m) => {
    const p = game.properties[m.id];
    return p && p.owner === me.id;
  });
  const [target, setTarget] = useState(owned.length ? owned[0].id : "");
  /* Perks that ask a question keep their own answer. One map rather than a
     state per perk, so a fourth one costs nothing to add. */
  const [choice, setChoice] = useState({});
  const mask = me.disguise;
  return (
    <div className="council-card">
      <div className="muted" style={{ marginBottom: 10 }}>
        Edges, never money. Nothing here can be sold, traded or turned into cash;
        a trader who never plays these games is slower, not poorer.
      </div>

      {/* What the room is currently being told about you, when it is not the
          truth. Your own figures everywhere else stay real — you can never
          be taken in by your own bluff. */}
      {mask && (
        <div className={"disguise-live " + (mask.pct > 0 ? "up" : "down")}>
          <b>The register is lying for you.</b>
          <span>
            The leaderboard has your net worth {mask.pct > 0 ? "up" : "down"} {Math.abs(mask.pct)}%:
            {" "}{fmt(Math.round(me.netWorth * (1 + mask.pct / 100)))} rather than {fmt(me.netWorth)}.
            Wears off <CountdownTo at={mask.until} />.
          </span>
        </div>
      )}

      {me.skill.perks.map((perk) => {
        const picked = choice[perk.id] || (perk.options ? perk.options[0].id : null);
        const blocked = perk.id === "disguise" && !!mask;
        return (
          <div className="perk" key={perk.id}>
            <div className="perk-head">
              <b>{perk.name}</b>
              <span className="perk-cost">{perk.cost}</span>
            </div>
            <div className="perk-blurb">{perk.blurb}</div>
            <div className="muted perk-note">{perk.note}</div>
            {perk.id === "extension" && owned.length > 0 && (
              <select value={target} onChange={(e) => setTarget(e.target.value)} className="perk-target">
                {owned.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
              </select>
            )}
            {perk.options && (
              <div className="perk-options">
                {perk.options.map((o) => (
                  <button key={o.id}
                          className={"perk-option" + (picked === o.id ? " on" : "")}
                          aria-pressed={picked === o.id}
                          onClick={() => setChoice((c) => ({ ...c, [perk.id]: o.id }))}>
                    <b>{o.label}</b><span>{o.what}</span>
                  </button>
                ))}
              </div>
            )}
            <button className="btn small" disabled={me.skill.points < perk.cost || blocked}
                    onClick={() => onAct("skill.perk", { perk: perk.id, propertyId: target, option: picked })}>
              {blocked ? "Already in hand"
                : me.skill.points < perk.cost ? `Need ${perk.cost - me.skill.points} more`
                : `Spend ${perk.cost}`}
            </button>
          </div>
        );
      })}
    </div>
  );
}

/* Rounds already shown, so switching tabs does not replay last night all
   over again.

   This Set is only the WITHIN-PAGE half of the answer. It dies with the page,
   which is why a reload used to replay last night from the top — a player who
   refreshed twice sat through the same five seconds three times. The durable
   half is `bluff.seenRound` on the server, written when the theatre is
   dismissed; the Set spares a round trip in between. */
const SEEN_INSPECTIONS = new Set();

/* THE RESOLUTION — Surveyor's Bluff.

   Built to the motion pack's contract, which is worth restating because it
   is the part that makes a bluffing game land rather than merely inform:

     0.0–4.3s   shared suspense. Both outcomes use the SAME route, the same
                checkpoints, the same copy and the same sound. Nothing on
                screen knows yet.
     4.3–5.8s   the branch. Stamp, flash and sound diverge.

   The server settled this hours ago on its own clock. Nothing here decides
   anything — the component is handed a result and presents it, which is why
   there is nothing to re-roll by reloading, and why the branch is held back
   until 4.3 seconds rather than being visible in the DOM from the start.

   WHOSE OUTCOME IS THIS. A player has two roles in a round: they file one
   survey and they call several. The animation follows the one with the most
   at stake — their own filing — and falls back to the first survey they
   called if they did not file. The rest of the calls tick in underneath
   afterwards.

   The stamp says what happened to YOU, not what the room ruled, because a
   green "CONDEMNED" reads as a mistake. An honest survey the room refused is
   the best result in the game and says VINDICATED. */

const RES_STEPS_AT = [1030, 2210, 3590];
const RES_REVEAL_AT = 4300;
const RES_TOTAL = 5800;

/* Sound is generated rather than loaded: four oscillators and a noise burst
   cost nothing to ship and cannot 404. The choice is remembered per browser,
   which is exactly what localStorage is for and exactly what it must not be
   trusted with — it throws in a private window, so every touch is guarded. */
const SOUND_KEY = "civexio.council.sound";
/* ---- the cues ----------------------------------------------------------

   SHORT SOUNDS FOR THINGS THAT HAPPEN.

   Everything here is synthesised from the oscillator helpers below — no
   audio files, no fetch, nothing added to the page weight, and nothing that
   can 404 on a slow connection. That also keeps the zero-dependency
   guarantee the server is built on: a game that needs a sound library is a
   game that needs a build step.

   Each cue is under 400ms and none of them repeat, because a sound a player
   triggers forty times an hour has to be something they stop noticing
   rather than something they come to resent. They all read the same mute
   preference the Council's theatres use, so one switch silences the lot.

   The tactics deliberately do NOT share a voice. Five buttons that make the
   same noise teach a player nothing; five that each sound like what they do
   mean the confirmation is heard before it is read. */

function cue(name) {
  if (!soundPreferred()) return;
  const c = CUES[name];
  if (c) { try { c(); } catch { /* audio is a nicety, never a failure */ } }
  /* The score steps back for the length of the cue. A stamp and a bar of
     harpsichord at the same level is two things shouting, and the one
     carrying information loses. */
  try { const m = music(); if (m) m.duck(700); } catch { /* fine */ }
}

/* A note a beat later, without pulling in a scheduler. */
const after = (ms, fn) => setTimeout(fn, ms);

const CUES = {
  /* THE DEED IS STAMPED. A wooden knock and two rising notes — the sound a
     rubber stamp and a small brass bell would make together. This is the
     one a player hears most, so it is the shortest and the warmest. */
  buy() {
    impact(0.09, 420);
    after(20, () => tone(523.25, 0.09, 0.03, "triangle"));
    after(105, () => tone(783.99, 0.16, 0.026, "sine"));
  },

  /* Sold: the same shape, falling instead of rising. Money leaving is not
     a defeat, so it resolves rather than sours. */
  sell() {
    impact(0.07, 360);
    after(20, () => tone(659.25, 0.09, 0.026, "triangle"));
    after(105, () => tone(392.00, 0.18, 0.024, "sine"));
  },

  /* Refused — not enough cash, not allowed, already done. Low, brief, and
     deliberately unmusical, so it cannot be mistaken for something working. */
  denied() {
    tone(196.00, 0.07, 0.025, "square");
    after(80, () => tone(155.56, 0.12, 0.022, "square"));
  },

  /* ---- one voice per tactic ---- */

  /* SPREAD RUMOURS. A whisper: filtered noise, no pitch, gone before you
     can place it. */
  "tactic.rumour"() {
    sweep(1800, 520, 0.22, 0.016, "sine");
    after(60, () => tone(1046.5, 0.05, 0.008, "sine"));
  },

  /* INCITE RENT STRIKE. A crowd's low grind — a sawtooth falling under a
     dissonant second. It should sound like trouble downstairs. */
  "tactic.strike"() {
    sweep(180, 88, 0.34, 0.05, "sawtooth");
    after(40, () => tone(146.83, 0.26, 0.022, "sawtooth"));
    after(150, () => impact(0.06, 300));
  },

  /* A DODGY TENANT. A door closing, and staying closed: a knock and a
     resigned drop of a fourth. */
  "tactic.dodgy"() {
    impact(0.1, 260);
    after(120, () => tone(220.00, 0.1, 0.028, "triangle"));
    after(215, () => tone(164.81, 0.24, 0.024, "triangle"));
  },

  /* INTERCEPT THE TAKINGS. Quick, light-fingered, upward — over before
     anybody looks round. */
  "tactic.theft"() {
    tone(1318.5, 0.04, 0.02, "square");
    after(45, () => tone(1760.0, 0.04, 0.018, "square"));
    after(95, () => sweep(2200, 640, 0.16, 0.014, "sine"));
  },

  /* CORPORATE TAKEOVER. The heaviest thing in the game: a struck bell over
     a low swell. It takes two hours to land and should sound like it. */
  "tactic.takeover"() {
    impact(0.14, 520);
    after(30, () => tone(110.00, 0.42, 0.038, "sine"));
    after(60, () => tone(329.63, 0.34, 0.03, "triangle"));
    after(230, () => tone(493.88, 0.3, 0.022, "sine"));
  },
};

function soundPreferred() {
  try { return window.localStorage.getItem(SOUND_KEY) !== "off"; } catch { return true; }
}
function rememberSound(on) {
  try { window.localStorage.setItem(SOUND_KEY, on ? "on" : "off"); } catch { /* fine */ }
}

/* ---- the soundtrack ----------------------------------------------------

   MUSIC IS OFF UNTIL ASKED FOR, AND CUES ARE ON UNTIL REFUSED. They are not
   the same decision and they must not share a switch. A confirmation noise
   half a second after a click is information; a continuous score is an
   opinion about how someone wants to spend their attention, and starting one
   uninvited is how a tab gets closed.

   The engine itself lives in public/soundtrack.js, loaded once by
   index.html and never unmounted — a Web Audio context cannot survive a
   reload, and it is what holds the phrase-boundary transitions together. */
/* Which day of the opening week this browser has already been shown. Per
   viewer and per browser, which is right: it is a popup, not a record — the
   claim itself lives on the server and cannot be had twice. */
const OPENING_SEEN_KEY = "civexio.openingweek.seen";
const MUSIC_KEY = "civexio.music";
const MUSIC_VOL_KEY = "civexio.music.volume";
const music = () => (typeof window !== "undefined" ? window.CivexioSound : null);

/* ON UNTIL REFUSED, AS OF 21 SEP 2026.

   This shipped off-by-default on the argument that a score is an opinion
   about how somebody wants to spend their attention. That argument is still
   true and it lost anyway: a soundtrack nobody ever hears is a soundtrack
   nobody can decide about, and the switch was three clicks deep in a panel
   most players open once. It is on, it is named in the guide on the way in,
   and the way out is one switch in the Account panel. */
function musicPreferred() {
  try { return window.localStorage.getItem(MUSIC_KEY) !== "off"; } catch { return true; }
}
function rememberMusic(on) {
  try { window.localStorage.setItem(MUSIC_KEY, on ? "on" : "off"); } catch { /* fine */ }
}
function musicVolume() {
  try {
    const v = Number(window.localStorage.getItem(MUSIC_VOL_KEY));
    return Number.isFinite(v) && v > 0 && v <= 1 ? v : 0.7;
  } catch { return 0.7; }
}
function rememberMusicVolume(v) {
  try { window.localStorage.setItem(MUSIC_VOL_KEY, String(v)); } catch { /* fine */ }
}

/* Which of the three pieces belongs to which tab. The map is deliberately
   coarse: the market, the map, your portfolio, the floor, the bank and the
   boards are all the same activity wearing different clothes, and a score
   that changed every time you looked at your own holdings would be noise
   with a tempo. Only the two rooms that feel different get their own music. */
const MUSIC_SCENE = {
  tactics: "tactics",
  council: "council",
};
const sceneForTab = (tab) => MUSIC_SCENE[tab] || "market";

/* Both switches have to be on. "Mute all audio" in the account panel means
   all of it, and a player who muted everything and then came back should not
   be met by an orchestra because the music switch was still set. */
function musicShouldPlay() {
  return soundPreferred() && musicPreferred();
}

let audioCtx = null;
function audio() {
  if (!audioCtx) {
    const Ctx = window.AudioContext || window.webkitAudioContext;
    if (!Ctx) return null;
    audioCtx = new Ctx();
  }
  if (audioCtx.state === "suspended") audioCtx.resume().catch(() => {});
  return audioCtx;
}
function tone(freq, duration = 0.08, gain = 0.03, type = "sine") {
  const ac = audio();
  if (!ac) return;
  const o = ac.createOscillator(), v = ac.createGain();
  o.type = type; o.frequency.value = freq;
  v.gain.setValueAtTime(gain, ac.currentTime);
  v.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + duration);
  o.connect(v).connect(ac.destination);
  o.start(); o.stop(ac.currentTime + duration);
}
function sweep(from, to, duration = 0.4, gain = 0.05, type = "triangle") {
  const ac = audio();
  if (!ac) return;
  const now = ac.currentTime;
  const o = ac.createOscillator(), v = ac.createGain();
  o.type = type;
  o.frequency.setValueAtTime(from, now);
  o.frequency.exponentialRampToValueAtTime(to, now + duration);
  v.gain.setValueAtTime(gain, now);
  v.gain.exponentialRampToValueAtTime(0.0001, now + duration);
  o.connect(v).connect(ac.destination);
  o.start(now); o.stop(now + duration);
}
function impact(weight = 0.11, cutoff = 760) {
  const ac = audio();
  if (!ac) return;
  const now = ac.currentTime;
  const length = Math.floor(ac.sampleRate * 0.14);
  const buffer = ac.createBuffer(1, length, ac.sampleRate);
  const data = buffer.getChannelData(0);
  for (let i = 0; i < length; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 4);
  const src = ac.createBufferSource(), filt = ac.createBiquadFilter(), v = ac.createGain();
  src.buffer = buffer;
  filt.type = "lowpass"; filt.frequency.value = cutoff;
  v.gain.setValueAtTime(weight, now);
  v.gain.exponentialRampToValueAtTime(0.0001, now + 0.14);
  src.connect(filt).connect(v).connect(ac.destination);
  src.start(now);
}

/* What the stamp says, what colour the room goes, and what the result reads.
   Four filing outcomes plus the quiet one, and the caller's version for a
   player who watched rather than filed. */
function resolutionOf(filing, call) {
  if (filing) {
    if (filing.outcome === "quiet") {
      return { tone: "void", stamp: "VOID", kicker: "NO QUORUM",
        status: "Too few traders looked. The inspector cannot rule on it.",
        title: "Too few callers", detail: "Nothing was settled. Every fee has been returned." };
    }
    const certified = filing.outcome === "certified";
    if (filing.honest && certified) {
      return { tone: "good", stamp: "CERTIFIED", kicker: "SURVEY UPHELD",
        status: "The records agree. The dossier receives the surveyor's seal.",
        title: "The claim stands", detail: "The records agree, and the city took you at your word." };
    }
    if (filing.honest && !certified) {
      return { tone: "good", stamp: "VINDICATED", kicker: "WRONGLY CONDEMNED",
        status: "The room was wrong. The records bear your survey out.",
        title: "They called it a fraud", detail: "It was true all along. Doubting an honest survey is the most expensive mistake on the board." };
    }
    if (!filing.honest && certified) {
      return { tone: "good", stamp: "PASSED", kicker: "BLUFF HELD",
        status: "No objection carries. The survey enters the register.",
        title: "Nobody looked twice", detail: "An invention entered the register as fact." };
    }
    return { tone: "bad", stamp: "FRAUD", kicker: "BLUFF EXPOSED",
      status: "The records conflict. The submitted bluff has been exposed.",
      title: "The records conflict", detail: "Your survey has been struck off, and it cost you." };
  }
  if (call) {
    if (call.quiet) {
      return { tone: "void", stamp: "VOID", kicker: "NO QUORUM",
        status: "Too few traders looked. The inspector cannot rule on it.",
        title: "It never settled", detail: "Too few callers. Your fee has been returned." };
    }
    return call.right
      ? { tone: "good", stamp: "READ", kicker: "CALLED CORRECTLY",
          status: "The records agree with the call you made.",
          title: "You saw through it", detail: "The inspector's records agreed with you." }
      : { tone: "bad", stamp: "MISREAD", kicker: "CALLED WRONGLY",
          status: "The records went the other way.",
          title: "You read it the other way", detail: "The records did not agree, and the fee is gone." };
  }
  return null;
}

const RES_STEPS = [
  "Claim entered into the survey ledger.",
  "Checking deeds against heritage records…",
  "The chief surveyor is applying the final seal…",
];

function ResolutionTheatre({ filing, call, calls, onClose }) {
  /* Memoised, and read through a ref by the timers below.

     resolutionOf() returns a fresh object every call, so using it directly
     as an effect dependency made the whole sequence restart on every render
     — and the case clock re-renders about sixty times a second. The 4.3s
     reveal was being cancelled and rescheduled before it could ever fire,
     which looked exactly like an animation that hangs. */
  const res = useMemo(() => resolutionOf(filing, call), [filing, call]);
  const resRef = useRef(res);
  resRef.current = res;
  const subject = filing || call;
  const reduced = useMemo(() => typeof window !== "undefined" && window.matchMedia
    && window.matchMedia("(prefers-reduced-motion: reduce)").matches, []);

  const [phase, setPhase] = useState(reduced ? "settled" : "running");
  const [step, setStep] = useState(0);
  const [clock, setClock] = useState(0);
  const [sound, setSound] = useState(soundPreferred);
  const soundRef = useRef(sound);
  soundRef.current = sound;

  /* One place that decides whether a cue is heard, so muting mid-sequence
     silences the rest of it rather than only the next one. */
  const cue = useCallback((fn) => { if (soundRef.current) fn(); }, []);

  useEffect(() => {
    if (reduced) return undefined;
    const timers = [];
    RES_STEPS_AT.forEach((at, i) => {
      timers.push(setTimeout(() => {
        setStep(i + 1);
        cue(() => tone(210 + (i + 1) * 85, 0.09, 0.025, "triangle"));
      }, at));
    });
    timers.push(setTimeout(() => {
      setPhase("settled");
      const verdict = resRef.current;
      cue(() => {
        impact(0.11, 760);
        if (verdict && verdict.tone === "good") {
          [523.25, 659.25, 783.99, 1174.66].forEach((n, i) => {
            timers.push(setTimeout(() => tone(n, 0.3 + i * 0.035, 0.048 - i * 0.005,
              i < 2 ? "triangle" : "sine"), 95 + i * 145));
          });
        } else if (verdict && verdict.tone === "bad") {
          timers.push(setTimeout(() => sweep(146.83, 55, 0.55, 0.072), 100));
          timers.push(setTimeout(() => tone(58.27, 0.48, 0.065, "sine"), 270));
          timers.push(setTimeout(() => tone(87.31, 0.08, 0.028, "square"), 560));
        } else {
          timers.push(setTimeout(() => tone(174.61, 0.4, 0.04, "sine"), 120));
        }
      });
    }, RES_REVEAL_AT));
    return () => timers.forEach(clearTimeout);
  }, [reduced, cue]);

  /* The case clock. Decorative, but it is what makes the wait read as a
     process rather than a hang. */
  useEffect(() => {
    if (reduced) return undefined;
    /* Tenths, not frames. The readout only shows hundredths of a second in
       units of ten, so re-rendering on every frame bought nothing and cost a
       render sixty times a second. */
    const t0 = Date.now();
    const id = setInterval(() => {
      const elapsed = Math.min(RES_TOTAL, Date.now() - t0);
      setClock(elapsed);
      if (elapsed >= RES_TOTAL) clearInterval(id);
    }, 100);
    return () => clearInterval(id);
  }, [reduced]);

  useEffect(() => {
    if (phase !== "settled") return undefined;
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [phase, onClose]);

  if (!res || !subject) return null;

  const settled = phase === "settled";
  const status = settled ? (res.status || res.title)
    : step === 0 ? "Opening the sealed dossier for verification…"
    : RES_STEPS[step - 1];
  const clockText = `00:${String(Math.floor(clock / 100)).padStart(2, "0")}`;
  const settledCalls = (calls || []).filter((c) => c !== call);

  return (
    <div className="res-overlay" role="dialog" aria-modal="true" aria-label="Council resolution">
      {/* THE BRANCH IS NOT IN THE DOM UNTIL IT IS DUE.

          The outcome class, the stamp and every word of the result are held
          back until the reveal — not merely hidden with CSS. A player who
          opens the inspector during the suspense finds nothing to read,
          which is the whole point of a sealed review. */}
      <div className={"res-stage " + phase + (settled ? " out-" + res.tone : "")}>
        <div className="res-flash" aria-hidden="true" />
        <article className="res-card">
          <header className="res-head">
            <div>
              <span className="res-no">
                {filing ? "YOUR DOSSIER" : "A SURVEY YOU CALLED"} · SEALED REVIEW
              </span>
              <h2>Surveyor's Bluff</h2>
            </div>
            <div className="res-tools">
              <button className="res-mute" onClick={() => { const n = !sound; setSound(n); rememberSound(n); }}
                      aria-pressed={sound} aria-label={sound ? "Mute sound" : "Turn sound on"}
                      title={sound ? "Mute" : "Sound on"}>
                {sound ? "♪" : "✕"}
              </button>
              <span className="res-timer">{clockText}</span>
            </div>
          </header>

          <p className="res-status" aria-live="polite">{status}</p>

          <div className="res-scene">
            <section className="res-desk">
              <div className="res-avatar" aria-hidden="true">{filing ? "YOU" : "◈"}</div>
              <p className="res-label">{filing ? "THE FILING TRADER" : "THE CITY INSPECTOR"}</p>
              <b>{filing ? "You" : "You"}</b>
              <small>{filing ? "One survey, filed" : "Call committed"}</small>
            </section>

            <div className="res-route" aria-hidden="true">
              <div className="res-line"><i /></div>
              {["CLAIM", "RECORD", "SEAL"].map((label, i) => (
                <div className={`res-cp c${i + 1}${step > i ? " lit" : ""}`} key={label}>
                  <span>{i + 1}</span><small>{label}</small>
                </div>
              ))}
              <div className="res-dossier">
                <span className="res-doc-head">PROPERTY DOSSIER</span>
                <b>{subject.property}</b>
                <i>{filing ? filing.claimLabel || "Heritage claim" : "Called " + (call.call === "certify" ? "genuine" : "false")}</i>
                <span className="res-wax">Y</span>
              </div>
              <div className="res-beam" />
              <div className="res-stamp">{settled ? res.stamp : "?"}</div>
            </div>

            <section className="res-claim">
              <div className="res-plate">
                <span className="res-house">⌂</span>
                <b>{subject.property}</b>
                <small>{filing ? "Filed by you" : "Filed by another trader"}</small>
              </div>
              <p className="res-label">THE RULING</p>
              <strong>
                {settled
                  ? (filing
                      ? (filing.outcome === "quiet" ? "Not enough callers"
                         : `${filing.certifies} certified · ${filing.condemns} condemned`)
                      : res.stamp)
                  : "Sealed"}
              </strong>
            </section>
          </div>

          <div className="res-verify" aria-hidden="true">
            <span className="res-verify-label">DOSSIER VERIFICATION</span>
            <span className="res-verify-track"><i /></span>
            <span className="res-verify-count">{step} / 3</span>
          </div>

          <div className="res-result">
            <p className="res-kicker">{settled ? res.kicker : ""}</p>
            <p className="res-title">{settled ? res.title : ""}</p>
            <p className="res-detail">{settled ? res.detail : ""}</p>
            {settled && !!settledCalls.length && (
              <div className="res-calls">
                {settledCalls.map((c, i) => (
                  <span className={`res-call ${c.quiet ? "void" : c.right ? "good" : "bad"}`} key={i}>
                    {c.quiet ? "–" : c.right ? "✓" : "✗"} {c.property}
                  </span>
                ))}
              </div>
            )}
          </div>

          <div className="res-foot">
            <button className="btn" onClick={onClose} disabled={!settled}>
              {settled ? "Close the file" : "The inspector is reading…"}
            </button>
          </div>
        </article>
      </div>
    </div>
  );
}

/* ---------------------------- the Gaming Council ----------------------- */

/* A game's own panel. The tab holds three games that share nothing but a field
   and a clock, and the first version ran them together down one column,
   which read as one long confusing thing rather than two short clear ones.
   Each now sits in its own coloured block with its name on top — and that
   name opens the rules, because a player who has to ask someone how a game
   works usually just stops playing it instead. */
/* TWO REGISTERS, NOT ONE.

   Every one of these games used to open with five paragraphs of careful
   prose, which is the right thing to have and the wrong thing to meet. A
   player clicking "how it works" is asking what to DO, and got an essay on
   why the odds are shaped the way they are.

   So `brief` is three or four numbered lines in the plainest words the game
   allows, and it comes first. `synopsis` is everything that was there
   before, and it now sits under a rule with its own heading, for the reader
   who has played a week and wants to know why. Neither is a summary of the
   other: the brief says what happens, the detail says why it is worth
   anything. */
function GameSection({ tone, name, tagline, brief, synopsis, strip, children }) {
  const [open, setOpen] = useState(false);
  return (
    <section className={"game-section tone-" + tone}>
      <header className="game-head">
        <button className="game-title" onClick={() => setOpen(!open)} aria-expanded={open}>
          <span className="game-kicker">GAME</span>
          <b>{name}</b>
          <span className="game-how">{open ? "hide the rules ▴" : "how it works ▾"}</span>
        </button>
        <span className="game-tagline">{tagline}</span>
      </header>
      {open && (
        <div className="game-synopsis">
          {brief && (
            <ol className="game-brief">
              {brief.map((line, i) => <li key={i}>{line}</li>)}
            </ol>
          )}
          {brief && synopsis && <h5 className="game-detail-head">Why it is worth playing</h5>}
          {synopsis.map((line, i) => (
            line && line.matrix
              ? <ScoreMatrix key={i} />
              : <p key={i}>{line}</p>
          ))}
        </div>
      )}
      {strip}
      <div className="game-body">{children}</div>
    </section>
  );
}

/* WHAT EVERY OUTCOME IS WORTH, WRITTEN DOWN.

   The rules used to describe the payoffs in a sentence, which meant a player
   could read them and still not know whether calling was worth it. A table is
   the honest form for a table. It shows what is CREDITED rather than the net,
   because "you pay 10 and get 15 back" is how people actually hold it in
   their heads, and a bare "+5" reads as a worse deal than it is. */
function ScoreMatrix() {
  const rows = [
    ["You file, and it is believed", "honest", "+10"],
    ["You file honestly and are condemned", "honest", "+18"],
    ["You file a fraud and it passes", "fraud", "+30"],
    ["You file a fraud and are caught", "fraud", "\u221220"],
  ];
  const calls = [
    ["You certify, and it was true", "10 back, plus 5", "+15"],
    ["You condemn, and it was a fraud", "10 back, plus 20", "+30"],
    ["You call it wrong, either way", "the fee is gone", "\u221210"],
  ];
  return (
    <div className="matrix">
      <div className="matrix-half">
        <span className="matrix-head">FILING</span>
        {rows.map(([what, kind, n], i) => (
          <div className={"matrix-row " + kind} key={i}>
            <span>{what}</span><b>{n}</b>
          </div>
        ))}
      </div>
      <div className="matrix-half">
        <span className="matrix-head">CALLING &middot; 10 A TIME</span>
        {calls.map(([what, how, n], i) => (
          <div className="matrix-row call" key={i}>
            <span>{what}<em>{how}</em></span><b>{n}</b>
          </div>
        ))}
      </div>
      <p className="matrix-note">
        Deception pays the most, catching it pays next, and agreeing with an honest
        filing pays least. That order is deliberate: the game should reward the person
        who doubted you more than the one who nodded along.
      </p>
    </div>
  );
}

/* How the assessors' round works, in the panel's own words. Kept short —
   the game is two taps, and a wall of rules in front of two taps is what
   makes a simple game look complicated. */
const TONTINE_HOW = [
  "One table for your field at a time. Sitting down costs a stake of 20 skill, and the Council puts 20 of its own beside every stake, so the pot is always twice what the traders put in.",
  "Nobody is drawn while you sit alone. The Council pays you 5 skill every evening you wait, for up to three evenings, and your field is told you are waiting. Then your stake comes back and the table rests for a day.",
  "The game starts when a second trader sits down. Seats stay open until the first draw that evening and then they close, so everyone at the table faces exactly the same draws. Anyone who missed it waits for the next table, which opens the day after the game ends.",
  "Every evening one name is drawn from the traders still in and the Council. A trader who is drawn leaves with nothing. If the Council is drawn, its money stays in the pot and only traders are left.",
  "The Council can never be the last one standing. When one trader is left, they take the whole pot and a free spin of the wheel.",
  "Before any draw you may take the call: an equal share of the pot, counting the Council as a share while it is still in, and walk away with it. Staying in is worth a little more on average. The call is certain.",
];

const GAZETTE_HOW = [
  "On Monday morning the Gazette prints six briefs. Everyone in your field reads all six. Nobody is told who was given which.",
  "Six briefs and a seventh token, the Blank, are dealt among the six of you, and ONE OF THE SEVEN IS NEVER DEALT AT ALL. So one of the printed briefs is being chased by nobody, and working out four of your rivals does not hand you the fifth.",
  "Through the week the paper prints what your field did. By name, but never in figures: \"bought in Micklegate, handsomely\" and not a price, a private offer made and not its amount. Today is never printed: you read the morning paper, not the wire.",
  "File your column any day you like: name each of your five rivals, or only the ones you are sure of. It pays more the earlier you file and costs more when you are wrong. A column is secret and it cannot be taken back.",
  "Your own brief pays on top if you see it through, but chasing it is what puts you in the paper. That is the whole game. So is chasing one you were never given.",
  "Every brief is settled from the Gazette and from nothing else. If the paper did not print it, it did not happen.",
];

const ASSESS_HOW = [
  "Five buildings are put before the whole exchange every evening. Name the one you think will have risen most by this time tomorrow, and the one you think will have fallen furthest.",
  "Everything you need is public. A building is pulled toward what the market thinks it is worth, and what the market thinks depends on its base value, the economic index and how its part of the city is trending, all of it on the Intelligence page.",
  "The five are chosen to be CLOSE. They are not five obvious answers with one right one; they are five buildings whose prospects are within about half a percentage point of each other, so reading them properly is worth something and guessing is not.",
  "Attacks land overnight, and an attack knocks value off a building. Who has been feuding with whom is on the news and on the map, and an assessor who reads those beats one who only does the arithmetic.",
  "One night is luck: a good assessor still loses most single nights, and that is deliberate. Over a week it is not luck at all. The table under the board is the one that matters.",
];

const BLUFF_HOW = [
  "Every building in York has a heritage feature (or none) and a structural condition. You can see the truth about the buildings you own. Nobody else can.",
  "Once a day you file a survey on one of your properties, claiming whatever you like about it. Claim what is really there and the filing is honest. Claim anything else and it is a fraud, and you never have to say which.",
  "You may only ever survey THREE of your buildings. The first three you file on become yours for the duration, so the same addresses keep coming round, and the field can start to learn who lies about what. Selling one frees its place.",
  "Everyone else in your field pays 10 skill to Certify a filing they believe, or Condemn one they think is invented. Nothing is written down for you: remembering who was right about which building last Tuesday is the game.",
  { matrix: true },
  "The inspector shuts the board each evening, reads for fifteen minutes, then announces. A fraud that passes pays its filer the most of anything on the table, which is exactly why somebody should have stopped it.",
];

const GAZETTE_BRIEF = [
  "Monday: six briefs are printed. One of them was dealt to you. Nobody is told who holds what.",
  "Every day the paper prints what your five rivals did: by name, but never in figures.",
  "Work out whose brief is whose, then file your column naming them.",
  "File early and each right name pays more; wait and you will be surer. Wrong names cost you either way.",
];

const TONTINE_BRIEF = [
  "Sit down for 20 skill. The Council puts 20 beside every stake.",
  "Alone, nobody is drawn: the Council pays you 5 an evening while you wait, for up to three.",
  "A second trader starts the game. One name is drawn every evening, the Council's included.",
  "Take the call and leave with a share, or stay in. The last trader standing takes the pot and a free spin.",
];

const ASSESS_BRIEF = [
  "Five buildings are put up every evening.",
  "Name the one you think will have risen most by this time tomorrow, and the one that will have fallen furthest.",
  "Everything you need is public and on the Intelligence page. One night is luck; a week of them is not.",
];

const COMMITTEE_BRIEF = [
  "Eight permits come up each evening. You have eight Support tokens a day, and one Smear and one Veto for the whole week.",
  "Spend them on the permits you want. While the board is open nobody sees what anybody placed; only that a permit has been touched.",
  "The single highest Support bid takes the permit. Equal top bids and nobody gets it; any Veto and nobody gets it at all.",
  "At the evening close it is all turned face up and the minutes name who did what.",
];

const BLUFF_BRIEF = [
  "Claim something about one of your own buildings, true or not.",
  "Read the claims your field filed and call each one honest or fraudulent.",
  "A fraud that gets past everybody pays. So does catching one.",
];

const COMMITTEE_HOW = [
  "Eight permits come before the committee each evening. You hold eight Support tokens a day, plus one Smear and one Veto, and those two last the whole week, not the evening.",
  "A permit goes to the single highest Support bid. Equal top bids and nobody gets it. Any Veto on a permit and nobody gets it at all.",
  "A Smear poisons a permit: whoever wins it loses the award instead of gaining it. It never helps you win. You get one a week, so it is a thing to pick your moment for.",
  "A full committee pays every permit in full. When fewer boards are sent the board pays less: 90% of each award with five boards, 80% with four, 70% with three and 60% with two.",
  "While the board is open you are told only whether a permit has been touched: never how many tokens, never whose, never which kind. Nobody has to be online at the same time, and going first costs you nothing.",
  "At the evening close everything is turned face up and the minutes name who did what. The board is blind only while it is open.",
];

/* Who you are playing, and how they play. */
/* ---- THE FIELD AS A HORSE RACE --------------------------------------------

   Aaron, 27 Sep 2026: the field's avatars as horses and jockeys, every one
   different, running, with their speed set by the points each has won
   against the others. So each trader in the field is a lane: a horse in its
   own coat, a jockey in its own silks, placed along the course by the net
   skill its trader has taken from the council's games this week (the leader
   nearest the post) and galloping faster the more it has won. They run in
   from the start when the tab opens. Reduced motion: they stand in place. */
const SILKS = [
  { body: "#1F4FA8", trim: "#F2C230", cap: "#F2C230", pattern: "hoops" },
  { body: "#177245", trim: "#FFFFFF", cap: "#FFFFFF", pattern: "sash" },
  { body: "#C62828", trim: "#FFFFFF", cap: "#FFFFFF", pattern: "spots" },
  { body: "#111111", trim: "#F4F1EA", cap: "#C62828", pattern: "quarters" },
  { body: "#6B2FA0", trim: "#F08A24", cap: "#F08A24", pattern: "chevron" },
  { body: "#F2C230", trim: "#111111", cap: "#111111", pattern: "stripes" },
];

/* THE COMPACT COURSE. Aaron, 27 Sep 2026, on the first draft: "far too
   childish and takes up too much space. I want the field space to still
   take the shape of 1 or two lines so minimal". So the field is one thin
   rail under the names: each trader a small ink silhouette, told apart by
   the colour of the jockey's silks (the same colour sits as a dot beside
   their name above), placed along the rail by the skill won this week and
   galloping faster the more they have won. */
/* WHO IS WHO, AND HOW FAST. Aaron, 28 Sep 2026: the gallop was right, but
   the silks were so small you had to hover to tell the horses apart, and
   nothing showed speed. So each horse now carries a saddle cloth in its
   trader's colours and a name tag above it, the field runs on two rails so
   the tags have room, and mud flies off the hooves: more of it, and flung
   further, the faster the horse is going. */
function MiniHorse({ silk, still, speed = 0 }) {
  const leg = (x, far, cls) => (
    <g className={"hl " + cls} style={{ transformOrigin: `${x}px 29px` }}>
      <path d={`M${x - 3.4} 27 L${x + 3.4} 27 L${x + 2.1} 37 L${x + 1.4} 45 L${x - 1.3} 45 L${x - 2.1} 37 Z`}
            fill="currentColor" opacity={far ? 0.45 : 1} />
    </g>
  );
  /* One fleck at a walk, five flat out; thrown up to about half a horse
     length at full tilt. */
  const flecks = still ? 0 : 1 + Math.round(speed * 4);
  return (
    <svg className={"horse mini-horse" + (still ? " is-still" : "")} viewBox="0 -4 80 54" width="44" height="30" aria-hidden="true"
         style={{ "--mud": `${(12 + speed * 40).toFixed(1)}px` }}>
      <g className="mud" aria-hidden="true">
        {Array.from({ length: flecks }, (_, k) => (
          <circle key={k} className={"mud-fleck mf" + (k % 5)} cx={k % 2 ? 46 : 22} cy="45" r={2.6 + (k % 3) * 0.9} />
        ))}
      </g>
      {leg(47, true, "hl-ff")}{leg(22, true, "hl-hf")}
      <g className="horse-body">
        <path d="M15 20 C 8 20, 5 26, 3 34 C 7 30, 10 27, 16 24 Z" fill="currentColor" className="horse-tail" />
        <path d="M14 23 C 14 16, 22 15, 32 16 C 42 17, 48 15, 52 19 L 53 27 C 50 31, 42 32, 32 32 C 22 32, 14 30, 14 23 Z" fill="currentColor" />
        <path d="M46 20 C 49 14, 53 9, 58 6 L 62 8 C 66 10, 71 12, 74 15 C 75 17, 73 18, 70 17.5 L 63 16 C 60 18, 56 22, 53 26 Z" fill="currentColor" />
        <path d="M58 6 L 59.5 2 L 61 7 Z" fill="currentColor" />
        {/* The saddle cloth: the trader's colours across the horse's back. */}
        <path d="M24 17.5 L 44 17.5 L 43 29.5 L 25 29.5 Z" fill={silk.body} stroke={silk.cap} strokeWidth="1.6" />
        <path d="M30 17 C 31 8, 39 3, 47 4.5 L 48.5 10 C 43 10, 38 13, 36 18.5 Z" fill={silk.body} stroke={silk.cap} strokeWidth="1.2" />
        <circle cx="50" cy="3.6" r="4.2" fill={silk.cap} stroke={silk.body} strokeWidth="1" />
      </g>
      {leg(49, false, "hl-fn")}{leg(24, false, "hl-hn")}
    </svg>
  );
}

/* A four letter tag for a phone, kept different from everybody else's in
   the field: the first four letters, or if two traders share those, the
   first two and the last two. */
function shortTag(pod, m) {
  const first = (n) => String(n || "").slice(0, 4);
  const mine = first(m.name);
  const clash = pod.some((o) => o.id !== m.id && first(o.name) === mine);
  const n = String(m.name || "");
  return clash && n.length > 4 ? n.slice(0, 2) + n.slice(-2) : mine;
}

/* Dark or light lettering on a tag, whichever reads on the silk. */
const tagInk = (hex) => {
  const n = parseInt(String(hex).slice(1), 16);
  const l = (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255;
  return l > 0.6 ? "#14150F" : "#FFFFFF";
};

function RaceTrack({ pod, meId, onOpen }) {
  const reduce = typeof window !== "undefined" && window.matchMedia
    && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const [started, setStarted] = useState(!!reduce);
  useEffect(() => { if (!reduce) { const t = setTimeout(() => setStarted(true), 60); return () => clearTimeout(t); } }, []);
  if (!pod || !pod.length) return null;
  const pts = pod.map((m) => m.weekPoints || 0);
  const lo = Math.min(0, ...pts), hi = Math.max(...pts);
  const span = Math.max(1, hi - lo);
  /* Two rails, and the horses alternate between them IN RUNNING ORDER, so
     two traders neck and neck are always on different rails and their name
     tags never sit on one another. */
  const order = [...pod].sort((a, b) => (a.weekPoints || 0) - (b.weekPoints || 0) || a.id - b.id).map((m) => m.id);
  return (
    <div className="race-rail" role="group" aria-label="The field as a race on skill won this week">
      <i className="race-rail-line lane-0" aria-hidden="true" />
      <i className="race-rail-line lane-1" aria-hidden="true" />
      <i className="race-rail-post" aria-hidden="true" />
      {pod.map((m) => {
        const p = m.weekPoints || 0;
        const f = (p - lo) / span;
        const silk = silkFor(pod, m.id);
        const you = m.id === meId;
        const lane = order.indexOf(m.id) % 2;
        const name = you ? "You" : String(m.name || "").length > 10 ? String(m.name).slice(0, 9) + "…" : m.name;
        return (
          <button key={m.id} type="button" className={"race-mini lane-" + lane + (you ? " is-you" : "")} onClick={() => onOpen(m)}
                  style={{ left: started ? `calc((100% - 48px) * ${f.toFixed(3)})` : "0px", "--stride": `${(0.95 - 0.57 * f).toFixed(2)}s`,
                           zIndex: 1 + Math.round(f * 10) }}
                  title={`${you ? "You" : m.name}: ${p > 0 ? "+" : ""}${p} skill this week`}
                  aria-label={`${you ? "You" : m.name}, ${p} skill this week`}>
            <span className="race-tag" style={{ background: silk.body, color: tagInk(silk.body), borderColor: silk.cap }}>
              <span className="race-tag-full">{name}</span>
              {/* On a phone the course is short, so the tag is too. */}
              <span className="race-tag-short">{you ? "You" : shortTag(pod, m)}</span>
            </span>
            <MiniHorse silk={silk} still={!!reduce} speed={f} />
          </button>
        );
      })}
    </div>
  );
}
/* Silks by seat, so each trader keeps one colour all week and no two in a
   field share one. */
function silkFor(pod, id) {
  const order = [...pod].map((m) => m.id).sort((a, b) => a - b);
  return SILKS[Math.max(0, order.indexOf(id)) % SILKS.length];
}

function PodStrip({ pod, meId, minSeats, weekEndsAt, onOpen }) {
  return (
    <div className="pod-strip">
      <span className="pod-label">The field this week</span>
      {pod.map((m) => (
        <button key={m.id} className={"pod-name" + (m.id === meId ? " you" : "")}
                onClick={() => onOpen(m)}
                title={m.nickname ? `${m.nickname.name}: ${m.nickname.blurb}` : `How ${m.name} plays`}>
          <span className="pod-who"><i className="pod-silk" style={{ background: silkFor(pod, m.id).body, boxShadow: `inset 0 0 0 2px ${silkFor(pod, m.id).cap}` }} aria-hidden="true" />{m.id === meId ? "you" : m.name}</span>
          {m.nickname && m.nickname.id !== "new" && (
            <span className="pod-nick">{m.nickname.name}</span>
          )}
        </button>
      ))}
      {pod.length < minSeats && (
        <span className="pod-warn">needs {minSeats - pod.length} more to settle anything</span>
      )}
      <span className="pod-redraw">redrawn in <CountdownTo at={weekEndsAt} /></span>
    </div>
  );
}

/* A field member's form across the games. Deliberately just the counts: a
   record tells you what somebody has DONE, and what it means is the read the
   player has to make for themselves. */
function PlayerCard({ who, meId, onClose }) {
  if (!who) return null;
  const b = who.bluff || {};
  const c = who.committee || {};
  const you = who.id === meId;
  const pct = (n, d) => (d ? Math.round((100 * n) / d) + "%" : "—");
  return (
    <div className="modal-backdrop player-backdrop" onClick={onClose}>
      <div className="modal-box player-box" onClick={(e) => e.stopPropagation()}
           role="dialog" aria-modal="true" aria-label={"How " + who.name + " plays"}>
        <div className="modal-inner">
          <h3 className="player-name">{who.name}{you && <span className="player-you">you</span>}</h3>
          {/* What the field has decided to call them. Earned from the record and
              never chosen — a nickname somebody picked for themselves is a
              costume; one the week handed them is a reputation. */}
          {who.nickname && (
            <div className={"player-nick" + (who.nickname.id === "new" ? " new" : "")}>
              <b>{who.nickname.name}</b>
              <span>{who.nickname.blurb}</span>
            </div>
          )}

          <div className="player-game">
            <span className="player-game-name">Surveyor's Bluff</span>
            <div className="player-rows">
              <div><span>Surveys filed</span><b>{b.filings || 0}</b></div>
              <div><span>Of those, true</span><b>{b.truthful || 0}</b></div>
              <div><span>Invented</span><b>{b.invented || 0}</b></div>
              <div><span>Frauds that passed</span><b>{b.fraudsPassed || 0}</b></div>
              <div><span>Calls right</span><b>{b.callsRight || 0}/{b.calls || 0} <em>{pct(b.callsRight || 0, b.calls || 0)}</em></b></div>
            </div>
          </div>

          <div className="player-game">
            <span className="player-game-name">The Planning Committee</span>
            <div className="player-rows">
              <div><span>Evenings played</span><b>{c.nights || 0}</b></div>
              <div><span>Permits won</span><b>{c.permits || 0}</b></div>
              <div><span>Of those, scandals</span><b>{c.scandals || 0}</b></div>
              <div><span>Vetoes spent</span><b>{c.vetoes || 0}</b></div>
              <div><span>Skill from the committee</span>
                <b className={(c.skill || 0) >= 0 ? "chg-up" : "chg-down"}>
                  {(c.skill || 0) >= 0 ? "+" : ""}{c.skill || 0}</b></div>
            </div>
          </div>

          {!b.filings && !c.nights && (
            <div className="empty" style={{ marginTop: 10 }}>
              {you ? "You have not played either game yet." : "Has not played yet. Nothing to read."}
            </div>
          )}
          <div className="confirm-actions" style={{ marginTop: 14 }}>
            <button className="btn secondary" onClick={onClose}>Close</button>
          </div>
        </div>
      </div>
    </div>
  );
}

/* ---------------------------- the trading floor -------------------------
   Two tabs that were always one thing: both are a property changing hands
   between players, and both are somewhere you go when you want a building
   somebody else already has. Keeping them apart meant checking two places
   to answer one question, and put an auction you were losing a tab away
   from the trade you would have taken instead. */
/* ---- THE MASTHEAD ----------------------------------------------------
   Aaron, 2 Oct 2026, asked for City tasks, the Trading floor, the Bank and
   the Leaderboard to look better, the Tactics figures "in a similar style to
   the portfolio banner". This is that banner as one piece: a band of colour,
   hairline divisions, a mono label, a serif figure and a line under it. Each
   page has its own colour (`tone`), and one tile may be the feature, marked
   in the page's accent. */
function Masthead({ tone, tiles, className }) {
  return (
    <div className={"mast mast-" + tone + (className ? " " + className : "")}>
      {tiles.filter(Boolean).map((t, i) => (
        <div key={t.label || i} className={"mast-tile" + (t.feature ? " is-feature" : "") + (t.wide ? " is-wide" : "")}>
          <span className="mast-label">{t.label}</span>
          <strong className="mast-value">{t.value}{t.unit ? <em> {t.unit}</em> : null}</strong>
          {t.extra || null}
          {t.foot ? <span className="mast-foot">{t.foot}</span> : null}
        </div>
      ))}
    </div>
  );
}

/* ---- CITY TASKS (1 Oct 2026; lib/tasks.js) -----------------------------

   The Council's paid work, for traders below £1.5m. One task at a time, a
   fixed fee, 24 hours (48 for a trade task, which needs another trader to
   answer). The board is posted at each evening's turnover. Everything a
   task asks for is done in the game's own places, so each task says where
   to go and takes you there. */
const TASK_GOES = {
  swap: ["floor", "Go to the trading floor"], wanted: ["floor", "Go to the trading floor"],
  set: ["floor", "Go to the trading floor"], match: ["floor", "Go to the trading floor"],
  sell: ["portfolio", "Go to your portfolio"], auction: ["floor", "Go to the auctions"],
  restore: ["portfolio", "Go to your portfolio"], secure: ["portfolio", "Go to your portfolio"],
  guard: ["tactics", "Go to your tactics"], books: ["bank", "Go to the bank"], survey: ["build", "Go to the Build tab"],
};
/* What sort of work each task is, for its colour and its picture. */
const TASK_GROUP = {
  swap: "trade", wanted: "trade", set: "trade", match: "trade", offer: "trade",
  sell: "market", auction: "market", cpo: "market", relet: "market",
  restore: "build", secure: "build", survey: "build", guard: "guard", books: "bank",
};
const TASK_GROUP_NAME = { trade: "Trade", market: "Market", build: "Buildings", guard: "Tactics", bank: "Bank" };
function TaskGlyph({ group }) {
  const p = { fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" };
  return (
    <svg className="task-glyph" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
      {group === "trade" && <g {...p}><path d="M4 8h13l-3-3" /><path d="M20 16H7l3 3" /></g>}
      {group === "market" && <g {...p}><path d="M5 19h9" /><path d="M8 15l6-6" /><rect x="11" y="3.5" width="8" height="5" rx="1" transform="rotate(45 15 6)" /></g>}
      {group === "build" && <g {...p}><path d="M4 11l8-6 8 6" /><path d="M6 10v9h12v-9" /><path d="M10 19v-5h4v5" /></g>}
      {group === "guard" && <g {...p}><path d="M12 3l7 3v5c0 4.5-3 8-7 10-4-2-7-5.5-7-10V6z" /><path d="M9 12l2 2 4-4" /></g>}
      {group === "bank" && <g {...p}><path d="M3 9l9-5 9 5" /><path d="M5 9v8M9.5 9v8M14.5 9v8M19 9v8" /><path d="M3 19h18" /></g>}
    </svg>
  );
}
function CityTaskCard({ t, mine, can, onAct, onOpen, onGo, busy }) {
  const go = TASK_GOES[t.kind];
  const group = TASK_GROUP[t.kind] || (t.trade ? "trade" : "build");
  const [sure, setSure] = useState(false);
  return (
    <article className={"task-card tg-" + group + (mine ? " is-mine" : "") + (t.trade ? " is-trade" : "")}>
      <header className="task-head">
        <span className="task-badge"><TaskGlyph group={group} /></span>
        <div className="task-titles">
          <span className="task-kind">{TASK_GROUP_NAME[group]} · {t.hours} hours</span>
          <h4>{t.title}</h4>
        </div>
        <strong className="task-fee"><small>Fee</small>{fmt(t.fee)}</strong>
      </header>
      <p className="task-brief">{t.brief}</p>
      {t.property && (
        <button type="button" className="task-link" onClick={() => onOpen(t.property)}>Open {t.name || "the building"}</button>
      )}
      {mine ? (
        <div className="task-mine">
          {t.kind === "relet" && t.boughtAt
            ? <p>Bought. Hold it until <CountdownTo at={t.boughtAt + 24 * 3600000} /> from now and the fee is yours.</p>
            : <p>Time left: <b><CountdownTo at={t.dueAt} /></b></p>}
          <div className="task-acts">
            {go && <button className="btn small" onClick={() => onGo(go[0])}>{go[1]}</button>}
            <button className="btn small secondary" onClick={() => (sure ? onAct("task.drop", {}) : setSure(true))}>
              {sure ? "Press again to give it up" : "Give it up"}
            </button>
          </div>
        </div>
      ) : (
        <div className="task-acts">
          <button className="btn small" disabled={!can || busy} onClick={() => onAct("task.take", { id: t.id })}>Take this task</button>
        </div>
      )}
    </article>
  );
}

function CityTasksTab({ me, now, onAct, onOpen, onGo }) {
  const c = me.cityTasks || {};
  if (!c.visible) {
    return <div className="card"><h3>City tasks</h3><p className="muted">City tasks are for traders below £1.5m net worth.</p></div>;
  }
  const can = c.eligible && !c.mine && !c.barred;
  const board = c.board || [];
  const fees = board.map((t) => t.fee);
  return (
    <div className="city-tasks">
      <Masthead tone="council" tiles={[
        { label: "Earned from the Council", value: fmt(c.earned || 0), foot: `${(c.history || []).length} task${(c.history || []).length === 1 ? "" : "s"} done` },
        { label: "Your task", value: c.mine ? c.mine.title : "None yet", wide: true,
          foot: c.mine ? <>Time left <CountdownTo at={c.mine.dueAt} /></> : c.barred ? "you can take another after the evening" : "take one from the board below" },
        { label: "On tonight's board", value: board.length, unit: board.length === 1 ? "task" : "tasks",
          foot: fees.length ? (Math.min(...fees) === Math.max(...fees) ? `${fmt(fees[0])} each` : `${fmt(Math.min(...fees))} to ${fmt(Math.max(...fees))} each`) : "all taken for tonight" },
        c.nextPostAt ? { label: "Next board in", value: <CountdownTo at={c.nextPostAt} />, feature: true, foot: `a new board every evening at ${eveningAt()}` } : null,
      ]} />
      <p className="task-lede">The City Council pays a fixed fee for work around York. Take one task at a time: a trade task gives you 48 hours,
        any other 24.</p>
      {!c.eligible && c.mine && (
        <p className="task-note">You have passed £1.5m. Finish this task and the City tasks tab will close: from here you pay the Council's rates rather than earn its fees.</p>
      )}
      {c.barred && !c.mine && <p className="task-note">You let a task lapse, so you can take another after the evening.</p>}

      {c.mine && (
        <section className="task-section">
          <h3 className="task-h">Your task</h3>
          <CityTaskCard t={c.mine} mine can={false} onAct={onAct} onOpen={onOpen} onGo={onGo} />
        </section>
      )}

      {c.eligible && (
        <section className="task-section">
          <h3 className="task-h">Tonight's board</h3>
          {board.length ? (
            <div className="task-grid">
              {board.map((t) => <CityTaskCard key={t.id} t={t} can={can} onAct={onAct} onOpen={onOpen} onGo={onGo} />)}
            </div>
          ) : <p className="muted">Every task on tonight's board has been taken. A new board goes up at {eveningAt()}.</p>}
        </section>
      )}

      {c.history && c.history.length > 0 && (
        <section className="task-section">
          <h3 className="task-h">Done</h3>
          <ul className="task-history">
            {c.history.map((t) => <li key={t.id}><span>{t.title}{t.where ? `, ${t.where}` : ""}</span><b>{fmt(t.fee)}</b></li>)}
          </ul>
        </section>
      )}
    </div>
  );
}

function FloorTab({ game, me, onAct }) {
  const live = game.auctions.filter((a) => a.status === "active").length;
  const incoming = game.trades.filter((o) => o.to === me.id).length;
  /* WHICH SIDE OPENS. Aaron, 25 Sep 2026: trades by default, because the
     "one away" panel that tells you who holds the building you are missing
     lives there — and it had been sitting behind the Auctions tab, which
     opened first on every visit, so the work on trade was invisible to the
     people it was for. Auctions open first only while one is actually
     running, because a live lot has a clock on it and a trade does not. An
     offer waiting for your answer still beats both. */
  const [side, setSide] = useState(incoming ? "trade" : live ? "auctions" : "trade");
  const running = game.auctions.filter((a) => a.status === "active");
  const nextClose = running.reduce((m, a) => (m == null || a.endsAt < m ? a.endsAt : m), null);
  const leading = running.filter((a) => a.currentBidder === me.id).length;
  const myLots = running.filter((a) => a.seller === me.id).length;
  const outgoing = game.trades.filter((o) => o.from === me.id).length;

  return (
    <div className="floor">
      <Masthead tone="floor" tiles={[
        { label: "Offers for you", value: incoming, feature: incoming > 0,
          foot: incoming ? `waiting for your answer` : "nothing waiting for an answer" },
        { label: "Your offers out", value: outgoing, foot: outgoing ? "with other traders" : "send one from Trades" },
        { label: "Auctions live", value: live,
          foot: nextClose ? <>the next closes in <CountdownTo at={nextClose} /></> : "nothing under the hammer" },
        { label: "You are leading", value: leading, unit: leading === 1 ? "lot" : "lots", foot: myLots ? `and ${myLots} of yours up for sale` : "bid on a lot to lead it" },
      ]} />
      {/* ABOVE THE SWITCH, BECAUSE IT BELONGS TO NEITHER SIDE. An appeal is
          not an auction and not a trade, and putting it behind one of the
          two sub-tabs would hide it from half the people standing on the
          floor — including, on a bad day, from the person who needs it. */}
      <AppealsBoard game={game} me={me} onAct={onAct} />
      <div className="floor-switch" role="tablist" aria-label="Trading floor">
        <button role="tab" aria-selected={side === "trade"}
                className={"floor-tab" + (side === "trade" ? " on" : "")}
                onClick={() => setSide("trade")}>
          Trades{incoming ? <em className="due">{incoming}</em> : null}
        </button>
        <button role="tab" aria-selected={side === "auctions"}
                className={"floor-tab" + (side === "auctions" ? " on" : "")}
                onClick={() => setSide("auctions")}>
          Auctions{live ? <em>{live} live</em> : null}
        </button>
      </div>
      {side === "auctions"
        ? <AuctionsTab game={game} me={me} onAct={onAct} />
        : <TradeTab game={game} me={me} onAct={onAct} />}
    </div>
  );
}

/* ---------------------------- the map spotlight -------------------------
   The whole map, over whatever you were doing, opening on the city and
   flying down to the one building you asked about. It closes back to the
   page you were on with your scroll position intact, which is the entire
   reason it is an overlay and not a tab switch. */
function MapSpotlight({ id, game, me, onOpen, onEquip, onAct, flash, onClose }) {
  const meta = metaFor(id);
  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);

  return (
    <div className="spotlight-backdrop" role="dialog" aria-modal="true"
         aria-label={meta ? `${meta.name} on the map` : "The map"}>
      <div className="spotlight-box">
        <header className="spotlight-head">
          <div>
            <span className="spotlight-kicker">WHERE IT STANDS</span>
            <b>{meta ? meta.name : "The city"}</b>
            {meta && <small>{meta.neighbourhoodName} · {REGIONS[meta.region].label}</small>}
          </div>
          <button className="btn small secondary" onClick={onClose}>Close</button>
        </header>
        <div className="spotlight-map">
          <MapTab game={game} me={me} onOpen={onOpen} onEquip={onEquip}
                  onAct={onAct} flash={flash} spotlight={id} />
        </div>
      </div>
    </div>
  );
}

/* ------------------------- the Planning Committee ---------------------- */

const TIER_LABEL = { gold: "GOLD", silver: "SILVER", bronze: "BRONZE" };

/* The presentation drawing for a scheme, when the pipeline has produced one.
   Keyed by the scheme's own name in the manifest (see tools/promote-art.js),
   so a permit always shows its own building and never a neighbour's. */
function permitArt(name) {
  const m = ART_MANIFEST.permits;
  return m && m[name] ? `/art/${m[name]}` : null;
}

/* The eight cities the exchange is built for, engraved along the head of the
   board. Not decoration for its own sake: York is the only market open, and
   the row is the standing promise that it is the first of eight. */
const CIVIC_LANDMARKS = [
  ["London", "M29 68h24M32 64h18l-2-36H34zM36 28h10l-1-10h-8zM40 18V8m-5 7h10M34 38h14M37 45h8M40 31v5"],
  ["Edinburgh", "M10 65h60M14 58h52v7H14zM18 58V37h12V25h12v12h20v21M22 37v-8h8v8m12 0V20h9v17m7 0v-7h7v11M24 47h6m17 0h8"],
  ["Cardiff", "M14 65h52M19 62V29h12v7h18v-7h12v33M15 29h20l-3-8H18zM45 29h20l-3-8H48zM31 62V48h18v14M24 42h6m20 0h6"],
  ["Newcastle", "M7 61h66M11 56h58M15 55c5-34 45-34 50 0M22 55c5-24 31-24 36 0M18 55V40m44 15V40M11 49h58"],
  ["Manchester", "M13 65h54M18 61V32h16V20h14v12h14v29M34 61V35h14v26M38 20l3-10 3 10M23 41h6m-6 9h6m24-9h5m-5 9h5M39 42h4v7h-4z"],
  ["Liverpool", "M12 65h56M18 61V27h14v34m16 0V27h14v34M21 27h8l-1-8h-6zM51 27h8l-1-8h-6zM25 19V9m30 10V9M22 42h7m22 0h7M37 61V34h6v27"],
  ["Birmingham", "M12 65h56M17 61V28h46v33M17 28l8-10h38v10M25 37h8v8h-8zm13 0h8v8h-8zm13 0h8v8h-8zM25 50h8v8h-8zm13 0h8v8h-8zm13 0h8v8h-8z"],
  ["York", "M10 65h60M16 61V32h12v-9l7 9h10l7-9v9h12v29M28 61V21h24v40M32 21l8-13 8 13M36 42h8v19M20 42h5m30 0h5"],
];

function LandmarkRow() {
  return (
    <div className="landmark-motifs" aria-hidden="true">
      {CIVIC_LANDMARKS.map(([city, d]) => (
        <span className="landmark" key={city} title={city}>
          <svg viewBox="0 0 80 80"><path d={d} /></svg>
        </span>
      ))}
    </div>
  );
}

/* The chair's gavel, resting on its block. It is the only thing on the board
   that moves of its own accord — one strike when the rulings begin. */
function CeremonialGavel() {
  return (
    <div className="gavel-box" aria-hidden="true">
      <svg className="ceremonial-gavel" viewBox="0 0 96 96">
        <defs>
          <linearGradient id="gavel-wood" x1="0" y1="0" x2="1" y2="1">
            <stop offset="0" stopColor="#bd8961" /><stop offset=".48" stopColor="#75472f" />
            <stop offset="1" stopColor="#3c2116" />
          </linearGradient>
          <linearGradient id="gavel-brass" x1="0" y1="0" x2="0" y2="1">
            <stop offset="0" stopColor="#f2dc8b" /><stop offset=".45" stopColor="#bb9134" />
            <stop offset="1" stopColor="#6f4e13" />
          </linearGradient>
        </defs>
        <g transform="rotate(-31 48 48)">
          <path className="gavel-handle-svg" d="M44 41h10v44c0 4-10 4-10 0Z" />
          <path className="gavel-head-svg" d="M18 20h62v25H18c-6 0-6-25 0-25Z" />
          <path className="gavel-end-svg" d="M12 18h12v29H12c-7 0-7-29 0-29Zm68 0h7c7 0 7 29 0 29h-7Z" />
          <path className="gavel-band-svg" d="M25 19h7v27h-7zm36 0h7v27h-7zM42 40h14v8H42Z" />
          <path className="gavel-engraving-svg" d="M39 27h15m-12 5h9M49 23v13" />
          <circle className="gavel-gem-svg" cx="49" cy="31" r="3" />
        </g>
        <path className="sound-block-svg" d="M16 82h60l7 7H9Z" />
        <path className="sound-block-band-svg" d="M20 78h52v5H20Z" />
      </svg>
    </div>
  );
}

/* The board furniture, shared by the live board and the replay so that the
   thing a player places tokens on and the thing they watch afterwards are
   visibly the same object. */
/* THE FRAME ON ITS OWN.

   The board's identity is the ridge border, the brass corners and the
   ground inside them; the York masthead, the landmarks and the gavel belong
   to the Planning Committee alone. Split out so the Council's other game can
   sit on the same furniture without borrowing the committee's letterhead —
   `tone` picks the ground, felt for a board the room plays on and ivory for
   a document one person fills in. */
function OrnateFrame({ tone = "felt", className = "", children }) {
  return (
    <div className={"ornate-board tone-" + tone + (className ? " " + className : "")}>
      <span className="corner corner-tl" aria-hidden="true" />
      <span className="corner corner-tr" aria-hidden="true" />
      <span className="corner corner-bl" aria-hidden="true" />
      <span className="corner corner-br" aria-hidden="true" />
      {children}
    </div>
  );
}

/* A masthead in the board's own voice: a rule, a title, a line of
   subtitle. The committee has its own grander one below. */
function FrameHead({ kicker, title, sub, status }) {
  return (
    <>
      <header className="frame-head">
        <span>{kicker}</span>
        <h3>{title}</h3>
        {sub && <p>{sub}</p>}
      </header>
      {status && <div className="board-rule"><span /><b>{status}</b><span /></div>}
    </>
  );
}

function BoardFrame({ session, status, children, footer }) {
  return (
    <OrnateFrame tone="felt">
      <LandmarkRow />
      <header className="board-masthead">
        <div className="masthead-spacer" aria-hidden="true" />
        <div>
          <span>THE CITY OF YORK</span>
          <h2>Planning Committee</h2>
          <p>Register of permits{session != null ? ` · Session ${session}` : ""}</p>
        </div>
        <CeremonialGavel />
      </header>
      <div className="board-rule"><span /><b>{status}</b><span /></div>
      {children}
      <footer className="board-footer">
        {footer || (<>
          <span>SUPPORT BUILDS THE MAJORITY</span><i>•</i>
          <span>A SMEAR POISONS THE AWARD</span><i>•</i>
          <span>ONE VETO STOPS A PERMIT</span>
        </>)}
      </footer>
    </OrnateFrame>
  );
}

/* Worn by whoever came out of the session ahead of the room. */
function WinnerCrown() {
  return (
    <span className="winner-crown" aria-label="Highest scorer">
      <svg viewBox="0 0 80 60" aria-hidden="true">
        <path className="crown-body" d="M8 43 4 16l18 13L29 8l11 17L51 8l7 21 18-13-4 27H8Z" />
        <path className="crown-shadow" d="m12 39-2-15 13 10 7-17 10 16 10-16 7 17 13-10-2 15Z" />
        <path className="crown-band" d="M8 43h64v10H8zM12 47h56" />
        <path className="crown-filigree" d="M22 42c1-8 8-8 9 0m18 0c1-8 8-8 9 0M40 39V27m-4 7 4-7 4 7" />
        <circle className="crown-tip" cx="4" cy="14" r="4" />
        <circle className="crown-tip" cx="29" cy="6" r="4" />
        <circle className="crown-tip" cx="51" cy="6" r="4" />
        <circle className="crown-tip" cx="76" cy="14" r="4" />
        <path className="crown-orb" d="M36 6a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-4v-4m-4 2h8" />
        <circle className="crown-jewel jewel-ruby" cx="20" cy="48" r="3" />
        <path className="crown-jewel jewel-sapphire" d="m40 44 4 4-4 4-4-4Z" />
        <circle className="crown-jewel jewel-emerald" cx="60" cy="48" r="3" />
      </svg>
    </span>
  );
}

/* The elevation drawn in CSS, standing in until there is a real drawing.
   Deliberately an architect's presentation slab rather than a photograph —
   the permits are schemes on paper, not buildings that exist. */
function PermitArt({ permit }) {
  const src = permitArt(permit.name);
  if (src) return <div className="permit-art"><img src={src} alt="" loading="lazy" /></div>;
  return <div className="permit-art drawn" aria-hidden="true" />;
}

const VERDICT = {
  awarded: "CARRIED", poisoned: "SCANDAL", vetoed: "VETOED",
  tied: "TIED", unbid: "NO BIDS",
};
const MARK = (kind) => (kind === "support" ? "S" : kind === "smear" ? "M" : "V");

/* THE REPLAY.

   The board is blind all evening and then the result arrived as a paragraph,
   which is an anticlimax for the one mechanic in the game where everybody
   acted against each other at once. This puts the concealed ballot back on
   the table one player at a time, lets the chair rule on each permit, and
   flies the points to whoever earned them.

   NOTHING HERE DECIDES ANYTHING. Every outcome, every delta and every
   recipient is read off the minutes the server already wrote; the component
   only chooses the order they appear in. */
function CommitteeTheatre({ minutes, meId, onClose }) {
  /* FROZEN AT THE CURTAIN.

     The minutes arrive inside shared state, and the server sends a FULL
     state every few minutes to heal patch drift (RESYNC_MS in server.js).
     Reading the live object made the sequence a dependency of that resync:
     when one landed mid-replay the effect tore down and re-ran, and every
     token was laid a second time on top of the ones already there — 49
     chips on a 30-chip board, no error, nothing going backwards. Measured
     with tools/committeeshot.mjs and RESYNC_MS turned down to 4s. A settled
     round cannot change, so the replay plays the copy it opened with. */
  const m = useRef(minutes).current;
  const stageRef = useRef(null);
  const flyRef = useRef(null);
  const wells = useRef(new Map());
  const trays = useRef(new Map());
  const reduced = useMemo(() => typeof window !== "undefined" && window.matchMedia
    && window.matchMedia("(prefers-reduced-motion: reduce)").matches, []);

  const seats = m.seats && m.seats.length
    ? m.seats
    : [...new Map(m.permits.flatMap((p) => p.placements.map((t) => [t.id, { id: t.id, name: t.who }])))
        .values()];

  const [phase, setPhase] = useState("open");
  const [status, setStatus] = useState("The clerk is reconstructing the concealed ballot");
  const [active, setActive] = useState(null);
  const [landed, setLanded] = useState([]);
  const [resolved, setResolved] = useState({});
  const [hit, setHit] = useState(null);
  const [scores, setScores] = useState(() => Object.fromEntries(seats.map((s) => [s.id, 0])));
  const [clock, setClock] = useState(0);
  const [sound, setSound] = useState(soundPreferred);
  const soundRef = useRef(sound);
  soundRef.current = sound;
  const cue = useCallback((fn) => { if (soundRef.current) fn(); }, []);

  /* Every token, in the order it will be laid: player by player, so the
     replay reads as a series of confessions rather than a shower. */
  const script = useMemo(() => {
    const bySeat = new Map(seats.map((s) => [s.id, []]));
    for (const p of m.permits) {
      for (const pl of p.placements) {
        if (!bySeat.has(pl.id)) bySeat.set(pl.id, []);
        for (let i = 0; i < pl.n; i += 1) {
          bySeat.get(pl.id).push({ seat: pl.id, permit: p.no, kind: pl.kind, i });
        }
      }
    }
    return bySeat;
  }, [m, seats]);

  const totalTokens = useMemo(
    () => [...script.values()].reduce((s, a) => s + a.length, 0), [script]);

  /* A six-player field can put sixty tokens on the table. At a fixed cadence
     that is a minute of watching chips land, so the stride closes up as the
     board fills and the whole replay stays inside about fifteen seconds. */
  const stride = Math.max(42, Math.min(115, Math.round(3400 / Math.max(1, totalTokens))));

  const fly = useCallback((from, to, node, ms, easing) => {
    const stage = stageRef.current, layer = flyRef.current;
    if (!stage || !layer || !from || !to) return null;
    const s = stage.getBoundingClientRect();
    const a = from.getBoundingClientRect(), b = to.getBoundingClientRect();
    node.style.left = `${a.left + a.width / 2 - s.left - 17}px`;
    node.style.top = `${a.top + a.height / 2 - s.top - 17}px`;
    layer.append(node);
    const dx = b.left + b.width / 2 - (a.left + a.width / 2);
    const dy = b.top + b.height / 2 - (a.top + a.height / 2);
    return { node, dx, dy, ms, easing };
  }, []);

  useEffect(() => {
    /* MOTION OFF MEANS THE ANSWER NOW, NOT THE SAME WAIT WITHOUT THE MOTION.

       The first pass here merely skipped the flights and kept the fourteen
       seconds of sequencing, which is the worst of both: nothing to watch
       and still a wait. Somebody who has asked their machine to stop moving
       things gets the finished board - every token laid, every permit
       ruled, every score final - the moment they open it. */
    if (reduced) {
      const all = [];
      for (const seat of seats) {
        for (const t of script.get(seat.id) || []) {
          all.push({ ...t, key: `${t.seat}:${t.permit}:${t.kind}:${t.i}`,
            mark: MARK(t.kind), who: seat.name });
        }
      }
      setLanded(all);
      setResolved(Object.fromEntries(m.permits.map((p) => [p.no, true])));
      const final = Object.fromEntries(seats.map((sx) => [sx.id, 0]));
      for (const p of m.permits) for (const r of p.paid || []) final[r.id] = (final[r.id] || 0) + r.delta;
      setScores(final);
      setPhase("done");
      setStatus("Final resolutions entered into the city register");
      return undefined;
    }

    const timers = [];
    const at = (ms, fn) => timers.push(setTimeout(fn, ms));
    let cursor = 620;

    at(0, () => { setPhase("replay"); setStatus("The clerk is reconstructing the concealed ballot"); });

    for (const seat of seats) {
      const mine = script.get(seat.id) || [];
      const start = cursor;
      at(start, () => {
        setActive(seat.id);
        setStatus(mine.length
          ? `Replaying ${seat.name}'s concealed assignments`
          : `${seat.name} sent an empty board`);
        cue(() => tone(330 + (seats.indexOf(seat) % 5) * 28, 0.12, 0.032, "triangle"));
      });
      mine.forEach((t, j) => at(start + 240 + j * stride, () => {
        const key = `${t.seat}:${t.permit}:${t.kind}:${t.i}`;
        const land = () => {
          setLanded((L) => [...L, { ...t, key, mark: MARK(t.kind), who: seat.name }]);
          cue(() => tone(520 + j * 15, 0.05, 0.016, "sine"));
        };
        const chip = document.createElement("span");
        chip.className = `flying-token ${t.kind}`;
        chip.textContent = MARK(t.kind);
        const plan = fly(trays.current.get(seat.id), wells.current.get(t.permit), chip);
        if (!plan) { land(); return; }
        const spin = j % 2 ? 380 : -330;
        chip.animate([
          { transform: "translate(0,0) scale(.65)", opacity: 0 },
          { transform: `translate(${plan.dx * 0.48}px,${plan.dy * 0.34 - 54}px) scale(1.16) rotate(${spin * 0.46}deg)`, opacity: 1, offset: 0.52 },
          { transform: `translate(${plan.dx}px,${plan.dy}px) scale(.72) rotate(${spin}deg)`, opacity: 1 },
        ], { duration: 400, easing: "cubic-bezier(.22,.68,.28,1)" }).onfinish = () => { chip.remove(); land(); };
      }));
      cursor = start + 240 + mine.length * stride + (mine.length ? 380 : 520);
    }

    /* The chair. */
    const rulings = cursor + 200;
    at(rulings, () => {
      setActive(null); setPhase("rulings");
      setStatus("The chair is entering every resolution into the minutes");
      cue(() => impact(0.13, 500));
    });
    m.permits.forEach((p, i) => at(rulings + 400 + i * 190, () => {
      setResolved((r) => ({ ...r, [p.no]: true }));
      cue(() => tone(p.outcome === "vetoed" ? 110 : p.outcome === "poisoned" ? 147 : 440 + i * 22,
        0.11, 0.026, p.outcome === "vetoed" || p.outcome === "poisoned" ? "square" : "triangle"));
    }));

    /* The money. One flight per player a permit actually paid — which for a
       vetoed permit is every player who cast one, not a single winner. */
    const paying = m.permits.filter((p) => p.paid && p.paid.length);
    const awards = rulings + 400 + m.permits.length * 190 + 320;
    at(awards, () => {
      setPhase("awards"); setStatus("Awards are passing from each permit to the players who earned them");
      cue(() => tone(659.25, 0.2, 0.038));
    });
    paying.forEach((p, i) => at(awards + 260 + i * 320, () => {
      for (const r of p.paid) {
        const pay = () => {
          setScores((S) => ({ ...S, [r.id]: (S[r.id] || 0) + r.delta }));
          setHit(r.id + ":" + p.no);
          cue(() => tone(r.delta > 0 ? 783.99 : 130.81, 0.18, 0.038, r.delta > 0 ? "sine" : "square"));
        };
        const orb = document.createElement("span");
        orb.className = "score-orb" + (r.delta < 0 ? " penalty" : "");
        orb.textContent = `${r.delta > 0 ? "+" : "−"}${Math.abs(r.delta)}`;
        const plan = fly(wells.current.get(p.no), trays.current.get(r.id), orb);
        if (!plan) { pay(); continue; }
        orb.animate([
          { transform: "translate(0,0) scale(.4)", opacity: 0 },
          { transform: `translate(${plan.dx * 0.48}px,${plan.dy * 0.25 - 70}px) scale(1.22)`, opacity: 1, offset: 0.5 },
          { transform: `translate(${plan.dx}px,${plan.dy}px) scale(.7)`, opacity: 1 },
        ], { duration: 620, easing: "cubic-bezier(.18,.7,.25,1)" }).onfinish = () => { orb.remove(); pay(); };
      }
    }));

    const end = awards + 260 + paying.length * 320 + 760;
    at(end, () => {
      setPhase("done"); setStatus("Final resolutions entered into the city register");
      cue(() => {
        impact(0.12, 640);
        [523.25, 659.25, 783.99, 1046.5].forEach((n, i) =>
          timers.push(setTimeout(() => tone(n, 0.32, 0.04 - i * 0.005, i < 2 ? "triangle" : "sine"), 110 + i * 130)));
      });
    });

    /* The minutes clock. Tenths, like the inspector's — a readout in
       hundredths does not need sixty renders a second. */
    const t0 = Date.now();
    const tick = setInterval(() => {
      const e = Math.min(end, Date.now() - t0);
      setClock(e);
      if (e >= end) clearInterval(tick);
    }, 100);

    return () => { timers.forEach(clearTimeout); clearInterval(tick); };
  }, [m, reduced, stride, script, cue, fly]);

  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);

  const done = phase === "done";
  const ranked = [...seats].sort((a, b) => (scores[b.id] || 0) - (scores[a.id] || 0));
  const activeSeat = seats.find((s) => s.id === active);
  const clockText = `00:${String(Math.floor(clock / 1000)).padStart(2, "0")}`;

  return (
    <div className="res-overlay" role="dialog" aria-modal="true" aria-label="The Planning Committee replay">
      <div className={"pc-theatre ph-" + phase} ref={stageRef}>
        <div className="table-light" aria-hidden="true" />
        <div className="wood-grain" aria-hidden="true" />
        <div className="union-inlay" aria-hidden="true" />

        <header className="pc-head">
          <div>
            <span className="pc-kicker">THE PLANNING COMMITTEE · CLOSED SESSION</span>
            <b>{status}</b>
          </div>
          <div className="table-inscription" aria-hidden="true">
            <b>UNITED KINGDOM CIVIC EXCHANGE</b>
            <i>ESTABLISHED FOR INDUSTRY · TRADE · ENTERPRISE</i>
          </div>
          <div className="res-tools">
            <button className="res-mute" onClick={() => { const n = !sound; setSound(n); rememberSound(n); }}
                    aria-pressed={sound} aria-label={sound ? "Mute sound" : "Turn sound on"}>
              {sound ? "♪" : "✕"}
            </button>
            <span className="res-timer">{clockText}</span>
            <button className="pc-close" onClick={onClose} aria-label="Close the replay">✕</button>
          </div>
        </header>

        {activeSeat && (
          <div className="player-callout" key={activeSeat.id}>
            <span>REPLAYING THE BOARD OF</span>
            <b>{activeSeat.name}{activeSeat.id === meId ? " (you)" : ""}</b>
          </div>
        )}

        <BoardFrame session={m.round} status={
          phase === "open" || phase === "replay" ? "Assignments being returned to the board"
            : phase === "rulings" ? "The chair is ruling" : "Resolutions final"}>
          <div className="permit-grid replay">
            {m.permits.map((p) => {
              const chips = landed.filter((t) => t.permit === p.no);
              const shut = !!resolved[p.no];
              return (
                <article className={"permit-tile t-" + p.tier + (shut ? " resolved o-" + p.outcome : "")}
                         key={p.no}>
                  <PermitArt permit={p} />
                  <div className="permit-meta"><span>{TIER_LABEL[p.tier]}</span><span>{p.award} SP</span></div>
                  <h3>{p.name}</h3>
                  <div className="token-well" ref={(el) => { if (el) wells.current.set(p.no, el); }}>
                    {chips.length
                      ? chips.map((t) => (
                          <span className={"board-token " + t.kind} key={t.key}
                                title={`${t.who}: ${t.kind}`}>{t.mark}</span>))
                      : <span className="empty-well">{shut ? "NOTHING PLACED" : "ASSIGNMENTS SEALED"}</span>}
                  </div>
                  <div className="tile-verdict">
                    <b>{VERDICT[p.outcome] || "RULED"}</b>
                    <strong>{p.delta ? `${p.delta > 0 ? "+" : "−"}${Math.abs(p.delta)} SP` : "—"}</strong>
                  </div>
                  <div className="resolution-stamp">{VERDICT[p.outcome] || "RULED"}</div>
                </article>
              );
            })}
          </div>
        </BoardFrame>

        <div className="pc-trays">
          {seats.map((s) => (
            <aside className={"player-tray" + (s.id === active ? " active" : "")
                             + (hit && hit.startsWith(s.id + ":") ? " score-hit" : "")}
                   key={s.id} ref={(el) => { if (el) trays.current.set(s.id, el); }}>
              <span className="tray-medallion">{(s.name || "?").slice(0, 1).toUpperCase()}</span>
              <div>
                <small>{s.id === meId ? "YOU" : "COMMITTEE MEMBER"}</small>
                <b>{s.name}</b>
              </div>
              <strong className={"player-score" + ((scores[s.id] || 0) < 0 ? " down" : "")}>
                {(scores[s.id] || 0) >= 0 ? "+" : "−"}{Math.abs(scores[s.id] || 0)} SP
              </strong>
            </aside>
          ))}
        </div>

        <div className="flying-layer" ref={flyRef} aria-hidden="true" />
        <div className="resolution-flash" aria-hidden="true" />

        {done && (
          <div className="final-minutes">
            <span className="minutes-kicker">THE MINUTES · SESSION {m.round}</span>
            <h2>Committee adjourned</h2>
            <p>Every application has had the chair's ruling and every point has reached its player.</p>
            <div className="score-cards">
              {ranked.map((s, i) => {
                const d = scores[s.id] || 0;
                /* The crown is for winning, not for finishing first. A field
                   where everybody came out level, or everybody down, has no
                   champion to crown — and handing one out anyway would be
                   the first dishonest thing on this board. */
                const champion = i === 0 && d > 0;
                return (
                  <div className={"score-card" + (champion ? " winner" : "")
                                 + (s.id === meId ? " yours" : "")} key={s.id}>
                    {champion && <WinnerCrown />}
                    <span className="rank-medal">{String(i + 1).padStart(2, "0")}</span>
                    <b>{s.name}{s.id === meId ? " (you)" : ""}</b>
                    <small>{champion ? "Committee champion"
                      : d > 0 ? "Came away up" : d < 0 ? "Came away down" : "Nothing landed"}</small>
                    <strong className={d < 0 ? "down" : ""}>
                      {d >= 0 ? "+" : "−"}{Math.abs(d)} SP
                    </strong>
                  </div>
                );
              })}
            </div>
            <button className="btn" style={{ marginTop: 16 }} onClick={onClose}>
              Read the written minutes
            </button>
          </div>
        )}
      </div>
    </div>
  );
}

function CommitteeBoard({ me, onAct }) {
  const c = me.committee;
  /* The board is edited locally and sent in one go. A token-at-a-time API
     would let a player watch the volume counter move in response to their
     own placements, which tells them nothing and looks like a leak. */
  const [draft, setDraft] = useState(() => {
    const d = {};
    for (const [no, t] of Object.entries(c.mine || {})) d[no] = { ...t };
    return d;
  });
  const [dirty, setDirty] = useState(false);

  const held = c.holding;
  const used = { support: 0, smear: 0, veto: 0 };
  for (const t of Object.values(draft)) {
    used.support += t.support || 0; used.smear += t.smear || 0; used.veto += t.veto || 0;
  }
  const left = {
    support: held.support - used.support,
    smear: held.smear - used.smear,
    veto: held.veto - used.veto,
  };

  const move = (no, kind, delta) => {
    if (c.committed) return;
    if (delta > 0 && left[kind] <= 0) return;
    setDraft((d) => {
      const cur = { support: 0, smear: 0, veto: 0, ...(d[no] || {}) };
      const next = Math.max(0, (cur[kind] || 0) + delta);
      return { ...d, [no]: { ...cur, [kind]: next } };
    });
    setDirty(true);
  };

  const send = () => {
    const board = [];
    for (const [no, t] of Object.entries(draft)) {
      for (const kind of ["support", "smear", "veto"]) {
        if (t[kind]) board.push({ permit: Number(no), kind, n: t[kind] });
      }
    }
    onAct("committee.commit", { board });
    setDirty(false);
  };

  if (c.committed) {
    return (
      <div className="council-card">
        <h4>Your board is before the committee</h4>
        <div className="muted" style={{ marginBottom: 10 }}>
          {c.sent} of {c.podSize} in your field {c.sent === 1 ? "has" : "have"} sent theirs. Everything is turned face up at the close.
        </div>
        <BoardFrame session={c.round} status="Your board is lodged. The chamber is sealed"
                    footer={<><span>SEALED UNTIL THE EVENING CLOSE</span></>}>
          <div className="permit-grid sent">
            {c.permits.map((p) => {
              const mine = c.mine[p.no] || {};
              const any = (mine.support || 0) + (mine.smear || 0) + (mine.veto || 0);
              return (
                <div className={"permit t-" + p.tier + (any ? " yours" : "")} key={p.no}>
                  <PermitArt permit={p} />
                  <span className="permit-tier">{TIER_LABEL[p.tier]}</span>
                  <b className="permit-name">{p.name}</b>
                  <span className={"permit-volume" + (p.touched ? " touched" : "")}>
                    {p.touched ? "Tokens placed" : "Nothing placed"}
                  </span>
                  {any > 0 && (
                    <span className="permit-mine">
                      {mine.support ? `${mine.support} support ` : ""}
                      {mine.smear ? `${mine.smear} smear ` : ""}
                      {mine.veto ? "VETO" : ""}
                    </span>
                  )}
                </div>
              );
            })}
          </div>
        </BoardFrame>
      </div>
    );
  }

  return (
    <div className="council-card">
      <h4>Tonight's permits</h4>
      <div className="muted" style={{ marginBottom: 10 }}>
        You see only whether a permit has been touched. You do not see how many tokens, whose, or which kind.
      </div>
      {c.boardScale < 1 && (
        /* A small committee pays a smaller board (29 Sep 2026). The figures
           on the permits are already what they pay with the boards in so
           far, counting this one; they can only rise. */
        <div className="hold-note" style={{ marginBottom: 10 }}>
          With {Math.max(2, c.sent + 1)} boards in, counting yours, every permit pays {Math.round(c.boardScale * 100)}% of
          its full award. The more of your field that sends a board, the more each permit pays: in full once six are in.
        </div>
      )}

      <div className="token-purse">
        <span className={"purse-chip support" + (left.support ? "" : " spent")}>
          {left.support}<em>support</em></span>
        <span className={"purse-chip smear" + (left.smear ? "" : " spent")}>
          {left.smear}<em>smear</em></span>
        <span className={"purse-chip veto" + (left.veto ? "" : " spent")}>
          {left.veto}<em>veto</em></span>
        {c.vetoSpent && <span className="purse-note">your veto is gone until Monday</span>}
        {c.smearSpent && <span className="purse-note">your smear is gone until Monday</span>}
      </div>

      <BoardFrame session={c.round} status="The chamber is open. Assignments remain concealed">
      <div className="permit-grid">
        {c.permits.map((p) => {
          const t = draft[p.no] || {};
          const any = (t.support || 0) + (t.smear || 0) + (t.veto || 0);
          return (
            <div className={"permit t-" + p.tier + (any ? " yours" : "")} key={p.no}>
              <PermitArt permit={p} />
              <span className="permit-tier">{TIER_LABEL[p.tier]} · {p.award}</span>
              <b className="permit-name">{p.name}</b>
              {/* Whether, not how many. A count told whoever placed second
                  exactly what they were outbidding while the first placed
                  blind — turn order should not be an advantage in a game
                  nobody has to be online for. */}
              <span className={"permit-volume" + (p.touched ? " touched" : "")}>
                {p.touched ? "Tokens placed" : "Nothing placed"}
              </span>
              <div className="permit-place">
                <button className="tok support" onClick={() => move(p.no, "support", 1)}
                        disabled={left.support <= 0} title="Support: one point toward your bid">
                  +<span>{t.support || 0}</span></button>
                <button className="tok smear" onClick={() => move(p.no, "smear", 1)}
                        disabled={left.smear <= 0}
                        title="Smear: whoever wins this permit loses the award instead. One a week.">
                  +<span>{t.smear || 0}</span></button>
                <button className="tok veto" onClick={() => move(p.no, "veto", 1)}
                        disabled={left.veto <= 0} title="Veto: voids it outright. One a week.">
                  +<span>{t.veto || 0}</span></button>
                {any > 0 && (
                  <button className="tok clear" onClick={() => {
                    setDraft((d) => ({ ...d, [p.no]: { support: 0, smear: 0, veto: 0 } })); setDirty(true);
                  }} title="Take them back">×</button>
                )}
              </div>
            </div>
          );
        })}
      </div>
      </BoardFrame>

      <button className="btn" style={{ width: "100%", marginTop: 12 }}
              disabled={!Object.values(draft).some((t) => (t.support || 0) + (t.smear || 0) + (t.veto || 0))}
              onClick={send}>
        Send the board to the committee
      </button>
      <div className="muted" style={{ marginTop: 8, fontSize: 11.5 }}>
        Once it is sent nothing can be moved. {dirty ? "You have unsent changes." : ""}
      </div>
    </div>
  );
}

/* The morning after. Everything face up, with names on it. */
const SEEN_COMMITTEE = new Set();

function CommitteeMinutes({ me }) {
  const m = me.committee.minutes;
  /* Opt-in, and never automatic. The minutes sit well down the Council tab,
     so a replay that started on its own would be playing to an empty room
     half the time — and a fourteen-second animation nobody asked for is a
     cost, not a reward. */
  const [replay, setReplay] = useState(false);
  /* THE WRITTEN MINUTES ARE NOW OPT-IN.

     Eight permits, each with a verdict, a winner, a bid and a list of who
     placed what, printed in full under a line that has already told you the
     only number most players came for — what they came away with. That is a
     page of reading a player has not asked for, sitting between them and
     the rest of the tab, every single morning.

     Closed by default, and the total above it stays visible either way, so
     shutting it costs nobody the answer. The theatre's own exit button
     opens it rather than dropping you back at a closed panel — somebody who
     has just watched the whole session is exactly the person who wants the
     detail. */
  const [written, setWritten] = useState(false);
  if (!m) return null;
  const watched = SEEN_COMMITTEE.has(m.round);

  if (m.nosit) {
    return (
      <div className="council-card">
        <h4>The minutes</h4>
        <div className="minutes-total">
          The committee did not sit: too few boards were sent. Your tokens were handed back.
        </div>
      </div>
    );
  }

  return (
    <div className="council-card">
      {replay && (
        <CommitteeTheatre minutes={m} meId={me.id} onClose={() => {
          SEEN_COMMITTEE.add(m.round); setReplay(false); setWritten(true);
        }} />
      )}
      <h4>The minutes</h4>
      <div className={"minutes-total " + (m.yourDelta >= 0 ? "up" : "down")}>
        Last night you came away {m.yourDelta >= 0 ? "+" : ""}{m.yourDelta} skill
      </div>
      <div className="minutes-acts">
        <button className="btn reveal-outcome" onClick={() => setReplay(true)}>
          <span aria-hidden="true">▶</span>
          {watched ? "Watch the session again" : "Reveal the outcome"}
        </button>
        <button className="btn secondary" aria-expanded={written}
                onClick={() => setWritten(!written)}>
          {written ? "Hide the written minutes ▴" : `Read the written minutes ▾`}
        </button>
      </div>
      {written && m.permits.map((p, i) => (
        <div className={"minute o-" + p.outcome} key={i}>
          <div className="minute-head">
            <b>{p.name}</b>
            <span className={"minute-tier t-" + p.tier}>{TIER_LABEL[p.tier]}</span>
          </div>
          <div className="minute-what">
            {p.outcome === "awarded" && <>went to <b>{p.winner}</b> on a bid of {p.topBid}, <span className="chg-up">+{p.delta}</span></>}
            {p.outcome === "poisoned" && <>went to <b>{p.winner}</b> on a bid of {p.topBid}, and the objections made it a scandal, <span className="chg-down">{p.delta}</span></>}
            {p.outcome === "vetoed" && <>was vetoed{p.topBid ? `, and a bid of ${p.topBid} died with it` : " before anyone bid"}</>}
            {p.outcome === "tied" && <>was tied at {p.topBid} and went to nobody</>}
            {p.outcome === "unbid" && <>drew no bids at all</>}
          </div>
          {!!p.placements.length && (
            <div className="minute-placements">
              {p.placements.map((pl, j) => (
                <span className={"placement k-" + pl.kind} key={j}>
                  {pl.who}: {pl.n} {pl.kind === "support" ? "support" : pl.kind}
                </span>
              ))}
            </div>
          )}
        </div>
      ))}
    </div>
  );
}

/* ---- THE TONTINE ------------------------------------------------------

   A roll of candles on parchment: one for each trader who sat down, and a
   seventh, taller and unnamed, for the Council — which put the purse up
   and stands in the hat with everybody else.

   Every flame burns the whole time. That is the point of the thing: the
   panel is never still, and the pulse quickens as the week runs on, so
   Monday feels like a decision and Saturday feels like one you have put
   off too long. It is the same animation throughout with the duration
   interpolated by the night — no second asset, no second state to keep
   in step.

   Three fates on the roll, and they look different because they ARE
   different:

     still in    a lit candle, breathing
     took        an empty holder with the sum they took beneath it. Gone,
                 intact, visibly better off than the stub beside it.
     struck      a cold stub, name ruled through. Still on the roll, still
                 read out — and when it comes out of the hat, nothing
                 happens, which is the reprieve everyone still lit is
                 hoping for.

   No GIF: it would be fixed at one size, weigh more than the rest of the
   panel, and could not be turned off for anybody who asked not to have
   motion. */
const FLAME = "M12 2.2c1.9 2.6 3.4 4.3 3.4 6.4 0 2.1-1.5 3.6-3.4 3.6S8.6 10.7 8.6 8.6c0-2.1 1.5-3.8 3.4-6.4Z";

function Candle({ name, state, took, tall, me, night, mark, struckNight }) {
  /* Monday breathes at three seconds, Saturday at a shade over one. */
  const beat = (3.1 - (night || 0) * 0.34).toFixed(2);
  const lit = state === "in";
  /* THE WAX GOES DOWN AS THE WEEK RUNS ON, AND A STRUCK CANDLE STOPS WHERE
     IT WAS. A trader who fell on Monday keeps Monday's height for the rest
     of the book, so by Saturday the row is a record of when people went:
     the tall stubs went early, the short ones very nearly made it. The
     flame does not travel down with the wax — the wick stretches to reach
     it, which is what makes a late candle look like it is running out. */
  const burnt = state === "struck" && struckNight != null ? struckNight : (night || 0);
  return (
    <div className={"tont-candle" + (tall ? " is-council" : "") + (me ? " is-me" : "")
      + (lit ? "" : state === "out" ? " is-gone" : " is-stub")
      + (mark ? " " + mark : "")}
      /* THE BURN RATE. Aaron's workbench had this at 4% a night, which
         reads as almost nothing across six nights and — worse — made a
         Monday death and a Thursday death the same height, which is the
         one thing the frozen wax is for. At 7.5% Saturday stands at 62%
         of Monday and the stubs sort themselves by when they fell. Two
         numbers, one line: put 0.04 and 1.5 back to undo it. */
      style={{ "--wax-level": (1 - burnt * 0.075).toFixed(3),
               "--wick-grow": (1 + burnt * 2.6).toFixed(2) + "px" }}>
      <svg viewBox="0 0 24 34" aria-hidden="true">
        {lit && (
          <g className="tont-flame" style={{ animationDuration: `${beat}s` }}>
            <path d={FLAME} className="tont-halo" />
            <path d={FLAME} className="tont-fire" />
          </g>
        )}
        {state === "struck" && <path className="tont-smoke" d="M12 11c2 -2 -2 -4 0 -6.5" />}
        {state === "out" ? (
          /* THE GHOST OF A CANDLE. An empty holder alone read as nothing at
             all — a gap in the row rather than a trader who walked away
             with the money. The outline says a candle stood here and was
             carried off, which is a different story from the stub beside
             it. */
          <rect className="tont-ghost" x="8.6" y="12.5" width="6.8" height="16" rx="1" />
        ) : (
          /* ONE RECT FOR EVERY STATE. A struck candle used to get its own
             short rect, which meant it snapped to the same stump whenever
             it fell. It now carries the full wax scaled by --wax-level,
             frozen at the night it went out. */
          <rect className="tont-wax" x="8.6" y="12.5" width="6.8" height="16" rx="1" />
        )}
        <path className="tont-holder" d="M6 29h12l-1.6 3.6H7.6Z" />
      </svg>
      <b>{tall ? "The Council" : me ? "You" : name}</b>
      <span>
        {tall ? (state === "in" ? "in the hat" : "drawn out")
          : state === "in" ? "still in"
          : state === "out" ? `took ${took}`
          : "struck"}
      </span>
    </div>
  );
}

/* THE GAZETTE.

   The visual brief was parchment and mystery, and the trap with both is
   that they turn into decoration. So the paper here is a real broadsheet:
   a masthead with a date line, the six briefs set as classified notices in
   two columns, and the week's editions stacked newest first with a proper
   rule between the days. The mystery is carried by what is MISSING — no
   figures anywhere, the briefs all in the same voice so none of them looks
   like anybody's, and your own column sealed the moment you file it.

   The one moving thing on the page is the wax seal, which breathes while
   your column is still open and stops dead once it is filed. It earns that
   because it is the only state on the page a player can still change. */

/* WHAT AN ORDER WILL COST YOU, worked out before you seal it.

   A MIRROR OF attackCost() IN lib/cards.js. The server charges the real
   figure at the 19:00 resolution; this is what the player is shown while
   they are still choosing. If the two ever disagree the quote is a lie, so
   tools/quotetest.mjs holds them to each other.

   ofTotal means the DISTRICT total, and it used to mean neither side's
   idea of one. lib/deck.js passed the single building's price as both
   figures and this quoted the same, so the two agreed perfectly on a
   number that was about a tenth of what the card was written to cost. They
   now both read the district, at live prices, from the same board.

   The aiming point is still one building, because that is how a district
   order is given; the bill is the district it sits in. */
/* The district comes from the catalogue, because the property payload on
   the wire carries no neighbourhood; the price comes from the live board,
   falling back to basePrice only for a building the poll has not sent.
   Takes a district code; a building id still works, for any order that is
   aimed through a building. */
function districtTotal(props, propId, code) {
  const hood = code || (metaFor(propId) || {}).neighbourhood;
  if (!hood) return 0;
  let total = 0;
  for (const m of ALL_PROPERTIES) {
    if (m.neighbourhood !== hood) continue;
    const live = props && props[m.id];
    /* A BUILDING PRICED AT ZERO IS NOT A MISSING BUILDING. `|| basePrice`
       across the whole expression treated a live price of 0 as absent and
       quoted the opening price instead, which is the one case where the
       quote would have been higher than the bill. The fallback now turns on
       whether the poll has sent the building at all. */
    total += live ? (live.currentPrice || 0) : (m.basePrice || 0);
  }
  return total;
}

/* A district code as a player reads it. The orders that name a district
   have no building to link to, and every place that lists an order used to
   print a dash for them. */
function districtLabel(code) {
  return (NEIGHBOURHOODS.find((n) => n.code === code) || {}).name || null;
}

function orderCost(card, propId, props, districtCode) {
  if (!card || !card.cost) return 0;
  if (card.cost.flat) return card.cost.flat;
  const live = props && propId ? props[propId] : null;
  const base = card.cost.ofTotal
    ? districtTotal(props, propId, districtCode)
    : (live ? (live.currentPrice || 0) : 0);
  return Math.round(base * (card.cost.pct || 0));
}

const NIGHT_NAME = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];

/* What the marked figures say when you hover them. One sentence, because it
   has to be readable in a tooltip on a phone, and it names the two facts a
   player actually needs: which way to look for the truth, and that nothing
   has been taken. */
const SUSPECT_NOTE = "The register has been corrupted. This figure is being reported wrong to you and "
  + "only to you. Other traders and the leaderboard see your true numbers, and nothing has been taken.";

function BriefCard({ brief, mine, picked }) {
  return (
    <div className={"gaz-brief" + (mine ? " is-mine" : "") + (picked ? " is-picked" : "")}>
      <b>{brief.title}</b>
      <p>{brief.text}</p>
      {mine && <span className="gaz-yours">dealt to you</span>}
    </div>
  );
}

/* THE DEDUCTION GAME, STOOD DOWN 25 SEP 2026. Kept whole behind
   GAZETTE_COLUMNS, like Surveyor's Bluff, rather than deleted: the paper it
   printed is still the paper, and the game may yet come back in another
   form. GazettePanel below is what players see now. */
function GazetteColumnsPanel({ game, me, onAct }) {
  const g = game.gazette;
  const [picks, setPicks] = useState({});
  /* THE THREE MOMENTS, EACH PLAYED ONCE.

     The paper is still by design; these are the only things on it that move,
     and each one marks a change in state the player owns. The seal press is
     the exception that needs no key — it can only happen once, because a
     column cannot be filed twice. The other two are keyed in localStorage so
     that a refresh or a tab switch does not replay yesterday's edition or
     make you watch Sunday's reveal a second time on Monday morning. */
  const [pressing, setPressing] = useState(false);
  const [inking, setInking] = useState(null);
  const [turning, setTurning] = useState(false);
  const wasFiled = useRef(!!(g && g.column));
  const filedNow = !!(g && g.column);
  const newest = g && g.edition && g.edition.length ? g.edition[0].night : null;
  const bookId = g ? g.bookId : null;
  const settledKey = g && g.settled && g.truth ? "s" + bookId : null;

  useEffect(() => {
    if (!filedNow || wasFiled.current) { wasFiled.current = filedNow; return; }
    wasFiled.current = true;
    setPressing(true);
    const t = setTimeout(() => setPressing(false), 1700);
    return () => clearTimeout(t);
  }, [filedNow]);

  useEffect(() => {
    if (newest == null || bookId == null) return;
    const key = bookId + ":" + newest;
    try {
      if (localStorage.getItem("gaz.edition") === key) return;
      localStorage.setItem("gaz.edition", key);
    } catch (e) { /* private mode: it plays, and plays again next time */ }
    setInking(newest);
    const t = setTimeout(() => setInking(null), 1500);
    return () => clearTimeout(t);
  }, [bookId, newest]);

  useEffect(() => {
    if (!settledKey) return;
    try {
      if (localStorage.getItem("gaz.settled") === settledKey) return;
      localStorage.setItem("gaz.settled", settledKey);
    } catch (e) { /* as above */ }
    setTurning(true);
    /* The last row starts at 4 x 180ms and runs for 800; a little over two
       seconds covers the whole column however many rivals there are. */
    const t = setTimeout(() => setTurning(false), 2400);
    return () => clearTimeout(t);
  }, [settledKey]);

  if (!g) return null;
  const filed = g.column;
  const tokens = [...g.briefs, { token: g.blank, title: "The Blank", text: "Dealt nothing to chase." }];
  const named = Object.values(picks).filter(Boolean).length;
  const titleOf = (t) => (tokens.find((x) => x.token === t) || {}).title || "—";
  /* What is on the page: the filed column once there is one, otherwise
     whatever is being put together now. */
  const shown = filed ? filed.picks : picks;

  return (
    <GameSection
      tone="gazette"
      name="The Gazette"
        brief={GAZETTE_BRIEF}
      tagline="Six briefs in print. Six hands dealt. Work out whose is whose before you have to say."
      synopsis={GAZETTE_HOW}
      strip={(
        <div className="council-strip">
          <div><span className="stat-label">Skill</span><span className="stat-value">{me.skill.points}</span></div>
          <div><span className="stat-label">Right pays</span>
            <span className="stat-value">{g.rate ? g.rate.right : "—"}</span></div>
          <div><span className="stat-label">Wrong costs</span>
            <span className="stat-value">{g.rate ? g.rate.wrong : "—"}</span></div>
          <div><span className="stat-label">Your column</span>
            <span className="stat-value">{filed ? NIGHT_NAME[filed.night] : "open"}</span></div>
          <div><span className="stat-label">Today's edition</span>
            <span className="stat-value"><CountdownTo at={g.closesAt} /></span></div>
          <div><span className="stat-label">Week settles</span>
            <span className="stat-value"><CountdownTo at={g.weekEndsAt} /></span></div>
        </div>
      )}
    >
      <div className={"gaz-paper" + (pressing ? " is-pressing" : "")}>
        <div className="gaz-masthead">
          <b>The York Gazette</b>
          <em>{NIGHT_NAME[g.night]} · the field's own edition</em>
        </div>

        {/* THE LEADER, ABOVE THE FOLD AND ABOVE THE BRIEFS.

            It is the only thing on this page that is the same for every
            hand, because it is the only thing that is not evidence. It is
            seeded on the week and the night rather than on the reader, so
            it does not move on a refresh and two players comparing notes
            see the same paper. */}
        {g.leader && (
          <div className="gaz-leader-block">
            <span className="gaz-lead-kicker">The leader</span>
            <h3 className="gaz-lead-head">{g.leader.headline}</h3>
            {g.leader.leader && <p className="gaz-lead-body">{g.leader.leader}</p>}
          </div>
        )}

        <h4 className="gaz-head">This week's briefs</h4>
        <p className="gaz-standfirst">
          {g.rivals.length
            ? <>Six were printed and {g.rivals.length + 1} hands dealt from seven. At least one
                of the notices below is being chased by nobody at all.</>
            : <>Six were printed. Nobody else has been seated in your field yet, so for the
                moment the only hand dealt is your own.</>}
        </p>
        <div className="gaz-briefs">
          {g.briefs.map((b) => (
            <BriefCard key={b.token} brief={b}
                       mine={!!(g.hand && g.hand.token === b.token)}
                       picked={Object.values(shown).includes(b.token)} />
          ))}
        </div>

        {g.hand && (
          <div className="gaz-hand">
            <span className="gaz-seal" aria-hidden="true" />
            <div>
              <span className="gaz-hand-kicker">Delivered to you, unopened by anyone else</span>
              <b>{g.hand.title}</b>
              <p>{g.hand.text}</p>
            </div>
          </div>
        )}

        <h4 className="gaz-head">The week so far</h4>
        {!g.edition.length ? (
          <p className="gaz-empty">
            Nothing has gone to press yet. The first edition prints when tonight closes;
            today's doings are never in today's paper.
          </p>
        ) : g.edition.map((day) => (
          <div className={"gaz-day" + (inking === day.night ? " is-inking" : "")} key={day.night}>
            <h5>{NIGHT_NAME[day.night]}</h5>
            <ul>
              {day.items.map((it, i) => (
                <li key={i}><b>{it.name}</b> {it.line}</li>
              ))}
              {!day.items.length && !day.dispatch && <li className="gaz-quiet">Nothing was reported.</li>}
            </ul>
            {/* THE CITY'S EVENING, UNDER THE COLUMN AND SET APART FROM IT.

                Everything above is a named trader being seen doing
                something, which is the evidence the whole game is played
                on. This is a count, about nobody — so it is printed as a
                footnote rather than another entry in the list, because a
                reader hunting a duellist must not mistake it for one. */}
            {day.dispatch && <p className="gaz-dispatch">{day.dispatch}</p>}
          </div>
        ))}

        <h4 className="gaz-head">Your column</h4>
        {g.settled && g.truth ? (
          <div className="gaz-settled">
            <p>
              The week is closed. {filed
                ? <>You filed on {NIGHT_NAME[filed.night]} and took <b>{filed.right}</b> of
                   them, at a cost of <b>{filed.wrong}</b>. {filed.paid >= 0 ? "+" : ""}{filed.paid} skill.</>
                : <>You filed nothing this week.</>}
            </p>
            <ul className="gaz-truth">
              {g.rivals.map((r, i) => {
                const t = (g.truth.find((x) => x.id === r.id) || {}).token;
                const said = filed ? filed.picks[r.id] : null;
                return (
                  <li key={r.id}
                      className={(said ? (said === t ? "right" : "wrong") : "")
                        + (turning ? " is-turning" : "")}
                      /* The stagger lives here rather than in nth-child CSS,
                         because five rivals is this week's number and not a
                         law. */
                      style={turning ? { animationDelay: (i * 0.18).toFixed(2) + "s" } : undefined}>
                    <b>{r.name}</b> held <em>{titleOf(t)}</em>
                    {said && said !== t && <span> (you said {titleOf(said)})</span>}
                  </li>
                );
              })}
            </ul>
          </div>
        ) : filed ? (
          <div className="gaz-filed">
            <span className="gaz-seal is-set" aria-hidden="true" />
            <div>
              <b>Filed on {NIGHT_NAME[filed.night]}, and sealed.</b>
              <p>
                Right pays {g.calls.right[filed.night]}, wrong costs {g.calls.wrong[filed.night]}.
                Nobody sees it, including you, until the week settles on Sunday.
              </p>
            </div>
          </div>
        ) : g.over ? (
          <p className="gaz-empty">The presses have stopped. Columns closed on Saturday.</p>
        ) : !g.rivals.length ? (
          /* A field of one. There is nobody to name, so the whole apparatus
             below would be a form with no rows and a button that cannot be
             pressed — which reads as broken rather than as empty. */
          <p className="gaz-empty">
            There is nobody else in your field yet, so there is nothing to work out. Your own
            brief still pays {g.completedPay} if you see it through, and the paper starts
            printing the moment somebody else sits down.
          </p>
        ) : (
          <>
            <p className="hint">
              Name as many of them as you like: an empty line costs nothing and earns
              nothing. Filing is final.
            </p>
            <div className="gaz-column">
              {g.rivals.map((r) => (
                <label key={r.id}>
                  <span>{r.name}</span>
                  <select value={picks[r.id] || ""}
                          onChange={(e) => setPicks({ ...picks, [r.id]: e.target.value })}>
                    <option value="">Say nothing</option>
                    {tokens.map((t) => {
                      /* TWO THINGS THE PLAYER ALREADY KNOWS ARE WRONG, so the
                         form should not let them be said: a token is dealt at
                         most once, and the one in your own hand is not in
                         anybody else's. Both are deduction the player has
                         already done — greying them out is not help, it is
                         just not making them click a guaranteed loss. */
                      const mine = g.hand && g.hand.token === t.token;
                      const used = Object.entries(picks)
                        .some(([k, v]) => v === t.token && Number(k) !== r.id);
                      return (
                        <option key={t.token} value={t.token} disabled={mine || used}>
                          {t.title}{mine ? " (yours)" : used ? " (already named)" : ""}
                        </option>
                      );
                    })}
                  </select>
                </label>
              ))}
            </div>
            <div className="gaz-act">
              <button className="btn" disabled={!named}
                      onClick={() => onAct("gazette.file", { picks })}>
                File the column: {named} name{named === 1 ? "" : "s"}
              </button>
              <span className="hint">
                {g.rate
                  ? <>Today each right name pays <b>{g.rate.right}</b> and each wrong one
                      costs <b>{g.rate.wrong}</b>. Both fall every day you wait.</>
                  : "The presses have stopped."}
                {" "}Turning up at all is worth {g.filedPay}.
              </span>
            </div>
            <table className="gaz-ladder">
              <tbody>
                <tr><th>filed</th>{NIGHT_NAME.slice(0, 6).map((d, i) => (
                  <td key={d} className={i === g.night ? "now" : ""}>{d.slice(0, 3)}</td>))}</tr>
                <tr><th>right pays</th>{g.calls.right.map((v, i) => (
                  <td key={i} className={i === g.night ? "now" : ""}>{v}</td>))}</tr>
                <tr><th>wrong costs</th>{g.calls.wrong.map((v, i) => (
                  <td key={i} className={i === g.night ? "now" : ""}>{v}</td>))}</tr>
              </tbody>
            </table>
          </>
        )}
      </div>
    </GameSection>
  );
}

/* ---- THE GAZETTE, FROM 25 SEP 2026: DIPLOMACY -----------------------------

   Aaron: "players currently aren't interested in watching how people behave
   through the week to make one decision". So the paper keeps its headline
   and leader, and the game on it becomes diplomacy inside your field. Offer
   a truce or an alliance to anyone in it; they accept or decline; then you
   BOTH lock it in, and each of you may lock in one pact a day. A truce pays
   both of you every evening it holds. An alliance is a pair with a York code
   name that scores by striking other alliances anywhere in the city — both
   of their members hit, by either of you, once per alliance. See
   lib/diplomacy.js for the rules as the server keeps them. */
const DIP_BRIEF = [
  "Offer a truce or an alliance to anyone in your field. They accept or decline.",
  "Once they accept, the pact is in force straight away.",
  "A truce pays you both every evening it holds, for the rest of the week.",
  "An alliance is a pair. It scores by hitting both members of another alliance, anywhere in the city.",
];
const DIP_HOW = [
  "Your field is the handful of traders you are seated with this week. Pacts are made inside it; alliances fight across the whole city.",
  "Invitations are free and can be sent at any time. If you both invite each other to the same thing, it is agreed at once.",
  "Accepting is committing: the moment an offer is accepted, the pact is in force.",
  "Truce: no orders between the two of you. Confirming one against a truce partner breaks it in public and costs you skill, exactly as breaking a Gentlemen's Agreement does. While it holds, you are both paid skill every evening.",
  "Alliance: a pair, and one in force at a time. You can still offer another while you are in one, but it cannot be accepted until you have walked out of the first, which costs skill and is printed in the paper. Allies cannot attack each other. You strike another alliance when both of its members have had one of your targeted orders land on them this week, by either of you. District cards do not count. Each alliance can be struck once.",
  "Everything ends with the week. The field is redrawn and the alliances go with it.",
];
const DIP_STATE_TEXT = {
  "invited": "Your offer is waiting on them.",
  "invited-you": "They have made you an offer.",
  "agreed": "Agreed. Lock it in to put it in force.",
  "locked-by-you": "You have locked it in. Waiting on them.",
  "locked-by-them": "They have locked it in. Your turn.",
  "confirmed": "In force.",
};

function DipField({ d, me, onAct, players = {} }) {
  return (
    <div className="dip-field">
      {d.field.map((m) => {
        const p = m.pact;
        const kind = p ? (p.kind === "truce" ? "Truce" : "Alliance") : null;
        /* One alliance in force at a time: you may agree another, but not
           lock it in until you have walked out of the one you are in. */
        const blockedByOwn = p && p.kind === "alliance" && d.alliance && p.state !== "confirmed";
        /* No lock in since 29 Sep 2026 unless a city switches it back on. */
        const lockStep = !!d.lockIn;
        const canLock = p && ["agreed", "locked-by-them"].includes(p.state) && !d.lockedToday && !blockedByOwn;
        return (
          <div key={m.id} className={"dip-row" + (p && p.state === "confirmed" ? " is-on" : "")}>
            <div className="dip-who">
              <b><TraderName player={players[m.id] || m} size={14} /></b>
              <span>{p ? <>{kind}{p.codeName ? <>: <i>{p.codeName}</i></> : null}. {DIP_STATE_TEXT[p.state]}</>
                : m.linked ? "A linked account. No pacts." : m.inAlliance ? "In an alliance. They would have to break it to join yours." : "No pact."}</span>
              {blockedByOwn && ["agreed", "locked-by-them", "invited-you"].includes(p.state) && (
                <span className="dip-warn">You are in {d.alliance.codeName}. Walk out of it {lockStep ? "to lock this one in" : "before you can accept this one"}.</span>
              )}
            </div>
            <div className="dip-acts">
              {!p && !m.linked && (
                <>
                  <button type="button" onClick={() => onAct("dip.invite", { to: m.id, kind: "truce" })}>Offer a truce</button>
                  <button type="button" onClick={() => onAct("dip.invite", { to: m.id, kind: "alliance" })}>Offer an alliance</button>
                </>
              )}
              {p && p.state === "invited" && (
                <button type="button" onClick={() => onAct("dip.withdraw", { pact: p.id })}>Withdraw the offer</button>
              )}
              {p && p.state === "invited-you" && (
                <>
                  <button type="button" className="is-go" onClick={() => onAct("dip.respond", { pact: p.id, accept: true })}>Accept</button>
                  <button type="button" onClick={() => onAct("dip.respond", { pact: p.id, accept: false })}>Decline</button>
                </>
              )}
              {lockStep && p && ["agreed", "locked-by-them"].includes(p.state) && (
                <button type="button" className="is-go" disabled={!canLock}
                        title={blockedByOwn ? `Walk out of ${d.alliance.codeName} first.` : d.lockedToday ? "You have locked in a pact today already." : undefined}
                        onClick={() => onAct("dip.lock", { pact: p.id })}>Lock it in</button>
              )}
            </div>
          </div>
        );
      })}
      {!d.field.length && <p className="gaz-empty">Nobody else has been seated in your field yet.</p>}
    </div>
  );
}

/* WALKING OUT OF AN ALLIANCE. Two presses, because it costs skill and is
   printed in the paper. The only way to lock in a different alliance. */
function WalkOut({ d, onAct }) {
  const [sure, setSure] = useState(false);
  const cost = (d.rules && d.rules.walkOutSkill) || 0;
  if (!d.alliance) return null;
  return (
    <div className="dip-walkout">
      {!sure ? (
        <button type="button" className="btn small secondary" onClick={() => setSure(true)}>Walk out of {d.alliance.codeName}</button>
      ) : (
        <>
          <span>Walking out is printed in the paper{cost ? ` and costs you ${cost} skill` : ""}. {d.alliance.partner.name} is told at once.</span>
          <button type="button" className="btn small danger" onClick={() => { setSure(false); onAct("dip.break", {}); }}>Walk out</button>
          <button type="button" className="btn small secondary" onClick={() => setSure(false)}>Stay</button>
        </>
      )}
    </div>
  );
}

/* THE PAIR'S BOARD, as Aaron described it: every other alliance a row, one
   column per member, marked as each falls, so two allies can see who is
   left and split the work between them. */
function DipBoard({ a, players = {} }) {
  if (!a.board.length) {
    return <p className="gaz-empty">No other alliance has formed yet this week. When one does, it appears here to be struck.</p>;
  }
  return (
    <div className="dip-board-wrap">
      <table className="dip-board">
        <thead><tr><th>Alliance</th><th>First member</th><th>Second member</th></tr></thead>
        <tbody>
          {a.board.map((r) => (
            <tr key={r.id} className={r.struck ? "is-struck" : ""}>
              <td><b>{r.codeName}</b>{r.struck ? <span className="dip-struck">Struck</span> : null}</td>
              {r.members.map((m) => (
                <td key={m.id} className={m.hitBy ? "is-hit" : ""}>
                  <TraderName player={players[m.id] || m} size={13} />
                  <span>{m.hitBy ? `Hit by ${m.hitBy}` : "Still standing"}</span>
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

/* TALKING TO YOUR FIELD. Signed, seen by your field only, three a day and
   200 characters each, kept for the week. Aaron, 25 Sep 2026: with pacts
   paying the way they do, the conversation is where the game is. */
function FieldMessages({ d, onAct }) {
  const [draft, setDraft] = useState("");
  const max = d.messageMax || 200;
  const left = d.messagesLeft ?? 0;
  const send = () => {
    const text = draft.trim();
    if (!text || text.length > max || !left) return;
    onAct("dip.say", { text });
    setDraft("");
  };
  return (
    <div className="dip-messages">
      <div className="dip-say">
        <label htmlFor="dip-say-text" className="dip-say-label">
          {left ? `Say something to your field. ${left} of 3 left today.` : "You have sent three today. The next can go after seven."}
        </label>
        <textarea id="dip-say-text" rows={2} maxLength={max} value={draft} disabled={!left}
                  placeholder="Offers, threats, who you are going after tonight…"
                  onChange={(e) => setDraft(e.target.value)} />
        <div className="dip-say-foot">
          <span className={"dip-say-count" + (draft.length > max - 20 ? " is-near" : "")}>{draft.length} / {max}</span>
          <button className="btn small" disabled={!left || !draft.trim()} onClick={send}>Send to the field</button>
        </div>
      </div>
      {d.messages && d.messages.length ? (
        <ul className="dip-msg-list">
          {d.messages.map((m) => (
            <li key={m.id} className={m.mine ? "is-mine" : ""}>
              <b>{m.mine ? "You" : m.from}</b>
              <span className="dip-msg-when">{fmtTime(m.at)}</span>
              <p>{m.text}</p>
            </li>
          ))}
        </ul>
      ) : <p className="gaz-empty">Nobody in your field has said anything yet this week.</p>}
    </div>
  );
}

function GazettePanel({ game, me, onAct }) {
  const g = game.gazette;
  const d = game.diplomacy;
  if (!g && !d) return null;
  const r = (d && d.rules) || {};
  return (
    <GameSection
      tone="gazette"
      name="The Gazette"
      brief={DIP_BRIEF}
      tagline="Make pacts in your field. Strike alliances across the city."
      synopsis={DIP_HOW}
      strip={(
        <div className="council-strip">
          <div><span className="stat-label">Skill</span><span className="stat-value">{me.skill.points}</span></div>
          <div><span className="stat-label">Your alliance</span>
            <span className="stat-value">{d && d.alliance ? d.alliance.codeName : "None"}</span></div>
          <div><span className="stat-label">Strikes</span>
            <span className="stat-value">{d && d.alliance ? d.alliance.strikes : "—"}</span></div>
          {d && d.lockIn && (
            <div><span className="stat-label">Lock in today</span>
              <span className="stat-value">{d.lockedToday ? <>Used · <CountdownTo at={d.nextLockAt} /></> : "Available"}</span></div>
          )}
          <div><span className="stat-label">Week ends</span>
            <span className="stat-value">{d ? <CountdownTo at={d.weekEndsAt} /> : "—"}</span></div>
        </div>
      )}
    >
      <div className="gaz-paper">
        <div className="gaz-masthead">
          <b>The York Gazette</b>
          <em>{g ? NIGHT_NAME[g.night] : ""} · the city's paper of record</em>
        </div>
        {/* The day's summary, the leader, moved to Intelligence and set
            plainly there. Aaron, 25 Sep 2026: the Gazette is the diplomacy
            game; the summary is intelligence. */}

        {d ? (
          <>
            <h4 className="gaz-head">Your field this week</h4>
            <p className="gaz-standfirst">
              Truces pay each of you {r.truceSkill} skill every evening they hold. Every alliance
              strike pays each ally {r.strikeSkill}{r.strikeCap ? `, up to ${r.strikeCap} strikes a week` : ", with no limit on strikes"}.
              Breaking a truce costs the one who breaks it {r.truceBreakSkill} skill.
              {!d.lockIn ? " An accepted offer is in force at once."
                : d.lockedToday ? " You have locked in a pact today; the next can be locked in after seven." : " You can lock in one pact today."}
            </p>
            <DipField d={d} me={me} onAct={onAct} players={game.players} />

            <h4 className="gaz-head">{d.alliance ? <>{d.alliance.codeName} <span className="ally-chip">Score {d.alliance.strikes}</span></> : "Your alliance"}</h4>
            {d.alliance ? (
              <>
                <p className="gaz-standfirst">
                  You and <TraderName player={game.players[d.alliance.partner.id] || d.alliance.partner} size={14} />. Land a targeted order on both
                  members of an alliance below and it is struck. District cards do not count. {d.alliance.strikes
                    ? `${d.alliance.strikes} struck so far this week.` : "Nothing struck yet this week."}
                </p>
                <DipBoard a={d.alliance} players={game.players} />
                <WalkOut d={d} onAct={onAct} />
              </>
            ) : (
              <p className="gaz-empty">You are not in an alliance this week. Offer one to somebody in your field above.</p>
            )}

            {d.truces.length > 0 && (
              <>
                <h4 className="gaz-head">Your truces</h4>
                <ul className="dip-truces">
                  {d.truces.map((t) => <li key={t.id}>With <b>{t.with}</b>. {t.broken ? "Broken." : "Holding."}</li>)}
                </ul>
              </>
            )}

            <AllianceBoard game={game} me={me} compact />

            <h4 className="gaz-head">Messages to your field</h4>
            <FieldMessages d={d} onAct={onAct} />

            <h4 className="gaz-head">Dispatches</h4>
            {d.events.length ? (
              <ul className="dip-events">
                {d.events.map((e, i) => <li key={i} className={"is-" + e.kind}>{e.text}</li>)}
              </ul>
            ) : (
              <p className="gaz-empty">No pacts have been made or broken yet this week.</p>
            )}
          </>
        ) : (
          <p className="gaz-empty">You have not been seated in a field this week yet.</p>
        )}

        <GazetteExchange stocks={game.stocks} players={game.players} />

        <GazetteRivalries me={me} players={game.players} onAct={onAct} />
      </div>
    </GameSection>
  );
}

/* THE WEEK ON THE EXCHANGE (Aaron, 1 Oct 2026: "a soaring stock price for
   a property is also an indicator of the stability/trust of the owner").
   The best and the worst floats of the last seven days, by owner, for the
   whole city to read. A collapsing price says an owner is under pressure,
   which is news the tactics game feeds on. */
const signedPct = (v) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v || 0).toFixed(1)}%`;
function GazetteExchange({ stocks, players = {} }) {
  const list = (stocks && stocks.list) || [];
  if (!list.length) return null;
  const named = list.filter((r) => metaFor(r.id)).map((r) => ({ ...r, m: metaFor(r.id), who: players[r.owner] }));
  const up = named.filter((r) => r.week > 0).sort((a, b) => b.week - a.week).slice(0, 3);
  const down = named.filter((r) => r.week < 0).sort((a, b) => a.week - b.week).slice(0, 3);
  const line = (r) => (
    <li key={r.id}>
      <b>{r.m.name}</b>
      <span className="gx-who">{r.who ? <TraderName player={r.who} size={13} /> : "No owner"}</span>
      <em className={r.week >= 0 ? "up" : "down"}>{signedPct(r.week)}</em>
    </li>
  );
  return (
    <>
      <h4 className="gaz-head">The week on the Exchange</h4>
      {!up.length && !down.length ? (
        <p className="gaz-empty">No listed building has moved this week.</p>
      ) : (
        <div className="gx-cols">
          <div>
            <span className="gx-kicker">Riding high</span>
            {up.length ? <ul className="gx-list">{up.map(line)}</ul> : <p className="gaz-empty">Nothing rose.</p>}
          </div>
          <div>
            <span className="gx-kicker">Under pressure</span>
            {down.length ? <ul className="gx-list">{down.map(line)}</ul> : <p className="gaz-empty">Nothing fell.</p>}
          </div>
        </div>
      )}
    </>
  );
}

/* DECLARED RIVALS, IN THE GAZETTE. Aaron, 27 Sep 2026: the counted
   rivalry was redundant, so a rivalry is now a declaration. Name up to two
   traders; every targeted order of yours that lands on one pays you a
   little skill; the price is that they read here that you have named them.
   The orders themselves stay anonymous. Rules in lib/rivalry.js. */
function GazetteRivalries({ me, players = {}, onAct }) {
  const r = me.rivalries || {};
  const mine = r.mine || [], against = r.against || [];
  const max = r.max || 2, reward = r.reward || 5, days = r.minDays || 7;
  const [pick, setPick] = useState("");
  const [sure, setSure] = useState(false);
  const taken = new Set([me.id, ...mine.map((x) => x.id), ...(me.linkedTo || [])]);
  const choices = Object.values(players).filter((p) => !taken.has(p.id)).sort((a, b) => a.name.localeCompare(b.name));
  const who = (x) => players[x.id] || { id: x.id, name: x.name };
  const now = Date.now();
  return (
    <>
      <h4 className="gaz-head">Rivals</h4>
      <p className="gaz-standfirst">
        Name up to {max} traders as your rivals. Every targeted order of yours that lands on a rival pays you {reward} skill,
        and your orders stay as anonymous as ever. The price: each rival reads here, in their own Gazette, that you have
        declared them. A declaration stands for at least {days} days. Your two rivals are also drawn in their own colour on the map.
      </p>
      <div className="gaz-rivals">
        <section>
          <h5>Your rivals</h5>
          {!mine.length ? <p className="gaz-empty">You have not declared anybody.</p> : (
            <ul className="gaz-rival-list">
              {mine.map((x, i) => (
                <li key={x.id}>
                  {i < TERR.rivals.length && <i className="rival-ink" style={{ background: TERR.rivals[i] }} title="Their colour on the map" />}
                  <TraderName player={who(x)} size={14} />
                  <span className="gaz-rival-meta">since {fmtDate(x.since)} · {x.hits} landed, +{x.skill} skill</span>
                  <button type="button" className="btn small secondary" disabled={now < x.withdrawFrom}
                          title={now < x.withdrawFrom ? `Stands until ${fmtDate(x.withdrawFrom)}` : undefined}
                          onClick={() => onAct("rival.withdraw", { rival: x.id })}>Withdraw</button>
                </li>
              ))}
            </ul>
          )}
          {mine.length < max && (
            <div className="gaz-rival-pick">
              <label htmlFor="rival-pick" className="sr-only">Choose a trader to declare</label>
              <select id="rival-pick" value={pick} onChange={(e) => { setPick(e.target.value); setSure(false); }}>
                <option value="">Choose a trader</option>
                {choices.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
              </select>
              {!sure ? (
                <button type="button" className="btn small" disabled={!pick} onClick={() => setSure(true)}>Declare a rival</button>
              ) : (
                <>
                  <span className="gaz-rival-warn">{(players[pick] || {}).name} will be told, in their Gazette.</span>
                  <button type="button" className="btn small" onClick={() => { onAct("rival.declare", { rival: Number(pick) }); setPick(""); setSure(false); }}>Declare</button>
                  <button type="button" className="btn small secondary" onClick={() => setSure(false)}>Not yet</button>
                </>
              )}
            </div>
          )}
        </section>
        <section>
          <h5>Declared against you</h5>
          {!against.length ? <p className="gaz-empty">Nobody has declared you a rival.</p> : (
            <ul className="gaz-rival-list">
              {against.map((x) => (
                <li key={x.id} className="is-against">
                  <TraderName player={who(x)} size={14} />
                  <span className="gaz-rival-meta">declared you a rival on {fmtDate(x.since)}</span>
                </li>
              ))}
            </ul>
          )}
        </section>
      </div>
    </>
  );
}

/* THE ALLIANCE STANDINGS. Aaron, 26 Sep 2026: alliances should be more
   obvious. Each is a pair with a York name drawn for it when it forms, both
   members link to their profiles, and the score is the number of other
   alliances it has struck. Shown on the Leaderboard and again in the
   Gazette, where alliances are made. */
function AllianceBoard({ game, me, compact = false }) {
  const d = game.diplomacy || { table: [] };
  const who = (m) => (game.players && game.players[m.id]) || { id: m.id, name: m.name };
  const rules = "Every alliance is a pair, named the moment it forms. It scores a point for each other alliance it strikes: both of that alliance's members hit by a targeted order from either ally. District cards do not count, each alliance can be struck once, and the table starts again every week.";
  return (
    <div className={"card ally-board" + (compact ? " is-compact" : "")}>
      <h3>Alliances this week</h3>
      {!compact && <p className="meta">{rules}</p>}
      {!d.table.length ? (
        <p className="meta">No alliances have been formed this week. Offer one to a trader under “Your field this week” in the Gazette, in the Gaming Council. The pair that strikes the most other alliances tops this board.</p>
      ) : (
        <ol className="ally-list">
          {d.table.map((a, i) => {
            const mine = a.members.some((m) => m.id === me.id);
            return (
              <li key={a.id} className={"ally-row" + (mine ? " is-mine" : "") + (a.broken ? " is-broken" : "")}>
                <span className="ally-rank">{i + 1}</span>
                <div className="ally-main">
                  <span className="ally-name">{a.codeName}{mine ? <em className="ally-you">Your alliance</em> : null}{a.broken ? <em className="ally-gone">Broken</em> : null}</span>
                  <span className="ally-members">
                    <TraderName player={who(a.members[0])} size={14} /> and <TraderName player={who(a.members[1])} size={14} />
                  </span>
                </div>
                <span className="ally-score"><b>{a.strikes}</b><small>{a.strikes === 1 ? "strike" : "strikes"}</small></span>
              </li>
            );
          })}
        </ol>
      )}
    </div>
  );
}

function TontinePanel({ game, me, onAct }) {
  const t = game.tontine;
  /* THE DRAW, PLAYED ONCE. The result arrives over the delta the moment the
     reading room opens, and by then the server already calls the loser
     struck, so if the table simply re-rendered a candle would go from lit
     to a cold stub between two frames and the whole evening would be
     missed. The sequence holds the drawn candle lit while the animation
     runs and only then lets the real state through.

     Once per player per draw, keyed on the table and the evening in
     localStorage, so a refresh does not replay a draw already watched. */
  const [seq, setSeq] = useState(null);
  const lastDraw = t && t.draws && t.draws.length ? t.draws[t.draws.length - 1] : null;
  const drawKey = lastDraw ? `${t.tableId}:${lastDraw.round}` : null;
  useEffect(() => {
    if (!drawKey) return;
    try {
      if (localStorage.getItem("tont.seen") === drawKey) return;
      localStorage.setItem("tont.seen", drawKey);
    } catch (e) { /* private mode: play it, and play it again next time */ }
    setSeq({ council: lastDraw.council, who: lastDraw.who, drawing: true, ending: false });
    const a = setTimeout(() => setSeq((q) => q && { ...q, ending: true }), 1400);
    const b = setTimeout(() => setSeq((q) => q && { ...q, drawing: false }), 3600);
    const c = setTimeout(() => setSeq(null), 4400);
    return () => { clearTimeout(a); clearTimeout(b); clearTimeout(c); };
  }, [drawKey]);
  if (!t) return null;

  const roll = t.roll || [];
  const mine = t.mine;
  const alive = roll.filter((r) => r.state === "in");
  const waiting = t.status === "open" ? alive[0] : null;
  const nameOf = (id) => (roll.find((r) => r.id === id) || {}).name || "A trader";
  const recent = t.last && t.last.at && Date.now() - t.last.at < 2 * 24 * 3600 * 1000 ? t.last : null;
  const sit = (label) => (
    <button className="btn" onClick={() => onAct("tontine.enter")}>{label}</button>
  );

  return (
    <GameSection
      tone="tontine"
      name="The Tontine"
      brief={TONTINE_BRIEF}
      tagline="Take a share of the pot and walk away, or stay at the table and hope your name stays in the hat."
      synopsis={TONTINE_HOW}
      strip={(
        <div className="council-strip">
          {/* SKILL LEADS EVERY STRIP ON THIS TAB, so a player looking for
              their balance finds it in the same place whichever game they
              are reading, and the smoke test reads it from exactly there. */}
          <div><span className="stat-label">Skill</span><span className="stat-value">{me.skill.points}</span></div>
          {(t.status === "open" || t.status === "playing" || t.status === "underway") && (
            <div><span className="stat-label">In the pot</span><span className="stat-value">{t.pot}</span></div>
          )}
          {(t.status === "playing" || t.status === "underway") && (
            <div><span className="stat-label">Traders in</span><span className="stat-value">{alive.length}</span></div>
          )}
          {t.call != null && (
            <div><span className="stat-label">The call</span><span className="stat-value">{t.call}</span></div>
          )}
          {(t.status === "playing" || t.status === "underway") && (
            <div><span className="stat-label">Next draw</span>
              <span className="stat-value"><CountdownTo at={t.nextDrawAt} /></span></div>
          )}
          {t.status === "open" && (
            <div><span className="stat-label">Evenings waited</span>
              <span className="stat-value">{t.alonePaid} of {t.loneEvenings}</span></div>
          )}
          {t.status === "resting" && t.reopensAt && (
            <div><span className="stat-label">Table opens</span>
              <span className="stat-value"><CountdownTo at={t.reopensAt} /></span></div>
          )}
        </div>
      )}
    >
      {lastDraw && (t.status === "playing" || t.status === "underway") && (
        <div className={"tont-draw" + (lastDraw.council ? " safe" : "")}>
          {lastDraw.council
            ? <><b>The Council's name came out of the hat.</b> Its money stays in the pot, and only traders are left.</>
            : <><b>{nameOf(lastDraw.who)} was struck from the table.</b> They take nothing.</>}
        </div>
      )}

      {roll.length > 0 && (
        <div className={"tont-roll" + (seq && seq.drawing ? " is-drawing" : "")}>
          {roll.map((r) => {
            /* The one candle the draught takes. It stays lit until the
               flame has actually gone out. */
            const drawn = !!(seq && seq.drawing && !seq.council && seq.who === r.id);
            const striking = drawn && seq.ending;
            /* Lit until the draught reaches it: the result is not given
               away in the first frame. */
            const state = drawn || r.state === "won" ? "in" : r.state;
            return (
              <Candle key={r.id} name={r.name} state={state}
                      took={r.took} me={r.me} night={t.evening} struckNight={r.struckAt}
                      mark={striking ? "is-striking" : ""} />
            );
          })}
          {/* The Council sits at the table as a taller candle. It can be
              drawn, and then it goes out, but it can never be last. */}
          <Candle key="council" tall night={t.evening}
                  state={t.councilIn || (seq && seq.drawing && seq.council) ? "in" : "struck"}
                  mark={seq && seq.drawing && seq.ending && seq.council ? "is-striking" : ""} />
        </div>
      )}

      <div className="tont-act">
        {t.status === "resting" ? (
          <span className="hint">
            Nobody joined the last table, so it rests today. It opens again at {eveningAt()}.
          </span>
        ) : t.status === "underway" && !mine ? (
          <span className="hint">
            A game is under way at your field's table. The next table opens the day after it ends.
          </span>
        ) : !mine ? (
          <>
            {t.canSit && sit(waiting ? `Sit down for ${t.stake} skill and start the game` : `Sit down for ${t.stake} skill`)}
            <span className="hint">
              {waiting
                ? `${waiting.name} is waiting. Sit down and the first name is drawn at ${eveningAt()}.`
                : t.status === "playing"
                ? `The game has started. Seats stay open until the first draw at ${eveningAt()}.`
                : `The Council puts ${t.match} beside your stake. Nobody is drawn until a second trader sits down. While you wait alone the Council pays you ${t.lonePay} skill an evening, for up to ${t.loneEvenings} evenings, and then your stake comes back.`}
            </span>
          </>
        ) : mine.state === "out" ? (
          <span className="hint">You took the call for {mine.took}. Your candle has left the table.</span>
        ) : mine.state === "struck" ? (
          <span className="hint">Your name came out of the hat. You have left the table.</span>
        ) : t.status === "open" ? (
          <>
            <button className="btn secondary" onClick={() => onAct("tontine.call")}>
              Stand up and take your stake back
            </button>
            <span className="hint">
              You are waiting at the table and your field has been told. The Council has paid you {t.alonePaid * t.lonePay} skill
              so far, {t.lonePay} each evening for up to {t.loneEvenings}. Standing up rests the table for a day.
            </span>
          </>
        ) : (
          <>
            <div className="tont-figures">
              <div><span>Stay in</span><b>1 in {t.odds}</b></div>
              <div><span>For the whole pot</span><b>{t.pot}</b></div>
              <div className="lit"><span>Staying is worth</span><b>{t.stay}</b></div>
              <div className="lit"><span>The call pays now</span><b>{t.call}</b></div>
            </div>
            <button className="btn danger" onClick={() => onAct("tontine.call")}>
              Take the call for {t.call} skill
            </button>
            <span className="hint">
              Or do nothing and stay at the table. Doing nothing is staying in. The last trader standing
              also wins {t.winnerSpins === 1 ? "a free spin" : `${t.winnerSpins} free spins`} of the wheel.
            </span>
          </>
        )}
      </div>

      {recent && !mine && (
        <p className="muted tont-last">
          {recent.outcome === "folded" ? "Nobody joined you at your last table, so your stake came back."
            : recent.me ? `You were the last one standing at your last table: ${recent.pot} skill and a free spin of the wheel.`
            : recent.state === "out" ? `You took the call for ${recent.took} at your last table. ${recent.winner} was the last one standing and took ${recent.pot}.`
            : `Your name came out of the hat at your last table. ${recent.winner} was the last one standing and took ${recent.pot}.`}
        </p>
      )}
    </GameSection>
  );
}

/* THE ASSESSORS' ROUND.

   Five buildings, two calls, ten seconds. The panel has one job beyond
   taking the calls: to make it obvious that this is a game about reading
   the market rather than guessing. So each building is shown with the
   price the round opened at AND what it is doing now — the move is
   visible while it happens, which is the whole pleasure of having called
   it, and it teaches a new assessor what they should have been looking at.

   The week's table sits underneath because one night is luck and seven is
   a verdict, and the game should say so rather than let somebody conclude
   from one bad evening that it is a lottery. */
function AssessorsRound({ game, me, onAct, onOpen }) {
  const a = game.assess;
  const [rise, setRise] = useState(null);
  const [fall, setFall] = useState(null);
  if (!a) return null;

  const chosenRise = rise || (a.mine && a.mine.rise) || null;
  const chosenFall = fall || (a.mine && a.mine.fall) || null;
  const lodged = a.mine && chosenRise === a.mine.rise && chosenFall === a.mine.fall;
  const ready = chosenRise && chosenFall && chosenRise !== chosenFall;

  const pick = (id) => {
    /* One tap names the riser, the next names the faller, a tap on a
       chosen building takes it back. No mode switch, no radio buttons —
       there are only two roles and the board can work out which is being
       filled. */
    if (id === chosenRise) { setRise(null); return; }
    if (id === chosenFall) { setFall(null); return; }
    if (!chosenRise) setRise(id);
    else setFall(id);
  };

  return (
    <GameSection
      tone="assess"
      name="The Assessors' Round"
        brief={ASSESS_BRIEF}
      tagline="Five buildings. Which rises most by tomorrow, and which falls furthest."
      synopsis={ASSESS_HOW}
      strip={(
        <div className="council-strip">
          <div><span className="stat-label">Skill</span><span className="stat-value">{me.skill.points}</span></div>
          <div><span className="stat-label">Each call right</span><span className="stat-value">{a.pointsEach}</span></div>
          <div><span className="stat-label">Both right</span>
            <span className="stat-value">{a.pointsEach * 2 + a.pointsBoth}</span></div>
          <div><span className="stat-label">Calls close in</span>
            <span className="stat-value"><CountdownTo at={a.closesAt} /></span></div>
          <div><span className="stat-label">Read out in</span>
            <span className="stat-value"><CountdownTo at={a.readAt} /></span></div>
        </div>
      )}
    >
      {a.last && (
        <div className={"assess-last" + (a.last.mine && a.last.mine.points ? " won" : "")}>
          <b>Last night</b>
          <span>{a.last.rise} rose furthest; {a.last.fall} fell furthest.</span>
          {a.last.mine
            ? <em>{a.last.mine.points
                ? `You called ${a.last.mine.rise && a.last.mine.fall ? "both" : "one"} of them: ${a.last.mine.points} skill.`
                : "Neither of your calls landed."}</em>
            : <em>You did not sit.</em>}
        </div>
      )}

      <div className="assess-slate">
        {a.slate.map((s) => {
          const move = s.openedAt > 0 ? (s.price / s.openedAt - 1) * 100 : 0;
          const role = s.id === chosenRise ? "rise" : s.id === chosenFall ? "fall" : null;
          return (
            <button key={s.id} className={"assess-card" + (role ? " is-" + role : "")}
                    onClick={() => pick(s.id)}>
              <span className={"assess-role"}>{role === "rise" ? "RISES MOST" : role === "fall" ? "FALLS FURTHEST" : ""}</span>
              <b>{s.name}</b>
              <span className="assess-where">{s.district}{s.stock ? ` · ${s.stock}` : ""}</span>
              <span className="assess-open">opened at {fmt(s.openedAt)}</span>
              <span className={"assess-now " + (move >= 0 ? "chg-up" : "chg-down")}>
                {fmt(s.price)} · {move >= 0 ? "+" : "−"}{Math.abs(move).toFixed(2)}%
              </span>
            </button>
          );
        })}
      </div>

      <div className="assess-actions">
        <button className="btn" disabled={!ready || lodged}
                onClick={() => onAct("assess.call", { riseId: chosenRise, fallId: chosenFall })}>
          {lodged ? "Lodged" : "Lodge your assessment"}
        </button>
        <span className="hint">
          {lodged
            ? "You can change it until the board shuts."
            : !chosenRise ? "Tap the one you think rises most."
            : !chosenFall ? "Now tap the one you think falls furthest."
            : "Lodge it."}
        </span>
      </div>

      {a.week && a.week.length > 1 && (
        <div className="assess-week">
          <div className="cluster-head">This week in your field</div>
          <table className="cf-table">
            <tbody>
              {a.week.map((r) => (
                <tr key={r.id}>
                  <th>
                    {r.name
                      ? <button className="prop-link" onClick={() => onOpen && onOpen(r.id)}>{r.me ? "You" : r.name}</button>
                      : "A trader"}
                    <em>{r.played} night{r.played === 1 ? "" : "s"} · {r.rises} risers, {r.falls} fallers</em>
                  </th>
                  <td>{r.points}</td>
                </tr>
              ))}
            </tbody>
          </table>
          <div className="hint">
            One night is luck. Seven is a verdict, and that is what this table is for.
          </div>
        </div>
      )}
    </GameSection>
  );
}

function CouncilTab({ game, me, onAct, onActResult }) {
  const b = me.bluff;
  const c = me.committee;
  const rec = b.record;
  const [showLedger, setShowLedger] = useState(false);
  const [who, setWho] = useState(null);

  /* Last night, played once. The key is the round it settled in, so a result
     is never replayed on a tab switch and a NEW one always is. */
  const settledRound = b.results && b.results.round;
  /* Already watched, either in this page's life or in any earlier one. The
     server's marker is what makes this survive a reload — and a phone. */
  const alreadySeen = (r) => r == null
    || SEEN_INSPECTIONS.has(r)
    || (b.seenRound != null && r <= b.seenRound);
  const [dismissed, setDismissed] = useState(() => alreadySeen(settledRound));
  useEffect(() => {
    if (alreadySeen(settledRound)) setDismissed(true);
  }, [settledRound, b.seenRound]);
  const closeInspection = useCallback(() => {
    if (settledRound != null) {
      SEEN_INSPECTIONS.add(settledRound);
      onAct("council.seen", { round: settledRound });
    }
    setDismissed(true);
  }, [settledRound, onAct]);

  if (!b) return <div className="empty">The council is not sitting.</div>;

  const latestFiling = b.results && b.results.filings[0];
  const latestCalls = (b.results && b.results.calls) || [];
  /* Nothing to watch once the game is stood down — and no stale round from
     the week it was switched off should come back as theatre. */
  const showTheatre = b.enabled && !dismissed && settledRound
    && (latestFiling || latestCalls.length);

  return (
    <div>
      {showTheatre && (
        <ResolutionTheatre filing={latestFiling} call={latestFiling ? null : latestCalls[0]}
                           calls={latestCalls} onClose={closeInspection} />
      )}
      <PlayerCard who={who} meId={me.id} onClose={() => setWho(null)} />

      <p className="intro">
        {b.enabled ? "Three games" : "The games"} played against the five other traders in your field for the week. This will earn skill
        points that can be used for advantages in the game. Players will develop nicknames
        in the field based on their actions in the week. Check out their playing history by
        clicking their name.
      </p>

      <PodStrip pod={b.pod} meId={me.id} minSeats={b.minAudits + 1}
                weekEndsAt={b.weekEndsAt} onOpen={setWho} />
      <RaceTrack pod={b.pod} meId={me.id} onOpen={setWho} />

      {/* THE ORDER ON THIS TAB IS DELIBERATE, AND IT IS NOT THE ORDER THESE
          WERE BUILT IN.

          Two things decide it. The first is the clock: the Tontine's call and
          the committee's board both shut this evening and cannot be taken
          back, while a Gazette column can be filed any day of the week. What
          expires soonest goes nearest the top.

          The second is height. The Gazette is three or four times the length
          of anything else here — six briefs, a week of copy, five dropdowns
          and a ladder — and when it sat first it buried two games with
          nightly deadlines behind a long read that has none. A panel that
          tall belongs last whatever else is true of it.

          Fixed, not sorted by whatever is closest to closing. People learn
          where a thing is by where it was yesterday, and a tab that
          rearranges itself to be helpful is a tab you have to read every
          time. The two stood-down games keep their slots in the middle, so
          turning either back on does not move the Gazette off the bottom. */}

      <TontinePanel game={game} me={me} onAct={onAct} />

      <GameSection
        tone="committee"
        name="The Planning Committee"
        brief={COMMITTEE_BRIEF}
        tagline="Eight permits. Nobody can see what anybody placed."
        synopsis={COMMITTEE_HOW}
        strip={c ? (
          <div className="council-strip">
            <div><span className="stat-label">Evenings</span><span className="stat-value">{c.record.nights}</span></div>
            <div><span className="stat-label">Permits won</span><span className="stat-value">{c.record.permits}</span></div>
            <div><span className="stat-label">Scandals</span><span className="stat-value">{c.record.scandals}</span></div>
            <div><span className="stat-label">Sent tonight</span>
              <span className="stat-value">{c.sent}/{c.podSize}</span></div>
            <div><span className="stat-label">Boards in</span>
              <span className="stat-value"><CountdownTo at={b.endsAt} /></span></div>
            <div><span className="stat-label">Outcome in</span>
              <span className="stat-value"><CountdownTo at={b.revealAt} /></span></div>
          </div>
        ) : null}
      >
        {c ? (
          <>
            {/* Ten permits need the whole width. Squeezed into a column they
                wrap to two narrow cards and the board stops reading as a
                board, which is the one thing this game needs it to do. */}
            <CommitteeBoard me={me} onAct={onAct} />
            <CommitteeMinutes me={me} />
          </>
        ) : <div className="empty">The committee is not sitting.</div>}
      </GameSection>

      <AssessorsRound game={game} me={me} onAct={onAct} onOpen={setWho} />

      {/* THE SURVEYOR'S BLUFF, STOOD DOWN. The whole section is behind the
          flag rather than deleted: the code, the styles and the records are
          all still here, and BLUFF_ENABLED=1 on the server puts it back on
          the tab exactly as it was. */}
      {b.enabled && (
      <GameSection
        tone="bluff"
        name="Surveyor's Bluff"
        brief={BLUFF_BRIEF}
        tagline="Claim what is under your building. Or claim what isn't."
        synopsis={BLUFF_HOW}
        strip={(
          <div className="council-strip">
            <div><span className="stat-label">Skill</span><span className="stat-value">{me.skill.points}</span></div>
            <div><span className="stat-label">Filed</span><span className="stat-value">{rec.filings}</span></div>
            <div><span className="stat-label">Frauds that passed</span><span className="stat-value">{rec.fraudsPassed}</span></div>
            <div><span className="stat-label">Calls right</span>
              <span className="stat-value">{rec.callsRight}/{rec.calls}</span></div>
            {/* The board shuts, THEN the verdict is read. One countdown could
                only ever be honest about one of them, which is why it used to
                say "settles in 0m" with the result already underneath it. */}
            <div><span className="stat-label">Filed in</span>
              <span className="stat-value"><CountdownTo at={b.endsAt} /></span></div>
            <div><span className="stat-label">Outcome in</span>
              <span className="stat-value"><CountdownTo at={b.revealAt} /></span></div>
          </div>
        )}
      >
        <div className="council-grid">
          <div>
            <FileDossier game={game} me={me} onAct={onAct} />
            <LastNight me={me} />
          </div>
          <div>
            <AuditBoard me={me} onAct={onAct} />
          </div>
        </div>
      </GameSection>
      )}

      {/* THE WHEEL LIVES IN THE GAMING COUNCIL.

          It spent one deploy at the foot of the Market, on the theory that
          you have finished looking at what is for sale and here is the one
          thing money cannot buy. The theory was fine and the measurement
          was not: the market runs sixteen thousand pixels, so the wheel sat
          sixteen screens below the fold and nobody ever reached it. Aaron
          asked for the Gaming Council at the outset and he was right — a
          pull is bought with skill, and skill is earned here.

          Its slot follows this tab's own two rules rather than overriding
          them. It does not expire — a free pull keeps until it is taken —
          so it does not outrank the three games that shut this evening. But
          it is tall, and the Gazette is taller and explicitly belongs last,
          so it goes here: after everything with a deadline, before the long
          read. */}
      <WheelPanel wheel={game.wheel} onActResult={onActResult} />

      <GazettePanel game={game} me={me} onAct={onAct} />


      <section className="game-section tone-plain">
        <header className="game-head">
          <div className="game-title static">
            <span className="game-kicker">SPEND</span>
            <b>Spend skill points</b>
          </div>
          <span className="game-tagline">{me.skill.points} to spend</span>
        </header>
        <div className="game-body">
          <div className="council-grid">
            <div><SkillShop game={game} me={me} onAct={onAct} /></div>
            <div>
              <div className="council-card">
                <h4 style={{ cursor: "pointer" }} onClick={() => setShowLedger(!showLedger)}>
                  Where your skill came from {showLedger ? "\u25b4" : "\u25be"}
                </h4>
                {showLedger && (
                  !me.skill.ledger.length
                    ? <div className="empty">Nothing yet.</div>
                    : me.skill.ledger.map((r, i) => (
                      <div className="ledger-row" key={i}>
                        <span className={r.delta >= 0 ? "chg-up" : "chg-down"}>
                          {r.delta >= 0 ? "+" : ""}{r.delta}
                        </span>
                        <span className="ledger-reason">{SKILL_REASON[r.reason] || r.reason}</span>
                        <span className="event-time">{fmtTime(r.t)}</span>
                      </div>
                    ))
                )}
              </div>
            </div>
          </div>
        </div>
      </section>

    </div>
  );
}

const SKILL_REASON = {
  "gazette.column": "your Gazette column settled",
  "gazette.done": "you saw your brief through",
  "dip.truce": "a truce that held",
  "dip.strike": "your alliance struck another",
  "leader.standing": "standing as a district leader",
  "bluff.audit": "called a survey",
  "bluff.right": "read one correctly",
  "bluff.refund": "fee returned, nothing settled",
  "bluff.honest": "your honest survey settled",
  "bluff.fraud": "your fraudulent survey settled",
  "committee.won": "a permit at the committee",
  "committee.scandal": "a permit that turned into a scandal",
  "perk": "spent on an edge",
  "grant.feechange": "a further payment when the audit fee changed",
  "test": "adjustment",
};

/* ---------------------------- 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>
  );
}

/* WHERE IS THIS, ACTUALLY?

   A card gives a price, a yield and a district name, and none of those tell
   a player whether the thing is inside the walls or out by the ring road —
   which is most of what a property in York is worth. The map already knows;
   it was just three clicks and a lost scroll position away.

   A context rather than six levels of prop drilling: the tile, the deed
   modal and the auction card all want the same one verb, and none of them
   has any business knowing what the root does with it. */
const SpotlightCtx = React.createContext(null);

/* OPENING A DEED FROM ANYWHERE.

   Same argument as the spotlight above, for the other verb. A property
   named in the tactics queue, in an offer, in the night's results is a
   property you want to LOOK at — what is it worth, who owns it, what has
   been done to it — and until now the name was dead text and the only way
   in was to go and find it on the market or the map.

   A context rather than another prop threaded through six components,
   because the root already owns the deed modal and none of these lists has
   any business knowing that. */
const DeedCtx = React.createContext(null);

function PropLink({ id, name, className }) {
  const open = useContext(DeedCtx);
  const label = name || (metaFor(id) || {}).name || "a property";
  if (!open || !id) return <span className={className}>{label}</span>;
  return (
    <button type="button" className={"prop-link" + (className ? " " + className : "")}
            title="Open the deed"
            onClick={(e) => { e.stopPropagation(); open(id); }}>{label}</button>
  );
}

function ShowOnMapLink({ meta, label = "Show on the map" }) {
  const show = useContext(SpotlightCtx);
  if (!show || !meta) return null;
  return (
    <button type="button" className="sv-link map-link"
            onClick={(e) => { e.stopPropagation(); show(meta.id); }}>
      {label} ⌖
    </button>
  );
}

/* A row of them, comma-separated, for the places that list several — an
   offer's two halves, most often. */
function PropList({ ids }) {
  if (!ids || !ids.length) return <>—</>;
  return (
    <>
      {ids.map((id, i) => (
        <React.Fragment key={id}>
          {i > 0 ? ", " : ""}
          <PropLink id={id} />
        </React.Fragment>
      ))}
    </>
  );
}

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

/* ---- the card frame ----------------------------------------------------

   A collectible card: photograph, rarity ribbon with its star count, a
   crest medallion for the building type, the name, the place, and a line
   of estate-agent copy. Everything except the photograph is drawn here.

   The photograph is the one thing that cannot be generated in code. The
   game ships with the drawn elevation in that slot and swaps in real
   artwork the moment any exists — see artFor() below. */

const STOCK_GLYPH = {
  "Studio":          "M4 12 l8-7 8 7 v8 h-16 Z",
  "Terrace":         "M2 20 v-8 l5-4 5 4 v8 Z M12 20 v-9 l5-4 5 4 v9 Z",
  "Semi":            "M3 20 v-7 l9-6 9 6 v7 Z M12 20 v-13",
  "Townhouse":       "M5 21 v-13 l7-5 7 5 v13 Z M9 21 v-6 h6 v6",
  "Detached House":  "M3 13 l9-7 9 7 v8 h-18 Z M10 21 v-5 h4 v5",
  "Period House":    "M4 21 v-11 l8-6 8 6 v11 Z M8 10 v-4 h2 v3",
  "Retail Unit":     "M4 9 h16 l-2 4 h-12 Z M6 13 v8 h12 v-8",
  "Depot":           "M3 21 v-9 h18 v9 Z M3 12 l4-5 h10 l4 5",
  "Green":           "M12 21 v-6 M12 15 a6 6 0 1 1 0-.1 Z",
  "Industrial Unit": "M3 21 v-9 l5 3 v-3 l5 3 v-3 l5 3 v6 Z",
  "Landmark":        "M12 3 l2 4 h-4 Z M9 7 h6 v14 h-6 Z M6 21 v-8 h3 M18 21 v-8 h-3",
};

/* Estate-agent copy, chosen from the id so it never changes under a player
   but reads as written rather than generated. */
const TAGLINES = {
  "Studio":          ["Compact living, city at your door.", "Small footprint, central everything.", "Your first rung, well placed."],
  "Terrace":         ["Heritage charm, modern comfort.", "Classic York, forever.", "A front door on a proper street."],
  "Semi":            ["Room to grow, close to town.", "Modern living, established community.", "Two halves of a good idea."],
  "Townhouse":       ["Space to live, history to love.", "Timeless elegance, central location.", "Four floors of York."],
  "Detached House":  ["Your next chapter, York style.", "Standing on its own, as it should.", "Quiet road, loud statement."],
  "Period House":    ["Provenance you can walk through.", "Built when things were built.", "Original features throughout."],
  "Retail Unit":     ["Prime position. Endless possibilities.", "Where business meets the street.", "Footfall, and plenty of it."],
  "Depot":           ["More space. More opportunity.", "Unglamorous. Extremely useful.", "The city runs on places like this."],
  "Green":           ["Green space, golden days.", "A little more room to breathe.", "The bit everyone wants to overlook."],
  "Industrial Unit": ["Create. Work. Belong.", "Yield without the charm tax.", "Honest square footage."],
  "Landmark":        ["Iconic. Historic. Unmissable.", "Live steps from greatness.", "There is only one."],
};

/* Real artwork, when there is any. public/art/manifest.json maps a building
   type to a list of image files; a property picks one from its own id, so
   the same property always shows the same picture and neighbours differ.
   No manifest, or no entry for that type, and the drawn elevation stands in
   — which means artwork can be added a type at a time without a rebuild. */
let ART_MANIFEST = {};
function loadArtManifest() {
  fetch("/art/manifest.json", { cache: "no-cache" })
    .then((r) => (r.ok ? r.json() : null))
    .then((m) => { if (m && typeof m === "object") ART_MANIFEST = m; })
    .catch(() => {});
}

function artFor(meta, prop) {
  /* A player's own personalised photograph, if this property has one,
     always wins over the base manifest — that is the whole point of
     spending a token on it. It is server-controlled (prop.art comes from
     shared state, not the client), so there's nothing to validate here. */
  if (prop?.art?.file) return prop.art.file;
  /* A LANDMARK IS ONE BUILDING AND GETS ITS OWN PHOTOGRAPH, OR NONE.

     Every landmark is handed stock:"Landmark" in the catalogue, so all five
     — the Minster, the Shambles, the Racecourse, the Railway Museum,
     Clifford's Tower — drew from a single shared pool picked by a hash of
     the property id. With one file in that pool every landmark in York was
     the Shambles. With five it would have been five landmarks wearing each
     other's faces, which is worse, because it looks deliberate.

     So they are keyed individually now, exactly as map skins are keyed by
     mapKey and permits by their scheme — see tools/promote-art.js. And a
     landmark with no picture of its own falls through to the DRAWN
     ELEVATION rather than to the pool: a generic drawing of a building is
     honest, and a photograph of the wrong famous building is not. */
  /* A built house has no photograph: it is drawn from what was built. */
  if (meta.built) return null;
  if (meta.stock === "Landmark") {
    const own = (ART_MANIFEST.landmarks || {})[meta.id];
    return own ? `/art/${own}` : null;
  }
  const list = ART_MANIFEST[meta.stock] || ART_MANIFEST[meta.category];
  if (!list || !list.length) return null;
  return `/art/${list[hashStr("art:" + meta.id) % list.length]}`;
}

function StockCrest({ meta }) {
  const d = STOCK_GLYPH[meta.stock] || STOCK_GLYPH["Terrace"];
  return (
    <span className={"crest q-" + (meta.rarity || "common")} aria-hidden="true">
      <svg viewBox="0 0 24 24" width="17" height="17">
        <path d={d} fill="none" stroke="currentColor" strokeWidth="1.9"
              strokeLinejoin="round" strokeLinecap="round" />
      </svg>
    </span>
  );
}

function RarityRibbon({ meta }) {
  const r = RARITIES[meta.rarity] || {};
  /* Colour and word only. Stars alongside both were a third way of saying
     the same thing, and the ribbon reads faster without them. */
  return (
    <span className={"ribbon q-" + (meta.rarity || "common")}>
      {(r.label || "Common").toUpperCase()}
    </span>
  );
}

/* The tooltip behind the cash-flow figure: the four things that make it up,
   so a negative number is never a mystery. */
/* WHERE THE HOURLY FIGURE COMES FROM.

   The banner has always carried a tooltip, which is a place to hide an
   explanation rather than give one: it needs a mouse, it vanishes, and it
   cannot hold the one line that actually answers the question people ask.

   And they do ask. A player reported their cash flow "dropping
   significantly" after they opened a bank account — it had not; what had
   happened, within the same hour, was the end of the interest-free day on
   their opening stake, which takes the stake's interest and its repayment
   from nothing to both at once. Nothing on the screen connected the number
   to the event, so the number got blamed on the last thing they clicked.

   So: every line, signed, adding up to the total, with the stake's two
   halves separated and the date the interest-free period ends written out.
   A figure you can audit stops being a rumour. */
/* KEYSTONES ARE COSMETIC, AND SAY SO WHEREVER THEY APPEAR. Aaron, 28 Sep
   2026: on the top bar, on hover and on click, in the guide and in
   Personalise. A paid currency that could be mistaken for an advantage is
   the fastest way to lose the players who pay nothing. */
const KEYSTONE_NOTE = "Keystones are for cosmetic upgrades only: they change how you look on the exchange, "
  + "and never buy property, cash, tactics cards, skill or any other advantage.";

function KeystoneModal({ me, onClose, onPersonalise }) {
  const n = (me.personalise && me.personalise.tokens) || 0;
  const photos = !!(me.personalise && me.personalise.enabled);
  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal-box ks-box" onClick={(e) => e.stopPropagation()}
           role="dialog" aria-modal="true" aria-label="Keystones">
        <div className="modal-inner">
          <h3>Keystones</h3>
          <p className="ks-lead"><b>For cosmetic upgrades only.</b> They change how you look on the exchange and
            nothing else: a Keystone never buys property, cash, tactics cards, skill or any other advantage.</p>
          <p>Every trader starts with one free. You have <b>{n}</b>.</p>
          <p>One Keystone unlocks your profile page for good: an avatar, a border, your flag, a line about
            yourself and a frame round your buildings.{photos ? " One also commissions a fresh photograph of a property you own." : ""}</p>
          <p className="muted">More are in the shop under Personalise: 4 for £10.</p>
          <div className="ks-acts">
            <button className="btn" onClick={onPersonalise}>Open Personalise</button>
            <button className="btn secondary" onClick={onClose}>Close</button>
          </div>
        </div>
      </div>
    </div>
  );
}

function CashFlowModal({ cf, me, now, onClose }) {
  if (!cf) return null;
  const rows = [
    { k: "Rent", v: cf.rent, note: cf.earning
        ? `${cf.earning} building${cf.earning === 1 ? "" : "s"} earning${cf.idle ? `, ${cf.idle} idle` : ""}`
        : cf.idle ? `${cf.idle} building${cf.idle === 1 ? "" : "s"}, none of them earning` : "You own nothing yet" },
    { k: "Running overheads", v: -(cf.overheads || 0), note: "10% of rent, less any building's ability" },
    { k: "To your shareholders", v: -(cf.dividendsOut || 0), note: "their share of your floated buildings' rent" },
    { k: "The Council's rates", v: -(cf.rates || 0), note: "on the rent you keep, above £1.5m net worth" },
    { k: "Deposit interest", v: cf.depositInterest, note: me.deposit > 0 ? `on ${fmt(me.deposit)} in the bank` : null },
    { k: "Bank loan interest", v: -cf.loanInterest, note: me.loan > 0 ? `on ${fmt(me.loan)} borrowed` : null },
    { k: "Opening stake interest", v: -(cf.starterInterest || 0),
      note: me.starterLoan > 0 ? `on ${fmt(me.starterLoan)} outstanding` : null },
    { k: "Opening stake repayment", v: -(cf.starterPrincipal || 0),
      note: "not a cost: this is the debt shrinking" },
  ].filter((r) => r.v);

  const introLeft = me.starterRate === 0 && me.starterIntroEndsAt > now;

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal-box" onClick={(e) => e.stopPropagation()}
           role="dialog" aria-modal="true" aria-label="Where your cash flow comes from">
        <div className="modal-inner">
          <button className="modal-close" onClick={onClose} aria-label="Close">×</button>
          <h3 style={{ fontFamily: "'Fraunces', serif", margin: "0 0 4px" }}>Cash flow, hour by hour</h3>
          <p className="muted" style={{ fontSize: 13, marginTop: 0 }}>
            What the next settlement will put into your account, and take out of it. Rent
            settles hourly; so does everything you owe.
          </p>

          {!rows.length ? (
            <div className="empty">Nothing moves at the next settlement. Buy a building and it starts earning the same hour.</div>
          ) : (
            <table className="cf-table">
              <tbody>
                {rows.map((r) => (
                  <tr key={r.k}>
                    <th>{r.k}{r.note ? <em>{r.note}</em> : null}</th>
                    <td className={r.v >= 0 ? "chg-up" : "chg-down"}>
                      {r.v >= 0 ? "+" : "−"}{fmt(Math.abs(r.v))}
                    </td>
                  </tr>
                ))}
                <tr className="cf-net">
                  <th>Net, every hour</th>
                  <td className={cf.net >= 0 ? "chg-up" : "chg-down"}>
                    {cf.net >= 0 ? "+" : "−"}{fmt(Math.abs(cf.net))}
                  </td>
                </tr>
              </tbody>
            </table>
          )}

          {introLeft && (
            <div className="hold-note ok" style={{ marginTop: 10 }}>
              Your opening stake charges no interest for another {spanOf(me.starterIntroEndsAt - now)}.
              When that ends, both stake lines above start, and this figure will fall by
              roughly {fmt(me.starterLoan * (me.starterRateAfter || 0) + (me.starterRepayPerHour || 0))} an
              hour. It is not something you did.
            </div>
          )}
          {cf.relief === 0 && (
            <div className="hold-note ok" style={{ marginTop: 10 }}>
              Your overheads are paused at the moment, so nothing you owe is being charged.
              When the pause ends they come back.
            </div>
          )}
          {cf.relief > 0 && cf.relief < 1 && (
            <div className="hold-note ok" style={{ marginTop: 10 }}>
              Everything you owe is discounted {Math.round((1 - cf.relief) * 100)}% this week.
            </div>
          )}
          {cf.idle > 0 && (
            <div className="hold-note warn" style={{ marginTop: 10 }}>
              {cf.idle} of your buildings {cf.idle === 1 ? "is" : "are"} not earning. A property pays
              rent only while it is maintained or improved; the Portfolio tab says which.
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

function cashFlowNote(cf) {
  const parts = [];
  if (cf.rent) parts.push(`rent ${fmt(cf.rent)}`);
  if (cf.overheads) parts.push(`overheads −${fmt(cf.overheads)}`);
  if (cf.dividendsOut) parts.push(`to shareholders −${fmt(cf.dividendsOut)}`);
  if (cf.rates) parts.push(`rates −${fmt(cf.rates)}`);
  if (cf.depositInterest) parts.push(`deposit interest ${fmt(cf.depositInterest)}`);
  if (cf.loanInterest) parts.push(`bank interest −${fmt(cf.loanInterest)}`);
  if (cf.starter) parts.push(`opening stake −${fmt(cf.starter)}`);
  if (cf.idle) parts.push(`${cf.idle} propert${cf.idle === 1 ? "y" : "ies"} not earning`);
  return parts.length ? `Next settlement: ${parts.join(", ")}` : "Nothing due at the next settlement";
}

/* THE WATCHLIST.

   A property stops appearing on the Market the moment someone buys it, which
   is right — that list is what you can buy — but it meant a property you
   were circling simply vanished, and the only way back to it was to remember
   its name and hunt it down on the map. A star keeps it in view whoever owns
   it, so "the one I want next" survives somebody else getting there first.

   The list lives on the server rather than in the browser, because a
   watchlist kept in one browser's storage is a watchlist a player loses the
   first time they open the game on their phone. */
function FavStar({ id, me, onAct, size = 16 }) {
  const on = (me.favourites || []).includes(id);
  return (
    <button className={"fav-star" + (on ? " on" : "")}
            title={on ? "Remove from your watchlist" : "Add to your watchlist"}
            aria-label={on ? "Remove from your watchlist" : "Add to your watchlist"}
            aria-pressed={on}
            onClick={(e) => { e.stopPropagation(); onAct("favourite", { propertyId: id, on: !on }); }}>
      <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true">
        <path d="M12 2.6l2.9 5.9 6.5.95-4.7 4.6 1.1 6.45L12 17.45 6.2 20.5l1.1-6.45-4.7-4.6 6.5-.95z"
              fill={on ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.6"
              strokeLinejoin="round" />
      </svg>
    </button>
  );
}

function Watchlist({ game, me, histories, onAct, onOpen }) {
  const ids = (me.favourites || []).filter((id) => metaFor(id) && game.properties[id]);
  if (!ids.length) return null;
  return (
    <div className="watchlist">
      <h3>Watchlist</h3>
      <div className="meta" style={{ marginBottom: 10 }}>
        {ids.length} propert{ids.length === 1 ? "y" : "ies"} you are keeping an eye on, owned or not.
      </div>
      {ids.map((id) => {
        const meta = metaFor(id);
        const p = game.properties[id];
        const owner = p.owner ? game.players[p.owner] : null;
        const change = changeFromHistory(histories[id] || [], p.currentPrice);
        return (
          <div className="watch-row" key={id} onClick={() => onOpen(id)}>
            <FavStar id={id} me={me} onAct={onAct} size={15} />
            <span className="watch-name">
              {meta.name}
              <span className="muted">
                {p.cpoSale ? " · compulsory sale"
                  : p.inAuction ? " · in auction"
                  : !p.owner ? " · available"
                  : p.owner === me.id ? " · yours"
                  : owner ? ` · ${owner.name}` : " · owned"}
              </span>
            </span>
            <span className={change >= 0 ? "chg-up" : "chg-down"}>{fmtPct(change)}</span>
            <span className="watch-price">{fmt(p.currentPrice)}</span>
          </div>
        );
      })}
    </div>
  );
}

/* A date as well as a time. fmtTime is the clock alone, which is right for a
   ledger row an hour old and useless for "joined". */
const fmtWhen = (t) => (t ? new Date(t).toLocaleDateString("en-GB",
  { day: "numeric", month: "short", year: "numeric" }) : "—");

/* ------------------------- the opening guide --------------------------- */
/* SIX CARDS, SKIPPABLE, AND REACHABLE AGAIN AFTERWARDS.

   A new player lands on a screen with eleven tabs, a ticker, a banner of
   figures and a market of 155 properties, and the game's actual rules — that
   an hour here is an hour in York, that their stake is matched by a debt, that
   skill never becomes money — are nowhere on it. The previous answer was to
   let them find out, and "the fun ends after 15-20 minutes" is partly what
   that produced.

   Skippable because a guide you cannot leave is a toll gate, and re-openable
   from the account panel because the parts that matter only become questions
   after half an hour of play. Marked as seen on the server rather than in the
   browser: a guide that reappears on a second device is worse than none, and
   one that vanishes with the cookies leaves no way back. */
/* THE OPENING GUIDE, IN AARON'S WORDS.

   Seven cards, shown once on a first sign-in and reachable again from
   Account -> Guide. The copy below is the author's, taken verbatim from
   guide-cards.md.

   Card 3's "interest-free for the first day" was, when it was written,
   untrue: starterGraceHours was 7, and since a game hour is a real hour
   that was seven real hours. The number moved to match the copy rather
   than the other way round — see lib/config.js — so this card and the game
   now say the same thing. If that config knob is ever changed again, this
   card is the second place to change. */
const GUIDE = [
  {
    title: "A market, not a board game",
    body: [
      "Civexio \u00b7 York is a live property exchange for York. Everyone trades the same 155 buildings at the same time, and prices move whether or not you are watching.",
      "There are no turns.",
    ],
  },
  {
    title: "An hour here is an hour in York",
    body: [
      "Rent settles hourly. Loans charge hourly. Both games on the Gaming Council close at the same time each evening.",
      "The clock in the green band at the top is York's own time, so you always know how long you have.",
    ],
  },
  {
    title: "Your starting cash is borrowed",
    body: [
      "You start with cash and a debt of exactly the same size, so your net worth begins at zero. Nobody is handed half a million.",
      "The debt charges no interest for the first day. After that it costs you every hour until you clear it.",
    ],
    /* THE CLOCK IS THE POINT, AND IT WAS NOT BEING SAID.

       "It costs you every hour" is true and means nothing to somebody who
       has never seen the figure. A trader who buys nothing pays about
       £46,000 a day from their second day on, and the first they knew about
       it was their cash going backwards — which reads as the game being
       broken rather than as the clock being the game.

       Computed from `rules` rather than typed, so it cannot go stale the
       way this card's "interest-free for the first day" line already did
       once when starterGraceHours moved. */
    loanClock: true,
  },
  {
    title: "One free deed to start you off",
    body: [
      "Every new trader gets one free pull of a property on the market draw, chosen at random from the ordinary lots. You keep whatever comes out.",
      "So nobody starts with nothing. This is your big break.",
    ],
  },
  {
    title: "Buy, then keep it earning",
    body: [
      "A property pays rent every hour. Improving it raises the rent, you must improve the property or maintain it at least weekly to continue receiving rent. Idle capital is the one thing this game punishes.",
    ],
  },
  /* THE COUNCIL CARD, REWRITTEN 19 SEP 2026.

     It named Surveyor's Bluff and said "two games", and both had stopped
     being true: the bluff is stood down and there are four. A guide card
     that names a game which is not on the tab is worse than no card, so
     this one is a list rather than a sentence — the four games are the
     thing a new trader needs, and each is one line saying what you do.

     Every game is in the same shape on purpose: you are put with the same
     five traders for the week, you spend a minute a day, and what comes
     out is skill rather than cash. */
  {
    title: "The Gaming Council",
    body: [
      "Three games against the same five traders all week: your field. Each takes a minute a day and pays in skill points, which buy advantages cash cannot.",
    ],
    steps: [
      ["The Gazette", "Make truces and alliances with your field. Alliances score by striking other alliances across the city."],
      ["The Tontine", "Sit at a table with your field. One name is drawn each evening; take the call and leave with a share, or stay in. The last trader standing takes the pot."],
      ["The Planning Committee", "Eight permits, bid on blind. The highest bidder takes it, unless somebody vetoes it."],
    ],
    body2: [
      "A trader who ignores this tab is slower, not poorer. Nothing here can cost you a building.",
    ],
  },
  {
    title: "Tactics are a deck of cards",
    body: [
      "Tactics are a deck of cards that let you attack, defend or modify your advantages.",
      "Four cards are dealt to you every evening. Choose the ones you want to use and confirm them before the evening lock.",
      "A defensive card does nothing until you arm it. Arm one and it waits in your hand and fires by itself when somebody attacks. You do not have to be here, and nobody can see you are holding it. It stays there until it fires, and costs you a draw every day it does.",
      "Three cards of different rarity are offered for sale daily to be added to your deck. Grow your abilities through your deck.",
    ],
  },
  /* KEYSTONES, AND WHAT THEY ARE NOT. Aaron, 28 Sep 2026: the guide has to
     say plainly that they are cosmetic only. */
  {
    title: "Keystones are for looks only",
    body: [
      "Keystones are for cosmetic upgrades only. They change how you look on the exchange and nothing else: a Keystone never buys property, cash, tactics cards, skill or any other advantage.",
      "Every trader starts with one free, and the count is on the bar at the top. One unlocks your profile page for good, with an avatar, a border, your flag, a line about yourself and a frame round your buildings.",
      "More are in the shop under Personalise. Nobody has to buy one to play, or to win.",
    ],
  },
  /* THE 1 OCTOBER CARDS: City tasks, the planning ballot and the city that
     grows, and the share market. Marked with their release so a trader who
     read the guide before them is shown these three once, on their own. */
  {
    release: "oct01",
    title: "City tasks",
    body: [
      "Below £1.5m net worth the Council pays fixed fees for work around York: broker a swap, answer a Wanted notice, close a set, re-let an empty unit, restore a frontage. Every task names a real building or district.",
      "A new board goes up every evening. Take one task at a time: 48 hours for a trade task, 24 for any other. The fees come from the rates the richest traders pay.",
    ],
  },
  {
    release: "oct01",
    title: "Plots by ballot, and a city that grows",
    body: [
      "Building plots are granted by the Council's planning ballot at each evening's turnover. Nobody applies: play on five days in seven and you are in the draw, with more tickets the further below the median net worth you are.",
      "Win and the plot is yours alone for 48 hours to survey and break ground. Below the median, the Council will hold the plot price for you, repaid from half the house's rent.",
      "The map shows how much of York is owned. At 60%, 75% and 90% the Council opens a new district on a real development site, York Central first, and for its first 48 hours only traders below the median may buy there.",
    ],
  },
  {
    release: "oct01",
    title: "Bank / Stocks",
    body: [
      "An owner with a credit line can float up to 49 of a building's 100 shares. With a deposit account you can buy them, and every hour you are paid that share of the building's rent.",
      "The market is open from 17:30 to 20:30 UK time, so the evening lands while it trades. A listed building that is hit at 20:00 drops there and then; one that holds an attack off rises. Read the evening coming and trade on it.",
      "Buying lifts a price gently; selling drops it hard, and a big sale hardest. Only the owner defends the building.",
    ],
  },
  /* THE LAST THING THEY READ IS THE THING THEY SHOULD DO.

     Everything above this card explains how the exchange works. None of it
     tells a new trader what to actually press, and "the fun ends after
     15-20 minutes" is partly a player standing in front of 155 buildings
     with no idea which end to start. Four moves, in the order they make
     sense: take the free pull, buy something with the proceeds, sit down at
     the Council, seal a card. Between them they open every part of the game
     that pays. */
  {
    title: "Your first four moves",
    body: [
      "If you do nothing else today, do these. They take about ten minutes between them.",
    ],
    steps: [
      ["Take your free pull", "The market draw on the Market tab. One lot, free, yours to keep. There is no reason to save it."],
      ["Buy a property", "Anything you can afford. A building earns rent every hour from the moment it is yours; cash sitting still earns nothing and the loan is charging."],
      ["Sit down at the Gaming Council", "Three games, a minute a day each, all against the same five traders. They pay in skill, which buys advantages cash cannot."],
      ["Choose a tactic", "Four cards are dealt to you every evening, and whatever you have not used is discarded at the next evening lock. An attack has to be confirmed before the evening lock. A shield defends you just by being in your hand, even if you never open the game. Arming one keeps it past the discard and puts it first in the queue."],
    ],
  },
  {
    title: "When something goes wrong",
    body: [
      "The Account button beside Sign out has your email and password, what you have paid for, this guide again, and a box to tell us what is broken.",
      "It is also where the music lives. There is a soundtrack that follows you between the market, the tactics room and the Gaming Council. Switch it off there, or mute the game entirely, and it will remember.",
      "Bug reports are read. Please send them so we can keep working\u2026",
    ],
    discord: true,
  },
];

/* The newest batch of cards, shown on their own to a trader who read the
   guide before they were written. */
const GUIDE_RELEASE = "oct01";
const GUIDE_NEW = GUIDE.filter((c) => c.release === GUIDE_RELEASE);

/* PAST £1.5M (Aaron, 1 Oct 2026): "the changes post 1.5 million can be
   given as additional guide cards released to players when they reach
   that milestone instead of all at once." Shown once, the first time a
   trader is over the line, and in the guide from then on. */
const GUIDE_RICH = [
  {
    title: "The Council's rates",
    body: [
      "You have passed £1.5m. From here the Council takes a share of all the rent you keep, at the bracket your net worth is in: 15% from £1.5m, 20% from £3m, 25% from £5m and 30% from £10m.",
      "It is collected with the rent every hour and pays for the City tasks that traders below £1.5m take. Your City tasks tab closes once any task you hold is done.",
    ],
  },
  {
    title: "Four ways to play from here",
    body: ["None of them is required. Mix them as you like."],
    steps: [
      ["Just pay", "Nothing to manage. The rate tops out at 30% of the rent you keep, and you carry on growing."],
      ["Float", "Offer 25 to 49 shares of a building for sale and be paid as they sell. Rent paid to shareholders is not yours, so it is not rated."],
      ["Develop", "Money spent this week on improvements, security or building takes up to a quarter off the bill."],
      ["Bank", "Bank levels 2, 3 and 4 take 1.5, 2.5 and 4 points off your rate. Deposit interest is never rated."],
    ],
    body2: ["All of them together never take off more than half the bill."],
  },
  {
    title: "Floating a building",
    body: [
      "Floating needs a credit line, bank level 2. You choose how many shares to offer and the price. They go on sale from 17:30 to 20:30 for up to three sessions, and you are paid as each one sells. Any left at the end come back to you.",
      "The building's value then follows its share price on the part you sold: a rising price lifts your net worth, an attack that knocks it down costs you. You keep the building and defend it alone. A floated building cannot be sold to the market; auction it, trade it, or buy every shareholder out at 25% over the price. Buy them out and the building's value, and so its rent, becomes a hundred shares at the price it finished at.",
    ],
  },
];

/* THE ONE HOUR BEFORE THE LOCK.

   A card you meant to seal and forgot is the most annoying way to lose a
   day in this game, and the tab gives no reason to look at it — the whole
   point of auto-arming shields is that you do not have to be here.

   Not six alerts ten minutes apart. That was the first proposal and it is
   six chances to be told the same thing, five of which arrive after you
   have either acted or decided not to. One strip, in the hour before the
   lock, counting down — a clock rather than a nag.

   AND ONLY IF THERE IS SOMETHING TO DO. A player whose hand is empty, or
   whose cards are all sealed or armed already, is told nothing at all. An
   alert that fires at everyone every evening is one everybody learns to
   stop reading, and it would be telling most players off for having
   finished. */
const LOCK_WARNING_MS = 60 * 60 * 1000;

/* THE HOUR BEFORE THE LOCK. Aaron, 28 Sep 2026: it also reminds players to
   take part in the Gaming Council, and it can be closed. Closing it holds
   for that evening only: it is keyed on the lock it warns about, so
   tomorrow's warning still arrives. */
function LockBar({ deck, onOpen, onCouncil }) {
  const [now, setNow] = useState(Date.now());
  const key = deck && deck.locksAt ? "civexio.lockbar.closed" : null;
  const [closedFor, setClosedFor] = useState(() => {
    try { return Number(window.localStorage.getItem("civexio.lockbar.closed")) || 0; } catch { return 0; }
  });
  useEffect(() => {
    const t = setInterval(() => setNow(Date.now()), 30000);
    return () => clearInterval(t);
  }, []);
  if (!deck || !deck.locksAt || deck.locked) return null;
  if (closedFor === deck.locksAt) return null;
  const left = deck.locksAt - now;
  if (left <= 0 || left > LOCK_WARNING_MS) return null;
  const idle = (deck.hand || []).filter((c) => !c.armed).length;
  const mins = Math.max(1, Math.round(left / 60000));
  const close = () => {
    setClosedFor(deck.locksAt);
    try { if (key) window.localStorage.setItem(key, String(deck.locksAt)); } catch { /* private window */ }
  };
  return (
    <div className="lock-bar" role="status">
      <span className="lock-bar-clock">{mins} MIN</span>
      <span>
        Orders close at the evening lock.
        {idle ? ` You are still holding ${idle} card${idle === 1 ? "" : "s"}.` : ""}
        {" "}Take your turn in the Gaming Council too.
      </span>
      {idle ? <button className="lock-bar-go" onClick={onOpen}>Open your deck</button> : null}
      {onCouncil && <button className="lock-bar-go" onClick={onCouncil}>Gaming Council</button>}
      <button className="lock-bar-x" onClick={close} aria-label="Close this reminder until tomorrow"
              title="Close until tomorrow">×</button>
    </div>
  );
}

/* WHAT THE STAKE ACTUALLY COSTS, IN A NUMBER.

   Worked from the same three knobs the server settles on — the loan, the
   hourly rate and the term — rather than typed into the copy, because the
   grace period has already moved once since this guide was written and a
   figure in prose would have quietly become a lie.

   The sum is a day of the amortisation the moment the interest-free opening
   ends: twenty-four hours of interest on a balance that is coming down, plus
   twenty-four slices of capital. Rounded to the nearest thousand, because a
   player does not need £46,188 and a precise number invites arguments about
   a figure that moves with relief and awards anyway. */
function LoanClockLine({ rules }) {
  const loan = rules.starterLoan || 0;
  const rate = rules.starterLoanRate || 0;
  const term = rules.starterTermHours || 0;
  if (!loan || !rate || !term) return null;
  const slice = loan / term;
  let bal = loan, day = 0;
  for (let h = 0; h < 24; h++) {
    const pay = Math.min(bal, slice);
    day += bal * rate + pay;
    bal = Math.max(0, bal - pay);
  }
  const round = Math.round(day / 1000) * 1000;
  return (
    <p className="guide-line guide-warn">
      <b>The clock is ticking.</b> From your second day the debt takes about{" "}
      <b>{fmt(round)} every 24 hours</b> out of your cash; you can watch it on the
      <b> cash flow</b> figure in the bar at the top. Rent has to cover it, so
      consider buying early and making sure it gives you a return.
    </p>
  );
}


/* ---- FIRST STEPS (Aaron, 2 Oct 2026) ---------------------------------------

   "We want users to jump into the game and roll their first property and run
   through the fun bits of the game fast." No tour to sit through: a new
   trader lands on the Market with the free draw already open, and a small
   card along the bottom takes them through the three fun things, in the real
   game, one after another:

     1. pull the free building;
     2. play a tactic card at a rival (it lands at the evening);
     3. send a board to the Planning Committee in the Gaming Council.

   Each step is done when the game says it is (a building owned, an order
   sealed, a board sent), so it survives a reload and cannot be ticked off by
   reading. Then a last card points at the rest of the city and gets out of
   the way. Skippable at every step; "Read the guide" opens the written
   guide. The one minute picture tour is kept, reachable from Account. */
const FS_KEY = (id) => `civexio.firstSteps.${id}`;
const fsGet = (id) => { try { return window.localStorage.getItem(FS_KEY(id)); } catch { return null; } };
const fsSet = (id, v) => { try { window.localStorage.setItem(FS_KEY(id), v); } catch { /* fine */ } };

function FirstSteps({ game, me, tab, setTab, onText, onDone }) {
  const owned = Object.values(game.properties || {}).some((p) => p && p.owner === me.id);
  const deck = game.deck || {};
  /* An order sealed for tonight, or one of theirs already resolved. On a
     replay (tools/replaytutorial.mjs) only an order that lands after the
     replay was set counts, so an old one does not tick the step. */
  const from = me.tutorialFrom || 0;
  const orders = [...(deck.sealed || []), ...(deck.recent || [])];
  const sealed = from ? orders.some((o) => (o.resolveAt || 0) > from) : orders.length > 0;
  /* THE FREE SPIN (Aaron, 2 Oct 2026: in place of the Planning Committee,
     "the gaming council page and then getting a free spin on the wheel of
     fortune"). Done once the free pull is spent AND the wheel has come to
     rest, so "You are in" never arrives while the wheel is still turning
     and gives the prize away. A pull that lands on Another Pull leaves one
     free, so the step simply asks for the next spin. */
  const wheel = game.wheel || null;
  const [wheelRested, setWheelRested] = useState(() => !wheel || (wheel.free || 0) === 0);
  /* On a replay the account may have no free pull left, so the step waits
     for a spin made after the replay was set, remembered in this browser. */
  const spunKey = `${FS_KEY(me.id)}.spunAt`;
  const [spunAt, setSpunAt] = useState(() => { try { return Number(window.localStorage.getItem(spunKey)) || 0; } catch { return 0; } });
  useEffect(() => {
    const rest = () => {
      setWheelRested(true);
      const t = Date.now();
      setSpunAt(t);
      try { window.localStorage.setItem(spunKey, String(t)); } catch { /* fine */ }
    };
    /* A second spin (after Another Pull) turns the wheel again: not at
       rest until it settles, or the free pull it spends would end the step
       while the wheel is still going round. */
    const turning = () => setWheelRested(false);
    window.addEventListener("civexio:wheel", rest);
    window.addEventListener("civexio:wheelspin", turning);
    return () => { window.removeEventListener("civexio:wheel", rest); window.removeEventListener("civexio:wheelspin", turning); };
  }, [spunKey]);
  const spun = !wheel || (from
    ? spunAt > from && wheelRested
    : (wheel.free || 0) === 0 && wheelRested);
  const freeSpin = wheel && (wheel.free || 0) > 0;
  const steps = [
    { key: "draw", done: owned, tab: "market", target: ".lotto-go",
      title: "Pull your free building", line: "One free pull from the market draw. Whatever comes out is yours, and it pays rent every hour.",
      go: "Open the draw", act: () => { setTab("market"); setTimeout(() => window.dispatchEvent(new CustomEvent("civexio:draw")), 150); } },
    { key: "card", done: sealed, tab: "tactics", target: ".deck-table .deck-section",
      title: "Play a card against another player", line: `Tap a card, pick another player's building and confirm it. Every order lands at ${eveningAt()}.`,
      go: "Go to your cards", act: () => setTab("tactics") },
    { key: "wheel", done: spun, tab: "council", target: ".wheel-go", pause: 1400,
      title: "Spin the wheel of fortune", line: `${freeSpin ? "Your first spin is free." : `A spin costs ${(wheel && wheel.cost) || 40} skill.`} The wheel is on the Gaming Council page, where the daily games pay skill. One of its twenty slices is a super rare card.`,
      go: "Go to the Gaming Council", act: () => setTab("council") },
  ];
  const at = steps.findIndex((x) => !x.done);
  const step = at >= 0 ? steps[at] : null;

  /* Straight in: the draw opens on the first look. */
  const jumped = useRef(false);
  useEffect(() => {
    if (jumped.current || owned) return;
    jumped.current = true;
    setTab("market");
    const t = setTimeout(() => window.dispatchEvent(new CustomEvent("civexio:draw")), 500);
    return () => clearTimeout(t);
  }, []);

  /* The thing to press glows, and is brought into view, on its own tab. */
  useEffect(() => {
    if (!step || tab !== step.tab) return;
    let el = null;
    /* The wheel waits a moment first, so the Gaming Council page is seen
       from its top before the page moves down to the spin. */
    const t = setTimeout(() => {
      el = document.querySelector(step.target);
      if (!el) return;
      el.classList.add("fs-glow");
      if (step.key !== "draw") { try { el.scrollIntoView({ behavior: "smooth", block: "center" }); } catch {} }
    }, step.pause || 350);
    return () => { clearTimeout(t); if (el) el.classList.remove("fs-glow"); };
  }, [step && step.key, tab]);

  const explore = [
    ["map", "Lead a district"], ["portfolio", "Your buildings"], ["floor", "Trade with traders"],
    ["bank", "Bank and shares"], ["build", "Build a house"], ["tasks", "City tasks"],
  ];
  return (
    <aside className="fs" role="region" aria-label="First steps">
      <div className="fs-in">
        <div className="fs-top">
          <span className="fs-dots" aria-label={step ? `Step ${at + 1} of 3` : "All three done"}>
            {steps.map((x, n) => <i key={x.key} className={x.done ? "is-done" : n === at ? "is-now" : ""}>{x.done ? "✓" : n + 1}</i>)}
          </span>
          <span className="fs-links">
            <button type="button" onClick={onText}>Read the guide</button>
            <button type="button" onClick={onDone}>{step ? "Skip" : "Close"}</button>
          </span>
        </div>
        {step ? (
          <div className="fs-body">
            <div className="fs-words"><b>{step.title}</b><span>{step.line}</span></div>
            {tab !== step.tab || step.key === "draw"
              ? <button type="button" className="fs-go" onClick={step.act}>{step.go}</button>
              : null}
          </div>
        ) : (
          <div className="fs-body fs-end">
            <div className="fs-words">
              <b>You are in.</b>
              <span>Your card lands at {eveningAt()}. The rest of the city is yours to find:</span>
            </div>
            <div className="fs-explore">
              {explore.map(([id, label]) => (
                <button type="button" key={id} onClick={() => { setTab(id); onDone(); }}>{label}</button>
              ))}
            </div>
          </div>
        )}
      </div>
    </aside>
  );
}

/* ---- THE FIRST MINUTE (Aaron, 2 Oct 2026) ----------------------------------

   "The beginning tutorial is far too wordy, causing people to drop off
   fast. We need a cool visual way of teaching the game (players can click
   an option to revert to the text instead). Players want to decide in under
   1 minute after logging in if this game is worth their time ... start with
   the market pull first then the tactics cards section then the gaming
   council and then the rest."

   Four scenes, each a few seconds of picture and one line of words, about
   forty seconds in all, then the free draw itself. It moves on by itself;
   a tap moves it on sooner. "Read it as text" opens the old guide at any
   point. Under reduced motion every scene shows its last frame and waits
   for a tap. Every number shown is the game's own: the deed's rent is its
   catalogue yield, the evening is the real evening (hourly in the guest
   city). */
const TOUR_SCENES = [
  { key: "draw", ms: 10500 },
  { key: "cards", ms: 12000 },
  { key: "council", ms: 10000 },
  { key: "city", ms: 10000 },
];

/* A building from the ordinary book, so the scene shows the kind of deed a
   first draw can really give: rare if one is cheap enough, else uncommon. */
function tourDeed() {
  const pool = ALL_PROPERTIES.filter((m) => !m.exclusive && !m.built && !m.zone && m.basePrice > 0 && m.basePrice < 290000);
  return pool.find((m) => m.rarity === "rare" && artFor(m, null))
    || pool.find((m) => m.rarity === "uncommon" && artFor(m, null))
    || pool.find((m) => artFor(m, null)) || pool[0] || ALL_PROPERTIES[0];
}
const tourRivalBuilding = (not) => ALL_PROPERTIES.find((m) => m.id !== (not && not.id) && !m.exclusive && !m.built && !m.zone
  && m.stock !== (not && not.stock) && artFor(m, null)) || ALL_PROPERTIES[1];

/* A figure that counts up from nothing once the scene reaches it. */
function TourCount({ to, from = 0, startMs = 0, ms = 1400, prefix = "£", stopAtMs = null }) {
  const [v, setV] = useState(from);
  useEffect(() => {
    let raf = 0, t0 = 0;
    const start = setTimeout(() => {
      const tick = (t) => {
        if (!t0) t0 = t;
        const p = Math.min(1, (t - t0) / ms);
        setV(from + (to - from) * (1 - Math.pow(1 - p, 3)));
        if (p < 1) raf = requestAnimationFrame(tick);
      };
      raf = requestAnimationFrame(tick);
    }, startMs);
    const stop = stopAtMs != null ? setTimeout(() => cancelAnimationFrame(raf), stopAtMs) : null;
    return () => { clearTimeout(start); if (stop) clearTimeout(stop); cancelAnimationFrame(raf); };
  }, [to, from, startMs, ms, stopAtMs]);
  return <>{prefix}{Math.round(v).toLocaleString("en-GB")}</>;
}

function TourDraw({ deed }) {
  const rent = (yieldPctOf(deed) / 100) * deed.basePrice;
  const photo = artFor(deed, null);
  return (
    <div className="ft-scene ft-draw">
      <div className="ft-back ft-b1" aria-hidden="true"><i>C</i></div>
      <div className="ft-back ft-b3" aria-hidden="true"><i>C</i></div>
      <div className="ft-flip">
        <div className="ft-face ft-face-back" aria-hidden="true"><i>C</i></div>
        <div className={"ft-face ft-face-front q-" + (deed.rarity || "common")}>
          <div className="ft-deed-art">
            {photo ? <img src={photo} alt="" /> : <PropertyArt meta={deed} height={null} />}
            <RarityRibbon meta={deed} />
          </div>
          <div className="ft-deed-cap">
            <b>{deed.name}</b>
            <span>{deed.neighbourhoodName}</span>
            <em>Rent <TourCount to={rent} startMs={4000} ms={1500} /> an hour</em>
          </div>
        </div>
        <span className="ft-stamp ft-stamp-yours">Yours</span>
      </div>
    </div>
  );
}

function TourCards({ rival }) {
  const rent = Math.round((yieldPctOf(rival) / 100) * rival.basePrice);
  const photo = artFor(rival, null);
  const hand = ["scaffold", "strike", "lead", "watchman"].filter((id) => DECK.cards[id]);
  const at = eveningAt();
  const before = (() => { const [h, m] = at.split(":").map(Number); const t = (h * 60 + m + 1439) % 1440; return `${String(Math.floor(t / 60)).padStart(2, "0")}:${String(t % 60).padStart(2, "0")}`; })();
  return (
    <div className="ft-scene ft-cards">
      <div className="ft-target">
        <div className="ft-target-art">{photo ? <img src={photo} alt="" /> : <PropertyArt meta={rival} height={null} />}</div>
        <div className="ft-target-cap">
          <span>A rival's building</span>
          <b className="ft-rent-live">+<TourCount to={rent} from={Math.round(rent * 0.2)} startMs={200} ms={5200} /> an hour</b>
          <b className="ft-rent-dead">Rent stopped for 12 hours</b>
        </div>
        <span className="ft-stamp ft-stamp-landed">Landed</span>
      </div>
      <div className="ft-clock" aria-hidden="true"><span className="ft-clock-a">{before}</span><span className="ft-clock-b">{at}</span></div>
      <div className="ft-hand">
        {hand.map((id, n) => (
          <div key={id} className={"ft-card ft-card-" + n + (id === "strike" ? " ft-card-play" : "")}>
            <TacticCard id={id} size="hand" />
          </div>
        ))}
      </div>
      <span className="ft-chip ft-chip-name">{(DECK.cards.strike || {}).name || "Incite Rent Strike"}</span>
      <span className="ft-chip ft-chip-sealed">Sealed until {at}</span>
    </div>
  );
}

const TOUR_GAMES = [
  { name: "The Gazette", line: "Truces and alliances",
    glyph: "M4 5h13v14H6a2 2 0 0 1-2-2Zm13 3h3v9a2 2 0 0 1-2 2M7 8h7M7 11h7M7 14h4" },
  { name: "The Tontine", line: "Last one standing takes the pot",
    glyph: "M5 11h14l-1.5 8h-11ZM8 11a4 4 0 0 1 8 0M12 4v3M9 5l1 2M15 5l-1 2" },
  { name: "The Planning Committee", line: "Bid blind for permits",
    glyph: "M6 3h9l4 4v14H6ZM14 3v5h5M9 13l2 2 4-4" },
];
function TourCouncil() {
  const seats = ["You", "F", "M", "T", "Q", "B"];
  return (
    <div className="ft-scene ft-council">
      <div className="ft-table" aria-hidden="true">
        {seats.map((s, n) => (
          <span key={n} className={"ft-seat ft-seat-" + n + (n === 0 ? " is-you" : "")}>{s}</span>
        ))}
        <span className="ft-table-mid">Your field</span>
      </div>
      <div className="ft-games">
        {TOUR_GAMES.map((g, n) => (
          <div key={g.name} className={"ft-game ft-game-" + n}>
            <svg viewBox="0 0 24 24" width="30" height="30" aria-hidden="true">
              <path d={g.glyph} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" strokeLinecap="round" />
            </svg>
            <div><b>{g.name}</b><span>{g.line}</span></div>
            <em className="ft-skill">+ skill</em>
          </div>
        ))}
      </div>
    </div>
  );
}

function TourCity() {
  const tiles = [
    { key: "lead", label: "Lead a district" },
    { key: "shares", label: "Trade shares" },
    { key: "build", label: "Build a house" },
    { key: "tasks", label: "Take City tasks" },
    { key: "honours", label: "Win honours" },
    { key: "deal", label: "Auction and trade" },
  ];
  const medal = (HONOURS || []).find((h) => h.tier === 2) || { tier: 2, glyph: FAST_GROWER_GLYPH };
  const pic = {
    lead: (
      <svg viewBox="0 0 120 80" aria-hidden="true">
        <path d="M10 60 L30 20 L62 14 L92 26 L110 58 L74 72 L34 70 Z" fill="#E9E4D4" stroke="#8B8471" strokeWidth="1.5" />
        <path className="ft-wash" d="M30 20 L62 14 L92 26 L74 46 L40 44 Z" fill="#1F8A4C" />
        <path className="ft-crown" d="M52 30l4 5 4-7 4 7 4-5-2 9H54Z" fill="#E2C468" />
      </svg>
    ),
    shares: (
      <svg viewBox="0 0 120 80" aria-hidden="true">
        <path d="M8 60h104" stroke="#C6BC9F" strokeWidth="1" />
        <path className="ft-line" d="M8 58 L26 52 L40 55 L56 40 L70 44 L86 26 L100 30 L112 16" fill="none" stroke="#2F7A4B" strokeWidth="3.5" strokeLinejoin="round" strokeLinecap="round" />
      </svg>
    ),
    build: (
      <svg viewBox="0 0 120 80" aria-hidden="true">
        <rect className="ft-b-base" x="34" y="62" width="52" height="6" fill="#8B8471" />
        <rect className="ft-b-walls" x="38" y="36" width="44" height="26" fill="#B4573E" />
        <path className="ft-b-roof" d="M32 38 L60 18 L88 38 Z" fill="#3E4A45" />
        <rect className="ft-b-door" x="56" y="48" width="9" height="14" fill="#1F3A5F" />
      </svg>
    ),
    tasks: (
      <svg viewBox="0 0 120 80" aria-hidden="true">
        <rect x="38" y="10" width="44" height="60" rx="3" fill="#F2EDE1" stroke="#8B8471" strokeWidth="1.5" />
        <rect x="50" y="6" width="20" height="8" rx="2" fill="#8B8471" />
        {[26, 40, 54].map((y, n) => (
          <g key={y}><path className={"ft-tick ft-tick-" + n} d={`M44 ${y} l4 4 8 -8`} fill="none" stroke="#2F7A4B" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
            <path d={`M60 ${y}h16`} stroke="#C6BC9F" strokeWidth="3" strokeLinecap="round" /></g>
        ))}
      </svg>
    ),
    honours: <div className="ft-medal"><Medal tier={medal.tier} glyph={medal.glyph} size={64} /></div>,
    deal: (
      <svg viewBox="0 0 120 80" aria-hidden="true">
        <rect x="30" y="64" width="60" height="6" rx="2" fill="#8B8471" />
        <g className="ft-gavel"><rect x="52" y="14" width="30" height="14" rx="3" fill="#7E4A2A" transform="rotate(-30 67 21)" />
          <rect x="44" y="30" width="6" height="34" rx="2" fill="#7E4A2A" transform="rotate(-30 47 47)" /></g>
      </svg>
    ),
  };
  return (
    <div className="ft-scene ft-city">
      {tiles.map((t, n) => (
        <div key={t.key} className={"ft-tile ft-tile-" + n + " ft-t-" + t.key}>
          <div className="ft-tile-pic">{pic[t.key]}</div>
          <b>{t.label}</b>
        </div>
      ))}
    </div>
  );
}

function FirstTour({ onClose, onText, onDraw }) {
  const reduced = typeof window !== "undefined" && window.matchMedia
    && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const [i, setI] = useState(0);
  const deed = useMemo(tourDeed, []);
  const rival = useMemo(() => tourRivalBuilding(deed), [deed]);
  const done = i >= TOUR_SCENES.length;
  const evening = eveningAt();
  const guestCity = !!(CAT && CAT.guest);
  /* The board is drawn at 340 by 330 and scaled to fit the screen. */
  const fit = () => typeof window === "undefined" ? 1
    : Math.max(0.8, Math.min(1.35, (Math.min(window.innerWidth, 640) - 32) / 340, (window.innerHeight - 330) / 330));
  const [k, setK] = useState(fit);
  useEffect(() => {
    const on = () => setK(fit());
    window.addEventListener("resize", on);
    return () => window.removeEventListener("resize", on);
  }, []);
  useEffect(() => {
    if (reduced || done) return;
    const t = setTimeout(() => setI((n) => n + 1), TOUR_SCENES[i].ms);
    return () => clearTimeout(t);
  }, [i, reduced, done]);
  useEffect(() => {
    const key = (e) => {
      if (e.key === "Escape") onClose();
      else if (e.key === "ArrowRight" || e.key === " ") { e.preventDefault(); setI((n) => Math.min(TOUR_SCENES.length, n + 1)); }
      else if (e.key === "ArrowLeft") setI((n) => Math.max(0, n - 1));
    };
    window.addEventListener("keydown", key);
    return () => window.removeEventListener("keydown", key);
  }, [onClose]);
  const words = [
    { h: "Your first building is free.", p: "Pull one from the market draw. Whatever comes out is yours, and it pays rent every hour." },
    { h: `Four cards a day. They land at ${evening}.`, p: guestCity
        ? "Strike a rival's rent, arm a shield, take a building. In the guest city the evening comes every hour."
        : "Strike a rival's rent, arm a shield, take a building. Nobody sees your move until the evening." },
    { h: "Five rivals. A minute a day.", p: "Three quick games against the same traders all week. Win skill and spend it on edges cash cannot buy." },
    { h: "And a whole city to play.", p: "You will find the rest as you go." },
  ];
  return (
    <div className={"ft" + (reduced ? " ft-still" : "")} style={{ "--k": k }} role="dialog" aria-modal="true" aria-label="Civexio in a minute">
      <div className="ft-top">
        <div className="ft-bars" aria-hidden="true">
          {TOUR_SCENES.map((s, n) => (
            <span key={s.key} className={n < i ? "is-done" : n === i ? "is-now" : ""}>
              <i style={n === i && !reduced ? { animationDuration: `${s.ms}ms` } : undefined} key={n === i ? "run" + i : "x"} />
            </span>
          ))}
        </div>
        <div className="ft-links">
          <button type="button" onClick={onText}>Read it as text</button>
          <button type="button" onClick={onClose} aria-label="Close the tour">Skip</button>
        </div>
      </div>
      {!done ? (
        <div className="ft-body" onClick={() => setI((n) => n + 1)}>
          <div className="ft-stage" key={TOUR_SCENES[i].key}>
            {i === 0 && <TourDraw deed={deed} />}
            {i === 1 && <TourCards rival={rival} />}
            {i === 2 && <TourCouncil />}
            {i === 3 && <TourCity />}
          </div>
          <div className="ft-words" key={"w" + i}>
            <h2>{words[i].h}</h2>
            <p>{words[i].p}</p>
          </div>
          <div className="ft-nav" onClick={(e) => e.stopPropagation()}>
            <button type="button" className="ft-back-btn" disabled={i === 0} onClick={() => setI((n) => Math.max(0, n - 1))}>Back</button>
            <button type="button" className="ft-next" onClick={() => setI((n) => n + 1)}>{i === TOUR_SCENES.length - 1 ? "Finish" : "Next"}</button>
          </div>
        </div>
      ) : (
        <div className="ft-body ft-end">
          <div className="ft-end-card" aria-hidden="true"><i>C</i></div>
          <div className="ft-words">
            <h2>Your free draw is waiting.</h2>
            <p>One pull from the market. Start with a building of your own.</p>
          </div>
          <button type="button" className="ft-cta" onClick={onDraw}>Take my free draw</button>
          <button type="button" className="ft-quiet" onClick={onClose}>Look around first</button>
          <button type="button" className="ft-quiet" onClick={() => setI(0)}>Watch again</button>
        </div>
      )}
    </div>
  );
}

function GuideOverlay({ rules, onClose, cards = GUIDE, kicker = "HOW CIVEXIO WORKS", finish = "Start trading" }) {
  const [i, setI] = useState(0);
  const step = cards[i];
  const last = i === cards.length - 1;

  useEffect(() => {
    const onKey = (e) => {
      if (e.key === "Escape") onClose();
      if (e.key === "ArrowRight" && !last) setI((n) => n + 1);
      if (e.key === "ArrowLeft" && i > 0) setI((n) => n - 1);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [i, last, onClose]);

  return (
    <div className="modal-backdrop guide-backdrop" onClick={onClose}>
      <div className="modal-box guide-box" onClick={(e) => e.stopPropagation()}
           role="dialog" aria-modal="true" aria-label="How Civexio works">
        <div className="modal-inner">
          <div className="guide-head">
            <span className="guide-kicker">{kicker} · {i + 1} OF {cards.length}</span>
            <button className="switch-link" onClick={onClose}>Skip</button>
          </div>
          <h3 className="guide-title">{step.title}</h3>
          {step.body.map((line, n) => <p className="guide-line" key={n}>{line}</p>)}
          {step.loanClock && rules ? <LoanClockLine rules={rules} /> : null}
          {step.discord && rules && rules.discordUrl ? (
            <p className="guide-line">
              And the other traders are on Discord:{" "}
              <a href={rules.discordUrl} target="_blank" rel="noopener noreferrer">join them here</a>.
              That is where deals get made and grievances get aired.
            </p>
          ) : null}
          {step.steps && (
            <ol className="guide-steps">
              {step.steps.map(([what, why], n) => (
                <li key={n}><b>{what}</b><span>{why}</span></li>
              ))}
            </ol>
          )}
          {/* A line AFTER the list, for a card whose list is the middle of
              the argument rather than the end of it. */}
          {step.body2 && step.body2.map((line, n) => (
            <p className="guide-line" key={"b2" + n}>{line}</p>
          ))}

          <div className="guide-dots" aria-hidden="true">
            {cards.map((_, n) => (
              <span className={"guide-dot" + (n === i ? " on" : n < i ? " past" : "")} key={n} />
            ))}
          </div>
          <div className="guide-actions">
            <button className="btn secondary" disabled={i === 0} onClick={() => setI(i - 1)}>Back</button>
            <button className="btn" onClick={() => (last ? onClose() : setI(i + 1))}>
              {last ? finish : "Next"}
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

/* ------------------------- the account panel --------------------------- */

/* THE OPENING WEEK, TO LOOK BACK AT. Aaron, 26 Sep 2026: the daily login
   calendar should be in the Library so a player can review it. A trader in
   their first seven days sees their own days, taken, missed and ahead, and
   can open today's; anybody else sees what the seven days hold. */
function OpeningWeekReview({ week, onOpenWeek }) {
  const cal = week && week.calendar ? week.calendar
    : OPENING_REWARDS.map((r) => ({ ...r, state: "ahead" }));
  if (!cal.length) return null;
  return (
    <div className="owr">
      <h4 className="account-h">{week ? `Your opening week: day ${week.day} of ${week.days}` : "The opening week"}</h4>
      <p className="account-note">
        {week
          ? "A reward for each of your first seven days, counted from the day you joined or last started over. A missed day stays missed; the next one is still waiting."
          : "Every trader's first seven days, counted from the day they join or start over, each with its own reward. A missed day stays missed; the next one is still waiting."}
      </p>
      <div className="owr-track">
        {cal.map((c) => (
          <div key={c.day} className={"owr-card is-" + c.state + (week && c.day === week.day ? " is-today" : "")}
               style={{ "--card-accent": OW_ACCENT[c.day] }}>
            <span className="owr-day">Day {c.day}</span>
            <div className="owr-art"><OwArt reward={c} /></div>
            <b>{c.label}</b>
            <span className="owr-state">
              {!week ? c.detail
                : c.state === "taken" ? "Taken" : c.state === "missed" ? "Missed"
                : c.day === week.day ? (week.gated ? "Locked" : "Today") : `Opens day ${c.day}`}
            </span>
          </div>
        ))}
      </div>
      {week && onOpenWeek ? <button className="btn small" onClick={onOpenWeek}>Open today's reward</button> : null}
    </div>
  );
}

function AccountPanel({ me, rules, week, onAct, onClose, onGuide, onTour, onOpenWeek, onSignedOut, flash }) {
  const [tab, setTab] = useState("account");
  const [acct, setAcct] = useState(null);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");

  /* Fetched rather than carried in the state stream. None of it changes
     minute to minute, and the email address has no business being in a
     payload that is broadcast on every tick. */
  useEffect(() => {
    let alive = true;
    api("/api/account").then((r) => { if (alive) setAcct(r.account); })
      .catch((e) => { if (alive) setErr(e.message); });
    return () => { alive = false; };
  }, []);

  const post = async (path, body) => {
    setBusy(true); setErr("");
    try {
      const r = await api(path, { method: "POST", body });
      if (r.account) setAcct(r.account);
      if (r.message) flash(r.message);
      return r;
    } catch (e) { setErr(e.message); return { error: e.message }; }
    finally { setBusy(false); }
  };

  const TABS = [
    ["account", "Account"],
    ["payments", "Payments"],
    ["guide", "Library"],
    /* What is built and what is coming, in its own tab rather than inside
       the account details (Aaron, 26 Sep 2026). */
    ["development", "Development"],
    ["bug", "Report a bug"],
    /* Before "Close account", deliberately. A player who has come to this
       panel because their position is hopeless should meet the door that
       lets them keep playing before the one that ends it. */
    ["restart", "Start over"],
    ["close", "Close account"],
  ];

  return (
    <div className="modal-backdrop account-backdrop" onClick={onClose}>
      <div className="modal-box account-box" onClick={(e) => e.stopPropagation()}
           role="dialog" aria-modal="true" aria-label="Your account">
        <button className="modal-close" onClick={onClose} aria-label="Close">×</button>
        <div className="modal-inner">
          <h3 className="account-title">Your account</h3>
          <div className="account-tabs">
            {TABS.map(([id, label]) => (
              <button key={id} className={"account-tab" + (tab === id ? " on" : "")}
                      onClick={() => { setTab(id); setErr(""); }}>{label}</button>
            ))}
          </div>

          {err && <div className="error-text" style={{ marginTop: 10 }}>{err}</div>}
          {!acct ? <div className="empty">Loading…</div> : (
            <div className="account-body">
              {tab === "account" && <AccountDetails acct={acct} post={post} busy={busy} />}
              {tab === "payments" && <AccountPayments acct={acct} />}
              {tab === "development" && (
                <div>
                  <p className="account-note">What is being played now, what is being built and what comes next. Built by one person, in the open.</p>
                  <Roadmap compact />
                </div>
              )}
              {tab === "guide" && (
                <div>
                  <h4 className="account-h">The guide</h4>
                  <p className="account-note">
                    {GUIDE.length} cards on how the market, the clock and the Gaming Council
                    work, ending with the four things to do first. It takes a minute.
                  </p>
                  <div className="field-row" style={{ gap: 8, flexWrap: "wrap" }}>
                    {onTour && <button className="btn" onClick={() => { onClose(); onTour(); }}>Watch the one minute tour</button>}
                    <button className="btn secondary" onClick={() => { onClose(); onGuide(); }}>Read the guide</button>
                  </div>
                  {rules && rules.discordUrl ? (
                    <>
                      <p className="account-note" style={{ marginTop: 14 }}>
                        Traders talk on Discord: deals, grievances, and whatever last night's
                        resolution did to somebody. It is where the rest of the exchange is.
                      </p>
                      <a className="btn secondary" href={rules.discordUrl}
                         target="_blank" rel="noopener noreferrer">Join the Discord</a>
                    </>
                  ) : null}
                  <OpeningWeekReview week={week} onOpenWeek={onOpenWeek ? () => { onClose(); onOpenWeek(); } : null} />
                </div>
              )}
              {tab === "bug" && <AccountBug post={post} busy={busy} />}
              {tab === "restart" && <AccountRestart me={me} onAct={onAct} onClose={onClose} />}
              {tab === "close" && (
                <AccountClose acct={acct} me={me} post={post} busy={busy} onSignedOut={onSignedOut} />
              )}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

function AccountDetails({ acct, post, busy }) {
  const [email, setEmail] = useState(acct.email || "");
  const [emailPw, setEmailPw] = useState("");
  const [cur, setCur] = useState("");
  const [next, setNext] = useState("");
  /* SOUND IS A BROWSER PREFERENCE, NOT AN ACCOUNT ONE.

     It lives in localStorage rather than on the server on purpose: a player
     who wants the game silent at work and audible at home wants that per
     device, and a preference that followed the account between them would
     be wrong in one of the two places every time. It is the same key the
     Council's theatres already read, so this one switch governs everything
     the game can make a noise with. */
  const [sound, setSound] = useState(soundPreferred);
  /* The soundtrack's own switch, and the piece currently playing. The engine
     changes scene on a phrase boundary rather than on the tap, so the name
     below is read back FROM the engine instead of being assumed — a label
     that changed instantly while the music had not would be the panel lying
     about the thing the player can hear. */
  const [musicOn, setMusicOn] = useState(musicPreferred);
  const [vol, setVol] = useState(musicVolume);
  const [scene, setScene] = useState(() => { const m = music(); return m ? m.currentScene() : "market"; });
  useEffect(() => {
    const m = music();
    if (!m) return;
    m.onChange((next) => setScene(next));
  }, []);
  const engine = music();
  const piece = engine && engine.scenes[scene] ? engine.scenes[scene] : null;

  return (
    <div>
      <div className="account-facts">
        <div><span>Trading as</span><b>{acct.username}</b></div>
        <div><span>Joined</span><b>{fmtWhen(acct.joinedAt)}</b></div>
        {acct.lastLogin > 0 && <div><span>Last signed in</span><b>{fmtWhen(acct.lastLogin)}</b></div>}
      </div>

      <div className="account-block">
        <h4>Sound</h4>
        <label className="switch-row">
          <input type="checkbox" checked={!sound}
                 onChange={(e) => {
                   const muted = e.target.checked;
                   setSound(!muted);
                   rememberSound(!muted);
                   const m = music();
                   if (m) {
                     if (muted) m.stop();
                     else if (musicPreferred()) m.start().catch(() => {});
                   }
                   if (!muted) cue("buy");
                 }} />
          <span>
            <b>Mute all audio</b>
            <em>Silences every sound the game makes: buying, tactics, the
                Gaming Council's results, and the soundtrack. Remembered on
                this device.</em>
          </span>
        </label>

        {/* A browser with no AudioContext at all gets no switch rather than a
            switch that does nothing. */}
        {engine && engine.available && (
          <>
            <label className="switch-row">
              <input type="checkbox" checked={musicOn} disabled={!sound}
                     onChange={(e) => {
                       const on = e.target.checked;
                       setMusicOn(on);
                       rememberMusic(on);
                       /* This change event IS the gesture the browser wants,
                          so the music may start from here and nowhere else in
                          this panel. */
                       if (on && sound) engine.start().catch(() => {});
                       else engine.stop();
                     }} />
              <span>
                <b>Play the soundtrack</b>
                <em>Three pieces that follow where you are (the market, the
                    tactics room, and the Gaming Council), changing at the end
                    of a musical phrase rather than the moment you move. Off
                    unless you ask for it.</em>
              </span>
            </label>

            {musicOn && sound && (
              <div className="music-now">
                <div className="music-piece">
                  <b>{piece ? piece.title : "Standing by"}</b>
                  <span>{piece ? piece.kicker : ""}</span>
                </div>
                <label className="music-vol">
                  <span>Volume</span>
                  <input type="range" min="10" max="100" step="5"
                         value={Math.round(vol * 100)}
                         onChange={(e) => {
                           const v = Number(e.target.value) / 100;
                           setVol(v);
                           rememberMusicVolume(v);
                           engine.setVolume(v);
                         }} />
                  <b>{Math.round(vol * 100)}%</b>
                </label>
              </div>
            )}
          </>
        )}
      </div>


      <h4 className="account-h">Email address</h4>
      {!acct.emailVerificationOn && (
        <p className="account-warn">
          We do not send anything to confirm a new address, so a typo here means a password
          reset you can never receive. Check it before you save.
        </p>
      )}
      <label className="field"><span>Address</span>
        <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></label>
      <label className="field"><span>Your password, to confirm it is you</span>
        <input type="password" value={emailPw} onChange={(e) => setEmailPw(e.target.value)} /></label>
      <button className="btn small" disabled={busy || !email || !emailPw}
              onClick={async () => {
                const r = await post("/api/account/email", { email, password: emailPw });
                if (!r.error) setEmailPw("");
              }}>Save address</button>

      <h4 className="account-h">Password</h4>
      <label className="field"><span>Current password</span>
        <input type="password" value={cur} onChange={(e) => setCur(e.target.value)} /></label>
      <label className="field"><span>New password <em>(at least 10 characters)</em></span>
        <input type="password" value={next} onChange={(e) => setNext(e.target.value)} /></label>
      <p className="account-note">Changing it signs out every other device.</p>
      <button className="btn small" disabled={busy || !cur || !next}
              onClick={async () => {
                const r = await post("/api/account/password", { current: cur, next });
                if (!r.error) { setCur(""); setNext(""); }
              }}>Change password</button>
    </div>
  );
}

function AccountPayments({ acct }) {
  if (!acct.purchases.length) {
    return (
      <div>
        <div className="empty">You have not bought anything.</div>
        <p className="account-note">
          Everything in Civexio that affects the market is earned. The shop sells appearance
          only (crests, facades, map styles) and never an advantage.
        </p>
      </div>
    );
  }
  return (
    <div>
      <p className="account-note">
        Card details are held by Paddle, our payment processor, and never by us, so there is
        nothing here to change. Use the receipt email from Paddle for a refund or to update a card.
      </p>
      {acct.purchases.map((p) => (
        <div className="receipt" key={p.ref}>
          <div className="receipt-head">
            <b>{p.sku || "Purchase"}</b>
            <span className="receipt-amount">
              {p.amount != null ? `${p.currency || "GBP"} ${Number(p.amount).toFixed(2)}` : "—"}
            </span>
          </div>
          <div className="receipt-meta">
            {fmtWhen(p.created_at)} · {p.status} · <span className="receipt-ref">{p.ref}</span>
          </div>
        </div>
      ))}
    </div>
  );
}

function AccountBug({ post, busy }) {
  const AREAS = ["The market", "The map", "The Gaming Council", "The trading floor",
    "The bank", "Signing in", "Something else"];
  const [area, setArea] = useState(AREAS[0]);
  const [body, setBody] = useState("");
  const [sent, setSent] = useState(false);
  if (sent) {
    return (
      <div>
        <div className="account-done">Sent. Thank you.</div>
        <button className="btn small secondary" onClick={() => { setSent(false); setBody(""); }}>
          Report something else
        </button>
      </div>
    );
  }
  return (
    <div>
      <p className="account-note">
        What you expected, and what happened instead. Your trading name and browser are
        attached automatically: the two things nobody remembers to include.
      </p>
      <label className="field"><span>Where</span>
        <select value={area} onChange={(e) => setArea(e.target.value)}>
          {AREAS.map((a) => <option key={a} value={a}>{a}</option>)}
        </select></label>
      <label className="field"><span>What happened</span>
        <textarea rows="5" value={body} maxLength={4000}
                  placeholder="I clicked Buy on the Shambles house and nothing happened…"
                  onChange={(e) => setBody(e.target.value)} /></label>
      <button className="btn small" disabled={busy || body.trim().length < 10}
              onClick={async () => {
                const r = await post("/api/account/bug", { area, body });
                if (!r.error) setSent(true);
              }}>Send it</button>
    </div>
  );
}

/* STARTING OVER.

   Two clicks, like closing the account, and for the same reason: it is
   irreversible and it happens to other people too — forty deeds land back
   on the market the moment it goes through.

   The list is blunt about what is lost. A player who restarts and then
   discovers their honours are gone has been tricked by a button, and the
   whole argument for offering this at all is that it is the honest route. */
function AccountRestart({ me, onAct, onClose }) {
  const [armed, setArmed] = useState(false);
  const r = me.restart || {};
  const days = r.nextAt ? Math.ceil((r.nextAt - Date.now()) / 86400000) : 0;

  if (!r.can) {
    return (
      <div>
        <h4 className="account-h">Starting over</h4>
        <p className="account-note">
          You started over already. You can do it again in {days} day{days === 1 ? "" : "s"}:
          once a month, because the opening position includes a free draw and a
          restart you could take at will would be a slot machine.
        </p>
      </div>
    );
  }

  if (!armed) {
    return (
      <div>
        <h4 className="account-h">Starting over</h4>
        <p className="account-note">
          If your position has become unplayable, you can go back to your first minute
          without registering again under another name. You keep the account, the sign in
          and anything you have paid for. Everything else goes.
        </p>
        <ul className="account-list">
          <li>Every property you hold goes back on the open market at once, and it is announced.</li>
          <li>Your honours, badges, rivalries and growth history are erased. The record does not survive.</li>
          <li>Cash, deposits, debts, skill and your tactics deck return to the opening position, including the free draw.</li>
          <li>Crests, themes and anything else bought with real money stay yours; they are simply unequipped.</li>
          <li>Once a month, and it cannot be undone.</li>
        </ul>
        <button className="btn danger" onClick={() => setArmed(true)}>I want to start over</button>
      </div>
    );
  }

  return (
    <div>
      <h4 className="account-h">Are you sure?</h4>
      <p className="account-warn">
        Your deeds go on the market the moment you press this, and your record is gone.
        There is no copy kept.
      </p>
      <button className="btn danger"
              onClick={() => { onAct("account.restart"); onClose(); }}>Start over now</button>
      <button className="btn secondary" style={{ marginLeft: 8 }}
              onClick={() => setArmed(false)}>Keep playing</button>
    </div>
  );
}

function AccountClose({ acct, post, busy, onSignedOut }) {
  const [password, setPassword] = useState("");
  const [name, setName] = useState("");
  const [armed, setArmed] = useState(false);
  const matches = name.trim().toLowerCase() === String(acct.username).toLowerCase();

  if (!armed) {
    return (
      <div>
        <h4 className="account-h">Closing your account</h4>
        <ul className="account-list">
          <li>Every property you hold goes back on the open market at once.</li>
          <li>Your email address, password and trading name are erased. <b>{acct.username}</b> becomes available to somebody else.</li>
          <li>Trades and auctions other players took part in stay in their records, shown as a former trader; their history should not develop holes because you left.</li>
          <li>You will not be able to sign in again, and this cannot be undone.</li>
        </ul>
        <button className="btn danger" onClick={() => setArmed(true)}>I want to close my account</button>
      </div>
    );
  }

  return (
    <div>
      <h4 className="account-h">Are you sure?</h4>
      <p className="account-warn">
        This is permanent. There is no way to bring the account back afterwards, and no copy kept.
      </p>
      <label className="field"><span>Your password</span>
        <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
      <label className="field">
        <span>Type <b>{acct.username}</b> to confirm</span>
        <input value={name} onChange={(e) => setName(e.target.value)} autoComplete="off" /></label>
      <div className="confirm-actions">
        <button className="btn danger" disabled={busy || !password || !matches}
                onClick={async () => {
                  const r = await post("/api/account/close", { password, name });
                  if (r.closed) onSignedOut();
                }}>Close my account for good</button>
        <button className="btn secondary" onClick={() => setArmed(false)}>Keep my account</button>
      </div>
    </div>
  );
}

/* THE CITY CLOCK.

   An hour in Civexio is an hour in York, so the wall clock is now part of
   the game's state rather than decoration: rent settles on it, upkeep runs
   out on it, and both minigames close on it. A player working out whether to
   file before tonight's close should not have to open another window to find
   out what time it is where the game lives.

   Europe/London rather than a fixed offset, and the zone name comes from the
   formatter rather than from a rule of thumb about the last Sunday in March.
   BST and GMT then look after themselves, including the two weekends a year
   when a hand-rolled offset would be silently an hour out. */
function CityClock() {
  const [now, setNow] = useState(() => new Date());
  useEffect(() => {
    const t = setInterval(() => setNow(new Date()), 1000);
    return () => clearInterval(t);
  }, []);

  let time = "", zone = "", day = "";
  try {
    const parts = new Intl.DateTimeFormat("en-GB", {
      timeZone: "Europe/London", hour12: false,
      weekday: "short", day: "numeric", month: "short",
      hour: "2-digit", minute: "2-digit", second: "2-digit", timeZoneName: "short",
    }).formatToParts(now);
    const get = (t) => (parts.find((p) => p.type === t) || {}).value || "";
    time = `${get("hour")}:${get("minute")}:${get("second")}`;
    zone = get("timeZoneName");
    day = `${get("weekday")} ${get("day")} ${get("month")}`;
  } catch {
    /* A browser without the zone data still gets a clock, just the local
       one — better than an empty box where the time should be. */
    time = now.toTimeString().slice(0, 8);
    zone = "local";
    day = now.toDateString().slice(0, 10);
  }

  return (
    <div className="city-clock" title="The game runs on York time: an hour here is an hour there">
      <span className="clock-day">{day}</span>
      <span className="clock-time">{time}</span>
      <span className="clock-zone">{zone}</span>
    </div>
  );
}

function BuyConfirm({ pending, me, onYes, onNo }) {
  if (!pending) return null;
  const { meta, price } = pending;
  const after = me.cash - price;
  return (
    <div className="modal-backdrop confirm-backdrop" onClick={onNo}>
      <div className="modal-box confirm-box" onClick={(e) => e.stopPropagation()} role="dialog"
           aria-modal="true" aria-label="Confirm purchase">
        <div className="modal-inner">
          <h3 style={{ fontFamily: "'Fraunces', serif", fontSize: 19, margin: "0 0 4px" }}>Buy {meta.name}?</h3>
          <div className="meta" style={{ marginBottom: 12 }}>
            <DistrictTag meta={meta} /><CategoryTag category={meta.category} />
          </div>
          <div className="confirm-figures">
            <div><span>Price</span><b>{fmt(price)}</b></div>
            <div><span>Cash after</span><b style={after < 0 ? { color: "var(--loss)" } : null}>{fmt(after)}</b></div>
          </div>
          <div className="confirm-actions">
            <button className="btn" onClick={onYes} autoFocus>Buy for {fmt(price)}</button>
            <button className="btn secondary" onClick={onNo}>Cancel</button>
          </div>
        </div>
      </div>
    </div>
  );
}

/* ---- A DODGY TENANT, ON THE BUILDING THEY ARE SITTING IN ---------------

   The card's text promised for weeks that the owner could pay to remove
   them and there was no way to do it anywhere in the game. This is it, and
   it belongs on the property rather than in the tactics tab because that is
   where the owner is when they notice the rent has stopped.

   BOTH NUMBERS, ALWAYS. The buy-out price rises every hour and the rent
   still to lose falls, and the whole card is the comparison between them —
   so showing the price alone would be showing half the decision. The panel
   says which way it currently points, and stops recommending the moment it
   stops being true.

   Quoted from the server (lib/view.js sends both, worked out with the same
   functions that charge), never computed here, so the button and the bill
   cannot drift apart. */
function DodgyTenant({ meta, prop, me, onAct, busy }) {
  const t = prop && prop.tenant;
  if (!t) return null;
  /* The button only where there is somebody to press it for: the market
     tile passes no onAct. */
  const mine = prop.owner === me.id && !!onAct;
  const worthIt = t.payoff < t.rentLeft;
  const short = me.cash < t.payoff;
  return (
    <div className={"tenant-box" + (mine ? "" : " is-theirs")} onClick={(e) => e.stopPropagation()}>
      <div className="tenant-head">
        <span className="tenant-mark" aria-hidden="true" />
        <b>Dodgy tenant</b>
        <span className="tenant-clock">out <CountdownTo at={t.until} /></span>
      </div>
      {mine ? (
        <>
          <div className="tenant-sums">
            <div><span>BUY THEM OUT</span><strong>{fmt(t.payoff)}</strong></div>
            <div><span>RENT STILL TO LOSE</span><strong>{fmt(t.rentLeft)}</strong></div>
          </div>
          <p className="tenant-note">
            {worthIt
              ? "Cheaper to pay them off than to ride it out, and the price rises every hour."
              : "Cheaper now to let it run. The price has passed what the rent is worth."}
          </p>
          <button className="btn small" disabled={!!busy || short}
            onClick={(e) => { e.stopPropagation(); onAct("tenant.payoff", { propertyId: meta.id }, "tenant"); }}>
            {short ? `You hold ${fmt(me.cash)}` : `Pay ${fmt(t.payoff)} and be rid of them`}
          </button>
        </>
      ) : (
        <p className="tenant-note">Somebody has stopped the rent here. It earns nothing until they go.</p>
      )}
    </div>
  );
}

function PropertyTile({ meta, prop, game, me, history, onOpen, onBuy, onFav, onAct, busy }) {
  const change = changeFromHistory(history, prop.currentPrice);
  const locked = meta.exclusive && me.netWorth < meta.minNetWorth;
  const rent = dailyRentFor(game, meta, prop);
  const photo = artFor(meta, prop);
  const lines = TAGLINES[meta.stock] || TAGLINES["Terrace"];
  const tagline = lines[hashStr("tag:" + meta.id) % lines.length];
  return (
    <div className={"pcard q-" + (meta.rarity || "common")} onClick={() => onOpen(meta.id)}>
      <div className="pcard-art">
        {photo
          ? <img src={photo} alt="" loading="lazy" onError={(e) => { e.target.style.display = "none"; }} />
          : <PropertyArt meta={meta} facade={ownerFacade(game, prop)} height={null} />}
        <RarityRibbon meta={meta} />
      </div>
      <div className="pcard-head">
        <StockCrest meta={meta} />
        <div className="pcard-title">
          <div className="pcard-name">{meta.name}</div>
          <div className="pcard-place">
            <svg viewBox="0 0 24 24" width="10" height="10" aria-hidden="true">
              <path d="M12 22s7-6.4 7-12a7 7 0 1 0-14 0c0 5.6 7 12 7 12Z" fill="currentColor" opacity=".85" />
              <circle cx="12" cy="10" r="2.4" fill="var(--paper, #fff)" />
            </svg>
            {/* THE DISTRICT, NAMED.

                This line used to read "York · Central" — the compass
                quadrant, the same three words on a third of the board. The
                district is the unit that territory, freeholds and the map's
                field report are all counted in, so it is the one that has to
                be readable off a card, and it gets the line to itself. */}
            {meta.neighbourhoodName}
          </div>
        </div>
      </div>
      <div className="pcard-tag">{tagline}</div>
      <div className="pcard-body">
        <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">Hourly rent {fmt(rent)} · {yieldPctOf(meta).toFixed(2)}% yield</div>
        <AbilityLine meta={meta} game={game} me={me} />
        {/* The market tile shows a tenant so a buyer knows what they are
            taking on, but never the button: this card is for buildings
            somebody else owns. */}
        <DodgyTenant meta={meta} prop={prop} me={me} onAct={null} busy={busy} />
        <div className="pcard-links">
          <ShowOnMapLink meta={meta} />
          <StreetViewLink meta={meta} />
        </div>
        <div style={{ marginTop: 8 }}>
          {prop.cpoSale ? <span className="auction-badge">Compulsory sale</span>
            : 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>}
          {onFav && <FavStar id={meta.id} me={me} onAct={onFav} />}
        </div>
      </div>
    </div>
  );
}
/* PERSONALISE THIS PROPERTY (Aaron, 30 Sep 2026: "Spend a keystone to
   choose a render from 4 options", and nothing else). One box, one line:

     an ordinary building   its type's photographs, side by side, one
                            Keystone to wear another (lib/looks.js);
     a house a player built its own renders. With none yet, a Keystone
                            asks for four of it as designed, the owner
                            keeps one, and all four stay with the house
                            for every later owner (lib/personalise.js).

   Every option can be opened full size before choosing (LookZoom). A
   landmark is one real building with its own picture, so it has none. */
const LOOK_LINE = "Spend a Keystone to choose a render from 4 options.";

function LookZoom({ list, index, pickLabel, onPick, onIndex, onClose }) {
  useEffect(() => {
    const k = (e) => {
      if (e.key === "Escape") onClose();
      else if (e.key === "ArrowRight") onIndex((index + 1) % list.length);
      else if (e.key === "ArrowLeft") onIndex((index + list.length - 1) % list.length);
    };
    window.addEventListener("keydown", k);
    return () => window.removeEventListener("keydown", k);
  }, [index, list.length]);
  const node = (
    <div className="look-zoom" role="dialog" aria-modal="true" aria-label={`Option ${index + 1} of ${list.length}`}
         onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="look-zoom-frame">
        <img src={list[index]} alt={`Option ${index + 1} of ${list.length}`} />
        <div className="look-zoom-bar">
          {list.length > 1 && <button type="button" className="look-zoom-nav" aria-label="Previous option"
            onClick={() => onIndex((index + list.length - 1) % list.length)}>‹</button>}
          <span className="look-zoom-count">{index + 1} of {list.length}</span>
          {list.length > 1 && <button type="button" className="look-zoom-nav" aria-label="Next option"
            onClick={() => onIndex((index + 1) % list.length)}>›</button>}
          <span style={{ flex: 1 }} />
          {onPick && <button type="button" className="look-zoom-pick" onClick={() => { onPick(index); onClose(); }}>{pickLabel}</button>}
          <button type="button" className="look-zoom-close" onClick={onClose}>Close</button>
        </div>
      </div>
    </div>
  );
  return typeof ReactDOM !== "undefined" && ReactDOM.createPortal ? ReactDOM.createPortal(node, document.body) : node;
}

/* The options as thumbnails: a press picks one, the corner button opens it
   full size. */
function LookGrid({ list, picked, current, onPick }) {
  const [zoom, setZoom] = useState(null);
  return (
    <>
      <div className="look-grid">
        {list.map((u, i) => (
          <div key={u} className={"look-thumb" + (i === picked ? " picked" : "") + (i === current ? " current" : "")}>
            <button type="button" className="look-thumb-pick" aria-pressed={i === picked}
                    aria-label={`Option ${i + 1}${i === current ? ", in use" : ""}`} onClick={() => onPick(i)}>
              <img src={u} alt="" loading="lazy" />
            </button>
            {i === current && <span className="look-thumb-tag">In use</span>}
            <button type="button" className="look-thumb-zoom" aria-label={`Enlarge option ${i + 1}`} title="Enlarge"
                    onClick={() => setZoom(i)}>
              <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
                <circle cx="6.5" cy="6.5" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
                <path d="M10 10 L14.5 14.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
                <path d="M4.5 6.5 H8.5 M6.5 4.5 V8.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
              </svg>
            </button>
          </div>
        ))}
      </div>
      {zoom != null && (
        <LookZoom list={list} index={zoom} onIndex={setZoom} onClose={() => setZoom(null)}
                  pickLabel="Pick this one" onPick={onPick} />
      )}
    </>
  );
}

function PersonaliseBox({ meta, prop, me, onAct }) {
  const p = me.personalise;
  const [pick, setPick] = useState(null);
  if (!p || meta.stock === "Landmark") return null;
  const L = p.looks || { cost: 1, variants: {}, houses: {} };
  const cost = L.cost || 1;
  const tokens = p.tokens || 0;
  const built = !!meta.built;
  const files = built ? ((L.houses || {})[meta.id] || [])
    : ((L.variants || {})[meta.stock] || (L.variants || {})[meta.category] || []);
  const urls = files.map((f) => built ? `/art/custom/${f}` : `/art/${f}`);
  const art = prop && prop.art;
  const currentFile = built
    ? (art && art.style === "house" ? art.file.replace(/^\/art\/custom\//, "") : null)
    : art ? (art.style === "variant" ? art.file.replace(/^\/art\//, "") : null)
    : files.length ? files[hashStr("art:" + meta.id) % files.length] : null;
  const current = currentFile ? files.indexOf(currentFile) : -1;
  const req = p.request;
  const reqIsThis = req && req.propertyId === meta.id;
  const canRender = built && p.enabled;
  /* Nothing to choose and nothing to make: say nothing at all. */
  if (!files.length && !canRender && !reqIsThis) return null;
  const left = `${tokens} Keystone${tokens === 1 ? "" : "s"} left`;

  return (
    <div className="improve-box look-box" id="personalise-box">
      <div style={{ fontWeight: 600, marginBottom: 3 }}>Personalise this property</div>
      <div className="muted" style={{ marginBottom: 8 }}>{LOOK_LINE}</div>

      {reqIsThis && req.status === "pending" && (
        <div className="muted">Rendering your four options. This usually takes under a minute.</div>
      )}

      {reqIsThis && req.status === "ready" && (
        /* The four just rendered. The Keystone was spent on making them,
           so none of these returns nothing. */
        <>
          <LookGrid list={req.previews && req.previews.length ? req.previews : [req.previewUrl]} picked={pick} current={-1} onPick={setPick} />
          <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
            <button className="btn small" disabled={pick == null}
                    onClick={() => onAct("art.apply", { requestId: req.id, index: pick })}>
              {pick == null ? "Pick one" : "Keep this one"}
            </button>
            <button className="btn small secondary" onClick={() => onAct("art.discard", { requestId: req.id })}>None of these</button>
          </div>
        </>
      )}

      {req && !reqIsThis && canRender && (
        <div className="muted">You have renders waiting on another property. Keep one or refuse them there first.</div>
      )}

      {!reqIsThis && files.length > 0 && <LookGrid list={urls} picked={pick} current={current} onPick={setPick} />}

      {!reqIsThis && (files.length > 0 || (!req && canRender)) && (
        <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
          {files.length > 0 && (
            <button className="btn small" disabled={pick == null || pick === current || tokens < cost}
                    onClick={() => onAct("look.choose", { propertyId: meta.id, file: files[pick] })}>
              {tokens < cost ? "No Keystones left" : pick == null || pick === current ? "Pick a render" : `Use this one (${left})`}
            </button>
          )}
          {!req && canRender && (
            <button className={"btn small" + (files.length ? " secondary" : "")} disabled={tokens < 1}
                    onClick={() => onAct("art.request", { propertyId: meta.id, style: "house" })}>
              {tokens < 1 ? "No Keystones left" : `${files.length ? "Render four more" : "Render my house"} (${left})`}
            </button>
          )}
        </div>
      )}
    </div>
  );
}

/* THE WANTED REGISTER, AT THE PROPERTY.

   Two faces of one thing. On a building you do not own it is a switch: say
   you want it, or stop saying so. On one you DO own it is the answer: how
   many have said it, and who.

   The count is public either way, because a demand signal nobody can see
   does not move anybody's price expectations. The names go to the owner
   alone — a public list of who wants what is a list of whom to outbid, and
   that would make a free declaration into one you think twice about. */
function WantedLine({ meta, prop, game, me, onAct }) {
  const [busy, setBusy] = useState(false);
  const mine = prop.owner === me.id;
  /* Nothing to say about a building nobody holds: there is no owner to tell,
     and the control the player wants there is Buy. */
  if (!prop.owner) return null;
  const listed = (me.myWants || []).includes(meta.id);
  const count = prop.wanted || 0;

  if (mine) {
    const row = (me.wantedFromMe || []).find((r) => r.propertyId === meta.id);
    const who = row ? row.who : [];
    if (!who.length) return null;
    return (
      <div className="wanted-line is-owner">
        <b>{who.length === 1 ? "One trader wants this" : `${who.length} traders want this`}</b>
        <p>{who.map((x) => x.name).join(", ")}. They may take an auction, or a trade.</p>
      </div>
    );
  }

  return (
    <div className="wanted-line">
      <div className="wanted-say">
        <b>{count === 0 ? "Nobody has asked for this"
          : count === 1 ? "One trader wants this"
          : `${count} traders want this`}</b>
        <p>Registering interest tells the owner, and nothing else. It is not a
           bid: no money moves and nothing is reserved.</p>
      </div>
      <button className={"btn small" + (listed ? " secondary" : "")} disabled={busy}
              onClick={async () => {
                setBusy(true);
                try { await onAct(listed ? "want.remove" : "want.add", { propertyId: meta.id }); }
                finally { setBusy(false); }
              }}>
        {busy ? "…" : listed ? "On your wanted list" : "I want this"}
      </button>
    </div>
  );
}

/* BOTH OF YOU ARE ONE AWAY, AND EACH HOLDS THE OTHER'S PIECE.

   The end of the search. One away tells you who holds what you need; the
   wanted register tells a holder that what they have is wanted; this needs
   neither of you to look, because the pair has already been found.

   Computed server side — see lib/matches.js — because a browser is only
   told its own holdings and cannot see anybody else's. Only mutual pairs
   appear: one-sided is a purchase, and sealed bids do purchases properly. */
function MutualMatches({ me, onPick }) {
  const rows = me.matches || [];
  if (!rows.length) return null;
  return (
    <section className="card one-away mutual-matches">
      <h3>You each hold what the other needs</h3>
      <p className="one-away-intro">
        Both of you are one property short, and each of you is holding the
        other's missing piece. Nothing here is proposed or owed, but this is
        the swap the fair value rule exists for, and it is the hardest one to
        find by hand.
      </p>
      {rows.map((m) => (
        <div key={m.who} className="gap-row match-row">
          <div className="gap-head">
            <b>{m.name}</b>
            <span className="gap-count">both one away</span>
          </div>
          <div className="match-swap">
            <div className="match-side">
              <span className="match-label">They hold</span>
              <b>{m.theyHold.name}</b>
              <span className="match-closes">closes your {m.theyHold.closes}</span>
            </div>
            <span className="match-arrow" aria-hidden="true">⇄</span>
            <div className="match-side">
              <span className="match-label">You hold</span>
              <b>{m.youHold.name}</b>
              <span className="match-closes">closes their {m.youHold.closes}</span>
            </div>
          </div>
          <button className="btn small" onClick={() => onPick && onPick(m)}>
            Set up this swap
          </button>
        </div>
      ))}
    </section>
  );
}

/* The owner's half, beside "one away" — the two ends of the same problem.
   One away says who holds what you need; this says who needs what you hold. */
function WantedFromMe({ me, game }) {
  const rows = me.wantedFromMe || [];
  if (!rows.length) return null;
  return (
    <section className="card one-away wanted-from-me">
      <h3>Wanted from you</h3>
      <p className="one-away-intro">
        Traders who have registered interest in something you hold. Nothing is
        owed either way, but a sale can start here instead of waiting for a
        sealed bid nobody knew to make.
      </p>
      {rows.map((r) => {
        const meta = metaFor(r.propertyId);
        if (!meta) return null;
        return (
          <div key={r.propertyId} className="gap-row">
            <div className="gap-head">
              <b>{meta.name}</b>
              <span className="gap-count">
                {r.who.length} {r.who.length === 1 ? "trader" : "traders"}
              </span>
            </div>
            <p className="gap-what">{r.who.map((x) => x.name).join(", ")}</p>
          </div>
        );
      })}
    </section>
  );
}

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

/* A COMPULSORY SALE, WHEREVER THE PROPERTY IS SHOWN.

   Compulsory Purchase Order used to hand the deed straight to whoever
   played it. Since 25 Sep 2026 it opens a sale at a fixed price: the trader
   who served the order has the first hour, then anybody may buy it until the
   sale lapses, and the owner is paid by whoever does. This is the one box
   that says so, with the buy button for whoever may use it right now. */
const saleTime = (t) => new Date(t).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" });
function CpoSale({ meta, prop, me, now, onAct, compact }) {
  const s = prop && prop.cpoSale;
  if (!s || s.endsAt <= now) return null;
  const mineToSell = prop.owner === me.id;
  const holder = (me.cpoFirstRefusal || []).includes(meta.id);
  const firstHour = now < s.firstUntil;
  const canBuy = !mineToSell && (!firstHour || holder);
  const line = mineToSell
    ? `Under a compulsory purchase order: for sale at ${fmt(s.price)}, paid to you when it sells. If nobody buys it by ${saleTime(s.endsAt)}, it stays yours.`
    : firstHour
      ? (holder
          ? `You served the order. Until ${saleTime(s.firstUntil)} only you may buy it, at ${fmt(s.price)}.`
          : `Compulsory sale at ${fmt(s.price)}. The trader who served the order may buy it first, until ${saleTime(s.firstUntil)}. After that anybody may, until ${saleTime(s.endsAt)}.`)
      : `Compulsory sale at ${fmt(s.price)}. Anybody may buy it until ${saleTime(s.endsAt)}. The owner is paid the price.`;
  return (
    <div className={"cpo-sale" + (compact ? " is-compact" : "")}>
      <span className="cpo-sale-tag">Compulsory sale</span>
      <p>{line}</p>
      {canBuy && onAct && (
        <button className="btn small" disabled={me.cash < s.price}
                onClick={(e) => { e.stopPropagation(); onAct("cpo.buy", { propertyId: meta.id }); }}>
          Buy for {fmt(s.price)}
        </button>
      )}
    </div>
  );
}

/* THE BUILDER'S CREDIT, pinned to the house rather than written in its
   rolling ownership history, which keeps only the last five entries and
   would lose the name exactly when the house had changed hands enough for
   it to matter. */
function BuiltCredit({ meta }) {
  if (!meta || !meta.built) return null;
  const b = meta.built;
  const when = new Date(b.at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" });
  const bits = [`Built by ${b.byName}, ${when}`];
  if (b.find) bits.push("a medieval floor was found in the trench");
  if (b.subsided) bits.push("it has subsidence until it is underpinned");
  else if (b.underpinned) bits.push("underpinned");
  return <div className="built-credit">{bits.map((x) => x.replace(/^./, (c) => c.toUpperCase())).join(". ")}.</div>;
}

function DetailModal({ meta, prop, game, me, now, history, onClose, onAct, focus }) {
  /* Opened from a portfolio card's "Choose the look": straight to the box. */
  useEffect(() => {
    if (focus !== "look") return;
    const t = setTimeout(() => {
      const el = document.getElementById("personalise-box");
      if (el) el.scrollIntoView({ block: "start", behavior: "smooth" });
    }, 60);
    return () => clearTimeout(t);
  }, [focus, meta.id]);
  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 ? earningState(prop, now) : null;
  const change = changeFromHistory(history, prop.currentPrice);
  const rent = dailyRentFor(game, meta, prop, now);
  /* What the rent is when it is being paid: a rent strike stops it for a
     while, but an improvement raises what it comes back to (Aaron, 30 Sep
     2026: his read "£0 → £0"). */
  const paidRent = dailyRentFor(game, meta, { ...prop, rentStrikeUntil: 0 }, 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_HOUR_MS;
  const coolLeft = cooling ? Math.max(0, 30 * GAME_HOUR_MS - (now - prop.lastImprovedAt)) : 0;
  const secLvl = prop.securityLevel || 0;
  const securityCost = Math.round(prop.currentPrice * 0.025 * (secLvl + 1));
  const securityCooling = prop.lastSecurityAt && now - prop.lastSecurityAt < 7 * GAME_HOUR_MS;
  const securityCoolLeft = securityCooling ? Math.max(0, 7 * GAME_HOUR_MS - (now - prop.lastSecurityAt)) : 0;
  const onStrike = prop.rentStrikeUntil > now;
  const linked = owner && (me.linkedTo || []).includes(prop.owner);
  const photo = artFor(meta, prop);

  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>
        {photo
          ? <img src={photo} alt="" style={{ width: "100%", height: 180, objectFit: "cover", display: "block" }}
              onError={(e) => { e.target.style.display = "none"; }} />
          : <PropertyArt meta={meta} height={180} facade={ownerFacade(game, prop)} />}
        <div className="modal-inner">
          <h3 style={{ fontFamily: "'Fraunces', serif", fontSize: 20, margin: "0 0 6px",
                       display: "flex", alignItems: "center", gap: 8 }}>
            {meta.name}<FavStar id={meta.id} me={me} onAct={onAct} size={17} />
          </h3>
          <div style={{ marginBottom: 8 }}>
            {/* The rarity, which the deed never showed — and which is the
                thing that decides what ability the building has at all. */}
            <RarityMark meta={meta} showName />
            <DistrictTag meta={meta} /><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>Hourly rent income: {fmt(rent)}</strong> · {yieldPctOf(meta).toFixed(2)}% hourly rental yield
          </div>
          {/* Every buyer should see what a building costs to run before
              they own it, not only after. */}
          <div className="rent-line overhead-line">
            Running overheads {+((prop.overheadPct || 0) * 100).toFixed(2)}% of rent, about {fmt(rent * (prop.overheadPct || 0))} an hour
            {(prop.overheadPct || 0) < 0.1 - 1e-9 ? `, cut by ${meta.abilityName}` : ""}.
          </div>
          {/* THE ABILITY, which was only ever drawn on the market grid tile.
              Open a property properly — from the Portfolio, the map, a
              search — and the one thing that makes it worth collecting
              rather than merely owning was invisible. */}
          <AbilityLine meta={meta} game={game} me={me} />
          <BuiltCredit meta={meta} />
          <div className="pcard-links">
            <ShowOnMapLink meta={meta} />
            <StreetViewLink meta={meta} />
          </div>

          <div style={{ marginTop: 12 }}>
            {locked ? <div className="hold-note warn">Requires {fmt(meta.minNetWorth)} net worth. Yours: {fmt(me.netWorth)}.</div>
              : prop.cpoSale && prop.cpoSale.endsAt > now ? <CpoSale meta={meta} prop={prop} me={me} now={now} onAct={onAct} />
              : 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.tone}>{hold.text}</div>
                  {/* THE TENANT, AND THE WAY TO BE RID OF THEM, IN THE DEED TOO
                      (Aaron, 30 Sep 2026: "how do you remove the tenant").
                      Until now the buy out was only on the portfolio card. */}
                  <DodgyTenant meta={meta} prop={prop} me={me} onAct={onAct} />
                  {onStrike && !prop.tenant && <div className="hold-note warn">Tenants are withholding rent until {fmtTime(prop.rentStrikeUntil)}.</div>}
                  {prop.lastTacticAt > 0 && now - prop.lastTacticAt < 7 * GAME_HOUR_MS && (
                    <div className="muted" style={{ margin: "3px 0" }}>
                      {prop.lastTacticSuccess ? "This property was targeted" : "An attempted tactic against this property failed"} recently
                      {(me.tacticReveals || {})[meta.id] ? `. Your Full terminal analytics subscription traces this to ${me.tacticReveals[meta.id]}.` : "."}
                    </div>
                  )}
                  {coverOffer(prop, now) && (
                    <button className="btn small" onClick={() => onAct("upkeep", { propertyId: meta.id })}
                            disabled={me.cash < (prop.upkeepCost || 0)} title={coverOffer(prop, now).note}>
                      {coverOffer(prop, now).label}
                    </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 from {fmt(paidRent)} to {fmt(paidRent * (1 + 0.08 * (lvl + 1)) / (1 + 0.08 * lvl))} an hour.</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 ${spanOf(coolLeft)}` : `Improve to level ${lvl + 1}/3`}
                        </button>
                      </>
                    )}
                  </div>
                  <div className="improve-box">
                    <div style={{ fontWeight: 600, marginBottom: 3 }}>{secLvl >= SECURITY_MAX_LEVEL ? `Maximum security, level ${secLvl}` : `Security level ${secLvl} of ${SECURITY_MAX_LEVEL}`}</div>
                    <div style={{ marginBottom: 5 }}>
                      {secLvl > 0 ? `Providing ${securityPct(secLvl)}% natural resistance against enemy attacks.` : "No natural resistance against enemy attacks yet."}
                    </div>
                    <div className="muted" style={{ marginBottom: 5 }}>
                      Each level turns away one attack in ten, and one that still lands does a tenth less. Fences, cameras and a watchful neighbour.
                    </div>
                    {secLvl < SECURITY_MAX_LEVEL && (
                      <>
                        <div>Cost {fmt(securityCost)} → level {secLvl + 1}, providing {securityPct(secLvl + 1)}% natural resistance against enemy attacks</div>
                        <button className="btn small" onClick={() => onAct("security", { propertyId: meta.id })} disabled={securityCooling} style={{ marginTop: 6 }}>
                          {securityCooling ? `Available in ${spanOf(securityCoolLeft)}` : `Upgrade to level ${secLvl + 1}/${SECURITY_MAX_LEVEL}`}
                        </button>
                      </>
                    )}
                  </div>
                  <WantedLine meta={meta} prop={prop} game={game} me={me} onAct={onAct} />
                  <PersonaliseBox meta={meta} prop={prop} me={me} onAct={onAct} />
                </div>
              ) : (
                <div>
                  <WantedLine meta={meta} prop={prop} game={game} me={me} onAct={onAct} />
                  {/* WHO HOLDS IT IS THE HEADLINE, NOT A FOOTNOTE.
                      This was one grey `muted` line carrying the owner, the
                      firm and the security level together, and a player
                      arriving from the map could not tell at a glance that
                      the building belonged to anybody at all — which is the
                      first thing they need to know, because it decides
                      whether the rest of the card is about buying or about
                      dealing with a person. The name now reads at the size
                      of the decision it governs; the security level, which
                      is detail, stays where detail belongs. */}
                  <div className="deed-owner">
                    <span className="deed-owner-label">Owned by</span>
                    <b><TraderName player={owner} /></b>
                    {owner && owner.firmName && <span className="firm-line">{owner.firmName}</span>}
                  </div>
                  <div className="muted" style={{ marginBottom: 8 }}>
                    Security {prop.securityLevel || 0}/{SECURITY_MAX_LEVEL}
                  </div>
                  {linked ? (
                    <div className="muted">You can't deal with that trader.</div>
                  ) : !me.canInteract ? (
                    <div className="gate-note">{me.interactionBlock}</div>
                  ) : (
                    <>
                      {/* An offer needs no weakness in the owner and no
                          anonymity — unlike every tactic, it is a thing you
                          do in the open, to somebody doing perfectly well. */}
                      <SealedBidBox meta={meta} prop={prop} me={me} owner={owner} onAct={onAct} />
                      {/* THE OLD TACTIC BUTTONS ARE GONE FROM HERE.

                          They fired game.launchTactic directly, which is the
                          pre-deck path: its own cooldown, its own prices, no
                          card spent, no queue, no shields, and instant rather
                          than resolved at the evening lock. Left in place
                          they would have been a second, cheaper, invisible
                          way to attack a building that bypassed every rule
                          the deck was built on — including the caps that stop
                          a player being mobbed.

                          What is offered instead is a door into the deck, so
                          a player who came here to attack is not simply told
                          no. */}
                      {meta.exclusive ? <div className="muted">Landmarks are protected from tactics.</div> : (
                        <div className="improve-box">
                          <div style={{ fontWeight: 600, marginBottom: 3 }}>Run a tactic</div>
                          <div className="muted" style={{ marginBottom: 8 }}>
                            Tactics are cards. Choose one from your hand, confirm it against this building
                            before the evening lock, and it resolves at {eveningAt()} with everybody
                            else's; you stay anonymous unless the card names you.
                          </div>
                          <div className="muted" style={{ marginTop: 6 }}>
                            Your hand is in the <b>Tactics</b> tab.
                          </div>
                          <div className="muted" style={{ marginTop: 6 }}>
                            Security {prop.securityLevel || 0}/{SECURITY_MAX_LEVEL} takes
                            {" "}{(prop.securityLevel || 0) * 10}% off the chance a card lands here,
                            and the same again off what it does.
                          </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)} on {fmtDate(h.time)}
              </div>
            ))}
        </div>
      </div>
    </div>
  );
}

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

/* ------------------------- the market draw --------------------------- */

/* The reveal, in four beats: the order is paid, the market shuffles, one
   deed is chosen, it turns over. The server has already decided — this only
   presents what happened, so there is nothing to re-roll by reloading and
   nothing here that can be wrong about the outcome.

   Timings mirror the CSS: 0.0–1.0s order, 1.0–3.5s shuffle, 3.5–4.6s
   selection, then the flip. The two JS timers below are the only thing
   keeping the two in step, so they are named rather than inlined. */
const DRAW_REVEAL_AT = 4600;

/* Sparks are generated rather than written out because each needs its own
   angle, distance, size and life, and forty hand-written spans would be
   forty chances to fat-finger one. Scaled by rarity: a common lot gets a
   polite handful, a landmark gets the full display. */
function drawSparks(rarity) {
  const count = { common: 0, uncommon: 8, rare: 18, ultra: 30, secret: 44 }[rarity] ?? 12;
  const tint = { uncommon: ["#8E979F", "#d8dde0"], rare: ["#B08C1E", "#f3da70"],
    ultra: ["#B4342E", "#f2b5b8", "#fff"], secret: ["#7A5AA8", "#f3da70", "#fff"] }[rarity] || ["#B08C1E"];
  const out = [];
  for (let i = 0; i < count; i++) {
    const angle = (i / count) * Math.PI * 2 + (i % 3) * 0.21;
    const dist = 90 + ((i * 37) % 120);
    out.push({
      x: Math.cos(angle) * dist + "px",
      y: Math.sin(angle) * dist + "px",
      size: (i % 4 === 0 ? 9 : i % 3 === 0 ? 6 : 4) + "px",
      spark: tint[i % tint.length],
      life: (700 + ((i * 53) % 500)) + "ms",
      delay: (((i * 29) % 260)) + "ms",
      spin: ((i % 2 ? 1 : -1) * (120 + (i * 17) % 200)) + "deg",
      star: i % 5 === 0,
    });
  }
  return out;
}

const DRAW_RARITY_LABEL = { common: "Common", uncommon: "Uncommon", rare: "Rare", ultra: "Ultra Rare", secret: "Historic" };

function DrawTheatre({ draw, cashBefore, onClose }) {
  const [phase, setPhase] = useState("playing");
  /* The server names the property; the catalogue is what turns that into a
     picture and a district. It is always loaded by the time anyone can draw,
     but a missing entry must degrade to a plain deed rather than throw
     inside the reveal — an exception here would leave the player looking at
     a blank overlay having just been charged. */
  const meta = ALL_PROPERTIES.find((m) => m.id === draw.propertyId) || null;
  const rarity = (meta && meta.rarity) || draw.rarity || "common";
  const photo = meta ? artFor(meta, null) : null;
  const sparks = useMemo(() => drawSparks(rarity), [rarity]);
  const gain = draw.free ? draw.value : draw.value - draw.fee;

  useEffect(() => {
    const id = setTimeout(() => setPhase("revealed"), DRAW_REVEAL_AT);
    return () => clearTimeout(id);
  }, []);

  /* Escape closes, but only once the deed has turned — closing mid-shuffle
     would look like the draw was cancelled when the money is already spent. */
  useEffect(() => {
    if (phase !== "revealed") return undefined;
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [phase, onClose]);

  const status = phase === "revealed"
    ? "The deed is registered in your name."
    : "Sealing the order. The market is shuffling.";

  return (
    <div className="draw-overlay" role="dialog" aria-modal="true" aria-label="Market draw">
      <div className={"draw-stage q-" + rarity + " " + phase}>
        <div className="draw-case">
          <div>
            <span className="draw-case-no">MARKET DRAW · {DRAW_BAND_LABEL[draw.band] || "OPEN MARKET"}</span>
            <h2>One sealed property deed</h2>
          </div>
          {draw.free
            ? <div className="draw-stamp">FREE<small>ON THE HOUSE</small></div>
            : <div className="draw-stamp">{fmt(draw.fee)}<small>PAID</small></div>}
        </div>

        <p className="draw-status" aria-live="polite">{status}</p>

        <div className="draw-space">
          <div className="draw-side">
            <span className="label">YOUR CASH</span>
            <strong>{fmt(cashBefore)}</strong>
            <span className="draw-debit">− {fmt(draw.fee)}</span>
            <div className="draw-rule"><i /></div>
            <span className="status">{phase === "revealed" ? "Order settled" : "Order placed"}</span>
          </div>

          <div className="draw-machine" aria-hidden="true">
            <div className="draw-orbit"><span /></div>
            <div className="draw-halo" />
            <div className="draw-burst">
              {phase === "revealed" && sparks.map((s, i) => (
                <span key={i} className={"draw-spark" + (s.star ? " star" : "")}
                      style={{ "--x": s.x, "--y": s.y, "--size": s.size, "--spark": s.spark,
                               "--life": s.life, "--delay": s.delay, "--spin": s.spin }} />
              ))}
            </div>
            <div className="draw-flare" />
            <div className="draw-deeds">
              <div className="draw-deed draw-ghost a"><span>PROPERTY DEED</span><i /></div>
              <div className="draw-deed draw-ghost b"><span>PROPERTY DEED</span><i /></div>
              <div className="draw-deed draw-ghost c"><span>PROPERTY DEED</span><i /></div>
              <div className="draw-deed draw-chosen">
                <div className="draw-back">
                  <span className="glyph">♜</span>
                  <span className="rule" />
                  <b>YORK</b>
                  <small>PROPERTY EXCHANGE</small>
                  <span className="draw-seal">Y</span>
                </div>
                <span className="draw-crown">♛</span>
                <div className="draw-face">
                  <div className="draw-photo">
                    {photo
                      ? <img src={photo} alt="" onError={(e) => { e.target.style.display = "none"; }} />
                      : meta ? <PropertyArt meta={meta} height={null} /> : null}
                  </div>
                  <span className="draw-tab">{(DRAW_RARITY_LABEL[rarity] || "Common").toUpperCase()}</span>
                  <div className="draw-copy">
                    <strong>{meta ? meta.name : "Deed registered"}</strong>
                    <small>YORK · {(meta && meta.neighbourhoodName) || "CITY OF YORK"}</small>
                    <b>{fmt(draw.value)}</b>
                  </div>
                </div>
              </div>
            </div>
          </div>

          <div className="draw-side">
            <span className="label">THE POOL</span>
            <strong>{draw.poolWas} deeds</strong>
            <div className="draw-rarity-list">
              {["common", "uncommon", "rare", "ultra"].map((k) => (
                <span key={k}>
                  <i className="draw-gem" style={{ background: (RARITIES[k] || {}).colour === "transparent" ? "#F7F4EB" : (RARITIES[k] || {}).colour }} />
                  {DRAW_RARITY_LABEL[k]}
                </span>
              ))}
            </div>
          </div>
        </div>

        <div className="draw-progress" aria-hidden="true"><i /></div>

        <div className="draw-result">
          <p className="kicker">{(DRAW_RARITY_LABEL[rarity] || "Common").toUpperCase()} PROPERTY DRAWN</p>
          <p className="title">{meta ? meta.name : "Your new property"}</p>
          <p className="detail">Added to your portfolio. Rent starts once you have held it an hour.</p>
          <span className={"verdict " + (gain >= 0 ? "up" : "down")}>
            {draw.free ? `+${fmt(gain)}, and it cost you nothing` : `${gain >= 0 ? "+" : "−"}${fmt(Math.abs(gain))} against the fee`}
          </span>
        </div>

        {phase === "revealed" && (
          <div className="draw-close">
            <button className="btn" onClick={onClose}>Take the deed</button>
          </div>
        )}
      </div>
    </div>
  );
}

const DRAW_BAND_LABEL = { ordinary: "ORDINARY LOTS", prime: "PRIME LOTS" };

/* The panel that sits at the top of the Market tab. The fee is derived
   server-side from what is actually left in the band, so it is quoted here
   rather than fixed — and the figure on screen is sent back with the order
   as a limit, so a fee that moved between reading and clicking is refused
   rather than charged. */
/* One bright button on the Market tab, and everything else behind it.

   The first cut put the two lots, their fees, pool sizes, ranges and averages
   straight on the tab — six numbers competing with the property grid before
   anyone had decided they were interested. A lottery wants one door: a thing
   you choose to walk through, which then explains itself and asks you to
   pick. So the tab carries a single call to action, the explanation lives in
   the modal it opens, and the lot cards only appear once someone is actually
   in the room. */
function DrawPanel({ game, me, onActResult }) {
  const draw = (me && me.draw) || null;
  const [open, setOpen] = useState(false);
  const [chosen, setChosen] = useState(null);
  /* The first minute's tour ends on "Take my free draw", which lands here
     and opens the draw rather than leaving the trader to find it. */
  /* A first draw from there opens with the free book already chosen, so the
     only thing left to press is "Take your free draw". */
  const welcomeBand = draw && (draw.welcome || 0) > 0 ? draw.welcomeBand : null;
  useEffect(() => {
    const go = () => { setOpen(true); if (welcomeBand) setChosen(welcomeBand); };
    window.addEventListener("civexio:draw", go);
    return () => window.removeEventListener("civexio:draw", go);
  }, [welcomeBand]);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  const [theatre, setTheatre] = useState(null);
  const [more, setMore] = useState(false);
  const [now, setNow] = useState(Date.now());

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

  useEffect(() => {
    if (!open) return undefined;
    const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open]);

  if (!draw || !draw.quotes || !draw.quotes.length) return null;

  const waiting = draw.readyAt > now;
  const left = Math.max(0, draw.readyAt - now);
  const waitText = left > 90 * 60000 ? spanOf(left) : `${Math.ceil(left / 60000)} min`;
  const pick = draw.quotes.find((qt) => qt.key === chosen) || null;

  async function commit(qt) {
    const pick = qt || draw.quotes.find((x) => x.key === chosen) || null;
    if (!pick || busy) return;
    setBusy(true); setError("");
    const cashBefore = me.cash;
    const res = await onActResult("draw", { band: pick.key, maxFee: pick.fee });
    setBusy(false);
    if (!res || res.error) { setError((res && res.error) || "The draw could not be made."); return; }
    setChosen(null);
    /* The room empties before the reveal: two stacked overlays would put the
       lot cards behind the deed flip, and the animation is the whole point. */
    setOpen(false);
    if (res.draw) setTheatre({ draw: res.draw, cashBefore });
  }

  const cheapest = draw.quotes.reduce((a, b) => (b.fee < a.fee ? b : a), draw.quotes[0]);
  /* One on the house, given at registration. It skips the fee and the wait,
     because making someone sit out a three-hour cooldown before they can use
     a welcome gift is a strange sort of welcome. */
  const free = (draw.free || 0) > 0;
  /* The very first pull: no rules to read before the fun part. One line and
     one button, with the full explanation a tap away (Aaron, 2 Oct 2026:
     "jump into the game and roll their first property"). */
  const welcomeQuote = (draw.welcome || 0) > 0 && !((draw.anywhere || 0) > 0)
    ? draw.quotes.find((x) => x.key === draw.welcomeBand) || null : null;
  const quick = !!welcomeQuote && !more;

  return (
    <>
      <div className={"lotto-bar" + (waiting ? " resting" : "")}>
        <span className="lotto-ticket" aria-hidden="true">◆</span>
        <div className="lotto-copy">
          <span className="lotto-kicker">CIVEXIO MARKET DRAW</span>
          <strong>Property lottery</strong>
          <span className="lotto-sub">
            {(draw.anywhere || 0) > 0
              ? "You have a draw on the house: one sealed deed, any lot, free."
              : (draw.welcome || 0) > 0
              ? "Your opening draw is on the house: one sealed deed from the ordinary lots, free."
              : waiting
                ? `The draw is settling. It opens again in ${waitText}.`
                : `One sealed deed, chosen at random. From ${fmt(cheapest.fee)}.`}
          </span>
        </div>
        <button className="btn lotto-go" disabled={waiting && !free} onClick={() => { setError(""); setOpen(true); }}>
          {free ? "Take your free draw" : waiting ? `Opens in ${waitText}` : "Enter the lottery"}
        </button>
      </div>

      {open && (
        <div className="modal-backdrop" onClick={() => setOpen(false)}>
          <div className="modal-box lotto-box" onClick={(e) => e.stopPropagation()}>
            <button className="modal-close" onClick={() => setOpen(false)} aria-label="Close">×</button>
            <div className="modal-inner">
              <span className="lotto-kicker">CIVEXIO MARKET DRAW</span>
              {quick ? (
                <div className="lotto-quick">
                  <h3 className="lotto-title">Your first building</h3>
                  <p className="lotto-lede">
                    One deed dealt at random from {welcomeQuote.pool} unsold buildings, worth {fmt(welcomeQuote.low)} to {fmt(welcomeQuote.high)}. It is on the house.
                  </p>
                  <button className="btn lotto-go" onClick={() => commit(welcomeQuote)} disabled={busy}>
                    {busy ? "Drawing…" : "Take your free draw"}
                  </button>
                  <button className="lotto-more" onClick={() => setMore(true)}>How the draw works</button>
                  {error && <div className="error-text" style={{ marginTop: 10 }}>{error}</div>}
                </div>
              ) : (<>
              <h3 className="lotto-title">Property lottery</h3>
              {/* THE OLD LEDE SAID "you are not buying a property — you are
                  buying a chance at one", which is not what happens. You
                  always get a deed. What is uncertain is WHICH one, and
                  therefore whether it was worth the price. Calling it a
                  chance at a property reads as a chance of coming away with
                  nothing, which would be a different and much worse
                  offer. */}
              <p className="lotto-lede">
                You are buying a property, but you do not get to choose <em>which</em> one. Pick a lot, pay its
                price, and the market deals you a deed at random from everything still unsold in that lot.
                You always come away with a building. Whether it was worth the price is the gamble.
              </p>
              <ul className="lotto-rules">
                <li><b>The price moves.</b> Each lot costs a little under what its average property is
                  worth right now, so as the cheap end of the city is bought up, the price climbs with it.</li>
                <li><b>The odds are near enough even.</b> Roughly half of all draws land above what you
                  paid and half below, but the best result in a lot is worth far more than the worst is
                  cheap.</li>
                <li><b>{free ? "Your free pull first, then one go every" : "One go every"} {draw.cooldownHours} hour{draw.cooldownHours === 1 ? "" : "s"}.</b>{" "}
                  Landmarks are never in the draw, and rent starts an hour after the deed is yours.</li>
              </ul>

              <div className="lotto-choose">Choose your lot</div>
              <div className="draw-bands">
                {draw.quotes.map((qt) => {
                  /* Free is a property of the BAND, not of the player. The
                     welcome credit is Ordinary-only, so a modal that showed
                     "Free" on both would be promising something the counter
                     would then refuse. */
                  const freeHere = (draw.anywhere || 0) > 0
                    || ((draw.welcome || 0) > 0 && qt.key === draw.welcomeBand);
                  const tooDear = !freeHere && me.cash < qt.fee;
                  return (
                    <button key={qt.key} className="draw-band" aria-pressed={chosen === qt.key}
                            disabled={tooDear}
                            onClick={() => setChosen(chosen === qt.key ? null : qt.key)}>
                      <span className="draw-band-name">{qt.label}</span>
                      <span className="draw-band-fee">{freeHere ? "Free" : fmt(qt.fee)}</span>
                      <span className="draw-band-blurb">{qt.blurb}</span>
                      <span className="draw-band-facts">
                        {qt.pool} still unsold · worth {fmt(qt.low)} to {fmt(qt.high)} · average {fmt(qt.mean)}
                      </span>
                      {!freeHere && (draw.welcome || 0) > 0 && (
                        <span className="draw-band-facts" style={{ color: "var(--muted, #6b6455)" }}>
                          Your welcome draw does not cover this band.
                        </span>
                      )}
                      {tooDear && <span className="draw-band-facts" style={{ color: "var(--loss)" }}>You are short {fmt(qt.fee - me.cash)}</span>}
                    </button>
                  );
                })}
              </div>

              {pick && (() => {
                const pickFree = (draw.anywhere || 0) > 0
                  || ((draw.welcome || 0) > 0 && pick.key === draw.welcomeBand);
                return (
                <div className="draw-confirm">
                  <span className="note">
                    {pickFree
                      ? `One deed out of ${pick.pool} in ${pick.label.toLowerCase()}, on the house. The average lot there is worth ${fmt(pick.mean)}, but any single draw can land under it.`
                      : `${fmt(pick.fee)} for one deed out of ${pick.pool} in ${pick.label.toLowerCase()}. The average lot there is worth ${fmt(pick.mean)}, better than the price, but any single draw can land under it, and there are no refunds.`}
                  </span>
                  <button className="btn lotto-go" onClick={() => commit()} disabled={busy}>
                    {busy ? "Drawing…" : pickFree ? "Take your free draw" : `Draw for ${fmt(pick.fee)}`}
                  </button>
                  <button className="btn secondary" onClick={() => setChosen(null)}>Back</button>
                </div>
                );
              })()}
              {error && <div className="error-text" style={{ marginTop: 10 }}>{error}</div>}
              </>)}
            </div>
          </div>
        </div>
      )}

      {theatre && <DrawTheatre draw={theatre.draw} cashBefore={theatre.cashBefore} onClose={() => setTheatre(null)} />}
    </>
  );
}

function MarketTab({ game, me, histories, onAct, onActResult, 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>
      <DrawPanel game={game} me={me} onActResult={onActResult} />
      <Watchlist game={game} me={me} histories={histories} onAct={onAct} onOpen={onOpen} />
      <p className="intro">Everything currently available to buy. Properties owned by other traders drop off this list. Find those on the Map, or on the Trading floor.</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} onFav={onAct} onAct={onAct}
                onBuy={(id) => onAct("buy", { propertyId: id })} />
            ))}
          </div>
        </div>
      ))}

    </div>
  );
}

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

/* ---- WHAT A HOLDING HAS DONE, OVER DAYS AND WEEKS -----------------------

   Aaron, 25 Sep 2026: the portfolio showed the price change "this session",
   the last forty moves this browser happened to see, which is minute by
   minute and gone on a refresh. Whether a building was a good buy is a
   question about days and weeks, and about what you paid. The server keeps
   an hourly series (lib/prices.js) and this reads it once when the page
   opens, and again every ten minutes while it stays open. */
function usePortfolioPrices(count) {
  const [data, setData] = useState(null);
  useEffect(() => {
    let live = true;
    const load = () => api("/api/prices").then((d) => { if (live) setData(d); }).catch(() => {});
    load();
    const id = setInterval(load, 10 * 60000);
    return () => { live = false; clearInterval(id); };
  }, [count]);
  return data;
}

const PRICE_RANGES = [["day", "Day", 24 * 3600000], ["week", "Week", 7 * 24 * 3600000],
  ["month", "Month", 30 * 24 * 3600000], ["held", "Since you took it", null]];
const shortDate = (t) => new Date(t).toLocaleDateString("en-GB", { day: "numeric", month: "short" });

function PriceTrack({ meta, p, data, now }) {
  const [range, setRange] = useState("week");
  const d = data && data.properties && data.properties[meta.id];
  const acq = d && d.acquired;
  const nowPrice = p.currentPrice;
  const series = ((d && d.series) || []).concat([{ t: now, p: nowPrice }]);
  const span = PRICE_RANGES.find((r) => r[0] === range);
  const from = span[2] ? now - span[2] : (acq ? acq.at : 0);
  const inRange = series.filter((x) => x.t >= from);
  const first = inRange.length ? inRange[0] : null;
  /* Short on history: say how far back the record goes rather than drawing
     a range the numbers cannot cover. */
  /* `paid` first: `covers` reads it, and a const read before its line is a
     ReferenceError. That is what took the portfolio down on "Since you took
     it", caught by tools/batchreview.mjs on Aaron's PC, 25 Sep 2026. */
  const paid = acq && acq.price;
  const covers = (range === "held" && paid) || (first && series.length > 1 && (!span[2] || series[0].t <= from + 3600000));
  /* Since you took it, the base is what you paid, so this figure and the
     gain beside the price paid are one number, not two that nearly agree. */
  const base = range === "held" && paid ? paid : first && first.p;
  const change = base > 0 ? ((nowPrice - base) / base) * 100 : 0;
  const gain = paid ? nowPrice - paid : null;
  const gainPct = paid ? (gain / paid) * 100 : null;
  const tone = (n) => (n >= 0 ? "chg-up" : "chg-down");
  return (
    <div className="price-track">
      <div className="price-track-paid">
        {paid ? (
          <>
            <span>{acq.how === "valued" ? "Valued" : "Paid"} {fmt(paid)} on {shortDate(acq.at)}</span>
            <b className={tone(gain)}>{gain >= 0 ? "+" : "−"}{fmt(Math.abs(gain))} ({fmtPct(gainPct)})</b>
          </>
        ) : <span>Held since {acq ? shortDate(acq.at) : "before the record began"}</span>}
      </div>
      <div className="price-track-ranges" role="tablist" aria-label={`Price history for ${meta.name}`}>
        {PRICE_RANGES.map(([k, label]) => (
          <button key={k} type="button" role="tab" aria-selected={range === k}
                  className={"price-range" + (range === k ? " on" : "")} onClick={() => setRange(k)}>{label}</button>
        ))}
      </div>
      <Sparkline history={inRange.map((x) => x.p)} color={change >= 0 ? "#2F6B45" : "#B0342A"} width={220} height={48} fill />
      <div className="price-track-foot">
        {covers
          ? <span className={tone(change)}>{fmtPct(change)} over the {range === "held" ? "time you have held it" : span[1].toLowerCase()}</span>
          : <span className="muted-plain">The price record starts {data && data.since ? shortDate(data.since) : "today"}, so this range is not complete yet.</span>}
      </div>
    </div>
  );
}

function PortfolioTab({ game, me, now, histories, onAct, onOpen, onGoBuild }) {
  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 prices = usePortfolioPrices(mine.length);
  const dailyTotal = mine.reduce((s, m) => s + dailyRentFor(game, m, game.properties[m.id], now), 0);
  /* RUNNING OVERHEADS, shown against the rent they come out of. The same
     share settlement takes, with the owner's relief if they have any. */
  const relief = me.cashFlow && typeof me.cashFlow.relief === "number" ? me.cashFlow.relief : 1;
  const overheadOf = (m) => {
    const p = game.properties[m.id];
    if (!(earningState(p, now) || {}).earning) return 0;
    return dailyRentFor(game, m, p, now) * (p.overheadPct || 0) * relief;
  };
  const overheadTotal = mine.reduce((s, m) => s + overheadOf(m), 0);
  const districtsHeld = new Set(mine.map((m) => m.neighbourhood)).size;
  const earningCount = mine.filter((m) => (earningState(game.properties[m.id], now) || {}).earning).length;
  const idleCount = mine.length - earningCount;
  const liveBonuses = mine.filter((m) => {
    const pr = abilityProgress(m, game, me);
    return pr ? pr.on : true;      /* an unconditional ability is always paying */
  }).length;

  /* A PREMIUM LISTING, NOT A DASHBOARD. This is the page a player comes to in
     order to feel good about what they own, so it is deliberately the
     opposite of the tactics table: no texture, no theatre, and the expense
     carried entirely by space and typography. A masthead in the palette's
     deep green states the position; the holdings sit beneath it on linen,
     one to a line, with the sort of air an agent's particulars would give
     them. */
  return (
    <div className="portfolio">
      {/* NOT THE PLAYER BAR AGAIN. Cash, property value, net worth and income
          earned are all four inches above this, in the bar that follows you
          round the whole game — repeating them here spent the most valuable
          space on the page saying nothing new.

          These are the things only this page knows, and every one of them is
          a prompt to do something: a lapsed building earns nothing until it
          is covered, an ability short of its set is a specific property to
          go and buy, and an unsecured building is the one a card will land
          on tonight. */}
      <div className="portfolio-total">
        <div className="block"><div className="label">Holdings</div>
          <div className="value">{mine.length}</div>
          <div className="foot">across {districtsHeld} district{districtsHeld === 1 ? "" : "s"}</div></div>
        {/* Aaron's wording, 28 Sep 2026. */}
        <div className="block feature"><div className="label">Hourly rent and overheads</div>
          <div className="value">{fmt(dailyTotal - overheadTotal)}</div>
          <div className="foot">{fmt(dailyTotal)} rent with {fmt(overheadTotal)} overheads</div></div>
        <div className="block"><div className="label">Earning</div>
          <div className="value">{earningCount} <em>of {mine.length}</em></div>
          <div className="foot">{idleCount ? `${idleCount} need attention` : "all of them working"}</div></div>
        <div className="block"><div className="label">Bonuses live</div>
          <div className="value">{liveBonuses} <em>of {mine.length}</em></div>
          <div className="foot">{liveBonuses === mine.length ? "every set complete" : "collect the set to switch one on"}</div></div>
        {/* Total income earned, moved here from the bar across the top of
            every page (Aaron, 26 Sep 2026): it is a portfolio figure. */}
        <div className="block"><div className="label">Total income earned</div>
          <div className="value">{fmt(me.totalIncome || 0)}</div>
          <div className="foot">rent collected since you joined</div></div>
      </div>

      <BuildsUnderway builds={(game.players[me.id] || {}).building} own onGo={onGoBuild} />

      {!mine.length ? <div className="empty">You don't own any properties yet.</div> : mine.map((meta) => {
        const p = game.properties[meta.id];
        const hold = earningState(p, now);
        const history = histories[meta.id] || [];
        const change = changeFromHistory(history, p.currentPrice);
        return (
          <div className="card" key={meta.id}>
            <div className="card-row">
              {/* THE PHOTOGRAPH, beside the price line rather than buried in
                  the modal. Particulars lead with the building; a list of
                  addresses and percentages is an accounts page. It opens the
                  deed, as does the title — the two things a hand reaches for. */}
              <button type="button" className="pf-shot" onClick={() => onOpen(meta.id)}
                      aria-label={`Open ${meta.name}`}>
                {artFor(meta, p)
                  ? <img src={artFor(meta, p)} alt="" loading="lazy"
                         onError={(e) => { e.target.style.display = "none"; }} />
                  : <PropertyArt meta={meta} facade={ownerFacade(game, p)} height={null} />}
              </button>
              <div style={{ minWidth: 200, flex: 1 }}>
                <h3 className="pf-title" onClick={() => onOpen(meta.id)}
                    role="button" tabIndex={0}
                    onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(meta.id); } }}>
                  {meta.name}
                </h3>
                <div className="meta"><DistrictTag meta={meta} /><RarityMark meta={meta} showName /><CategoryTag category={meta.category} /> {fmt(p.currentPrice)}</div>
                <BuiltCredit meta={meta} />
                <PriceTrack meta={meta} p={p} data={prices} now={now} />
                <div className="rent-line">Hourly rent {fmt(dailyRentFor(game, meta, p, now))} · {yieldPctOf(meta).toFixed(2)}% hourly rental yield</div>
                <div className="rent-line overhead-line">
                  Overheads {fmt(overheadOf(meta))} an hour, {+(((p.overheadPct || 0) * relief) * 100).toFixed(2)}% of rent
                  {(p.overheadPct || 0) < 0.1 - 1e-9 ? ` (cut by ${meta.abilityName})` : ""}
                  {" · "}net {fmt(dailyRentFor(game, meta, p, now) - overheadOf(meta))}
                </div>
                <div className={"hold-note " + hold.tone}>{hold.text}</div>
                {/* A DODGY TENANT IS NOT THE SAME AS A RENT STRIKE, and this is
                    where the owner does something about it. The strike line
                    below still covers a plain Incite Rent Strike; a tenant gets
                    its own panel because it is the only one with a way out. */}
                <DodgyTenant meta={meta} prop={p} me={me} onAct={onAct} />
                {p.rentStrikeUntil > now && !p.tenant && <div className="hold-note warn">Tenants are withholding rent until {fmtTime(p.rentStrikeUntil)}.</div>}
                <div className="pf-levels">
                  <span><b>Improvement</b> level {p.improvementLevel || 0} of 3</span>
                  <span><b>Security</b> level {p.securityLevel || 0} of {SECURITY_MAX_LEVEL}{(p.securityLevel || 0) > 0 ? `, ${securityPct(p.securityLevel)}% natural resistance against enemy attacks` : ", no resistance yet"}</span>
                </div>
                <div className="pcard-links">
                  <ShowOnMapLink meta={meta} />
                  <StreetViewLink meta={meta} />
                </div>
              </div>
              {/* THE ACTIONS STAY TOP RIGHT (Aaron, 1 Oct 2026). A free row of
                  buttons grew wider with "Cover a week for £X" and wrapped
                  under the whole card; a fixed column cannot. */}
              <div className="pf-acts">
                {p.cpoSale && p.cpoSale.endsAt > now ? <CpoSale meta={meta} prop={p} me={me} now={now} onAct={onAct} compact />
                  : p.inAuction ? <span className="auction-badge">Under auction</span> : (
                  <>
                    {coverOffer(p, now) && (
                      <>
                        <button className="btn small" onClick={() => onAct("upkeep", { propertyId: meta.id })}
                                disabled={me.cash < (p.upkeepCost || 0)}>
                          {coverOffer(p, now).label}
                        </button>
                        <span className="muted-plain">{coverOffer(p, now).note}</span>
                      </>
                    )}
                    <button className="btn small secondary" onClick={() => onOpen(meta.id)}>Improve / secure</button>
                    {meta.stock !== "Landmark" && me.personalise && (
                      <button className="btn small secondary" onClick={() => onOpen(meta.id, "look")}>Choose the look</button>
                    )}
                    {/* A built house changes hands only at auction (Aaron,
                        27 Sep 2026): no sale to the market, no trade. */}
                    {!(meta.built && !(game.builder && game.builder.tradeable)) && (
                      <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>
                    {meta.built && !(game.builder && game.builder.tradeable) && (
                      <span className="muted-plain">A built house only changes hands at auction.</span>
                    )}
                  </>
                )}
              </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 hour</option><option value="3">3 hours</option><option value="7">7 hours</option><option value="14">14 hours</option>
                </select>
                <button className="btn" onClick={() => { onAct("auction.start", { propertyId: meta.id, startPrice: Number(startPrice), hours: Number(duration) }); setAuctionOpen(null); }}>List it</button>
                <button className="btn secondary" onClick={() => setAuctionOpen(null)}>Cancel</button>
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
}

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

/* ---- HOW LONG A LOT HAS LEFT, AS THE BIGGEST THING ON IT ------------

   It was "ends in 2h 14m" in small grey type in the middle of the listing
   line, recomputed only when the poll re-rendered. Aaron, 25 Sep 2026: the
   time left should be clear. So it is its own block with its own clock:
   every thirty seconds while there are hours to go, every second in the last
   hour, where minutes and seconds are both shown because that is when they
   matter. Three states, colour and words both, so it reads without colour:
   open, closing (under fifteen minutes), final (under the anti snipe window,
   where any bid pushes the close out by two and a half minutes — which is the
   one thing a bidder in the last minutes needs to know). */
const AUCTION_EXTENSION_MS = 150000;
function AuctionClock({ endsAt, startedAt }) {
  const [now, setNow] = useState(Date.now());
  const left = Math.max(0, endsAt - now);
  const fast = left < 3600000;
  useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), fast ? 1000 : 30000);
    return () => clearInterval(id);
  }, [fast]);
  const state = left <= 0 ? "closed" : left < AUCTION_EXTENSION_MS ? "final"
    : left < 15 * 60000 ? "closing" : "open";
  const mm = Math.floor(left / 60000), ss = Math.floor((left % 60000) / 1000);
  const figure = left <= 0 ? "Closing"
    : fast ? `${mm}:${String(ss).padStart(2, "0")}` : spanOf(left);
  const span = startedAt && endsAt > startedAt ? endsAt - startedAt : 0;
  const gone = span ? Math.min(1, Math.max(0, (now - startedAt) / span)) : null;
  const words = { open: "Time left", closing: "Closing soon", final: "Final minutes", closed: "Bidding closed" }[state];
  return (
    <div className={"auction-clock is-" + state} role="timer" aria-live={state === "final" ? "polite" : "off"}>
      <span className="auction-clock-label">{words}</span>
      <strong className="auction-clock-figure">{figure}</strong>
      {gone != null && (
        <span className="auction-clock-bar" aria-hidden="true"><i style={{ width: `${Math.round(gone * 100)}%` }} /></span>
      )}
      {state === "final" && (
        <em className="auction-clock-note">A bid now moves the close back to two and a half minutes.</em>
      )}
    </div>
  );
}

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 to 14 hours, 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>}
      {(() => {
        /* Compulsory sales sit with the auctions because they are the other
           way a deed is up for grabs against its owner's wishes. */
        const t = Date.now();
        const sales = ALL_PROPERTIES.filter((m) => {
          const p = game.properties[m.id];
          return p && p.cpoSale && p.cpoSale.endsAt > t;
        });
        if (!sales.length) return null;
        return (
          <>
            <div className="cluster-head">Compulsory sales</div>
            {sales.map((m) => (
              <div className="card" key={"cpo-" + m.id}>
                <h3>{m.name}</h3>
                <div className="meta"><DistrictTag meta={m} /> value {fmt(game.properties[m.id].currentPrice)}</div>
                <CpoSale meta={m} prop={game.properties[m.id]} me={me} now={t} onAct={onAct} />
              </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));
        const prop = game.properties[a.propertyId];
        const photo = meta ? artFor(meta, prop) : null;
        return (
          <div className={"card auc-card" + (a.currentBidder === me.id ? " is-leading" : "") + (isMine ? " is-mine" : "")} key={a.id}>
            <div className="card-row">
              <div className="auc-shot" aria-hidden="true">
                {photo
                  ? <img src={photo} alt="" loading="lazy" onError={(e) => { e.target.style.display = "none"; }} />
                  : meta ? <PropertyArt meta={meta} facade={ownerFacade(game, prop)} height={null} /> : null}
              </div>
              <div className="auc-main">
                <h3>{meta.name} {a.foreclosure && <span className="foreclosure-badge">Foreclosure</span>}</h3>
                <div className="meta">
                  <DistrictTag meta={meta} />
                  {a.foreclosure
                    ? <>seized from {isMine ? "you" : <TraderName player={game.players[a.seller]} />} · opened at half value</>
                    : <>listed by {isMine ? "you" : <TraderName player={game.players[a.seller]} />}</>}
                </div>
              </div>
              <div className="auc-bid">
                <div className="stat-label">{a.currentBidder === me.id ? "Your bid leads" : "Current bid"}</div>
                <div className="price">{fmt(a.currentBid)}</div>
                <div className="muted">{bidder ? (a.currentBidder === me.id ? "you are ahead" : `by ${bidder.name}`) : "no bids yet"}</div>
              </div>
            </div>
            {/* Under the whole row rather than inside its left half, so every
                lot draws its clock at the same width whether or not the final
                minutes note is showing. */}
            <AuctionClock endsAt={a.endsAt} startedAt={a.startedAt} />
            {!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;
            const meta = metaFor(a.propertyId);
            return <div key={a.id} className="muted" style={{ padding: "4px 0" }}>{meta ? meta.name : "A property"}: {bidder ? `sold to ${bidder.name} for ${fmt(a.currentBid)}` : "no winning bid"}</div>;
          })}
        </>
      )}
    </div>
  );
}

/* ---------------------------- Build ------------------------------ */
/* THE BUILDER'S TAB. Aaron, 25 Sep 2026; the design is
   claude/house-builder-mode.md and the bench is preview/build-tab.html.

   Plots first, because the plot is the prize: eight real York buildings a
   week, their street, frontage and asking price in the open, their ground
   and tier behind a paid survey. Then the drawing board for a plot you have
   surveyed, drawn live as you choose, with every pound itemised. Then the
   build itself, a stage an evening, and its diary. Then the houses you have
   finished. The server prices everything (lib/builder.js) and this quotes
   from the same tables it sends, so the page and the till agree. */
const BLD_LABEL = {
  brick: { red: "York red", white: "Limewashed", buff: "Buff stock", grey: "Grey gault" },
  door: { forest: "Forest green", claret: "Claret", navy: "Navy", black: "Black", ochre: "Ochre", ivory: "Ivory" },
  roof: { slate: "Welsh slate", pantile: "Clay pantile", stone: "Stone flag" },
  windows: { casement: "Casement", sash: "Sash, six over six" },
};
const BLD_SWATCH = {
  brick: { red: "#A24E35", white: "#DED4BE", buff: "#C09A6B", grey: "#9AA0A0" },
  door: { forest: "#1F4A3C", claret: "#7E1F33", navy: "#243C58", black: "#26282A", ochre: "#A9762A", ivory: "#DCD2B8" },
  roof: { slate: "#5A6066", pantile: "#A65E3A", stone: "#7C7769" },
};
const TIER_WORD = { common: "Common", uncommon: "Uncommon", rare: "Rare", ultra: "Ultra Rare" };
const TIER_STEP = ["common", "uncommon", "rare", "ultra"];

function builderQuote(B, plot, d) {
  if (!plot || !d.type) return null;
  const build = (plot.buildCosts || {})[d.type] || 0;
  const finish = (B.finish.roof[d.roof] || 0) + (B.finish.windows[d.windows] || 0);
  const spec = (d.spec || []).reduce((a, k) => a + ((B.spec[k] || {}).cost || 0), 0);
  const rate = plot.report ? plot.report.insuranceRate : 0;
  const insurance = d.insure && rate > 0 ? Math.round(((plot.price + build) * rate) / 100) * 100 : 0;
  const total = plot.price + build + finish + spec + insurance;
  /* The estimate on completion, before anything goes wrong: the plot plus
     the share of the build that becomes value, lifted by the tier. */
  let tier = plot.report ? plot.report.tier : "common";
  if ((d.spec || []).length >= B.maxSpec) tier = TIER_STEP[Math.min(3, TIER_STEP.indexOf(tier) + 1)];
  const mult = ((RARITIES[tier] || {}).priceMult) || 1;
  const buildSpend = build + finish + spec;
  const value = Math.min((plot.price + buildSpend) * 0.95, (plot.price + buildSpend * B.valueFromSpend) * mult);
  return { plot: plot.price, build, finish, spec, insurance, total, value: Math.round(value / 1000) * 1000, tier };
}

function BuildTab({ game, me, now, onAct, onOpen }) {
  const B = game.builder;
  const [openPlot, setOpenPlot] = useState(null);
  const [surveyAsk, setSurveyAsk] = useState(null);
  if (!B) return <div className="empty">Building is not open yet.</div>;
  const plot = openPlot && B.plots.find((p) => p.id === openPlot);
  const building = !!B.build;
  return (
    <div className="bld">
      <p className="intro">
        {/* Aaron's words, 27 Sep 2026. */}
        Build a new property with your design. It will cost a bit more, take a few days if all goes well, but will be
        something special that permanently holds your name and perhaps some extra abilities.{" "}
        {B.ballot && B.ballot.on
          ? "Plots are granted by the Council's planning ballot at each evening's turnover."
          : <>{B.plots.length ? `${B.plots.length} plot${B.plots.length === 1 ? "" : "s"} on offer this week` : "No plots on offer at the moment"}; the next release is {new Date(B.nextReleaseAt).toLocaleString("en-GB", { weekday: "long", hour: "2-digit", minute: "2-digit" })}.</>}
        {!B.tradeable ? " A built house can only change hands at auction: it cannot be sold to the market or traded." : ""}
      </p>

      {B.ballot && B.ballot.on && <BallotCard ballot={B.ballot} now={now} />}

      {B.build && <BuildProgress b={B.build} now={now} />}

      {plot && !building ? (
        <DrawingBoard B={B} plot={plot} me={me} onAct={onAct} onClose={() => setOpenPlot(null)} />
      ) : null}

      <h3 className="bld-head">{B.ballot && B.ballot.on ? "Your planning permission" : "Plots for sale"}</h3>
      {!B.plots.length ? <div className="empty">{B.ballot && B.ballot.on
        ? "You hold no plot at the moment. Win one in the planning ballot and it appears here, yours alone for 48 hours."
        : "Every plot for sale has been taken. More are released on Monday."}</div> : (
        <div className="bld-plots">
          {B.plots.map((p) => (
            <div key={p.id} className={"bld-plot" + (openPlot === p.id ? " is-open" : "") + (p.granted ? " is-granted" : "")}>
              <PlotArt plot={p} />
              <div className="bld-plot-head">
                <b>{p.address}</b>
                <span>{p.district}</span>
              </div>
              {p.granted && (
                <div className="bld-grant">
                  <span className="bld-grant-seal" aria-hidden="true">Granted</span>
                  <span>Yours alone until {ukWhen(p.grantUntil)}: <b>{hoursLeft(p.grantUntil, now)}</b> to survey and break ground.
                    {p.canDefer ? " The Council will hold the plot price for you if you wish." : ""}</span>
                </div>
              )}
              <dl className="bld-facts">
                <div><dt>Frontage</dt><dd>{p.frontage} m</dd></div>
                <div><dt>Footprint</dt><dd>{Math.round(p.area)} m²</dd></div>
                <div><dt>Asking</dt><dd>{fmt(p.price)}</dd></div>
                <div><dt>Can build</dt><dd>{p.fits.map((t) => B.types[t].name).join(", ")}</dd></div>
              </dl>
              {p.report ? (
                <div className="bld-report">
                  <span className={"bld-tier t-" + p.report.tier}>{TIER_WORD[p.report.tier]}</span>
                  <span><b>{p.report.groundLabel}.</b> {p.report.risk}.</span>
                </div>
              ) : (
                <div className="bld-unsurveyed">Ground unsurveyed. The report shows the ground, its risks and the plot's tier.</div>
              )}
              <StreetViewLink meta={p} />
              <div className="bld-plot-acts">
                {!p.surveyed ? (
                  /* THE PRICE IS ON THE SECOND STEP, NOT THE BUTTON. Aaron,
                     26 Sep 2026: the first press asks, the second pays. */
                  surveyAsk === p.id ? (
                    <div className="bld-confirm" role="group" aria-label={`Confirm a survey of ${p.address}`}>
                      <p>A survey of <b>{p.address}</b> costs <b>{fmt(B.surveyCost)}</b>. It reports the ground, its risks and the plot's tier, and only you see it.</p>
                      {me.cash < B.surveyCost ? <p className="bld-short">You hold {fmt(me.cash)}, which is not enough.</p> : null}
                      <div className="bld-confirm-acts">
                        <button className="btn small" disabled={me.cash < B.surveyCost}
                                onClick={() => { setSurveyAsk(null); onAct("build.survey", { plot: p.id }); }}>Pay {fmt(B.surveyCost)} and survey</button>
                        <button className="btn small secondary" onClick={() => setSurveyAsk(null)}>Cancel</button>
                      </div>
                    </div>
                  ) : (
                    <button className="btn small" onClick={() => setSurveyAsk(p.id)}>Commission a survey</button>
                  )
                ) : building ? (
                  <span className="muted-plain">One build at a time. Finish the one you have first.</span>
                ) : (
                  <button className="btn small" onClick={() => setOpenPlot(openPlot === p.id ? null : p.id)}>
                    {openPlot === p.id ? "Close the drawing board" : "Design a house here"}
                  </button>
                )}
              </div>
            </div>
          ))}
        </div>
      )}

      {B.houses.length > 0 && (
        <>
          <h3 className="bld-head">Houses you have built</h3>
          <div className="bld-houses">
            {B.houses.map((h) => {
              const meta = metaFor(h.id);
              return (
                <div key={h.id} className="bld-house">
                  {meta && <button type="button" className="bld-house-art" onClick={() => onOpen(h.id)} aria-label={`Open ${h.name}`}>
                    <PropertyArt meta={meta} height={null} />
                  </button>}
                  <div>
                    <b>{h.name}</b>
                    <div className="muted-plain">Valued at {fmt(h.value)}</div>
                    {h.subsided && (
                      <div className="bld-flaw">
                        It has subsidence and earns 10% less rent until it is underpinned.
                        <button className="btn small" disabled={me.cash < h.underpinCost}
                                onClick={() => onAct("build.underpin", { propertyId: h.id })}>Underpin it · {fmt(h.underpinCost)}</button>
                      </div>
                    )}
                  </div>
                </div>
              );
            })}
          </div>
        </>
      )}
    </div>
  );
}

/* A Council deadline, in York's own time whatever the reader's clock says. */
const ukWhen = (t) => new Date(t).toLocaleString("en-GB", { timeZone: "Europe/London", weekday: "long", hour: "2-digit", minute: "2-digit", hour12: false });
const ukDay = (t) => new Date(t).toLocaleDateString("en-GB", { timeZone: "Europe/London" });
const hoursLeft = (until, now) => {
  const h = Math.max(0, Math.round((until - now) / 3600000));
  return h >= 1 ? `${h} hour${h === 1 ? "" : "s"}` : "under an hour";
};

/* THE PLANNING BALLOT, from the trader's side (lib/ballot.js). Nobody
   applies: playing five days in seven is the entry. What a trader can do
   about it is play, so the card shows the seven days and how many tickets
   they would hold, and says plainly why they might be sitting a draw out. */
function BallotCard({ ballot, now }) {
  const today = ukDay(now) === ukDay(ballot.nextAt);
  const when = `${today ? "today" : new Date(ballot.nextAt).toLocaleDateString("en-GB", { timeZone: "Europe/London", weekday: "long" })} at ${eveningAt(ballot.nextAt)}`;
  const tickets = ballot.tickets;
  const status = ballot.sittingOut === "grant"
    ? "You hold planning permission, so you sit this draw out. Build on your plot, or let it lapse, to go back in."
    : ballot.sittingOut === "build"
      ? "Your house is going up, so you sit the draw out until it is finished."
      : ballot.inDraw
        ? `You are in the draw ${when} with ${tickets === 1 ? "one ticket" : `${tickets} tickets`}.`
        : `Play on ${ballot.need - ballot.days} more day${ballot.need - ballot.days === 1 ? "" : "s"} this week to enter the draw.`;
  const last = ballot.last;
  return (
    <section className="card ballot-card" aria-label="The planning ballot">
      <div className="ballot-head">
        <h3>The planning ballot</h3>
        <span className="ballot-next">Next draw {when}</span>
      </div>
      <div className="ballot-days" role="img" aria-label={`Played on ${ballot.days} of the last 7 days; ${ballot.need} enter the draw`}>
        {Array.from({ length: 7 }, (_, i) => (
          <i key={i} className={i < ballot.days ? "on" : ""} />
        ))}
        <span>{ballot.days} of the last 7 days played · {ballot.need} enter</span>
      </div>
      <p className="ballot-status">{status}</p>
      <p className="bld-note">
        Every trader who plays five days in seven is entered. One ticket each, and up to {["none", "one", "two", "three", "four", "five", "six"][ballot.maxTickets] || ballot.maxTickets} for a
        trader furthest below the median net worth{ballot.below ? ", which you are" : ""}. A winner has {ballot.grantHours} hours
        to survey and break ground{ballot.below ? ", and may leave the plot price with the Council" : ""}.
      </p>
      {ballot.owing > 0 && (
        <p className="ballot-owing">The Council holds {fmt(ballot.owing)} of your plot price, repaid from half your house's rent.</p>
      )}
      {last && last.winners && last.winners.length > 0 && (
        <p className="ballot-last">Last draw: {last.winners.map((w) => `${w.name} in ${w.district}`).join(", ")}.</p>
      )}
    </section>
  );
}

/* PLANNING PERMISSION GRANTED. Aaron, 1 Oct 2026: winning has to feel like
   winning. The notice rises, the address is set, and the Council's seal
   comes down on it; then the trader is taken to the plot or lets it wait.
   It waits for the evening to have been watched, so the two never
   compete, and shows once (ballot.seen). */
function GrantReveal({ plot, onGo, onLater }) {
  const goRef = useRef(null);
  useEffect(() => { const t = setTimeout(() => goRef.current && goRef.current.focus(), 1600); return () => clearTimeout(t); }, []);
  useEffect(() => {
    const key = (e) => { if (e.key === "Escape") onLater(); };
    window.addEventListener("keydown", key);
    return () => window.removeEventListener("keydown", key);
  }, [onLater]);
  return (
    <div className="grant-veil" role="dialog" aria-modal="true" aria-labelledby="grant-title">
      <div className="grant-notice">
        <div className="grant-crest">The Council · Planning ballot</div>
        <h2 id="grant-title" className="grant-title">Planning permission granted</h2>
        <div className="grant-plot"><PlotArt plot={plot} /></div>
        <p className="grant-address"><b>{plot.address}</b><span>{plot.district}</span></p>
        <p className="grant-terms">
          Drawn in the Council's planning ballot. The plot is yours alone for 48 hours: survey it and break ground before
          {" "}{ukWhen(plot.grantUntil)}.
          {plot.canDefer ? ` The Council will hold the ${fmt(plot.price)} plot price for you if you wish.` : ""}
        </p>
        <div className="grant-seal" aria-hidden="true">
          <svg viewBox="0 0 120 120" width="120" height="120">
            <circle cx="60" cy="60" r="54" fill="#F4E7C5" stroke="#6E1423" strokeWidth="5" />
            <circle cx="60" cy="60" r="44" fill="none" stroke="#6E1423" strokeWidth="1.5" strokeDasharray="3 3" />
            <text x="60" y="56" textAnchor="middle" fontFamily="Fraunces, Georgia, serif" fontSize="15" fontWeight="700" fill="#6E1423" letterSpacing="0.5">GRANTED</text>
            <text x="60" y="76" textAnchor="middle" fontFamily="IBM Plex Mono, monospace" fontSize="9" fill="#6E1423" letterSpacing="1.5">THE COUNCIL</text>
          </svg>
        </div>
        <div className="grant-acts">
          <button ref={goRef} className="btn" onClick={onGo}>Go to the plot</button>
          <button className="btn secondary" onClick={onLater}>Later</button>
        </div>
      </div>
    </div>
  );
}

function DrawingBoard({ B, plot, me, onAct, onClose }) {
  const [d, setD] = useState(() => ({
    type: plot.fits[0], brick: "red", door: "forest", roof: "slate", windows: "casement",
    spec: [], insure: false, name: "", defer: false,
  }));
  const set = (k, v) => setD((x) => ({ ...x, [k]: v }));
  const toggleSpec = (k) => setD((x) => {
    const has = x.spec.includes(k);
    if (has) return { ...x, spec: x.spec.filter((s) => s !== k) };
    if (x.spec.length >= B.maxSpec) return x;
    return { ...x, spec: x.spec.concat(k) };
  });
  const q = builderQuote(B, plot, d);
  /* AFFORDABLE BUILD: the Council holds the plot price for a ballot winner
     below the median, repaid from half the finished house's rent. */
  const due = q ? q.total - (d.defer && plot.canDefer ? q.plot : 0) : 0;
  const art = useMemo(() => builtArt(d, 4), [d.type, d.brick, d.door, d.roof, d.windows, d.spec.join()]);
  const rate = plot.report ? plot.report.insuranceRate : 0;
  const chip = (k, v, cost) => (
    <button key={v} type="button" className={"bld-opt" + (d[k] === v ? " on" : "")} onClick={() => set(k, v)} aria-pressed={d[k] === v}>
      {BLD_SWATCH[k] && <span className="bld-sw" style={{ background: BLD_SWATCH[k][v] }} />}
      {BLD_LABEL[k][v]}{cost ? <em>+{fmt(cost)}</em> : null}
    </button>
  );
  return (
    <section className="card bld-board" aria-label={`Drawing board for ${plot.address}`}>
      <header className="bld-board-head">
        <div><h3>{plot.address}</h3><span>{plot.district} · {plot.frontage} m frontage · {plot.report ? plot.report.groundLabel : ""}</span></div>
        <button className="btn small secondary" onClick={onClose}>Close</button>
      </header>
      <div className="bld-board-grid">
        <div className="bld-controls">
          <div className="bld-group"><h4>Build type, limited by the frontage</h4>
            <div className="bld-opts">
              {Object.keys(B.types).map((t) => {
                const fits = plot.fits.includes(t);
                return (
                  <button key={t} type="button" disabled={!fits} className={"bld-opt" + (d.type === t ? " on" : "")}
                          title={fits ? "" : "The frontage will not take it"} onClick={() => set("type", t)} aria-pressed={d.type === t}>
                    {B.types[t].name}{fits ? <em>{fmt(plot.buildCosts[t])}</em> : <em>Too {plot.frontage < B.types[t].minFront ? "narrow" : "wide"}</em>}
                  </button>
                );
              })}
            </div>
          </div>
          <div className="bld-group"><h4>Brick, finish only</h4><div className="bld-opts">{Object.keys(B.finish.brick).map((v) => chip("brick", v))}</div></div>
          <div className="bld-group"><h4>Door, finish only</h4><div className="bld-opts">{Object.keys(B.finish.door).map((v) => chip("door", v))}</div></div>
          <div className="bld-group"><h4>Roof, finish only</h4><div className="bld-opts">{Object.keys(B.finish.roof).map((v) => chip("roof", v, B.finish.roof[v]))}</div></div>
          <div className="bld-group"><h4>Windows, finish only</h4><div className="bld-opts">{Object.keys(B.finish.windows).map((v) => chip("windows", v, B.finish.windows[v]))}</div></div>
          <div className="bld-group"><h4>Specification: up to {B.maxSpec}, each carries an ability</h4>
            <div className="bld-specs">
              {Object.entries(B.spec).map(([k, s]) => {
                const on = d.spec.includes(k);
                const full = !on && d.spec.length >= B.maxSpec;
                return (
                  <button key={k} type="button" className={"bld-spec" + (on ? " on" : "")} disabled={full}
                          aria-pressed={on} onClick={() => toggleSpec(k)}>
                    <span className="bld-box" aria-hidden="true" />
                    <span><b>{s.name}</b><span>{s.text}</span></span>
                    <em>{fmt(s.cost)}</em>
                  </button>
                );
              })}
            </div>
            <p className="bld-note">
              Two items lift the finished house one tier, and a house with two abilities pays
              12% of its rent in overheads rather than 10%.
            </p>
          </div>
          <div className="bld-group"><h4>Insurance</h4>
            {rate > 0 ? (
              <button type="button" className={"bld-spec" + (d.insure ? " on" : "")} aria-pressed={d.insure} onClick={() => set("insure", !d.insure)}>
                <span className="bld-box" aria-hidden="true" />
                <span><b>Underwrite the build</b><span>{plot.report.risk}. If the ground moves, the underwriter pays to put it right and it costs an evening, not the house.</span></span>
                <em>{fmt(q.insurance || Math.round(((plot.price + (plot.buildCosts[d.type] || 0)) * rate) / 100) * 100)}</em>
              </button>
            ) : <p className="bld-note">Firm gravel. No underwriter will take the premium, and you do not need one.</p>}
          </div>
          {plot.canDefer && (
            <div className="bld-group"><h4>The plot price</h4>
              <button type="button" className={"bld-spec" + (d.defer ? " on" : "")} aria-pressed={d.defer} onClick={() => set("defer", !d.defer)}>
                <span className="bld-box" aria-hidden="true" />
                <span><b>Let the Council hold it</b><span>Pay nothing for the plot now. Half the finished house's rent goes to the Council until the {fmt(plot.price)} is repaid. Sell the house first and the balance comes out of the sale.</span></span>
                <em>{fmt(plot.price)}</em>
              </button>
            </div>
          )}
          <div className="bld-group"><h4>Name, optional</h4>
            <input id="bld-name" className="bld-name" maxLength={24} placeholder={`${plot.address} ${B.types[d.type].name}`}
                   value={d.name} onChange={(e) => set("name", e.target.value.replace(/[^A-Za-z' ]/g, ""))} />
            <p className="bld-note">Letters, spaces and an apostrophe, up to three words. Names are checked before anyone else sees them; leave it blank and the house takes its street.</p>
          </div>
        </div>
        <div className="bld-preview">
          <svg viewBox="0 0 300 150" width="100%" className="art" role="img" aria-label="Your design"
               dangerouslySetInnerHTML={{ __html: art }} />
          {q && (
            <table className="bld-bill"><tbody>
              <tr><th>The plot</th><td>{d.defer && plot.canDefer ? "Held by the Council" : fmt(q.plot)}</td></tr>
              <tr><th>Building it</th><td>{fmt(q.build)}</td></tr>
              {q.finish > 0 && <tr><th>Roof and windows</th><td>{fmt(q.finish)}</td></tr>}
              {q.spec > 0 && <tr><th>Specification</th><td>{fmt(q.spec)}</td></tr>}
              {q.insurance > 0 && <tr><th>Insurance</th><td>{fmt(q.insurance)}</td></tr>}
              <tr className="bld-total"><th>Paid now</th><td>{fmt(due)}</td></tr>
              <tr><th>Valued when finished, about</th><td>{fmt(q.value)} · {TIER_WORD[q.tier]}</td></tr>
            </tbody></table>
          )}
          <p className="bld-note">
            Four stages, one an evening at {eveningAt()}. Every build takes at least one knock, and the survey says what this ground can do.
          </p>
          <button className="btn" disabled={!q || me.cash < due}
                  onClick={() => onAct("build.start", { plot: plot.id, ...d, defer: !!(d.defer && plot.canDefer) })}>
            Break ground · {fmt(due)}
          </button>
        </div>
      </div>
    </section>
  );
}

/* HOUSES GOING UP. Aaron, 27 Sep 2026: a build under way shows on the
   builder's portfolio and on their profile, where anyone can see it. Drawn
   from the public player list, so the two can never disagree. */
/* Aaron, 28 Sep 2026: your own build opens the Build screen, where its
   stages and the next 19:00 are. Somebody else's is not a link: the Build
   screen only ever shows the build that is yours. */
function BuildsUnderway({ builds, own, onGo }) {
  if (!builds || !builds.length) return null;
  const go = own && onGo ? onGo : null;
  return (
    <section className="bld-under" aria-label={own ? "Your builds under way" : "Builds under way"}>
      <h4 className="bld-under-h">{own ? "Your build under way" : "Building now"}{builds.length > 1 ? "s" : ""}</h4>
      {builds.map((b, i) => go
        ? <button type="button" key={b.address + i} className="bld-under-link" onClick={go}
                  title="Open this build on the Build screen">
            <BuildUnderwayRow b={b} link />
          </button>
        : <BuildUnderwayRow key={b.address + i} b={b} />)}
    </section>
  );
}

function BuildUnderwayRow({ b, link = false }) {
  const art = useMemo(() => builtArt(b.design, Math.min(4, b.stage)), [b.address, b.stage]);
  const pct = Math.round((Math.min(b.stage, b.stages) / b.stages) * 100);
  return (
    <div className="bld-under-row">
      <svg viewBox="0 0 300 150" className="bld-under-art" role="img" aria-label={`${b.typeName} at stage ${b.stage + 1} of ${b.stages}`}
           dangerouslySetInnerHTML={{ __html: art }} />
      <div className="bld-under-body">
        <b>{b.name || b.address}</b>
        <span className="muted-plain">{b.name ? `${b.address}, ` : ""}{b.district} · {b.typeName}</span>
        <div className="bld-under-bar" role="progressbar" aria-valuemin={0} aria-valuemax={b.stages} aria-valuenow={b.stage}>
          <i style={{ width: pct + "%" }} />
        </div>
        <span className="bld-under-stage">{b.stageName} under way, stage {Math.min(b.stage + 1, b.stages)} of {b.stages}</span>
        {link && <span className="bld-under-go">See it on the Build screen ›</span>}
      </div>
    </div>
  );
}

function BuildProgress({ b, now }) {
  const art = useMemo(() => builtArt(b.design, Math.min(4, b.stage)), [b.id, b.stage]);
  return (
    <section className="card bld-progress" aria-label="Your build">
      <header className="bld-board-head">
        <div><h3>{b.design.name || b.address}</h3><span>{b.address}, {b.district}</span></div>
        <span className="bld-next">Next stage at {eveningAt(b.nextAt)} · <CountdownTo at={b.nextAt} /></span>
      </header>
      <div className="bld-board-grid">
        <svg viewBox="0 0 300 150" width="100%" className="art" role="img" aria-label={`The build at stage ${b.stage} of 4`}
             dangerouslySetInnerHTML={{ __html: art }} />
        <div>
          <ol className="bld-stages">
            {b.stages.map((s, i) => (
              <li key={s} className={i < b.stage ? "done" : i === b.stage ? "now" : ""}>{s}</li>
            ))}
          </ol>
          <div className="muted-plain">Spent so far {fmt(b.spent)}{b.insured ? ", insured" : ""}.</div>
          {b.subsided && <div className="bld-flaw">The ground moved. The house will have subsidence until it is underpinned.</div>}
          {b.find && <div className="bld-find">A medieval floor was found in the trench. It is recorded on the deed.</div>}
        </div>
      </div>
      <h4 className="bld-diary-head">Site diary</h4>
      <ul className="bld-diary">
        {b.diary.map((e, i) => <li key={i}><time>{fmtDate(e.at)}</time>{e.text}</li>)}
      </ul>
    </section>
  );
}

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

/* BANK / STOCKS (1 Oct 2026). The bank as it was, and beside it the share
   market (lib/shares.js): every listed building, its price moving between
   17:30 and 20:30, and a trader's own shares, orders, floats and rates. */
function BankTab({ game, me, now, onAct, onOpen }) {
  const [pane, setPane] = useState(() => {
    try { return sessionStorage.getItem("ukpx.bankPane") || "bank"; } catch { return "bank"; }
  });
  const pick = (p) => { setPane(p); try { sessionStorage.setItem("ukpx.bankPane", p); } catch {} };
  if (!game.stocks) return <BankPane game={game} me={me} now={now} onAct={onAct} />;
  return (
    <div>
      <div className="bank-switch" role="tablist" aria-label="Bank or stocks">
        <button role="tab" aria-selected={pane === "bank"} className={"map-switch-btn" + (pane === "bank" ? " active" : "")} onClick={() => pick("bank")}>Bank</button>
        <button role="tab" aria-selected={pane === "stocks"} className={"map-switch-btn" + (pane === "stocks" ? " active" : "")} onClick={() => pick("stocks")}>
          Stocks{game.stocks.open ? <span className="stk-live" role="img" aria-label="market open" title="The market is open" /> : null}
        </button>
      </div>
      {pane === "stocks"
        ? <StocksPane game={game} me={me} now={now} onAct={onAct} onOpen={onOpen} />
        : <BankPane game={game} me={me} now={now} onAct={onAct} />}
    </div>
  );
}

const pctChange = (a, b) => (b > 0 ? ((a - b) / b) * 100 : 0);
const fmtShare = (n) => "£" + Math.round(n || 0).toLocaleString("en-GB");
const untilText = (t, now) => {
  const m = Math.max(0, Math.round((t - now) / 60000));
  return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m`;
};
const hms = (ms) => {
  const s = Math.max(0, Math.floor(ms / 1000));
  const p = (n) => String(n).padStart(2, "0");
  return `${p(Math.floor(s / 3600))}:${p(Math.floor((s % 3600) / 60))}:${p(s % 60)}`;
};
const stillPage = () => typeof window !== "undefined" && window.matchMedia
  && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const arrow = (x) => (x >= 0 ? "▲" : "▼");

/* A clock that moves every second, for the bell countdowns. */
function useSecond() {
  const [t, setT] = useState(Date.now());
  useEffect(() => {
    const id = setInterval(() => setT(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);
  return t;
}

/* THE PAGE'S COPY OF THE EXCHANGE'S ARITHMETIC (lib/shares.js execute()).
   The same curves, spread and stamp duty, so what the ticket promises is
   what the market does, and an order that would trip the circuit breaker
   says so before it is placed. */
function previewOrder(r, S, side, n) {
  const F = Math.max(1, r.floated);
  let after = side === "buy"
    ? r.price * (1 + (S.buyImpact * Math.pow(n, 0.85)) / F)
    : r.price * Math.max(0.05, 1 - (S.sellImpact * Math.pow(n, 1.15) * (1 + (r.sold || 0) / F)) / F);
  after = Math.min(r.ceiling, Math.max(r.floor, after));
  const each = ((r.price + after) / 2) * (side === "buy" ? 1 + S.spread : 1 - S.spread);
  const gross = each * n;
  const stamp = gross * S.stamp;
  return {
    after, each, total: side === "buy" ? gross + stamp : gross - stamp,
    move: (after / r.price - 1) * 100,
    halts: !!S.open && Math.abs(after / (r.ref || r.price) - 1) >= (S.breaker || 0.2),
  };
}

/* A PRICE THAT MOVES WHEN IT MOVES. When a trade lands the number counts
   to its new value and the cell flashes green or red, once. Nothing moves
   while the price is still, and nothing at all for a reader who asked for
   stillness. */
function LivePrice({ value, className = "" }) {
  const prev = useRef(value);
  const [shown, setShown] = useState(value);
  const [flash, setFlash] = useState(null);
  useEffect(() => {
    const from = prev.current;
    prev.current = value;
    if (from === value) return;
    setFlash({ dir: value > from ? "up" : "down", n: Date.now() });
    if (stillPage()) { setShown(value); return; }
    let raf = 0;
    const t0 = performance.now();
    const step = (t) => {
      const k = Math.min(1, (t - t0) / 600);
      const e = 1 - Math.pow(1 - k, 3);
      setShown(from + (value - from) * e);
      if (k < 1) raf = requestAnimationFrame(step);
    };
    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [value]);
  return (
    <span key={flash ? flash.n : 0} className={"live-price " + className + (flash ? ` flash-${flash.dir}` : "")}>
      {fmtShare(shown)}
    </span>
  );
}

/* THE BOARD. The bell is the drama of a three hour market, so the time to
   it is the biggest thing on the page and it counts in seconds. */
function MarketBoard({ S, list }) {
  const now = useSecond();
  const [o, c] = S.hours || ["17:30", "20:30"];
  const left = S.open ? S.closesAt - now : S.opensAt - now;
  const span = S.spanMs || 3 * 3600000;
  const done = S.open ? Math.min(1, Math.max(0, 1 - left / span)) : 0;
  const moves = list.map((r) => pctChange(r.price, r.prevClose));
  const index = moves.length ? moves.reduce((a, b) => a + b, 0) / moves.length : 0;
  return (
    <section className={"mkt-board" + (S.open ? " is-open" : "")} aria-label="The share market">
      <div className="mkt-state">
        <i className="mkt-lamp" aria-hidden="true" />
        <div>
          <b>{S.open ? "Market open" : "Market shut"}</b>
          <span>Trading {o} to {c} UK time</span>
        </div>
      </div>
      <div className="mkt-clock" role="timer" aria-live="off">
        <span>{S.open ? "Closing bell in" : "Opening bell in"}</span>
        <b>{hms(left)}</b>
      </div>
      <div className="mkt-index">
        <span>York shares today</span>
        <b className={index >= 0 ? "up" : "down"}>{arrow(index)} {Math.abs(index).toFixed(1)}%</b>
        <span>{list.length} listed</span>
      </div>
      <div className="mkt-session" aria-hidden="true">
        <i style={{ width: `${done * 100}%` }} />
        <span>{o}</span><span>{c}</span>
      </div>
      {S.open
        ? <p className="mkt-note">The evening lands at {eveningAt()} while the market trades: a listed building that is hit drops at once, one that holds an attack off rises.</p>
        : <p className="mkt-note">Orders placed now are filled together at the opening price at {o}.</p>}
    </section>
  );
}

/* THE TAPE. Every listed share, scrolling, as it would above a trading
   floor. It is repeated until it is wider than any screen so the loop has
   no join, and stands still for a reader who asked for stillness. */
function StockTicker({ list }) {
  if (!list.length) return null;
  const one = list.map((r) => {
    const m = metaFor(r.id);
    const ch = pctChange(r.price, r.prevClose);
    return { id: r.id, name: m ? m.name : r.id, price: r.price, ch, halted: r.halted };
  });
  const reps = Math.max(1, Math.ceil(8 / one.length));
  const half = Array.from({ length: reps }, () => one).flat();
  const item = (x, i, hidden) => (
    <span key={(hidden ? "b" : "a") + i} className="tape-item" aria-hidden={hidden || undefined}>
      <b>{x.name}</b> {fmtShare(x.price)}{" "}
      <em className={x.halted ? "halt" : x.ch >= 0 ? "up" : "down"}>{x.halted ? "HALTED" : `${arrow(x.ch)} ${Math.abs(x.ch).toFixed(1)}%`}</em>
    </span>
  );
  return (
    <div className="stk-tape" role="marquee" aria-label="Share prices">
      <div className="stk-tape-track" style={{ animationDuration: `${Math.max(30, half.length * 6)}s` }}>
        {half.map((x, i) => item(x, i, false))}
        {half.map((x, i) => item(x, i, true))}
      </div>
    </div>
  );
}

/* Today's three stories: the biggest rise, the biggest fall, the best
   paying share. */
function Movers({ list, onPick }) {
  if (list.length < 2) return null;
  const ch = (r) => pctChange(r.price, r.prevClose);
  const up = list.slice().sort((a, b) => ch(b) - ch(a))[0];
  const down = list.slice().sort((a, b) => ch(a) - ch(b))[0];
  const best = list.slice().sort((a, b) => b.yield7 - a.yield7)[0];
  const tile = (label, r, value, cls) => {
    const m = metaFor(r.id);
    return (
      <button key={label} className={"mover " + cls} onClick={() => onPick(r.id)}>
        <span>{label}</span>
        <b>{m ? m.name : r.id}</b>
        <em>{value}</em>
      </button>
    );
  };
  return (
    <div className="movers">
      {tile("Biggest riser", up, `${arrow(ch(up))} ${Math.abs(ch(up)).toFixed(1)}%`, ch(up) >= 0 ? "up" : "down")}
      {tile("Biggest faller", down, `${arrow(ch(down))} ${Math.abs(ch(down)).toFixed(1)}%`, ch(down) >= 0 ? "up" : "down")}
      {tile("Best yield", best, `${best.yield7.toFixed(1)}%`, "pay")}
    </div>
  );
}

function StocksPane({ game, me, now, onAct, onOpen }) {
  const S = game.stocks;
  const mine = me.shares || {};
  const [openId, setOpenId] = useState(null);
  const held = new Map((mine.holdings || []).map((h) => [h.id, h]));
  const list = S.list || [];
  const pick = (id) => {
    setOpenId(id);
    setTimeout(() => { const el = document.getElementById(`stk-${id}`); if (el) el.scrollIntoView({ behavior: stillPage() ? "auto" : "smooth", block: "start" }); }, 60);
  };
  return (
    <div className="stk">
      <MarketBoard S={S} list={list} />
      <StockTicker list={list} />

      {mine.rates && mine.rates.due && (
        <p className="stk-note">Your rates are {mine.rates.afterBank}% of the rent you keep. What you have paid, and the ways to pay less, are in the Bank.</p>
      )}

      {!mine.canTrade && (
        <p className="stk-note">Buying and selling shares needs a bank account. A deposit account opens with its balance moved, not spent.</p>
      )}

      <Movers list={list} onPick={pick} />

      <h3 className="stk-h">Listed buildings</h3>
      {!list.length ? (
        <div className="empty">
          No building is listed yet. An owner with a credit line can offer 25 to 49 of a building's 100 shares for sale,
          and is paid as they sell, from {(S.hours || [])[0]} to {(S.hours || [])[1]}.
        </div>
      ) : (
        <div className="stk-list">
          {list.map((r) => {
            const m = metaFor(r.id);
            if (!m) return null;
            const ch = pctChange(r.price, r.prevClose);
            const isOpen = openId === r.id;
            const h = held.get(r.id);
            return (
              <div key={r.id} id={`stk-${r.id}`} className={"stk-row" + (isOpen ? " is-open" : "") + (r.halted ? " is-halted" : "")}>
                <button className="stk-line" onClick={() => setOpenId(isOpen ? null : r.id)} aria-expanded={isOpen}>
                  <span className="stk-name">
                    <b>{m.name}</b>
                    <span>{m.neighbourhoodName} · {(RARITIES[m.rarity] && RARITIES[m.rarity].label) || m.rarity}</span>
                    {h ? <span className="stk-mine">You hold {h.shares}</span> : null}
                    {r.offer ? <span className="stk-offer">Offer · {r.offer.left} for sale</span> : null}
                  </span>
                  <span className="stk-spark">
                    <Sparkline history={r.spark.length ? r.spark : [r.price]} color={ch >= 0 ? "var(--gain)" : "var(--loss)"} width={110} height={32} fill />
                  </span>
                  <span className="stk-price">
                    <LivePrice value={r.price} />
                    <em className={"stk-pill " + (ch >= 0 ? "up" : "down")}>{arrow(ch)} {Math.abs(ch).toFixed(1)}%</em>
                  </span>
                  <span className="stk-yield"><span>Yield 7d</span>{r.yield7.toFixed(1)}%</span>
                  {r.halted ? <span className="stk-halt">Halted · trading resumes at {eveningAt(r.halted)}</span> : null}
                </button>
                {isOpen && <Prospectus r={r} m={m} game={game} me={me} mine={mine} market={S} onAct={onAct} onOpen={onOpen} />}
              </div>
            );
          })}
        </div>
      )}

      {(mine.orders || []).length > 0 && (
        <>
          <h3 className="stk-h">Orders for the opening bell</h3>
          <ul className="stk-orders">
            {mine.orders.map((o) => {
              const m = metaFor(o.propertyId);
              return (
                <li key={o.id}>
                  <span className={"stk-side " + o.side}>{o.side === "buy" ? "Buy" : "Sell"}</span>
                  <span>{o.qty} share{o.qty === 1 ? "" : "s"} in {m ? m.name : o.propertyId}</span>
                  <button className="btn small secondary" onClick={() => onAct("shares.cancel", { id: o.id })}>Cancel</button>
                </li>
              );
            })}
          </ul>
        </>
      )}

      <Holdings mine={mine} list={list} onPick={pick} />

      <YourFloats mine={mine} onAct={onAct} />

      <FloatBox mine={mine} market={S} rates={mine.rates} onAct={onAct} />
    </div>
  );
}

/* YOUR SHARES as a position: what they cost, what they are worth, and the
   difference in the colour it deserves. */
function Holdings({ mine, list, onPick }) {
  const hs = mine.holdings || [];
  if (!hs.length) return null;
  const cost = hs.reduce((a, h) => a + h.cost, 0);
  const worth = hs.reduce((a, h) => a + h.value, 0);
  const pl = worth - cost;
  return (
    <section className="card stk-port" aria-label="Your shares">
      <div className="stk-port-head">
        <h3>Your shares</h3>
        <div><b>{fmt(worth)}</b><em className={pl >= 0 ? "up" : "down"}>{pl >= 0 ? "+" : "−"}{fmt(Math.abs(pl))} ({cost ? ((pl / cost) * 100).toFixed(1) : "0.0"}%)</em></div>
      </div>
      <div className="stk-port-rows">
        {hs.map((h) => {
          const m = metaFor(h.id);
          const p = h.value - h.cost;
          const pct = h.cost ? (p / h.cost) * 100 : 0;
          return (
            <button key={h.id} className="stk-port-row" onClick={() => onPick(h.id)}>
              <span><b>{m ? m.name : h.id}</b><span>{h.shares} share{h.shares === 1 ? "" : "s"} · cost {fmt(h.cost)}</span></span>
              <span className="stk-port-bar" aria-hidden="true"><i className={p >= 0 ? "up" : "down"} style={{ width: `${Math.min(100, Math.abs(pct) * 2)}%` }} /></span>
              <span className={"stk-port-pl " + (p >= 0 ? "up" : "down")}>{fmt(h.value)}<em>{p >= 0 ? "+" : "−"}{Math.abs(pct).toFixed(1)}%</em></span>
            </button>
          );
        })}
      </div>
      <p className="stk-note">Worth is counted cautiously: the lower of the price and its 24 hour average. Dividends so far: {fmt(mine.dividends || 0)}.</p>
    </section>
  );
}

/* THE SESSION CHART. The last day against time, with this session shaded
   and the price it opened at dashed across it. While the market is open
   the circuit breaker's two limits are drawn across the session, where the
   price is near enough to them to matter, and the live end of the line is
   marked. Earlier hours are yesterday's market: they are drawn, and never
   measured against today's limits. */
function SessionChart({ r, S }) {
  const W = 560, H = 170, P = 10, R = 74;
  const now = Date.now();
  let pts = r.spark.length ? r.spark : [r.price];
  let ts = r.sparkT && r.sparkT.length === pts.length ? r.sparkT : pts.map((_, i) => now - (pts.length - 1 - i) * 1800000);
  const start = S.open ? S.closesAt - (S.spanMs || 3 * 3600000) : null;
  /* Open, the chart is the session and the two hours before it, so the
     session fills the picture as it runs; shut, the whole last day. */
  let t0 = Math.min(ts[0], now - 3600000);
  if (start) {
    t0 = start - 2 * 3600000;
    const inside = ts.map((t, i) => i).filter((i) => ts[i] >= t0);
    const before = ts.map((t, i) => i).filter((i) => ts[i] < t0).pop();
    const np = inside.map((i) => pts[i]), nt = inside.map((i) => ts[i]);
    /* The line enters the window where it really was at that moment. */
    if (before !== undefined) {
      const nx = inside.length ? inside[0] : null;
      const v = nx === null ? pts[before] : pts[before] + (pts[nx] - pts[before]) * ((t0 - ts[before]) / Math.max(1, ts[nx] - ts[before]));
      np.unshift(v); nt.unshift(t0);
    }
    if (np.length) { pts = np; ts = nt; }
  }
  const t1 = now;
  const ref = r.ref || pts[pts.length - 1];
  const lim = S.open ? [ref * (1 - (S.breaker || 0.2)), ref * (1 + (S.breaker || 0.2))] : [];
  let lo = Math.min(...pts, ref), hi = Math.max(...pts, ref);
  const shown = lim.filter((v) => v > lo - (hi - lo) * 0.6 && v < hi + (hi - lo) * 0.6);
  lo = Math.min(lo, ...shown); hi = Math.max(hi, ...shown);
  const padY = (hi - lo) * 0.08 || ref * 0.02;
  lo -= padY; hi += padY;
  const x = (t) => P + ((t - t0) / Math.max(1, t1 - t0)) * (W - P - R);
  const y = (v) => P + (1 - (v - lo) / (hi - lo)) * (H - 2 * P);
  const xy = pts.map((v, i) => [x(ts[i]), y(v)]);
  xy.push([x(now), y(r.price)]);
  const line = xy.map(([a, b]) => `${a.toFixed(1)},${b.toFixed(1)}`).join(" ");
  const up = r.price >= ref;
  const col = up ? "var(--gain)" : "var(--loss)";
  const sx = start ? Math.max(P, x(start)) : null;
  return (
    <svg className="stk-chart" viewBox={`0 0 ${W} ${H}`} role="img"
         aria-label={`Price over the last day, from ${fmtShare(pts[0])} to ${fmtShare(r.price)}, against an open of ${fmtShare(ref)}`}>
      {sx !== null && (
        <g>
          <rect x={sx} y={P / 2} width={Math.max(0, x(now) - sx)} height={H - P} fill="currentColor" opacity="0.06" />
          <text x={sx + 4} y={P + 10} fontSize="10.5" fill="currentColor" opacity="0.7">{x(now) - sx > 70 ? "This session" : ""}</text>
        </g>
      )}
      <polygon points={`${xy[0][0]},${y(ref)} ${line} ${xy[xy.length - 1][0]},${y(ref)}`} fill={col} opacity="0.13" />
      <line x1={P} x2={W - R} y1={y(ref)} y2={y(ref)} stroke="currentColor" strokeOpacity="0.45" strokeDasharray="4 4" />
      <text x={W - R + 6} y={y(ref) + 4} fontSize="11" fill="currentColor" opacity="0.75">Open {fmtShare(ref)}</text>
      {shown.map((v) => (
        <g key={v}>
          <line x1={sx} x2={W - R} y1={y(v)} y2={y(v)} stroke="var(--gold)" strokeOpacity="0.85" strokeDasharray="1 4" strokeWidth="2" strokeLinecap="round" />
          <text x={W - R + 6} y={y(v) + 4} fontSize="11" fill="var(--gold)">Halt {fmtShare(v)}</text>
        </g>
      ))}
      <polyline points={line} fill="none" stroke={col} strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
      <circle className="stk-pulse" cx={x(now)} cy={y(r.price)} r="9" fill={col} />
      <circle cx={x(now)} cy={y(r.price)} r="4" fill={col} />
    </svg>
  );
}

/* THE SHARE PRICE OVER DAYS AND WEEKS (Aaron, 1 Oct 2026: "the shares have
   to show previous pricing like the portfolio property pricing shows").
   The same ranges and the same reading as a building's PriceTrack: what you
   paid on average and where it stands now, then the line over a day, a
   week, a month, or the whole time you have held the shares (or since the
   building was floated, if you hold none). Asked for when the share is
   opened, and again every ten minutes while it stays open. */
function SharePriceTrack({ r }) {
  const [data, setData] = useState(null);
  const [range, setRange] = useState("week");
  useEffect(() => {
    let live = true;
    const load = () => api(`/api/shares/history?id=${encodeURIComponent(r.id)}`).then((d) => { if (live) setData(d); }).catch(() => {});
    load();
    const t = setInterval(load, 10 * 60000);
    return () => { live = false; clearInterval(t); };
  }, [r.id]);
  const now = Date.now();
  const mine = data && data.mine;
  const ranges = [["day", "Day", 24 * 3600000], ["week", "Week", 7 * 24 * 3600000], ["month", "Month", 30 * 24 * 3600000],
    ["held", mine ? "Since you bought" : "Since it floated", null]];
  const span = ranges.find((x) => x[0] === range);
  const series = ((data && data.series) || []).concat([{ t: now, p: r.price }]);
  const from = span[2] ? now - span[2] : (mine && mine.since) || (data && data.listedAt) || 0;
  const inRange = series.filter((x) => x.t >= from);
  const first = inRange.length ? inRange[0] : null;
  const base = range === "held" && mine ? mine.avg : first && first.p;
  const change = base > 0 ? ((r.price - base) / base) * 100 : 0;
  const covers = range === "held" || (data && series.length > 1 && series[0].t <= from + 3600000);
  const tone = (x) => (x >= 0 ? "chg-up" : "chg-down");
  const gain = mine ? (r.price - mine.avg) * mine.shares : null;
  return (
    <div className="price-track stk-track">
      <div className="price-track-paid">
        {mine ? (
          <>
            <span>You hold {mine.shares} at an average of {fmtShare(mine.avg)}{mine.since ? `, since ${shortDate(mine.since)}` : ""}</span>
            <b className={tone(gain)}>{gain >= 0 ? "+" : "\u2212"}{fmt(Math.abs(gain))} ({fmtPct(((r.price - mine.avg) / mine.avg) * 100)})</b>
          </>
        ) : <span>{data ? `Floated on ${shortDate(data.listedAt)}` : "Reading the record\u2026"}</span>}
      </div>
      <div className="price-track-ranges" role="tablist" aria-label="Share price history">
        {ranges.map(([k, label]) => (
          <button key={k} type="button" role="tab" aria-selected={range === k}
                  className={"price-range" + (range === k ? " on" : "")} onClick={() => setRange(k)}>{label}</button>
        ))}
      </div>
      <Sparkline history={inRange.map((x) => x.p)} color={change >= 0 ? "#2F6B45" : "#B0342A"} width={320} height={56} fill />
      <div className="price-track-foot">
        {covers
          ? <span className={tone(change)}>{fmtPct(change)} over the {range === "held" ? (mine ? "time you have held them" : "time since it floated") : span[1].toLowerCase()}</span>
          : <span className="muted-plain">This share has not been listed for a whole {span[1].toLowerCase()} yet.</span>}
      </div>
    </div>
  );
}

/* THE PROSPECTUS: what a buyer can know about a building before putting
   money in it. Shields stay secret, as everywhere. */
function Prospectus({ r, m, game, me, mine, market, onAct, onOpen }) {
  const prop = game.properties[r.id] || {};
  const owner = prop.owner ? game.players[prop.owner] : null;
  const others = (market.list || []).filter((x) => x.id !== r.id && (game.properties[x.id] || {}).owner === prop.owner).length;
  const range = Math.max(1, r.ceiling - r.floor);
  return (
    <div className="stk-pro">
      <div className="stk-pro-grid">
        <div className="stk-pro-chart">
          <SessionChart r={r} S={market} />
          <SharePriceTrack r={r} />
          <div className="stk-band" aria-label={`Can fall to ${fmtShare(r.floor)} and rise to ${fmtShare(r.ceiling)}`}>
            <i style={{ left: `${Math.min(100, Math.max(0, ((r.price - r.floor) / range) * 100))}%` }} />
            <span>Floor {fmtShare(r.floor)}</span><span>Deed {fmtShare(r.deed)}</span><span>Ceiling {fmtShare(r.ceiling)}</span>
          </div>
          <dl className="stk-facts">
            <div><dt>Rent a share, 7 days</dt><dd>{fmt(r.rent7)}</dd></div>
            <div><dt>Rent a share, 30 days</dt><dd>{fmt(r.rent30)}</dd></div>
            <div><dt>Paid rent, last 3 days</dt><dd>{r.uptime}% of hours</dd></div>
            <div><dt>Attacks, 7 days</dt><dd>{r.attacks7} ({r.landed7} landed)</dd></div>
            <div><dt>Security · improvements</dt><dd>{prop.securityLevel || 0} · {prop.improvementLevel || 0}</dd></div>
            <div><dt>{r.offer ? "Offered · still for sale" : "Floated · with the Exchange"}</dt><dd>{r.floated} · {r.available}</dd></div>
            <div><dt>Owner</dt><dd>{owner ? <TraderName player={owner} size={13} /> : "Nobody"}{others ? ` · ${others} other float${others === 1 ? "" : "s"}` : ""}</dd></div>
            <div><dt>Open · last close</dt><dd>{fmtShare(r.open)} · {fmtShare(r.prevClose)}</dd></div>
            <div><dt>Since it listed · 7 days</dt><dd>{signedPct(r.sinceList)} · {signedPct(r.week)}</dd></div>
            {(me.analyticsLevel || 0) >= 1 && <div><dt>Traded this session</dt><dd>{r.volume} shares</dd></div>}
          </dl>
        </div>
        <TradeTicket r={r} m={m} S={market} me={me} mine={mine} onAct={onAct} onOpen={onOpen} />
      </div>
    </div>
  );
}

/* THE TRADE TICKET. Buy or sell, how many, and before anything is pressed:
   where the price goes, what it costs, and whether the order would halt
   the share. When it fills, the slip is stamped. */
function TradeTicket({ r, m, S, me, mine, onAct, onOpen }) {
  const [side, setSide] = useState("buy");
  const [qty, setQty] = useState(1);
  const [slip, setSlip] = useState(null);
  const [busy, setBusy] = useState(false);
  const h = (mine.holdings || []).find((x) => x.id === r.id);
  const heldN = h ? h.shares : 0;
  useEffect(() => {
    if (!slip) return;
    const t = setTimeout(() => setSlip(null), 3600);
    return () => clearTimeout(t);
  }, [slip]);
  let maxN = 0;
  if (side === "sell") maxN = heldN;
  else {
    const cap = S.open ? r.available : 100;
    for (let n = cap; n >= 1; n--) { if (previewOrder(r, S, "buy", n).total <= (me.cash || 0)) { maxN = n; break; } }
  }
  const n = Math.max(1, Math.min(Math.max(1, maxN), Math.floor(Number(qty) || 1)));
  const pv = previewOrder(r, S, side, n);
  const blocked = !mine.canTrade || !!r.halted || maxN < 1 || (side === "buy" && S.open && r.available < 1);
  const why = !mine.canTrade ? "Buying and selling shares needs a bank account."
    : r.halted ? `Trading in this share is halted until ${eveningAt(r.halted)}.`
    : side === "sell" && heldN < 1 ? "You hold no shares in it to sell."
    : side === "buy" && S.open && r.available < 1 ? "None are for sale. The Exchange sells on whatever holders sell to it."
    : side === "buy" && maxN < 1 ? "You cannot afford a share at this price."
    : null;
  const go = async () => {
    setBusy(true);
    const done = await onAct("shares.trade", { propertyId: r.id, side, shares: n });
    setBusy(false);
    if (done) setSlip({ side, n, each: pv.each, total: pv.total, open: S.open, key: Date.now() });
  };
  return (
    <section className={"ticket is-" + side} aria-label={`Trade shares in ${m.name}`}>
      <div className="ticket-side" role="radiogroup" aria-label="Buy or sell">
        <button role="radio" aria-checked={side === "buy"} className={side === "buy" ? "on" : ""} onClick={() => setSide("buy")}>Buy</button>
        <button role="radio" aria-checked={side === "sell"} className={side === "sell" ? "on" : ""} onClick={() => setSide("sell")}>Sell</button>
      </div>
      <div className="ticket-qty">
        <label htmlFor={`tq-${r.id}`}>Shares</label>
        <div className="ticket-step">
          <button aria-label="One fewer" onClick={() => setQty(Math.max(1, n - 1))}>&minus;</button>
          <input id={`tq-${r.id}`} type="number" min={1} max={100} value={qty} onChange={(e) => setQty(e.target.value)} />
          <button aria-label="One more" onClick={() => setQty(Math.min(Math.max(1, maxN), n + 1))}>+</button>
        </div>
        <div className="ticket-chips">
          {[1, 5, 10].map((k) => <button key={k} disabled={maxN < k} onClick={() => setQty(k)}>{k}</button>)}
          <button disabled={maxN < 1} onClick={() => setQty(maxN)}>Max {maxN || ""}</button>
        </div>
      </div>
      <dl className="ticket-sum">
        <div><dt>{S.open ? "Price now" : "Last price"}</dt><dd><LivePrice value={r.price} /></dd></div>
        <div><dt>{S.open ? "Price after your order" : "Your order moves it to about"}</dt>
          <dd className={pv.move >= 0 ? "up" : "down"}>{fmtShare(pv.after)} <em>{arrow(pv.move)} {Math.abs(pv.move).toFixed(1)}%</em></dd></div>
        <div><dt>{side === "buy" ? "You pay" : "You receive"}{S.open ? "" : ", about"}</dt>
          <dd><b>{fmt(pv.total)}</b><span className="sub">{fmtShare(pv.each)} a share with the spread and stamp duty</span></dd></div>
      </dl>
      {pv.halts && <p className="ticket-warn">This order would move the price past the {Math.round((S.breaker || 0.2) * 100)}% limit and halt trading in it for {S.haltMinutes || 15} minutes.</p>}
      {why && <p className="ticket-why">{why}</p>}
      <button className="ticket-go" disabled={blocked || busy} onClick={go}>
        {S.open ? `${side === "buy" ? "Buy" : "Sell"} ${n} share${n === 1 ? "" : "s"} · ${fmt(pv.total)}` : `${side === "buy" ? "Buy" : "Sell"} ${n} at the opening bell`}
      </button>
      <div className="ticket-foot">
        <span>You hold {heldN}</span>
        <button className="switch-link" onClick={() => onOpen(r.id)}>Open the deed</button>
      </div>
      {slip && (
        <div key={slip.key} className={"fill-slip is-" + slip.side} role="status">
          <span className="fill-stamp">{slip.open ? (slip.side === "buy" ? "Bought" : "Sold") : "Order placed"}</span>
          <b>{slip.n} share{slip.n === 1 ? "" : "s"} · {m.name}</b>
          <span>{slip.open ? `${fmtShare(slip.each)} a share, ${fmt(slip.total)} in all` : `Fills at the opening price at ${(S.hours || [])[0]}`}</span>
        </div>
      )}
    </section>
  );
}

/* FLOATING, AS AN OFFER FOR SALE (Aaron, 1 Oct 2026). The owner picks the
   building, how many shares and the price; the page suggests a price worth
   so many days of the building's rent, and sets holding it, floating it
   and selling it side by side so the choice is made on the numbers. */
function FloatBox({ mine, market, rates, onAct }) {
  const quotes = mine.floatQuotes || [];
  const [id, setId] = useState("");
  const [n, setN] = useState(market.minFloat || 25);
  const [ask, setAsk] = useState("");
  if (!mine.canTrade) return null;
  if (!mine.canFloat) {
    return <p className="stk-note">A credit line (bank level 2) lets you offer shares in one of your own buildings.</p>;
  }
  if (!quotes.length) return null;
  const qt = quotes.find((x) => x.id === id) || quotes[0];
  const meta = metaFor(qt.id);
  const k = Math.max(market.minFloat, Math.min(market.maxFloat, Number(n) || market.minFloat));
  const price = ask === "" ? qt.price : Math.max(0, Number(ask) || 0);
  const inBand = price >= qt.floor - 0.005 && price <= qt.ceiling + 0.005;
  const net = (x) => x * (1 - market.spread) * (1 - market.stamp);
  const raised = net(price * k);
  const rate = rates && rates.due ? (rates.afterBank || 0) / 100 : 0;
  const rentDay = qt.rentDay || 0;
  const keepDay = rentDay * (1 - rate);
  const givenUp = (rentDay * k) / 100 * (1 - rate);
  const days = givenUp > 0 ? raised / givenUp : 0;
  const saleDays = keepDay > 0 ? qt.sale / keepDay : 0;
  const sessions = mine.offerSessions || 3;
  return (
    <section className="card stk-float" aria-label="Float a building">
      <h3>Float a building</h3>
      <p className="stk-note">
        Offer {market.minFloat} to {market.maxFloat} of a building's 100 shares at your price. They go on sale for up to {sessions} sessions and you are paid as each one sells; any left at the end come back to you.
        The building's value then follows its share price on the part you sold. You defend it alone, and it can no longer be sold to the market.
      </p>
      <div className="stk-float-row">
        <label htmlFor="stk-float-b">Building</label>
        <select id="stk-float-b" value={qt.id} onChange={(e) => { setId(e.target.value); setAsk(""); }}>
          {quotes.map((x) => <option key={x.id} value={x.id}>{(metaFor(x.id) || {}).name || x.id}</option>)}
        </select>
      </div>
      <div className="stk-float-row">
        <label htmlFor="stk-float-n">Shares</label>
        <input id="stk-float-n" type="range" min={market.minFloat} max={market.maxFloat} value={k} onChange={(e) => setN(e.target.value)} />
        <b className="stk-float-k">{k}</b>
      </div>
      <div className="stk-float-row">
        <label htmlFor="stk-float-p">Price a share</label>
        <input id="stk-float-p" type="number" inputMode="decimal" min={qt.floor} max={qt.ceiling} step="1"
               value={ask === "" ? Math.round(qt.price) : ask} onChange={(e) => setAsk(e.target.value)} />
        <button type="button" className="btn small secondary" onClick={() => setAsk("")}>Suggested</button>
      </div>
      <p className="stk-note">
        Suggested {fmtShare(qt.price)} a share: what one share of its rent earns in {mine.offerDays || 15} days. It can be offered at {fmtShare(qt.floor)} to {fmtShare(qt.ceiling)} (deed value {fmtShare(qt.deed)}).
      </p>
      <div className="stk-compare" role="table" aria-label={`Hold, float or sell ${meta ? meta.name : "the building"}`}>
        <div className="stk-cmp" role="row">
          <b role="cell">Hold</b>
          <span role="cell">{fmt(keepDay)} a day{rate ? ` after ${Math.round(rate * 1000) / 10}% rates` : ""}, for as long as you hold it.</span>
        </div>
        <div className="stk-cmp is-pick" role="row">
          <b role="cell">Float {k}</b>
          <span role="cell">Up to <strong>{fmt(raised)}</strong> if every share sells at your price, for {fmt(givenUp)} a day of rent you give up: about {Math.round(days)} days of it.{rate ? " The rent shareholders take is not rated." : ""}</span>
        </div>
        <div className="stk-cmp" role="row">
          <b role="cell">Sell</b>
          <span role="cell">{fmt(qt.sale)} now from the market: about {Math.round(saleDays)} days of what it pays you.</span>
        </div>
      </div>
      {!inBand && <p className="error-text">Choose a price from {fmtShare(qt.floor)} to {fmtShare(qt.ceiling)}.</p>}
      <button className="btn" disabled={!inBand} onClick={() => onAct("shares.float", { propertyId: qt.id, shares: k, price: Math.round(price * 100) / 100 })}>
        Offer {k} shares at {fmtShare(price)}
      </button>
    </section>
  );
}

/* THE OWNER'S OWN FLOATS: the offer as it sells, the building's value with
   its shares, and the way back (Aaron: "Yes, at 25% over"). */
function YourFloats({ mine, onAct }) {
  const list = mine.listed || [];
  const [confirm, setConfirm] = useState(null);
  if (!list.length) return null;
  const premium = Math.round((mine.buybackPremium ?? 0.25) * 100);
  return (
    <section className="card stk-yours" aria-label="Your floated buildings">
      <h3>Your floated buildings</h3>
      {list.map((x) => {
        const m = metaFor(x.id);
        const sure = confirm === x.id;
        return (
          <div key={x.id} className="stk-your">
            <div className="stk-your-head">
              <b>{m ? m.name : x.id}</b>
              <span>Valued at {fmt(x.value)} <em>(deed {fmt(x.deed)})</em></span>
            </div>
            <p className="stk-note">
              {x.offerLeft > 0
                ? `On offer: ${x.sold} of ${x.floated} sold so far for ${fmt(x.raised)}. ${x.offerLeft} still for sale until ${eveningAt(x.offerEnds)} on ${new Date(x.offerEnds).toLocaleDateString("en-GB", { timeZone: "Europe/London", weekday: "long" })}; any left then come back to you.`
                : `${x.sold} shares in other hands or the Exchange's. The offer raised ${fmt(x.raised)}.`}
            </p>
            {x.buyback.shares > 0 && (
              sure ? (
                <div className="stk-your-act">
                  <span>Buy back {x.buyback.shares} share{x.buyback.shares === 1 ? "" : "s"} at {fmtShare(x.buyback.each)} each, {fmt(x.buyback.total)} in all? The building is then valued at {fmt(x.buyback.newDeed)} (deed now {fmt(x.deed)}), and its rent follows.</span>
                  <button className="btn small" onClick={() => { setConfirm(null); onAct("shares.buyback", { propertyId: x.id }); }}>Buy them back</button>
                  <button className="btn small secondary" onClick={() => setConfirm(null)}>Not now</button>
                </div>
              ) : (
                <button className="btn small secondary" onClick={() => setConfirm(x.id)}>
                  Buy out the shareholders at {premium}% over: {fmt(x.buyback.total)}
                </button>
              )
            )}
          </div>
        );
      })}
    </section>
  );
}

/* THE COUNCIL'S RATES, for a trader above £1.5m: what they pay, what they
   have paid, where it goes, and the four ways to pay less, none of them
   required. In the Bank (Aaron, 1 Oct 2026: "Tax payers need to see how much
   is going into tax"), with the honours a ratepayer earns along the way. */
const RATE_HONOURS = ["ratepayer", "civic-pillar", "benefactor"];
function RatesCard({ rates }) {
  const fmtM = (n) => "£" + (n / 1e6).toFixed(n % 1e6 ? 1 : 0) + "m";
  const ladder = RATE_HONOURS.map((id) => HONOURS.find((h) => h.id === id)).filter(Boolean)
    .map((h) => ({ ...h, at: h.goal || 0 }));
  const next = ladder.find((h) => rates.paid < h.at);
  const prev = next ? (ladder[ladder.indexOf(next) - 1] || { at: 0 }).at : 0;
  const pct = next ? Math.max(0, Math.min(100, ((rates.paid - prev) / (next.at - prev)) * 100)) : 100;
  return (
    <section className="card rates-card" aria-label="The Council's rates">
      <div className="rates-head">
        <h3>The Council's rates</h3>
        <b>{rates.due ? `${rates.afterBank}%` : "Not due"}</b>
      </div>
      <div className="rates-tally">
        <div><span>Today</span><b>{fmt(rates.today || 0)}</b></div>
        <div><span>Last 7 days</span><b>{fmt(rates.week || 0)}</b></div>
        <div><span>All time</span><b>{fmt(rates.paid || 0)}</b></div>
      </div>
      <p className="stk-note rates-where">
        Where it goes: the rates pay for the Council's City tasks, done by traders below {fmtM(rates.threshold)}.
        The Council has paid out {fmt(rates.cityPaid || 0)} for them so far.
      </p>
      {ladder.length > 0 && (
        <div className="rates-honour">
          {next ? (
            <>
              <div className="rates-honour-line">
                <Medal tier={next.tier} glyph={next.glyph} size={34} locked />
                <span><b>{next.name}</b> at {fmt(next.at)} paid. {fmt(Math.max(0, next.at - rates.paid))} to go.</span>
              </div>
              <div className="rates-bar" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct)}>
                <i style={{ width: pct + "%" }} />
              </div>
            </>
          ) : (
            <div className="rates-honour-line">
              <Medal tier={3} glyph={ladder[ladder.length - 1].glyph} size={34} />
              <span>Every ratepayer's honour is yours.</span>
            </div>
          )}
        </div>
      )}
      {rates.due ? (
        <>
          <p className="stk-note">
            Above {fmtM(rates.threshold)} net worth the Council takes a share of all the rent you keep, at the bracket your net worth is in
            ({rates.bands.map((b) => `${b.rate}% from ${fmtM(b.from)}`).join(", ")}). Your rate is {rates.rate}%
            {rates.bankCut ? `, ${rates.afterBank}% after your bank's ${rates.bankCut} point cut` : ""}.
          </p>
          <ul className="rates-ways">
            <li><b>Just pay.</b> Nothing to manage.</li>
            <li><b>Float.</b> Rent paid to shareholders is not yours, so it is not rated.</li>
            <li><b>Develop.</b> Money spent this week on improvements, security or building takes up to a quarter off. Allowance left: {fmt(rates.allowanceLeft)}.</li>
            <li><b>Bank.</b> Levels 2, 3 and 4 take {[2, 3, 4].map((l) => +((rates.cuts[l] || 0) * 100).toFixed(1)).join(", ").replace(/, ([^,]*)$/, " and $1")} points off your rate. Deposit interest is never rated.</li>
          </ul>
          <p className="stk-note">All relief together never takes off more than half the bill.</p>
        </>
      ) : (
        <p className="stk-note">Your net worth is below {fmtM(rates.threshold)}, so nothing is due today.</p>
      )}
    </section>
  );
}

function BankPane({ game, me, now, onAct }) {
  const [depositField, setDepositField] = useState("");
  const [withdrawField, setWithdrawField] = useState("");
  const [borrowField, setBorrowField] = useState("");
  const [repayField, setRepayField] = useState("");
  const [closing, setClosing] = useState(false);
  const reopenHours = (game.rules && game.rules.bankReopenHours) || 24;

  const level = me.bankLevel || 0;
  const tier = BANK_TIERS[Math.min(level, BANK_TIERS.length - 1)];
  const nextTier = BANK_TIERS[level + 1];
  const depositMult = (me.cashFlow && me.cashFlow.depositRateMult) || 1;
  const inArrears = me.loan > 0 && me.arrearsSince > 0;
  const graceLeft = inArrears ? Math.max(0, tier.graceHours * GAME_HOUR_MS - (now - me.arrearsSince)) : 0;
  /* Being under water is no longer a reason for anything to be taken from
     you — see 6.17.0 — so the panel warns about the thing that IS: an
     overdrawn account, with however long the bank's tier allows. */
  const overdrawn = me.cash < 0;
  const deficitHours = ((game.rules && game.rules.cashDeficitHours) || 1) + (tier.negativeHours || 0);
  const deficitLeft = overdrawn && me.cashDeficitSince
    ? Math.max(0, deficitHours * GAME_HOUR_MS - (now - me.cashDeficitSince)) : 0;
  const mins = (ms) => `${Math.ceil(ms / 60000)} min`;
  const [starterField, setStarterField] = useState("");
  const starterRate = (game.rules && game.rules.starterLoanRate) || 0.0025;

  const earns = level >= 1 ? me.deposit * tier.depositRate * depositMult : 0;
  const costs = level >= 2 ? me.loan * tier.loanRate : 0;
  const net = earns - costs;

  return (
    <div className="bankp">
      <Masthead tone="bank" tiles={[
        { label: "Cash to hand", value: fmt(me.cash), foot: me.cash < 0 ? "overdrawn" : "spendable now" },
        { label: "Banked", value: level >= 1 ? fmt(me.deposit) : "No account", foot: level >= 1 ? `${(tier.depositRate * depositMult * 100).toFixed(2)}% an hour` : `open one for ${fmt((BANK_TIERS[1] || {}).cost || 0)}` },
        { label: "Borrowed", value: level >= 2 ? fmt(me.loan) : "None", foot: level >= 2 ? `${fmt(me.borrowCapacity)} still available` : "credit opens at tier 2" },
        { label: "Interest each hour", value: `${net < 0 ? "−" : "+"}${fmt(Math.abs(net))}`, feature: true,
          foot: level >= 1 ? `${fmt(earns)} earned${level >= 2 ? `, ${fmt(costs)} owed` : ""}` : "nothing earning yet" },
      ]} />
      <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 an hour, alongside the rents.</p>

      {(inArrears || overdrawn) && (
        <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.graceHours > 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>}
          {overdrawn && <div style={{ fontSize: 13 }}>Your account is overdrawn at {fmt(me.cash)}. {deficitLeft > 0 ? `About ${mins(deficitLeft)} to find some cash before a property is sold to find it for you.` : "A property is sold at the next settlement to cover it."}</div>}
        </div>
      )}

      <div className="card bank-tier-card">
        {/* THE LADDER. Four tiers, the ones you hold filled, the next one
            marked: where you stand is a picture before it is a sentence. */}
        <ol className="bank-ladder" aria-label={`Banking tier ${level} of ${BANK_TIERS.length - 1}`}>
          {BANK_TIERS.slice(1).map((t, i) => (
            <li key={t.level} className={(t.level <= level ? "is-held" : "") + (t.level === level + 1 ? " is-next" : "")}>
              <span className="bl-step">{t.level}</span>
              <span className="bl-name">{t.name}</span>
              <span className="bl-cost">{t.level <= level ? "Held" : t.openingBalance ? `${fmt(t.cost)} on deposit` : fmt(t.cost)}</span>
            </li>
          ))}
        </ol>
        <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)}%/hr · {nextTier.loanRate ? `loans ${(nextTier.loanRate * 100).toFixed(2)}%/hr` : "no lending"} ·
              {" "}{nextTier.graceHours ? `${nextTier.graceHours} hour grace` : "no grace on arrears"}
              {nextTier.foreclosureFloor > tier.foreclosureFloor
                && ` · forced sales open at ${Math.round(nextTier.foreclosureFloor * 100)}% of value`}
              {nextTier.protectedDepositPct > 0
                && ` · ${Math.round(nextTier.protectedDepositPct * 100)}% of deposits ring fenced`}
            </div>
            <button className="btn" onClick={() => onAct("bank.upgrade")}
                    disabled={me.cash < nextTier.cost || (level === 0 && me.bankReopenAt > now)}>
              {nextTier.openingBalance ? `Open with ${fmt(nextTier.cost)} on deposit` : `Open for ${fmt(nextTier.cost)}`}
            </button>
            {level === 0 && me.bankReopenAt > now && (
              <div className="hint bank-wait">
                You closed an account recently. A new one can be opened
                from {new Date(me.bankReopenAt).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" })}.
              </div>
            )}
            {nextTier.openingBalance && (
              <div className="hint">
                You keep the {fmt(nextTier.cost)}: it moves from cash into the account and earns
                from the same hour. It stays in while the account is open.
              </div>
            )}
          </>
        ) : <div className="muted">You hold the highest tier the bank offers.</div>}
      </div>

      {me.shares && me.shares.rates && (me.shares.rates.due || me.shares.rates.paid > 0) && <RatesCard rates={me.shares.rates} />}

      {me.starterLoan > 0 && (
        <div className="card">
          <h3>Your opening stake</h3>
          <p className="muted" style={{ fontSize: 13, marginTop: 0 }}>
            You started with {fmt(me.starterLoan + 0)} of cash matched by a debt of the same size, so your
            net worth began at zero. Clearing it is the first real milestone.
          </p>
          <div className="blocks">
            <div className="block"><div className="label">Outstanding</div><div className="value">{fmt(me.starterLoan)}</div></div>
            <div className="block"><div className="label">Rate</div>
              <div className="value">{me.starterRate > 0 ? `${(me.starterRate * 100).toFixed(2)}%/hr` : "Interest-free"}</div></div>
            <div className="block"><div className="label">Costs you</div>
              <div className="value">{me.starterRate > 0 ? `${fmt(me.starterLoan * me.starterRate)}/hr` : "Nothing yet"}</div></div>
          </div>
          {me.starterRate === 0 && me.starterIntroEndsAt > now && (
            <div className="hold-note ok" style={{ marginTop: 8 }}>
              Interest free for another {spanOf(me.starterIntroEndsAt - now)}. After that it costs
              {" "}{fmt(me.starterLoan * starterRate)} an hour, which a decently run portfolio covers comfortably.
            </div>
          )}
          {me.starterRate > 0 && (
            <div className="hold-note warn" style={{ marginTop: 8 }}>
              Interest comes straight out of your cash. Let the balance fall below zero and you have
              an hour to put it right before a property is sold to do it for you.
            </div>
          )}
          <div className="field-row" style={{ marginTop: 8 }}>
            <label>Repay</label>
            <input type="number" value={starterField} onChange={(e) => setStarterField(e.target.value)} placeholder="0" />
            <button className="btn small secondary" onClick={() => setStarterField(String(Math.floor(Math.min(me.cash, me.starterLoan))))}>Max</button>
            <button className="btn small" onClick={() => { onAct("bank.repayStarter", { amount: starterField }); setStarterField(""); }}>Pay off</button>
          </div>
        </div>
      )}

      <div className={"card bank-acct" + (level < 1 ? " is-locked" : "")}>
        <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">Banked</div><div className="value">{fmt(me.deposit)}</div></div>
              {/* THE RATE YOU ARE ACTUALLY PAID. Aaron, 27 Sep 2026: a player
                  holding Private Client or Rail Fortune could not see the
                  uplift. The account's list rate is shown struck through
                  beside the raised one, and the next settlement uses it. */}
              <div className="block"><div className="label">Rate</div>
                <div className="value">{(tier.depositRate * depositMult * 100).toFixed(2)}%/hr</div>
                {depositMult > 1.0001 && (
                  <div className="foot deposit-uplift">
                    <s>{(tier.depositRate * 100).toFixed(2)}%</s> raised {Math.round((depositMult - 1) * 100)}% by your properties
                  </div>
                )}</div>
              <div className="block"><div className="label">Earns next settlement</div><div className="value">{fmt(me.deposit * tier.depositRate * depositMult)}</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" />
              {/* Max means the most you can actually take, not the balance —
                  an opening balance that cannot be withdrawn should never be
                  offered by a button and then refused by the server. */}
              <button className="btn small secondary"
                      onClick={() => setWithdrawField(String(Math.max(0, Math.floor(me.deposit - (me.bankMinBalance || 0)))))}>Max</button>
              <button className="btn small" onClick={() => { onAct("bank.withdraw", { amount: withdrawField }); setWithdrawField(""); }}>Take out</button>
            </div>
            {me.bankMinBalance > 0 && (
              <div className="hint">
                {fmt(me.bankMinBalance)} of this is the opening balance on the account. It earns
                like the rest and stays in while the account is open. Close the account to take it out.
              </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>
            {/* CLOSING. The only way the opening balance ever comes out, so it
                sits here rather than in a settings corner. Two steps, because
                it gives up any higher tier and those fees are not refunded. */}
            <div className="bank-close">
              {!closing ? (
                <button className="btn small secondary" onClick={() => setClosing(true)}>Close the account</button>
              ) : me.loan > 0 ? (
                <div className="bank-close-ask">
                  <p>Repay the {fmt(me.loan)} you owe on the credit line before closing the account.</p>
                  <button className="btn small secondary" onClick={() => setClosing(false)}>Back</button>
                </div>
              ) : (
                <div className="bank-close-ask">
                  <p>
                    {fmt(me.deposit)} comes back to your cash, the opening balance included, and you
                    stop earning interest.
                    {level >= 2 ? ` You also give up the ${tier.name}. The ${fmt(BANK_TIERS.slice(2, level + 1).reduce((t, b) => t + b.cost, 0))} paid for the higher tiers is not refunded.` : ""}
                    {" "}A new account cannot be opened for {reopenHours} hours, and takes {fmt(BANK_TIERS[1].cost)} again.
                  </p>
                  <div className="bank-close-acts">
                    <button className="btn small" onClick={() => { onAct("bank.close", {}); setClosing(false); }}>Close it</button>
                    <button className="btn small secondary" onClick={() => setClosing(false)}>Keep it open</button>
                  </div>
                </div>
              )}
            </div>
          </>
        )}
      </div>

      <div className={"card bank-acct" + (level < 2 ? " is-locked" : "")}>
        <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)}%/hr</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 bank-small-print">
        <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.
          {tier.protectedDepositPct > 0 && <> The bank cannot touch the {Math.round(tier.protectedDepositPct * 100)}% of your deposits your account ring fences.</>}
          {" "}Once grace runs out the bank seizes your <strong>least valuable</strong> property and lists it as a one hour foreclosure auction opening at {Math.round(tier.foreclosureFloor * 100)}% of 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 ------------------------------ */

/* A hostile bid is the only thing in the game that can take a property off
   you, so it gets the loudest surface in it: the defender sees what is
   happening, why, what it costs to stop, and how long they have. */
/* Tabling a sealed offer. The premium floor is enforced on the server; it
   is mirrored here so the field opens on a number that will actually be
   accepted rather than teaching the player what "too low" means by
   rejecting them once. */
function SealedBidBox({ meta, prop, me, owner, onAct }) {
  const rules = me.sealedBid;
  /* Every hook runs before the first early return, unconditionally. The
     same mistake one floor up in Exchange took the whole app down with
     React error #310; here it would only have needed `sealedBid` to arrive
     a render late to do the same to the property modal. */
  const floor = rules ? Math.ceil(prop.currentPrice * (1 + rules.minPremium)) : 0;
  const [amount, setAmount] = useState(String(floor));
  if (!rules) return null;
  const mine = (me.sealedBids || []).find((b) => b.propertyId === meta.id);
  const value = Number(amount) || 0;
  const fee = Math.round(value * rules.feePct);

  if (mine) {
    return (
      <div className="improve-box">
        <div style={{ fontWeight: 600, marginBottom: 3 }}>Your offer stands</div>
        <div className="muted" style={{ marginBottom: 8 }}>
          {fmt(mine.amount)} for {meta.name}, held in escrow until {fmtTime(mine.expiresAt)}.
          {" "}{owner ? owner.name : "The owner"} hasn't answered yet.
        </div>
        <button className="btn small secondary" onClick={() => onAct("bid.withdraw", { bidId: mine.id })}>
          Withdraw the offer
        </button>
      </div>
    );
  }

  return (
    <div className="improve-box">
      <div style={{ fontWeight: 600, marginBottom: 3 }}>Make a sealed offer</div>
      <div className="muted" style={{ marginBottom: 8 }}>
        Private for {rules.hours === 1 ? "an hour" : `${rules.hours} hours`}. {owner ? owner.name : "The owner"} can
        refuse for nothing, but a refused offer is published, so the whole exchange learns
        {" "}{meta.name} is wanted and what you were willing to pay. Minimum {fmt(floor)}.
      </div>
      <div className="field-row">
        <label>Offer</label>
        <input type="number" value={amount} min={floor} onChange={(e) => setAmount(e.target.value)} />
        <button className="btn small" disabled={value < floor || me.cash < value + fee}
          onClick={() => onAct("bid.open", { propertyId: meta.id, amount: value })}>
          Table it
        </button>
      </div>
      <div className="muted" style={{ marginTop: 6 }}>
        {fmt(value)} held in escrow plus {fmt(fee)} in fees, which you don't get back either way.
      </div>
    </div>
  );
}

/* Offers waiting on this player — theirs to answer, or theirs to wait on.
   Sits with the takeover alerts because both are things with a clock on
   them that a player must not scroll past. */
function SealedBidAlerts({ me, now, onAct }) {
  const list = me.sealedBids || [];
  if (!list.length) return null;
  return (
    <>
      {list.map((b) => {
        const meta = metaFor(b.propertyId);
        const left = Math.max(0, b.expiresAt - now);
        const mins = Math.max(1, Math.round(left / 60000));
        if (b.role !== "owner") {
          return (
            <div className="takeover-alert" key={b.id}>
              <div>
                <strong>Your offer on {meta ? meta.name : "a property"} is with its owner.</strong>
                <div style={{ fontSize: 13, marginTop: 3 }}>
                  {fmt(b.amount)}, held in escrow. They have {mins} minute{mins === 1 ? "" : "s"} left to answer.
                </div>
              </div>
            </div>
          );
        }
        return (
          <div className="takeover-alert" key={b.id}>
            <div>
              <strong>A sealed offer of {fmt(b.amount)} for {meta ? meta.name : "a property"}.</strong>
              <div style={{ fontSize: 13, marginTop: 3 }}>
                {mins} minute{mins === 1 ? "" : "s"} to answer. Refusing costs you nothing, but the offer
                is then published, and everyone will know the property is wanted.
              </div>
            </div>
            <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
              <button className="btn small" onClick={() => onAct("bid.accept", { bidId: b.id })}>Accept {fmt(b.amount)}</button>
              <button className="btn small secondary" onClick={() => onAct("bid.refuse", { bidId: b.id })}>Refuse</button>
            </div>
          </div>
        );
      })}
    </>
  );
}

function TakeoverAlerts({ game, me, now, onAct }) {
  const list = me.takeovers || [];
  if (!list.length) return null;
  return (
    <>
      {list.map((t) => {
        const meta = metaFor(t.propertyId);
        const left = Math.max(0, t.resolvesAt - now);
        /* left is already real milliseconds (resolvesAt is a real timestamp),
           so convert with real-time constants — never with GAME_DAY_MS, which
           is a game-economy unit that can be tuned independently of the
           wall clock and previously caused this to overstate remaining time
           by up to 24x. */
        const when = spanOf(left);
        if (t.role === "defender") {
          return (
            <div className="takeover-alert" key={t.id}>
              <div>
                <strong>{meta ? meta.name : "A property"} is under a hostile bid.</strong>
                <div style={{ fontSize: 13, marginTop: 3 }}>
                  It changes hands in {when} unless you see it off. Paying {fmt(t.defenceFee)} ends it now;
                  if it completes you are paid {fmt(t.stake)} for the property.
                </div>
              </div>
              <button className="btn" disabled={me.cash < t.defenceFee}
                      onClick={() => onAct("takeover.defend", { propertyId: t.propertyId })}>
                See it off for {fmt(t.defenceFee)}
              </button>
            </div>
          );
        }
        return (
          <div className="takeover-alert mine" key={t.id}>
            <div>
              <strong>Your bid for {meta ? meta.name : "a property"} is running.</strong>
              <div style={{ fontSize: 13, marginTop: 3 }}>
                It completes in {when}. The owner can end it by paying you off, and your stake of {fmt(t.stake)} comes back if they do.
              </div>
            </div>
          </div>
        );
      })}
    </>
  );
}

/* ---- the interception ------------------------------------------------ */

/* Four seconds of shared suspense, then the seal comes down and the answer
   is there. Both outcomes play exactly the same until that moment, which is
   the whole point: the server settled it before the first frame, and a run
   that telegraphed the result early would just be a slower way of reading
   the notification. */
/* WHAT WAS WRONG WITH THE OLD ONE, SO IT DOES NOT COME BACK.

   Four and a quarter seconds, and the middle of it was a progress bar
   captioned EXCHANGE VERIFICATION filling from 1/3 to 3/3. A loading bar
   is not drama. It is the game admitting it has nothing to show you while
   it waits for a timer it set itself.

   Worse, the sequence leaked its own ending. The status line read
   "Following the takings out of <property>" from the first frame, which
   promises a take before anybody has opened anything; the coins were built
   from theft.took, so an empty purse meant no coins flew and a player who
   had seen it twice knew the answer a second in; and a fake clock counted
   to 04:28 as though something were being measured.

   THE RULE NOW: nothing on screen may know more than the crew does. Until
   the vault is open, the screen knows as little as the player.

   Three beats, 2.6s, and only the last tells you anything:
     0.00  the approach   the route draws toward the vault, door shut
     0.95  the attempt    the wheel turns, and resists if there is security
     1.70  the outcome    the door opens, holds, or opens on nothing      */
const THEFT_APPROACH_MS = 950;
const THEFT_ATTEMPT_MS = 1700;
const THEFT_SETTLE_AT = 2600;

function theftCoins(count) {
  const out = [];
  for (let i = 0; i < count; i++) {
    out.push({
      travel: (150 + ((i * 41) % 190)) + "px",
      arc: (((i % 2 ? -1 : 1) * (14 + (i * 23) % 52))) + "px",
      life: (760 + ((i * 67) % 420)) + "ms",
      delay: (120 + ((i * 53) % 420)) + "ms",
    });
  }
  return out;
}

function TheftTheatre({ theft, meta, me, onClose }) {
  const [phase, setPhase] = useState("running");
  const [clock, setClock] = useState(0);
  /* Twelve coins for a full purse, fewer for a thin one, none for an empty
     till — the animation should not promise more than the ledger delivers. */
  const coins = useMemo(
    () => theftCoins(theft.took > 0 ? Math.max(4, Math.min(12, Math.round(theft.took / 2000))) : 0),
    [theft.took]);

  useEffect(() => {
    const a = setTimeout(() => setPhase("attempt"), THEFT_APPROACH_MS);
    const b = setTimeout(() => setPhase("opening"), THEFT_ATTEMPT_MS);
    const c = setTimeout(() => setPhase("settled"), THEFT_SETTLE_AT);
    return () => { clearTimeout(a); clearTimeout(b); clearTimeout(c); };
  }, []);

  /* THE NUMBER ARRIVES, IT DOES NOT APPEAR. A figure that fades in has
     already happened; one that counts up is happening to you. Half a
     second, eased, landing on the exact amount rather than near it. */
  useEffect(() => {
    if (phase !== "settled") return undefined;
    const target = theft.success ? theft.took : theft.stake;
    if (!target) { setClock(0); return undefined; }
    const started = Date.now();
    const id = setInterval(() => {
      const t = Math.min(1, (Date.now() - started) / 520);
      setClock(Math.round(target * (1 - Math.pow(1 - t, 3))));
      if (t >= 1) clearInterval(id);
    }, 40);
    return () => clearInterval(id);
  }, [phase, theft.success, theft.took, theft.stake]);

  useEffect(() => {
    if (phase !== "settled") return undefined;
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [phase, onClose]);

  const settled = phase === "settled";
  const atVault = phase === "attempt" || phase === "opening" || settled;
  const won = settled && theft.success && theft.took > 0;
  const lost = settled && !theft.success;
  const empty = settled && theft.success && theft.took === 0;

  /* What the player's own cash did. On a botched job the stake is already
     gone and has gone to them, so the honest figure to show is the stake,
     not the take. */
  const myDelta = !settled ? 0 : theft.success ? theft.took : -theft.stake;
  /* The counted figure, so the ledger and the headline agree at every
     frame of the count rather than only at the end of it. */
  const counted = settled ? clock : 0;

  const kicker = lost ? "INTERCEPTION FAILED"
    : empty ? "NOTHING TO TAKE" : "TRANSFER SECURED";
  const title = lost ? `${fmt(counted)} lost`
    : empty ? "The till was empty" : `${fmt(counted)} secured`;
  const detail = lost
    ? `The crew was stopped on the doorstep. Your stake went to ${theft.ownerName || "the owner"} as compensation${theft.securityLevel > 0 ? `. That property carries security level ${theft.securityLevel}.` : "."}`
    : empty
      ? `The job came off, but ${theft.ownerName || "the owner"} had nothing loose to take. Money in a bank deposit cannot be lifted.`
      : "Added to your cash. It does not count as earned, so it will not fund another move against a player.";

  return (
    <div className="thf-overlay" role="dialog" aria-modal="true" aria-label="Interception">
      <div className={"thf-card " + phase + (atVault ? " at-vault" : "")
        + (won ? " won" : "") + (lost ? " lost" : "") + (empty ? " empty" : "")}>
        <div className="thf-head">
          <div>
            <span className="thf-no">TACTIC · PRIVATE LEDGER</span>
            <h2>Intercepted transfer</h2>
          </div>
          {/* No clock. A fake one counted to 04:28 here, measuring nothing
              and implying something was being checked. */}
        </div>

        {/* NOTHING HERE KNOWS MORE THAN THE CREW. The old line promised a
            take from the first frame, before anything had been opened. */}
        <p className="thf-status" aria-live="polite">
          {settled
            ? (theft.success
                ? (theft.took > 0 ? "They are out, and they are carrying." : "They are out, and carrying nothing.")
                : "They did not get out.")
            : phase === "opening" ? "The door is giving…"
            : phase === "attempt" ? `At the vault${theft.securityLevel > 0 ? ", and it is guarded" : ""}.`
            : `Your crew is inside ${meta ? meta.name : "the property"}.`}
        </p>

        <div className="thf-exchange">
          <div className="thf-acct">
            <div className="thf-avatar">{(me.name || "?").slice(0, 2).toUpperCase()}</div>
            <span className="who">YOUR LEDGER</span>
            <span className="name">{me.name}</span>
            <span className="bal">{fmt(me.cash)}</span>
            <span className={"delta " + (myDelta >= 0 ? "up" : "down")}>
              {settled ? `${myDelta >= 0 ? "+" : "−"} ${fmt(counted)}` : ""}
            </span>
          </div>

          <div className="thf-route" aria-hidden="true">
            <div className="thf-line"><i /></div>
            <div className="thf-cp c1"><span>1</span><small>ORIGIN</small></div>
            <div className="thf-cp c2"><span>2</span><small>LEDGER</small></div>
            <div className="thf-cp c3"><span>3</span><small>SEAL</small></div>
            <div className="thf-order"><i>Y</i></div>
            <div className="thf-beam" />
            {/* Built from the take, so they may not be rendered before the
                take is known — their very number was the leak. */}
            <div className="thf-coins">
              {coins.map((c, i) => (
                <span key={i} className="thf-coin"
                      style={{ "--travel": c.travel, "--arc": c.arc, "--life": c.life, "--delay": c.delay }} />
              ))}
            </div>
            {/* Only once there is something to stamp. A "?" hanging over
                the route for two seconds is the screen performing suspense
                rather than causing any. */}
            {settled && (
              <div className="thf-stamp">{theft.success ? "CLEARED" : "STOPPED"}</div>
            )}
          </div>

          <div className="thf-acct">
            <div className="thf-vault">
              <div className="thf-door"><span className="thf-wheel"><i /><i /><i /><i /></span></div>
              <span className="thf-glow" />
            </div>
            <span className="who">THEIR LEDGER</span>
            <span className="name">{theft.ownerName || "The owner"}</span>
            <span className="bal">{meta ? meta.name : ""}</span>
            <span className={"delta " + (settled && theft.success && theft.took > 0 ? "down" : "up")}>
              {settled ? (theft.success
                ? (theft.took > 0 ? `− ${fmt(theft.took)}` : "untouched")
                : `+ ${fmt(theft.stake)}`) : ""}
            </span>
          </div>
        </div>

        {/* THE LOADING BAR IS GONE. "EXCHANGE VERIFICATION 1/3 … 3/3" was
            three and a half seconds of a bar filling while the game waited
            for its own timer. It measured nothing and it was the single
            least interesting thing on the screen. */}

        <div className="thf-result">
          <p className="kicker">{kicker}</p>
          <p className="title">{title}</p>
          <p className="detail">{detail}</p>
        </div>

        {settled && (
          <div className="thf-close">
            <button className="btn" onClick={onClose}>Close the ledger</button>
          </div>
        )}
      </div>
    </div>
  );
}

/* ---- THE TACTICS DECK: one card, drawn the way it will always be drawn ---
   The rarity owns the OUTLINE, not a pip in the corner — a pip is invisible
   at the size a card is actually read, which is a hand of three on a phone.
   Same rule the property card has always used (.pcard in styles.css: "the
   rarity colour owns the frame, the ribbon and the crest"), same colours,
   same ribbon in the same corner. A player should not have to learn that
   gold means one thing on a building and another on a tactic.

   Every colour and the ring's thickness come from the server catalogue,
   which reads lib/cards.js — so this, the Combat Library and the art review
   gallery are three views of one list rather than three copies of it. */
function cardArt(id) {
  const list = ART_MANIFEST.Tactic;
  const file = `tactic-${id}.webp`;
  return list && list.includes(file) ? `/art/${file}` : null;
}

/* THE EVENING, IN YORK TIME. Aaron, 28 Sep 2026: the times shown must be
   the real time in York, BST or GMT. The evening resolves at 19:00 UTC,
   which is 20:00 while the clocks are forward, and the page said "19:00"
   all summer. Worked out from the instant rather than written down, so it
   is right on both sides of every clock change. `at` is a resolution (or a
   lock); left out, it is today's. */
function eveningAt(at = null, minsBefore = 0) {
  let t = at;
  /* The guest city's evening comes round every RESOLUTION_MINUTES. */
  if (t == null && DECK && DECK.resolutionMinutes && DECK.resolutionMinutes < 1440) {
    const ms = DECK.resolutionMinutes * 60000;
    t = Math.floor(Date.now() / ms) * ms + ms;
  }
  if (t == null) {
    const d = new Date();
    t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), (DECK && DECK.resolveHourUtc) ?? 19, 0, 0, 0);
  }
  return new Date(t - minsBefore * 60000).toLocaleTimeString("en-GB",
    { timeZone: "Europe/London", hour: "2-digit", minute: "2-digit", hour12: false });
}

const CARD_KIND_LABEL = {
  attack: "Attack", shield: "Shield", self: "Your position",
  info: "Information", social: "Between players",
};

function TacticCard({ id, size = "full", armed, selected, dimmed, onClick, onZoom, badge, children }) {
  const c = DECK.cards[id];
  if (!c) return null;
  const r = (DECK.rarityColours || {})[c.rarity] || { ring: "#C6BC9F", ribbon: "#5A5246", tint: "#C6BC9F", ink: "#fff", label: c.rarity };
  const art = cardArt(id);
  const kind = [CARD_KIND_LABEL[c.kind] || c.kind, c.target].filter(Boolean).join(" · ");
  /* A DIV, NEVER A BUTTON, even when it is clickable. The magnifier has to
     live inside the card, and a button inside a button is invalid markup that
     React warns about — which the smoke test treats as a failure, correctly.
     So the card takes the keyboard behaviour a button would have given it. */
  return (
    <div onClick={onClick}
         role={onClick ? "button" : undefined} tabIndex={onClick ? 0 : undefined}
         onKeyDown={onClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } } : undefined}
         className={["tcard", "size-" + size, armed ? "is-armed" : "", selected ? "is-selected" : "",
                     dimmed ? "is-dimmed" : "", onClick ? "is-clickable" : ""].filter(Boolean).join(" ")}
         style={{ "--ring": r.ring, "--ribbon": r.ribbon, "--rink": r.ink,
                  "--rtint": r.tint || r.ring, borderWidth: (DECK.ringPx || 8) + "px" }}
         aria-label={`${c.name}, ${r.label} ${CARD_KIND_LABEL[c.kind] || c.kind}`}>
      {art
        ? <img className="tcard-art" src={art} alt="" loading="lazy" />
        : <span className="tcard-art tcard-art-blank" aria-hidden="true" />}
      <span className="tcard-keyline" aria-hidden="true" />
      <span className="tcard-corner bl" aria-hidden="true" /><span className="tcard-corner br" aria-hidden="true" />
      <span className="tcard-ribbon">{r.label}</span>
      {badge ? <span className="tcard-badge">{badge}</span> : null}
      {/* Armed is shown OUTSIDE the card now, above it in the hand (Aaron,
          28 Sep 2026): inside, it was small and read as part of the art. */}
      <span className="tcard-plate">
        <span className="tcard-kind">{kind}</span>
        <span className="tcard-name">{c.name}</span>
        <span className="tcard-text">{c.text}</span>
      </span>
      {onZoom ? (
        <button type="button" className="tcard-zoom" title={`Look at ${c.name}`}
          aria-label={`Look closely at ${c.name}`}
          onClick={(e) => { e.stopPropagation(); onZoom(id); }}>
          <svg viewBox="0 0 24 24" width="13" height="13" aria-hidden="true">
            <circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" strokeWidth="2" />
            <path d="M15.4 15.4 L21 21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
          </svg>
        </button>
      ) : null}
      {children}
    </div>
  );
}

/* ---- a card, big enough to read ---------------------------------------
   The rules text is the card. At hand size it is four words wide and legible
   only to somebody who already knows what it says, which is nobody on their
   first week. This is the same card at a size where the art is worth looking
   at and the effect can actually be read. */
function CardZoom({ id, onClose }) {
  const c = DECK.cards[id];
  if (!c) return null;
  const r = (DECK.rarityColours || {})[c.rarity] || {};
  return (
    <div className="res-overlay" role="dialog" aria-modal="true" aria-label={c.name} onClick={onClose}>
      <div className="card-zoom" onClick={(e) => e.stopPropagation()}>
        <TacticCard id={id} size="full" />
        <div className="card-zoom-side">
          <p className="kicker" style={{ color: r.ring }}>
            {r.label} · {CARD_KIND_LABEL[c.kind] || c.kind}{c.target ? " · " + c.target : ""}
          </p>
          <h3>{c.n}. {c.name}</h3>
          <p className="card-zoom-text">{c.text}</p>
          {c.flavour ? <p className="card-zoom-flavour">{c.flavour}</p> : null}
          <dl className="card-zoom-facts">
            <div><dt>In the market</dt><dd>{c.marketable ? fmt(c.price)
              : c.rarity === "superrare" ? "Never. Only the wheel of fortune gives one"
              : "Never. It is a starter card"}</dd></div>
            {c.starter ? <div><dt>Starter</dt><dd>Everyone begins with one</dd></div> : null}
            {c.kind === "shield"
              ? <div><dt>Arming</dt><dd>{c.arms === "property" ? "Name a building, then it waits" : "Automatic: it waits in your hand"}</dd></div>
              : null}
          </dl>
          <button className="btn secondary" onClick={onClose}>Close</button>
        </div>
      </div>
    </div>
  );
}

/* HOW THE TACTICS PAGE WORKS, IN ONE PLACE. Aaron, 28 Sep 2026: the notes
   under Your hand, Armed shields and Your discard moved into one drop down,
   with a section on card rotation. Closed by default: it is for reading
   once, and the table is for playing. */
function TacticsHowItWorks({ onLibrary }) {
  const size = DECK.handSize || 4;
  const words = ["no", "one", "two", "three", "four", "five", "six"];
  const w = (x) => words[x] || String(x);
  return (
    <details className="tactics-how">
      <summary>How it works</summary>
      <div className="tactics-how-body">
        <section>
          <h4>Card rotation</h4>
          <p>Your hand is refreshed to {w(size)} cards every day at the evening lock. Just before it is,
            every card in your hand is discarded, whether you used it or not, unless it is armed.</p>
          <p>Armed cards count towards your hand size. With two shields armed you are dealt {w(Math.max(0, size - 2))} new
            cards, not {w(size)}. The wheel of fortune's extra cards prize and an Open the Books card add cards to the next deal.</p>
        </section>
        <section>
          <h4>Your hand</h4>
          <p>Choose a card to use it. Attacks are chosen and confirmed during the day and all resolve together at {eveningAt()}; nobody
            sees what you chose until then. Cards that help you or another player are played straight away.
            Tap the magnifier on any card to read it in full.</p>
        </section>
        <section>
          <h4>Armed shields</h4>
          <p>A shield is armed from your hand. An armed shield stays until it fires or you stand it down, and it
            fires by itself when you are attacked, whether or not you are here. The first on the list is spent
            first; use the arrows to change the order. A shield you have not armed still answers an attack on the
            evening it is in your hand.</p>
        </section>
        <section>
          <h4>Your discard</h4>
          <p>Everything you have played goes to your discard, which only you can see. When the draw pile runs out,
            the discard is shuffled back in, so nothing is ever lost.</p>
        </section>
      </div>
      {/* Aaron, 28 Sep 2026: send players to the library for the full list. */}
      <p className="tactics-how-lib">
        Every card, what it does, its rarity and its price are in the Combat Library.{" "}
        {onLibrary && <button type="button" className="btn secondary small" onClick={onLibrary}>Open the Combat Library</button>}
      </p>
    </details>
  );
}

/* ---- The Combat Library ------------------------------------------------
   All thirty cards that can be bought, and the one that cannot. Linked from
   the tactics how-it-works, and worth being complete rather than tidy: a
   deckbuilder where you cannot see what is in the market is a deckbuilder
   where buying is a coin toss, and the whole design rests on players making
   an informed choice about what to let into their deck. */
/* What a card in the library says in place of a price. Aaron, 28 Sep 2026:
   the super rares said "Starter only", which is wrong; they come from the
   wheel of fortune. */
function libraryBadge(c) {
  if (c.marketable) return fmt(c.price);
  /* No badge on a super rare: the note under the card says where it comes
     from, once (Aaron, 28 Sep 2026). */
  if (c.rarity === "superrare") return null;
  return c.starter ? "Starter only" : "Not for sale";
}

function CombatLibrary({ onClose }) {
  const [rarity, setRarity] = useState("all");
  const [kind, setKind] = useState("all");
  /* Every card in the library can be looked at closely (28 Sep 2026). */
  const [zoom, setZoom] = useState(null);
  const all = Object.values(DECK.cards || {}).sort((a, b) => a.n - b.n);
  const shown = all.filter((c) =>
    (rarity === "all" || c.rarity === rarity) &&
    (kind === "all" || (kind === "shield" ? c.kind === "shield" : c.kind !== "shield")));
  const price = (k) => fmt(((DECK.prices || {})[k]) || 0);

  return (
    <div className="res-overlay" role="dialog" aria-modal="true" aria-label="The Combat Library" onClick={onClose}>
      <div className="library" onClick={(e) => e.stopPropagation()}>
        <header className="library-head">
          <div>
            <p className="kicker">The tactics deck</p>
            <h2>The Combat Library</h2>
            <p className="library-sub">
              {/* Aaron's wording, 28 Sep 2026. */}
              Every card in the market. Three are offered to you each day of differing rarities,
              common ({price("common")}), uncommon ({price("uncommon")}) and rare ({price("rare")}).
              Super rare cards can be won from the wheel of fortune. Obtaining a card adds it to your
              deck for good, which makes every other card in it rarer. That is the whole game: a lean
              deck draws its best card most days, a fat one draws it once a fortnight.
            </p>
            <p className="library-sub">Tap any card to look at it closely.</p>
          </div>
          <button className="btn secondary small" onClick={onClose}>Close</button>
        </header>
        {/* THE RULES EVERY CARD IS SUBJECT TO. A card states its own cost and
            every effect it has; these apply to all of them, so they are said
            once here rather than on thirty cards. Aaron, 25 Sep 2026: nothing
            a player pays or suffers may go unwritten. */}
        <div className="library-rules">
          <b>Rules for every card</b>
          <p>
            Orders are confirmed during the day and all resolve together at {eveningAt()}. Security on a
            building makes an attack less likely to land and weakens any percentage it takes, by
            a tenth for each level. One shield answers each attack, armed shields first. A building
            takes at most three attacks an evening and a trader at most six. A trader who holds
            fewer than two buildings cannot be targeted. An order's cost is taken at {eveningAt()}
            whether it lands or not. One turned away by those limits or by an agreement costs nothing.
          </p>
        </div>
        <div className="library-filters">
          {[["all", "Everything"], ["common", "Common"], ["uncommon", "Uncommon"], ["rare", "Rare"], ["superrare", "Super rare"]].map(([k, label]) => (
            <button key={k} className={"chip" + (rarity === k ? " on" : "")} onClick={() => setRarity(k)}>{label}</button>
          ))}
          <span className="library-filter-gap" />
          {[["all", "All"], ["attack", "Offence"], ["shield", "Defence"]].map(([k, label]) => (
            <button key={k} className={"chip" + (kind === k ? " on" : "")} onClick={() => setKind(k)}>{label}</button>
          ))}
          <span className="library-count">{shown.length} of {all.length}</span>
        </div>
        <div className="library-grid">
          {shown.map((c) => (
            <figure key={c.id} className="library-item">
              <TacticCard id={c.id} size="mid" badge={libraryBadge(c)} onClick={() => setZoom(c.id)} onZoom={setZoom} />
              <figcaption>
                <b>{c.n}. {c.name}</b>
                {c.flavour ? <i>{c.flavour}</i> : null}
                {c.starter ? <em className="library-starter">Everyone starts with this one.</em> : null}
                {c.rarity === "superrare" ? <em className="library-starter">Only from the wheel of fortune.</em> : null}
              </figcaption>
            </figure>
          ))}
        </div>
      </div>
      {zoom && <div onClick={(e) => e.stopPropagation()}><CardZoom id={zoom} onClose={() => setZoom(null)} /></div>}
    </div>
  );
}

/* ---- THE EVENING RECKONING --------------------------------------------
   What a sealed order looks like when it resolves. Built from Aaron's war-
   table study, with three differences, all of them because this one is
   driven by what actually happened rather than by a preview button:

   1. IT DRAWS THE SECURITY THE BUILDING ACTUALLY HAS. The study always
      showed three layers. Real security runs 0 to 3, and at 0 there is
      nothing to breach — drawing three barriers round an undefended
      property would make security look like it was working when it was not,
      which is the one lie a screen like this must not tell.
   2. IT USES THE GAME'S MUTE SWITCH, not one of its own. A second sound
      toggle is a second thing to find and a second thing to disagree with
      the first.
   3. THE MINUTES ARE FROZEN on mount. The Planning Committee replay laid
      every token twice because the five-minute full-state resync re-ran its
      effect mid-flight; a ref taken once cannot do that.

   Three outcomes, which are exactly the three lib/combat.js returns:
   the shield fired, security held, or it went through.

   ---- WHAT WAS WRONG WITH IT, AND WHAT CHANGED --------------------------

   It was a loading bar. Three faults, and the first one is why:

   1. MOST ORDERS HAD NOTHING TO ANIMATE. The checkpoints are built from the
      target's security level, and most buildings in York have none — so
      `stages` was an empty array, nothing breached, and the only moving
      object on screen was a progress bar that ran for 1.3 seconds.
   2. NOTHING WAS WITHHELD. The card, the attacker, the building, the owner,
      the security level and the value were all on screen at frame zero, and
      every one of them was already in the list the player clicked "Watch"
      from. The single new fact was the verdict, and it arrived in one lump.
   3. THE VALUE WAS THE SPOILER. "VALUE £262,000" sat there from the start
      and later gained "-> £241,000". Putting the first number up early
      announces that a second one is coming.

   The fix is not more motion. It is finding the thing that is actually
   unknown and making the screen wait on it. In this game that is ONE
   question, and it is the same question for every order whatever the
   security level: IS ANYTHING ARMED? The attacker cannot know. The owner
   may not remember. So every replay now has a defence beat — the deed comes
   up out of the dark, there is a held moment where the answer is live, and
   only then does a shield snap up or fail to. The building is not named
   until the order reaches it, and the value is not quoted until it is
   struck.

   Length follows what happened rather than padding a bar: an undefended
   order is short because little happened to it, and a three-layer approach
   is long because it earned the time. */

const RECK_STAGE_MS = 820;
/* The held moment on the deed. Long enough to be a beat rather than a
   flicker, short enough that a player watching their fourth replay of the
   evening is not waiting on it. */
const RECK_HOLD_MS = 760;
/* The step numbers are a ladder, not a count: 0 is the order opened, 1..n
   are the checkpoints, 50 is the deed reached and the defence question
   open, 99 is the ruling. Named so a comparison reads as what it means. */
const RECK_DEED = 50;
const RECK_RULED = 99;
const RECK_REVEAL_PAD = 900;

function ReckoningTheatre({ outcome, onClose }) {
  /* Frozen on mount. See note 3 above — this is the bug that cost a day. */
  const o = useRef(outcome).current;
  const [step, setStep] = useState(-1);
  const card = DECK.cards[o.card] || { name: o.card, rarity: "common" };
  const meta = o.propertyId ? metaFor(o.propertyId) : null;
  const security = Math.max(0, Math.min(SECURITY_MAX_LEVEL, o.security || 0));
  const stages = Array.from({ length: security }, (_, i) => i);
  const mode = o.landed ? "success" : o.blockedBy === "security" || o.blockedBy === "trust" ? "denied"
    : o.blockedBy === "botched" ? "botched" : "shield";

  useEffect(() => {
    const ids = [];
    const at = (ms, fn) => ids.push(setTimeout(fn, ms));
    at(120, () => { setStep(0); cue(CUE_FOR["card." + o.card] || "tactic." + o.card); });
    stages.forEach((_, i) => at(400 + i * RECK_STAGE_MS, () => { setStep(i + 1); impact(0.05, 300); }));
    /* THE DEED, AND THE HELD MOMENT ON IT. The building arrives here rather
       than being on screen from the start, and the question of whether
       anything is armed is open for exactly as long as this beat lasts. A
       low note under it, because silence reads as the animation having
       stopped rather than as suspense. */
    /* A FLOOR ON THE APPROACH, and it is the undefended case that needs it.
       With no checkpoints the order arrived at the deed in 520ms, which is
       quick enough that the eye has not settled before the question is
       already being asked. 760ms minimum gives the plate time to come up out
       of the dark and be read as a building rather than as a flicker. A
       defended approach is already well past this and is untouched. */
    const deedAt = Math.max(760, 400 + stages.length * RECK_STAGE_MS + 120);
    at(deedAt, () => { setStep(RECK_DEED); tone(146.83, 0.5, 0.022, "sine"); });
    const revealAt = deedAt + RECK_HOLD_MS;
    at(revealAt, () => {
      setStep(RECK_RULED);
      if (mode === "success") { tone(392.0, 0.3, 0.035, "sine"); after(110, () => tone(523.25, 0.34, 0.03, "sine")); }
      else if (mode === "shield") { impact(0.12, 420); after(90, () => tone(123.47, 0.4, 0.03, "triangle")); }
      else { tone(220.0, 0.22, 0.03, "square"); after(130, () => tone(174.61, 0.3, 0.026, "square")); }
    });
    return () => ids.forEach(clearTimeout);
  }, []);

  /* A value knock and a cash lift are different sentences. Only one of them
     is ever true of a given card, and neither is true of a card that was
     stopped. */
  const lost = o.valueBefore && o.valueAfter ? o.valueBefore - o.valueAfter : 0;
  const took = o.took || 0;
  const RULING = {
    success: { kicker: "ORDER PENETRATED",
      title: `${meta ? meta.name : o.district ? (districtLabel(o.district) || "The district") : "The target"} was hit`,
      copy: lost ? `${card.name} went through everything in its way. ${fmt(lost)} came off the value.`
          : took ? `${card.name} went through everything in its way. ${fmt(took)} lifted in cash.`
                 : `${card.name} went through everything in its way.` },
    shield:  { kicker: "CONCEALED DEFENCE REVEALED",
      title: `${DECK.cards[o.blockedBy]?.name || "A shield"} stopped the order`,
      copy: o.blockedBy === "listed"
        ? "The building is listed. The order could not touch it."
        : "A defence nobody could see was armed and waiting. The order never reached the deed." },
    denied:  o.blockedBy === "trust"
      ? { kicker: "HELD IN TRUST", title: "The deed cannot be moved",
          copy: "The building is Held In Trust. No order can take it from its owner, and no concealed defence was spent." }
      : { kicker: "SECURITY HELD", title: "The order was turned back",
          copy: "The building's own protections rejected it. No concealed defence was spent." },
    botched: { kicker: "BOTCHED", title: "The job went wrong",
      copy: "Nothing was taken, and the owner was told who tried." },
  }[mode];

  return (
    <div className="res-overlay" role="dialog" aria-modal="true" aria-label="The evening reckoning" onClick={onClose}>
      <div className={"reckoning mode-" + mode + (step >= RECK_RULED ? " is-resolved" : "")
                      + (step === RECK_DEED ? " is-holding" : "")} onClick={(e) => e.stopPropagation()}>
        <header className="reck-head">
          <span>THE EVENING LOCK · NO RECALL</span>
          <h3>{step >= RECK_RULED ? RULING.title : "Order resolving"}</h3>
        </header>

        <div className="reck-line">
          <section className="reck-packet" aria-label="The order">
            <small>ORDER · {String(card.n || 0).padStart(2, "0")}</small>
            <b className="reck-packet-name">{card.name}</b>
            <em>{o.mine ? "Yours" : (o.attackerName || "An unnamed trader")}</em>
          </section>

          <div className="reck-route" aria-hidden="true">
            <span className="reck-rail"><i className="reck-fill" style={{ width: step >= RECK_DEED ? "100%" : `${Math.max(0, step) / (stages.length + 1) * 100}%` }} /></span>
            {security === 0
              ? (<div className={"reck-stage open" + (step >= RECK_DEED ? " reached" : "")}>
                   <b>UNDEFENDED</b>
                   <small>{step >= RECK_DEED ? "AT THE DEED" : "NOTHING IN THE WAY"}</small>
                 </div>)
              : stages.map((i) => (
                  <div key={i} className={"reck-stage" + (step > i ? " breached" : "")}>
                    <span>{String(i + 1).padStart(2, "0")}</span>
                    <b>{["PERIMETER", "SITE BARRIER", "DEED ACCESS"][i]}</b>
                    <small>{step > i ? "BREACHED" : "HOLDING"}</small>
                  </div>
                ))}
            {/* THE ANSWER, AND ONLY AT THE END. While the deed beat is
                running this space is deliberately empty and deliberately
                lit — the whole point is that nobody yet knows whether
                anything is going to come up out of it. */}
            {step === RECK_DEED
              ? <div className="reck-counter is-waiting"><span>?</span><b>ANYTHING ARMED?</b><small>UNKNOWN</small></div>
              : null}
            {mode === "shield" && step >= RECK_RULED
              ? <div className="reck-counter"><span>!</span><b>{DECK.cards[o.blockedBy]?.name || "DEFENCE"}</b><small>CONCEALED</small></div>
              : null}
            {mode !== "shield" && step >= RECK_RULED
              ? <div className="reck-counter is-clear"><span>—</span><b>NOTHING ARMED</b><small>THE DEED WAS OPEN</small></div>
              : null}
          </div>

          {/* THE BUILDING IS NOT NAMED UNTIL THE ORDER GETS THERE. It used
              to sit here from the first frame, which is the same building
              the player just clicked "Watch" beside — so the screen opened
              by telling them something they had come from. */}
          <figure className={"reck-target" + (step >= RECK_DEED ? " is-found" : " is-dark")}>
            <figcaption>
              {step >= RECK_DEED ? (
                <>
                  <small>{o.ownerName || "The owner"}</small>
                  <strong>{meta ? meta.name : "A property"}</strong>
                  <span>SECURITY {security} OF {SECURITY_MAX_LEVEL}</span>
                </>
              ) : (
                <>
                  <small>THE DEED</small>
                  <strong className="reck-unknown">· · ·</strong>
                  <span>NOT YET REACHED</span>
                </>
              )}
            </figcaption>
            {/* AND THE VALUE IS NOT QUOTED UNTIL IT IS STRUCK. A figure on
                screen from the start, later gaining an arrow and a second
                figure, tells the reader in advance that a second figure is
                coming. Both numbers arrive together or the first one is a
                spoiler for the second. */}
            {step >= RECK_RULED && o.valueBefore ? (
              <div className="reck-value">
                <span>VALUE</span>
                <b>{fmt(o.valueBefore)}</b>
                {o.landed && o.valueAfter && o.valueAfter !== o.valueBefore
                  ? <><i>→</i><b className="reck-after">{fmt(o.valueAfter)}</b></> : null}
              </div>
            ) : null}
            {step >= RECK_RULED && took ? (
              <div className="reck-value">
                <span>TAKEN</span>
                <b className="reck-after">{fmt(took)}</b>
              </div>
            ) : null}
          </figure>
        </div>

        <div className="reck-banner">
          <small>{step >= RECK_RULED ? RULING.kicker : step >= RECK_DEED ? "AT THE DEED" : "AWAITING RULING"}</small>
          <strong>{step >= RECK_RULED ? RULING.title
                 : step >= RECK_DEED ? "Is anything armed?"
                 : "Order in transit"}</strong>
          <p>{step >= RECK_RULED ? RULING.copy
            : step >= RECK_DEED
              ? "A shield fires by itself, and it does not have to be armed to do it: any shield in the hand will answer. Nobody knows whether one is waiting here: not the trader who sent this, and not always the one who owns it."
              : "Security, a concealed defence, or nothing at all. The ruling comes at the close."}</p>
          {o.position > 1 && step >= RECK_RULED
            ? <em className="reck-position">You were {["", "first", "second", "third", "fourth", "fifth", "sixth"][o.position] || o.position + "th"} onto it tonight.</em>
            : null}
        </div>

        <button className="btn" onClick={onClose}>{step >= RECK_RULED ? "Close" : "Skip"}</button>
      </div>
    </div>
  );
}

/* ---- THE TACTICS TAB ---------------------------------------------------
   Four cards in hand, three in the market, one evening. The old version of
   this tab was five buttons and a confirm dialog; the complaint that started
   all this was that it was the least engaging screen in a game where it
   should have been among the most.

   The shape of the screen follows the shape of the decision: what you are
   holding, what it would cost, and who it is going to happen to — in that
   order, with the cost quoted before anything is committed and a countdown
   running against the evening lock, because after 18:45 nothing can be
   changed and a player should never discover that by being refused. */
/* THE TARGET MAP.

   Picking a building out of a dropdown is picking a name out of a list of
   names, and a list of names tells you nothing about the thing that
   actually decides an attack: WHERE it is. Whether your rival's holdings
   are three streets in one district or scattered across the city, whether
   the building you are about to hit sits next to two of your own, whether
   somebody is quietly assembling the district you thought you led — none of
   that is in a dropdown and all of it is on a map.

   So: the fifteen districts, drawn plain, with a dot for every building the
   card could legally land on. Click a dot, it becomes the target. The
   dropdown stays beside it — a map is a bad way to pick precisely, and a
   worse way to pick with a keyboard — and the two are the same control
   from two directions.

   It borrows the city geometry the map tab already loads and caches, so it
   costs nothing once the map has been opened once, and loads it lazily if
   it has not. No roads, no buildings, no labels: at this size they would be
   noise, and the question here is only "where in town". */
function TargetMap({ candidates, chosen, onPick, tone = "#B03A2A", mine }) {
  const { geo } = useCityGeo(true);
  const model = useMemo(() => (geo ? buildCityModel(geo) : null), [geo, ALL_PROPERTIES.length]);
  const ids = useMemo(() => new Set((candidates || []).map((m) => m.id)), [candidates]);
  if (!candidates || !candidates.length) return null;
  if (!model) return <div className="target-map is-loading">Drawing the city…</div>;
  const pts = model.props.filter((p) => ids.has(p.id));
  const rest = model.props.filter((p) => !ids.has(p.id));
  return (
    <div className="target-map">
      <svg viewBox={`0 0 ${MAP_W} ${MAP_H}`} role="group"
           aria-label="The city, with every building this card could be played against">
        {model.cells.map((c) => (
          <path key={c.code} d={c.d} fill="#E7E0CB" stroke="#B9B098" strokeWidth="0.8" />
        ))}
        {/* Everything else in town, faintly — so the targets read as a
            pattern against the city rather than as the whole of it. */}
        {rest.map((p) => (
          <circle key={p.id} cx={p.x.toFixed(1)} cy={p.y.toFixed(1)} r="2.2" fill="#B9B098" />
        ))}
        {pts.map((p) => {
          const on = chosen === p.id;
          return (
            <g key={p.id} className="target-dot" onClick={() => onPick(p.id)}>
              <title>{p.meta.name} · {p.meta.neighbourhoodName || ""}</title>
              {/* The sheet is 340 units wide and draws at 150 CSS pixels, so
                  everything here is a bit over twice the size it looks in the
                  numbers — a 3-unit dot is a pixel and a half on screen, which
                  is not a thing anybody can click. */}
              {on && <circle cx={p.x.toFixed(1)} cy={p.y.toFixed(1)} r="12" fill="none"
                             stroke={tone} strokeWidth="2.4" />}
              <circle cx={p.x.toFixed(1)} cy={p.y.toFixed(1)} r={on ? 7 : 5.4}
                      fill={mine ? "#1F8A4C" : tone} stroke="#2B2A22" strokeWidth="1" />
              {/* The hit area is only a little bigger than the dot. A generous
                  one sounds kinder and is not: in a dense district the pads
                  overlap and you click a building two streets from the one you
                  aimed at, with no way to tell. What you can see is what you
                  get, and the dropdown beside this is there for precision. */}
              <circle cx={p.x.toFixed(1)} cy={p.y.toFixed(1)} r="8" fill="transparent" />
            </g>
          );
        })}
      </svg>
      <div className="hint">
        {chosen
          ? (metaFor(chosen) || {}).name
          : `${candidates.length} building${candidates.length === 1 ? "" : "s"} in range. Click one.`}
      </div>
    </div>
  );
}

/* WHAT IS STANDING GUARD, AND IN WHAT ORDER.

   Armed shields are the only cards that live across the daily discard, and
   until now there was nowhere to see them as a set — you had to click each
   card in turn to find out. They are also the only cards with an ORDER that
   matters, because exactly one answers each attack and it is the first on
   this list that can. So the list is the strategy: what you are willing to
   spend first.

   Absent entirely when nothing is armed. An empty panel explaining a
   mechanic nobody is using is clutter, and this tab has plenty already. */
/* AWAITING (Aaron, 29 Sep 2026). A confirmed order used to leave the hand
   and appear only as a line in the tactical history. It now has a section
   of its own under the hand, and it can be recalled until the evening
   lock: the card goes back into the hand and nothing is charged, because
   an order is paid for when it resolves. */
function AwaitingOrders({ deck, act, busy, players = {} }) {
  const waiting = deck.sealed || [];
  if (!waiting.length) return null;
  const target = (p) => p.propertyId ? <>on <PropLink id={p.propertyId} /></>
    : p.district ? <>on {districtLabel(p.district) || "a district"}</>
    : p.targetUser ? <>on {(players[p.targetUser] || {}).name || "a trader"}</>
    : null;
  return (
    <section className="deck-section awaiting-orders">
      <header className="deck-head">
        <h3>Awaiting</h3>
        <p className="deck-sub">Confirmed and waiting for the evening. You can recall one until the cards lock at {eveningAt(deck.locksAt)}.</p>
      </header>
      <ol className="armed-list">
        {waiting.map((p) => (
          <li key={p.id}>
            <span className="armed-n" aria-hidden="true">◷</span>
            <div className="armed-what">
              <b>{(DECK.cards[p.card] || {}).name || p.card}</b>
              <em>{target(p)}{p.cost ? <>, {fmt(p.cost)} at {eveningAt(p.resolveAt)}</> : null}</em>
              {p.held && <em>Held at the gate by a Watchman. It goes in at the next evening.</em>}
            </div>
            <div className="armed-acts">
              {p.canRecall ? (
                <button className="btn small recall-btn" disabled={!!busy}
                        onClick={() => act("card.recall", { play: p.id }, "recall:" + p.id)}>
                  Recall
                </button>
              ) : <span className="muted">{p.held ? "Held over" : "Locked"}</span>}
            </div>
          </li>
        ))}
      </ol>
      <p className="order-note">
        Recalling puts the card back in your hand and nothing is charged. An agreement the order
        broke when you confirmed it stays broken.
      </p>
    </section>
  );
}

function ArmedShields({ deck, owned, act, busy, players = {} }) {
  const byCopy = new Map((deck.hand || []).map((c) => [c.copy, c]));
  const armed = (deck.armedOrder || []).map((c) => byCopy.get(c)).filter((c) => c && c.armed);
  if (!armed.length) return null;
  const nameOf = (id) => (owned.find((m) => m.id === id) || {}).name || "a building";
  const move = (i, d) => {
    const order = armed.map((a) => a.copy);
    const j = i + d;
    if (j < 0 || j >= order.length) return;
    [order[i], order[j]] = [order[j], order[i]];
    act("card.order", { order }, "order");
  };
  return (
    <section className="deck-section armed-shields">
      <header className="deck-head"><h3>Armed shields</h3></header>
      <ol className="armed-list">
        {armed.map((c, i) => (
          <li key={c.copy}>
            <span className="armed-n">{i + 1}</span>
            <div className="armed-what">
              <b>{c.name}</b>
              {c.armedProp ? <em>over {nameOf(c.armedProp)}</em> : null}
              {c.armedUser ? <em>passes to {(players[c.armedUser] || {}).name || "a trader"}</em> : null}
            </div>
            <div className="armed-acts">
              <button className="btn secondary small" disabled={!!busy || i === 0}
                      title="Spend this one sooner" onClick={() => move(i, -1)}>↑</button>
              <button className="btn secondary small" disabled={!!busy || i === armed.length - 1}
                      title="Spend this one later" onClick={() => move(i, 1)}>↓</button>
              <button className="btn secondary small" disabled={!!busy}
                      onClick={() => act("card.disarm", { copy: c.copy }, "disarm")}>
                Stand down
              </button>
            </div>
          </li>
        ))}
      </ol>
      <p className="order-note">
        One shield answers each attack and it is the first on this list that can, so the
        order is which you are willing to spend first. Standing one down gives back its
        draw. It still answers an attack tonight, after the armed ones, but it goes with the
        rest of your hand at the next draw unless you arm it again.
      </p>
    </section>
  );
}


/* ---- THE TACTICAL HISTORY ---------------------------------------------
   Called the war history in the code — the wire key, the components and the
   CSS all still say `war`, and renaming those would touch the server patch,
   the merge whitelist and the protocol test for a word no player sees. To a
   player it is the TACTICAL HISTORY, which is the only name that appears on
   screen.

   The complaint this answers: players did not know they had been attacked.
   The evening's ruling went out as one notification and scrolled away, and
   the next morning a building was quietly earning less with no account of
   why. Fine while a strike cost 1.4% of a day; not fine now one stops
   twelve hours of rent.

   Read chronologically, newest night first, both directions in one list —
   a log of only what was done TO you reads as a grievance, and the exchange
   of blows is the part worth coming back for. Incoming rows carry the
   weight; your own are quieter.

   The attacker's name is whatever the server sent and nothing else. Most
   cards do not give one away, and inferring it here from anything else on
   the row would undo the secrecy the whole defence game rests on. */
const WAR_DAY = (t) => new Date(t).toLocaleDateString("en-GB",
  { weekday: "long", day: "numeric", month: "long" });
/* The outcome is a sentence, so it opens like one. Done in script rather
   than with ::first-letter because the same phrase is also painted onto the
   share card, where CSS cannot follow it. A figure is left alone — "£18,400
   lifted" is already doing the right thing. */
const sentence = (t) => (t ? t.charAt(0).toUpperCase() + t.slice(1) : t);


/* ---- SHARING A NIGHT --------------------------------------------------

   A picture, not a link. A shared URL needs a public page, a permalink and
   a decision about what a stranger is allowed to see of somebody else's
   game; a PNG needs none of that and is what actually gets posted. It also
   cannot leak anything, because it can only draw what the player was
   already looking at — an attacker the card never named is "an unnamed
   trader" on the card exactly as on the screen.

   Drawn at 1200x630 because that is the size every social preview crops
   to, and on the game's own paper rather than a generic dark card: the
   point of sharing it is that it looks like Civexio. */
const SHARE_W = 1200, SHARE_H = 630;

function drawShareCard(ctx, night, meName) {
  const PAPER = "#E7E1D2", INK = "#1B2420", FOREST = "#1F4B3F";
  const BRICK = "#A23E27", MUTED = "#6b6455", LINE = "#C6BC9F";
  const serif = "'Fraunces', Georgia, serif";
  const mono = "'IBM Plex Mono', ui-monospace, monospace";

  ctx.fillStyle = PAPER;
  ctx.fillRect(0, 0, SHARE_W, SHARE_H);

  /* Laid paper: hairlines every 4px at a whisper, so the ground has a
     texture at full size and reads as flat colour in a thumbnail. */
  ctx.strokeStyle = "rgba(27,36,32,0.035)";
  ctx.lineWidth = 1;
  for (let y = 0; y < SHARE_H; y += 4) {
    ctx.beginPath(); ctx.moveTo(0, y + 0.5); ctx.lineTo(SHARE_W, y + 0.5); ctx.stroke();
  }

  /* The city's band across the top, the same green the exchange wears. */
  ctx.fillStyle = FOREST;
  ctx.fillRect(0, 0, SHARE_W, 86);
  ctx.fillStyle = "#f2efe4";
  ctx.font = `700 30px ${serif}`;
  ctx.fillText("Civexio", 56, 54);
  ctx.fillStyle = "#b9cdbe";
  ctx.font = `400 22px ${serif}`;
  ctx.fillText("· York", 172, 54);
  ctx.fillStyle = "rgba(242,239,228,.72)";
  ctx.font = `500 13px ${mono}`;
  const kicker = `THE EVENING LOCK · ${eveningAt()}`;
  ctx.fillText(kicker, SHARE_W - 56 - ctx.measureText(kicker).width, 52);

  /* A rule box, like the Gazette's. */
  ctx.strokeStyle = LINE; ctx.lineWidth = 2;
  ctx.strokeRect(40, 118, SHARE_W - 80, SHARE_H - 118 - 72);

  const against = night.rows.filter((r) => !r.mine);
  const landed = against.filter((r) => r.status === "landed").length;

  ctx.fillStyle = INK;
  ctx.font = `700 42px ${serif}`;
  ctx.fillText(WAR_DAY(night.at), 72, 186);

  ctx.fillStyle = against.length ? BRICK : MUTED;
  ctx.font = `600 19px ${mono}`;
  ctx.fillText(
    against.length
      ? `${against.length} AGAINST YOU · ${landed} LANDED, ${against.length - landed} STOPPED`
      : "NOTHING CAME YOUR WAY",
    72, 220);

  ctx.strokeStyle = LINE; ctx.lineWidth = 1;
  ctx.beginPath(); ctx.moveTo(72, 244); ctx.lineTo(SHARE_W - 72, 244); ctx.stroke();

  /* FIVE ROWS. Rows start at y=288 and step 54, and the rule box closes at
     558 — so a sixth lands exactly on the frame and the seventh in the
     footer. Past five a shared card is a spreadsheet anyway, and nobody
     reposts a spreadsheet. */
  const SHOWN = 5;
  let y = 288;
  for (const r of night.rows.slice(0, SHOWN)) {
    const card = DECK.cards[r.card] || { name: r.card };
    const meta = r.property ? metaFor(r.property) : null;
    const stopped = r.status !== "landed";
    const what = stopped
      ? (r.blockedBy === "security" ? "turned back by security"
        : r.blockedBy === "listed" ? "the building was listed"
        : r.blockedBy === "trust" ? "the building is Held In Trust"
        : r.blockedBy === "botched" ? "botched, and the owner was told who tried"
        : DECK.cards[r.blockedBy] ? `stopped by ${DECK.cards[r.blockedBy].name}` : "stopped")
      : r.took ? `${fmt(r.took)} lifted`
      : r.valueOff ? `${fmt(r.valueOff)} off the value`
      : (r.did || "landed");

    if (!r.mine) { ctx.fillStyle = BRICK; ctx.fillRect(72, y - 20, 3, 28); }

    ctx.globalAlpha = r.mine ? 0.55 : 1;
    ctx.fillStyle = r.mine ? MUTED : BRICK;
    ctx.font = `600 12px ${mono}`;
    ctx.fillText(r.mine ? "YOURS" : "AGAINST YOU", 90, y - 6);

    ctx.fillStyle = INK;
    ctx.font = `600 21px ${serif}`;
    ctx.fillText(card.name, 232, y);

    ctx.fillStyle = MUTED;
    ctx.font = `400 17px ${serif}`;
    ctx.fillText(meta ? meta.name : "—", 560, y);

    ctx.fillStyle = stopped ? "#2F6B45" : (r.mine ? MUTED : "#B0342A");
    ctx.font = `500 16px ${mono}`;
    const line = sentence(what);
    const w = ctx.measureText(line).width;
    ctx.fillText(line, SHARE_W - 96 - w, y);
    ctx.globalAlpha = 1;

    y += 54;
  }

  if (night.rows.length > SHOWN) {
    ctx.fillStyle = MUTED;
    ctx.font = `400 15px ${serif}`;
    ctx.fillText(`and ${night.rows.length - SHOWN} more that night`, 232, y - 6);
  }

  ctx.fillStyle = MUTED;
  ctx.font = `400 16px ${serif}`;
  ctx.fillText(meName ? `${meName}'s ledger` : "A trader's ledger", 72, SHARE_H - 34);
  ctx.fillStyle = FOREST;
  ctx.font = `600 16px ${mono}`;
  const url = "play.civexio.com";
  ctx.fillText(url, SHARE_W - 72 - ctx.measureText(url).width, SHARE_H - 34);
}

async function shareWarNight(night, meName) {
  const canvas = document.createElement("canvas");
  canvas.width = SHARE_W; canvas.height = SHARE_H;
  const ctx = canvas.getContext("2d");
  if (!ctx) return false;
  /* The card is set in the game's faces. Drawing before they have loaded
     silently falls back to Times, which looks like somebody else's game. */
  try { if (document.fonts && document.fonts.ready) await document.fonts.ready; } catch { /* draw anyway */ }
  drawShareCard(ctx, night, meName);

  const blob = await new Promise((res) => canvas.toBlob(res, "image/png"));
  if (!blob) return false;
  const name = `civexio-${new Date(night.at).toISOString().slice(0, 10)}.png`;

  /* A phone shares; a desktop downloads. canShare is tested with the actual
     file because Safari reports true for share() and false for files. */
  try {
    const file = new File([blob], name, { type: "image/png" });
    if (navigator.canShare && navigator.canShare({ files: [file] })) {
      await navigator.share({ files: [file], title: "Civexio · York" });
      return true;
    }
  } catch { /* cancelled, or not permitted — fall through to the download */ }

  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url; a.download = name;
  document.body.appendChild(a); a.click(); a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 4000);
  return true;
}

/* ---- THE OPENING WEEK ---------------------------------------------------

   Opens by itself on the first look of the day and can be dismissed. Seven
   days, no reset: a missed day is shown as MISSED rather than quietly
   dropped, because the gap is the thing that makes tomorrow matter. Day one
   pays nothing and shows the week instead — a brand-new trader's first
   popup handing them nothing would be a poor greeting, so what it hands
   them is the reason to come back.

   THE ANIMATION HOOK. The card turning over is a one-line CSS class here
   (`is-opening`) and nothing else; Aaron's gacha sequence replaces the body
   of .ow-card without touching any of the state above it. */
/* THE OPENING WEEK, AS A WEEK RATHER THAN A ROW OF BOXES.

   What this replaced was seven equal boxes and a Take button: the reward was
   a line of text that appeared where the button had been, and the thing the
   player had actually won was never shown. An advent calendar whose doors do
   not open is a table.

   Three decisions, all Aaron's:

   NO BUTTON. The modal already opens by itself on the first look of each
   day, so the reward is taken the moment it is seen. A button between a
   player and a gift they cannot refuse is a click that asks nothing. The
   claim fires on mount and the animation plays off the server's answer, so
   what you watch is what was actually banked, not an optimistic guess.

   THE REWARDS ARE THE GAME'S, NOT THE BENCH'S. The study had ten thousand
   pounds, tactic cards and a rare-card finale. The week actually gives
   £25,000, ten skill, a committee veto, a pull of the wheel, £50,000 and a
   permanent cut to the opening stake. Art and copy follow lib/openingweek.js,
   which is the one list the panel, the claim and the test all read.

   NO PURPLE. The bench's finale was purple and purple is the Historic
   property tier. Day seven is gold. */

const OW_ACCENT = {
  1: "#87A980", 2: "#CFAA40", 3: "#718AB7", 4: "#C4774E",
  5: "#C49123", 6: "#E0B93C", 7: "#E8CA5A",
};

/* Bigger and longer as the week goes on, so day seven is visibly the end of
   something rather than the seventh of seven identical bursts. */
const OW_BURSTS = {
  1: { count: 40,  rings: 0, reach: 260, size: 9,  life: 1.0, spread: 0.45, ringGap: 0, ringLife: 0,
       colours: ["#CFE3D3", "#87A980", "#EFE8CE"] },
  2: { count: 90,  rings: 1, reach: 300, size: 11, life: 1.1, spread: 0.55, ringGap: 0.2, ringLife: 1,
       colours: ["#F1D66E", "#FFF0B0", "#CFAA40", "#FFFFFF"] },
  3: { count: 80,  rings: 1, reach: 300, size: 11, life: 1.1, spread: 0.55, ringGap: 0.2, ringLife: 1,
       colours: ["#9DB4DC", "#E8EEF8", "#718AB7", "#FFFFFF"] },
  4: { count: 80,  rings: 1, reach: 300, size: 11, life: 1.1, spread: 0.55, ringGap: 0.2, ringLife: 1,
       colours: ["#E0A07C", "#C4774E", "#F6E3D2", "#FFFFFF"] },
  5: { count: 120, rings: 3, reach: 340, size: 13, life: 1.3, spread: 0.7, ringGap: 0.24, ringLife: 1.05,
       colours: ["#E2C468", "#FFF3B1", "#C49123", "#E8A9B8", "#FFFFFF"] },
  6: { count: 140, rings: 3, reach: 340, size: 13, life: 1.3, spread: 0.75, ringGap: 0.24, ringLife: 1.05,
       colours: ["#F5DC68", "#FFF5C5", "#E0B93C", "#FFFFFF"] },
  7: { count: 230, rings: 7, reach: 380, size: 16, life: 1.8, spread: 1.7, ringGap: 0.23, ringLife: 1.2,
       colours: ["#F0D98A", "#FFF5C5", "#E8CA5A", "#B08C1E", "#FFFFFF"] },
};

/* One drawing per KIND, not per day, so the two cash days differ by the
   figure on them rather than by having been drawn twice. Everything is CSS
   and a handful of elements — no images to ship, and it recolours with the
   day accent. */
function OwArt({ reward }) {
  const k = reward.kind;
  if (k === "reveal") return (
    <div className="ow-art ow-art-brief" aria-hidden="true"><i /><i /><em>YORK</em></div>
  );
  if (k === "cash") return reward.day >= 6 ? (
    <div className="ow-art ow-art-vault" aria-hidden="true">
      <span>{reward.label}</span><i /><i />
    </div>
  ) : (
    <div className="ow-art ow-art-coins" aria-hidden="true">
      <span>£</span><i /><i /><i />
    </div>
  );
  if (k === "skill") return (
    <div className="ow-art ow-art-skill" aria-hidden="true">
      <span>{reward.value}</span><i /><i /><i />
    </div>
  );
  /* A permit, and the stamp that stops it. The veto is the only reward in
     the week that is a power over somebody else, so it is the only one drawn
     as an action rather than an object. */
  if (k === "veto") return (
    <div className="ow-art ow-art-veto" aria-hidden="true">
      <b><em>PERMIT</em><s /><s /><s /></b><span>VETO</span>
    </div>
  );
  /* The wheel in miniature, and it turns. Twenty divisions would be mush at
     this size, so it is eight — the point is the object, not the odds. */
  if (k === "wheel") return (
    <div className="ow-art ow-art-wheel" aria-hidden="true">
      <b /><i /><em>Y</em>
    </div>
  );
  /* A rate falling. The one reward that is not a thing you receive but a
     number that gets smaller, for good. */
  if (k === "rate") return (
    <div className="ow-art ow-art-rate" aria-hidden="true">
      <b>%</b><i /><span>▼</span>
    </div>
  );
  return <div className="ow-art" aria-hidden="true" />;
}

/* "Come back tomorrow for A free pull of the wheel" — the labels are written
   to stand alone as a card heading, so they start with a capital, and three
   of the seven then read wrong the moment they are dropped into a sentence.
   Lowered only where the word is an ordinary one: £50,000 and "10 skill
   points" are left exactly as they are. */
const lowerFirst = (t) =>
  (/^[A-Z](?=[a-z]|\s)/.test(t || "") && !/^I\s/.test(t)
    ? t[0].toLowerCase() + t.slice(1) : t);

function OpeningWeek({ week, onAct, onClose }) {
  const [awarded, setAwarded] = useState(false);
  const [message, setMessage] = useState(null);
  const [error, setError] = useState(null);
  const celebRef = useRef(null);
  const fired = useRef(false);

  const day = week ? week.day : 0;
  const canClaim = !!(week && week.canClaim);

  /* THE AWARD TAKES ITSELF.

     Fired once, on mount, guarded by a ref rather than by state so a
     re-render in the same tick cannot double-claim. The server refuses a
     second claim anyway, but a refusal that shows the player an error for a
     reward they did receive is worse than the request never being sent. */
  useEffect(() => {
    if (!week) return;
    if (fired.current) return;
    fired.current = true;
    if (!canClaim) return;
    let live = true;
    (async () => {
      const r = onAct ? await onAct("week.claim", {}) : null;
      if (!live) return;
      if (r && r.ok) { setMessage(r.message); setAwarded(true); }
      else if (r && r.error) setError(r.error);
      else setAwarded(true);
    })();
    return () => { live = false; };
  }, [week && week.day]);

  /* The confetti is built the moment the award lands, not on mount, so a day
     that awards nothing throws nothing. */
  useEffect(() => {
    if (!awarded) return;
    celebrationBurst(celebRef.current, OW_BURSTS[day]);
    return () => { if (celebRef.current) celebRef.current.replaceChildren(); };
  }, [awarded, day]);

  if (!week) return null;
  const cal = week.calendar || [];
  const today = week.today;
  const entered = cal.filter((c) => c.state === "taken").length + (awarded ? 1 : 0);

  return (
    <div className="modal-backdrop ow-backdrop" onClick={onClose}>
      <div className={"modal-box ow-box day-" + day + (awarded ? " awarded" : "")}
           style={{ "--ow-accent": OW_ACCENT[day] || "#B08C1E" }}
           onClick={(e) => e.stopPropagation()}
           role="dialog" aria-modal="true" aria-label="Your opening week">
        <div className="ow-patina" aria-hidden="true" />
        <div className="ow-rays" aria-hidden="true" />
        <div className="celebration-layer ow-celebration" ref={celebRef} aria-hidden="true" />

        <div className="modal-inner">
          <div className="ow-head">
            <div>
              <span className="ow-kicker">YOUR OPENING WEEK · DAY {day} OF {week.days}</span>
              <h3>Seven days of advantage</h3>
              <p className="ow-intro">
                {week.gated
                  ? "The finale is the one day the week does not hand over."
                  : "Your reward is taken the moment you enter the exchange."}
              </p>
            </div>
            <button className="switch-link" onClick={onClose}>Close</button>
          </div>

          <div className="ow-progress" aria-hidden="true">
            {cal.map((c) => (
              <i key={c.day} className={c.state === "taken" ? "done"
                : c.state === "missed" ? "missed"
                : c.day === day ? "current" : ""} />
            ))}
          </div>

          <div className="ow-track">
            {cal.map((c) => {
              const state = c.day === day && awarded ? "taken" : c.state;
              return (
                <div key={c.day}
                     className={"ow-card is-" + state + (c.day === day ? " is-active" : "")
                       + (c.day === day && awarded ? " is-awarded" : "")}
                     style={{ "--card-accent": OW_ACCENT[c.day] }}>
                  <header><span>DAY</span><b>{c.day}</b></header>
                  <OwArt reward={c} />
                  <div className="ow-copy">
                    <h4>{c.label}</h4>
                    <p>{c.detail}</p>
                  </div>
                  <footer>
                    <span className="ow-state">
                      {c.state === "missed" ? "Not entered"
                        : c.state === "taken" ? "Entered"
                        : c.day === day ? (awarded ? "Taken just now" : week.gated ? "Locked" : "Yours today")
                        : c.day === week.days ? `${Math.min(week.needs, week.have)} of ${week.needs} secured`
                        : `Opens day ${c.day}`}
                    </span>
                    <b className="ow-stamp">
                      {c.state === "missed" ? "MISSED" : c.day === day && week.gated ? "LOCKED" : "TAKEN"}
                    </b>
                  </footer>
                </div>
              );
            })}
          </div>

          <div className="ow-foot">
            <div className="ow-attend">
              <span>ATTENDANCE</span>
              <strong>{entered} of {week.days} days entered</strong>
            </div>
            <p className="ow-next">
              {error ? error
                : week.gated
                  ? `The last day needs ${week.needs} of days two to six, and you have ${week.have}. A week has to be lived rather than bookended.`
                : message ? message
                : day < week.days
                  ? `Come back tomorrow for ${lowerFirst((cal.find((c) => c.day === day + 1) || {}).label) || "the next of it"}.`
                  : "Your opening week is complete."}
            </p>
            <div className="ow-seal" aria-hidden="true"><span>Y</span><small>EBORACUM</small></div>
          </div>
        </div>

        {/* The verdict, over the week. Same device as the wheel's, for the
            same reason: the answer lands on top of the thing it answers. */}
        {today && (
          <div className="ow-banner" aria-live="polite">
            {day === week.days && <i className="ow-crown" aria-hidden="true">♛</i>}
            <small>DAY {day} ENTERED</small>
            <strong>{today.label}</strong>
            <p>{today.detail}</p>
            <i className="ow-marquee" aria-hidden="true" />
          </div>
        )}
      </div>
    </div>
  );
}

/* ---- THE WHEEL ----------------------------------------------------------

   THE SERVER DRAWS THE SLICE AND THE WHEEL IS TOLD WHERE TO STOP. Never the
   other way round: a wheel that decides its own result in the browser is a
   wheel anybody can decide for themselves. `spinWheel` returns the index and
   this spins to it, which is also why the animation can be replaced wholesale
   without the odds changing by a hair.

   THE HOOK FOR THE REAL ANIMATION. Everything below the button is a single
   component reading one number — `landedOn`. Aaron's wheel drops in by
   replacing WheelFace and keeping that prop. */
/* THE WHEEL, AS DRAWN IN THE BENCH.

   This is the v1 study Aaron built, ported rather than reinterpreted: the
   wedges, the prize written radially on each, a numbered ring outside them,
   brass studs, the engraved inscription, a ridged gold rim with an inner
   bezel, the Y FORTUNE hub, and a crowned pointer the wheel passes under.

   The version this replaces was a CSS conic-gradient — twenty coloured
   segments with no divisions, no numbers, no rim and a "▼" for a pointer.
   At tile size that is a pie chart, and a pie chart does not read as
   something you spin.

   The SVG is built from the slice list the SERVER sends, so renaming a
   prize or reordering the wheel needs no drawing work here. The rim, bezel,
   hub and pointer are CSS layers over it, exactly as the bench has them. */
const WHEEL_COLOURS = {
  /* Claret, not the bench's purple: purple is the Historic property tier.
     See the note in lib/cards.js. */
  "super-rare": "#7E1F33", "rare": "#a57b16", "cash-50": "#8b6723",
  "cash-25": "#315e4d", "skill": "#31486d", "uncommon": "#737b7f",
  "security": "#21483f", "cards": "#544272", "again": "#66532f",
};
const wheelPolar = (r, deg) => {
  const a = (deg - 90) * Math.PI / 180;
  return [300 + r * Math.cos(a), 300 + r * Math.sin(a)];
};

/* THE WHEEL'S OWN VOICE.

   Ported from the v1 bench, not approximated. The first attempt shipped the
   DRAWING and left the animation behind — a CSS transition and a pointer
   wobble — and was described as "the v1 wheel", which it was not. This is
   the rest of it: a filtered-noise whirr that tracks the wheel's speed, a
   tick as every division passes under the tongue, and a verdict chord that
   knows what you won.

   It uses the GAME's AudioContext via audio(), not one of its own, so the
   "Mute all audio" switch in the account panel governs it like everything
   else. One context per page; a second would keep playing after a mute. */
let wheelNoise = null;
function wheelNoiseBuffer(ctx) {
  if (wheelNoise && wheelNoise.sampleRate === ctx.sampleRate) return wheelNoise.buf;
  const buf = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate);
  const d = buf.getChannelData(0);
  for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
  wheelNoise = { sampleRate: ctx.sampleRate, buf };
  return buf;
}

/* The rushing of the rim, band-passed and driven by speed. Held open for the
   whole spin and faded rather than stopped, so it does not click off. */
function wheelWhirrStart() {
  if (!soundPreferred()) return null;
  const ctx = audio(); if (!ctx) return null;
  try {
    const src = ctx.createBufferSource();
    src.buffer = wheelNoiseBuffer(ctx); src.loop = true;
    const filter = ctx.createBiquadFilter(); filter.type = "bandpass"; filter.Q.value = 2.2;
    const gain = ctx.createGain(); gain.gain.value = 0.0001;
    src.connect(filter).connect(gain).connect(ctx.destination);
    src.start();
    return { ctx, src, filter, gain };
  } catch { return null; }
}
function wheelWhirrUpdate(w, speed) {
  if (!w) return;
  try {
    const now = w.ctx.currentTime, n = Math.min(1, speed / 1500);
    w.filter.frequency.setTargetAtTime(160 + n * 1450, now, 0.025);
    w.gain.gain.setTargetAtTime(0.008 + n * 0.058, now, 0.025);
  } catch { /* a context torn down mid-spin is not worth a crash */ }
}
function wheelWhirrStop(w) {
  if (!w) return;
  try {
    const now = w.ctx.currentTime;
    w.gain.gain.setTargetAtTime(0.0001, now, 0.12);
    w.src.stop(now + 0.42);
  } catch { /* ignore */ }
}

/* One division passing the tongue: two short oscillators plus a noise chip.
   Lighter and higher the faster the wheel is going, which is what makes the
   slowing audible before it is visible. */
function wheelTick(speed, delay = 0) {
  if (!soundPreferred()) return;
  const ctx = audio(); if (!ctx) return;
  try {
    const now = ctx.currentTime + delay;
    const fast = Math.min(1, speed / 900), weight = 0.078 - fast * 0.027;
    [0, 0.014].forEach((offset, i) => {
      const t0 = now + offset;
      const osc = ctx.createOscillator(), gain = ctx.createGain(), filter = ctx.createBiquadFilter();
      osc.type = i ? "square" : "triangle";
      osc.frequency.setValueAtTime((i ? 118 : 174) + fast * 72, t0);
      osc.frequency.exponentialRampToValueAtTime(i ? 76 : 105, t0 + 0.045);
      filter.type = "bandpass"; filter.frequency.value = i ? 760 : 1180; filter.Q.value = i ? 1.8 : 1.25;
      gain.gain.setValueAtTime(0.0001, t0);
      gain.gain.exponentialRampToValueAtTime(weight * (i ? 0.52 : 1), t0 + 0.002);
      gain.gain.exponentialRampToValueAtTime(0.0001, t0 + (i ? 0.047 : 0.071));
      osc.connect(filter).connect(gain).connect(ctx.destination);
      osc.start(t0); osc.stop(t0 + 0.085);
    });
    const noise = ctx.createBufferSource(), ng = ctx.createGain(), nf = ctx.createBiquadFilter();
    noise.buffer = wheelNoiseBuffer(ctx);
    nf.type = "bandpass"; nf.frequency.value = 1450; nf.Q.value = 0.9;
    ng.gain.setValueAtTime(0.035 - fast * 0.01, now);
    ng.gain.exponentialRampToValueAtTime(0.0001, now + 0.036);
    noise.connect(nf).connect(ng).connect(ctx.destination);
    noise.start(now); noise.stop(now + 0.05);
  } catch { /* ignore */ }
}

/* The award, and it knows what it is announcing: four notes for an ordinary
   slice, five for a rare, seven plus bells, drums and a crash for the
   jackpot. */
function wheelVerdict(sliceId) {
  if (!soundPreferred()) return;
  const ctx = audio(); if (!ctx) return;
  try {
    const now = ctx.currentTime;
    const sup = sliceId === "super-rare", rare = sliceId === "rare", free = sliceId === "again";
    const notes = sup ? [62, 66, 69, 74, 78, 81, 86] : rare ? [62, 66, 69, 74, 78]
      : free ? [57, 62, 66, 69, 74] : [62, 65, 69, 74];
    const hz = (n) => 440 * Math.pow(2, (n - 69) / 12);
    notes.forEach((note, i) => {
      const osc = ctx.createOscillator(), gain = ctx.createGain();
      osc.type = i % 2 ? "triangle" : "sine";
      osc.frequency.value = hz(note);
      const t0 = now + i * (sup ? 0.068 : free ? 0.09 : 0.085);
      gain.gain.setValueAtTime(0.0001, t0);
      gain.gain.exponentialRampToValueAtTime(sup ? 0.14 : rare ? 0.105 : 0.085, t0 + 0.018);
      gain.gain.exponentialRampToValueAtTime(0.0001, t0 + (sup ? 1.55 : rare ? 1.1 : 0.85));
      osc.connect(gain).connect(ctx.destination);
      osc.start(t0); osc.stop(t0 + 1.65);
    });
    if (rare || sup) {
      const crash = ctx.createBufferSource(), cg = ctx.createGain(), cf = ctx.createBiquadFilter();
      crash.buffer = wheelNoiseBuffer(ctx);
      cf.type = "highpass"; cf.frequency.value = 1900;
      cg.gain.setValueAtTime(0.0001, now);
      cg.gain.exponentialRampToValueAtTime(sup ? 0.16 : 0.1, now + 0.025);
      cg.gain.exponentialRampToValueAtTime(0.0001, now + (sup ? 2.6 : 1.55));
      crash.connect(cf).connect(cg).connect(ctx.destination);
      crash.start(now); crash.stop(now + 2.7);
      (sup ? [0, 0.28, 0.57, 1.02, 1.34] : [0, 0.38]).forEach((offset, i) => {
        const drum = ctx.createOscillator(), dg = ctx.createGain(), t0 = now + offset;
        drum.type = "sine";
        drum.frequency.setValueAtTime(i % 2 ? 105 : 132, t0);
        drum.frequency.exponentialRampToValueAtTime(42, t0 + 0.23);
        dg.gain.setValueAtTime(sup ? 0.17 : 0.12, t0);
        dg.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.42);
        drum.connect(dg).connect(ctx.destination);
        drum.start(t0); drum.stop(t0 + 0.45);
      });
    }
    if (sup) [74, 78, 81, 86, 90].forEach((note, i) => {
      const bell = ctx.createOscillator(), bg = ctx.createGain(), t0 = now + 0.82 + i * 0.095;
      bell.type = "sine"; bell.frequency.value = hz(note);
      bg.gain.setValueAtTime(0.0001, t0);
      bg.gain.exponentialRampToValueAtTime(0.1, t0 + 0.008);
      bg.gain.exponentialRampToValueAtTime(0.0001, t0 + 1.8);
      bell.connect(bg).connect(ctx.destination);
      bell.start(t0); bell.stop(t0 + 1.9);
    });
    const low = ctx.createOscillator(), lg = ctx.createGain();
    low.type = "sine";
    low.frequency.setValueAtTime(92, now);
    low.frequency.exponentialRampToValueAtTime(54, now + 0.4);
    lg.gain.setValueAtTime(0.1, now);
    lg.gain.exponentialRampToValueAtTime(0.0001, now + 0.55);
    low.connect(lg).connect(ctx.destination);
    low.start(now); low.stop(now + 0.6);
  } catch { /* ignore */ }
}

/* THE SPIN ITSELF, DRIVEN FRAME BY FRAME.

   The first port used `transition: transform 5.4s` and called it done. A CSS
   transition cannot tick, cannot drive a whirr from its own speed, and
   cannot flick a pointer off each division, because nothing in it knows how
   fast the wheel is going at any instant. All three of those ARE the
   animation.

   So this runs the bench's loop: eight-plus turns eased by 1-(1-t)^2.65 over
   8.2 seconds, the derivative giving instantaneous speed, a tick fired on
   every division boundary crossed, and a pointer that ratchets while the
   wheel is slow and springs while it is fast.

   THE SERVER DECIDES WHERE IT STOPS. The bench chose its own index; here the
   index arrives from the server before a frame is drawn, and the animation
   is told where to land. A wheel that picks its own result in the browser is
   one anybody can pick for themselves.

   Reduced motion gets the result with no spin, no whirr and no ticks — the
   verdict chord still plays, because that is an announcement rather than
   motion. */
const WHEEL_SPIN_MS = 8200;

/* THE CELEBRATION.

   Three of the twenty divisions are worth more than a line of text, and each
   is given its own treatment so you know what you have before you read it:

     super rare    claret and gold. Two hundred and forty pieces, seven burst
                   rings, rays across the whole face, and the panel itself
                   flashing behind it. Once in twenty pulls.
     rare          gold alone. A hundred and ten pieces, three rings, one
                   sweep of rays, no panel flash.
     another pull  green. Forty six pieces, no rings, no rays. A small
                   kindness, and it should read as one.

   Everything else lands quietly, which is the only reason these three mean
   anything. Ported from the v1 bench with the purple taken out: purple is
   the Historic property tier and cannot also mean jackpot. */
const WHEEL_BURSTS = {
  "super-rare": { count: 240, rings: 7, reach: 380, size: 16, life: 1.8,  spread: 1.9,
                  ringGap: 0.23, ringLife: 1.2,
                  colours: ["#F0D98A", "#FFF5C5", "#C4405C", "#E8A9B8", "#FFFFFF"] },
  "rare":       { count: 110, rings: 3, reach: 320, size: 12, life: 1.15, spread: 0.58,
                  ringGap: 0.28, ringLife: 1,
                  colours: ["#F5DC68", "#FFF3B1", "#C49123", "#FFFFFF"] },
  "again":      { count: 46,  rings: 0, reach: 320, size: 11, life: 1.15, spread: 0.58,
                  ringGap: 0, ringLife: 0,
                  colours: ["#8DE0B6", "#E8F0B6", "#5CA88C"] },
};

/* Several hundred nodes that live for two seconds and are then thrown away.
   Built by hand rather than rendered, because React would have to diff every
   one of them on each unrelated state change for no benefit at all.

   Takes a spec rather than a wheel outcome, because the opening week throws
   the same confetti for its own seven days and two copies of this would
   drift apart the first time either was tuned. */
function celebrationBurst(layer, spec) {
  if (!layer) return;
  if (layer.__sweep) { clearTimeout(layer.__sweep); layer.__sweep = 0; }
  layer.replaceChildren();
  if (!spec) return;
  if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
  const rnd = Math.random;
  for (let i = 0; i < spec.count; i++) {
    const piece = document.createElement("i");
    /* One in three a streamer, one in seven a star, one in nine a coin. The
       overlaps are deliberate — they thin the rarer shapes out. */
    piece.className = "celebration-particle" +
      (i % 9 === 0 ? " coin" : i % 7 === 0 ? " star" : i % 3 === 0 ? " streamer" : "");
    const angle = rnd() * Math.PI * 2;
    const reach = 140 + rnd() * spec.reach;
    const s = piece.style;
    s.setProperty("--travel-x", (Math.cos(angle) * reach).toFixed(1) + "px");
    s.setProperty("--travel-y", (Math.sin(angle) * reach - (50 + rnd() * 160)).toFixed(1) + "px");
    s.setProperty("--size", (7 + rnd() * spec.size).toFixed(1) + "px");
    s.setProperty("--particle", spec.colours[Math.floor(rnd() * spec.colours.length)]);
    s.setProperty("--duration", (0.9 + rnd() * spec.life).toFixed(2) + "s");
    s.setProperty("--delay", (rnd() * spec.spread).toFixed(2) + "s");
    s.setProperty("--start-rotation", (rnd() * 90).toFixed(0) + "deg");
    s.setProperty("--end-rotation", (360 + rnd() * 1200).toFixed(0) + "deg");
    layer.append(piece);
  }
  for (let i = 0; i < spec.rings; i++) {
    const ring = document.createElement("b");
    ring.className = "burst-ring";
    const s = ring.style;
    s.setProperty("--ring-x", (25 + rnd() * 50).toFixed(1) + "%");
    s.setProperty("--ring-y", (22 + rnd() * 48).toFixed(1) + "%");
    s.setProperty("--ring-color", spec.colours[i % spec.colours.length]);
    s.setProperty("--ring-delay", (0.08 + i * spec.ringGap).toFixed(2) + "s");
    s.setProperty("--ring-duration", spec.ringLife + "s");
    layer.append(ring);
  }
  /* Several hundred finished nodes are still several hundred nodes. Sweep
     them once the last one has flown, so the page does not carry a dead
     confetti field around for the rest of the evening. */
  const life = (spec.spread + 0.9 + spec.life + 0.4) * 1000;
  layer.__sweep = setTimeout(() => { layer.replaceChildren(); layer.__sweep = 0; }, life);
}

const wheelCelebration = (layer, type) => celebrationBurst(layer, WHEEL_BURSTS[type]);

function WheelFace({ landedOn, spinning, slices, onSettled, won = null }) {
  const list = (slices && slices.length) ? slices : [];
  const n = list.length || 20;
  const step = 360 / n;
  const wheelRef = useRef(null);
  const pointerRef = useRef(null);
  const rotation = useRef(0);
  const raf = useRef(0);
  const celebRef = useRef(null);

  useEffect(() => {
    if (landedOn == null) return;
    const wheel = wheelRef.current, tongue = pointerRef.current;
    if (!wheel) return;

    const reduced = window.matchMedia
      && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const slice = list[landedOn] || {};
    const land = () => {
      wheelVerdict(slice.id);
      wheelCelebration(celebRef.current, slice.id);
      if (onSettled) onSettled(slice.id);
    };

    if (reduced) {
      rotation.current = 360 - landedOn * step;
      wheel.style.transform = `rotate(${rotation.current}deg)`;
      land();
      return;
    }

    /* Where it has to finish, and how far round to get there. */
    const currentMod = ((rotation.current % 360) + 360) % 360;
    const targetMod = ((360 - landedOn * step) % 360 + 360) % 360;
    const alignment = (targetMod - currentMod + 360) % 360;
    const delta = (8 + Math.floor(Math.random() * 2)) * 360 + alignment;
    const start = performance.now(), from = rotation.current;

    let previous = start;
    let lastBoundary = Math.floor(from / step);
    const pointerState = { angle: 0, velocity: 0 };
    const whirr = wheelWhirrStart();

    const paint = () => {
      if (tongue) tongue.style.transform =
        `translateX(-50%) rotate(${pointerState.angle.toFixed(2)}deg)`;
    };
    /* Fast: the tongue is thrown off each stud and springs back. */
    const settle = (dt) => {
      pointerState.velocity += (-185 * pointerState.angle - 19 * pointerState.velocity) * dt;
      pointerState.angle = Math.max(-24, Math.min(8,
        pointerState.angle + pointerState.velocity * dt));
      paint();
    };
    /* Slow: it rides the edge of each division and drops off the end. */
    const ratchet = (dt, speed, phase) => {
      if (speed > 165) return settle(dt);
      const safe = phase < 0.0001 || 1 - phase < 0.0001 ? 0 : phase;
      const target = -21 * Math.pow(safe, 1.72);
      const response = 1 - Math.exp(-dt * (speed < 55 ? 25 : 18));
      pointerState.angle += (target - pointerState.angle) * response;
      pointerState.velocity = 0;
      paint();
    };

    const frame = (now) => {
      const t = Math.min(1, (now - start) / WHEEL_SPIN_MS);
      const eased = 1 - Math.pow(1 - t, 2.65);
      const dt = Math.min(0.04, (now - previous) / 1000);
      /* The derivative of the easing IS the speed, which is what lets the
         sound and the pointer know what the wheel is doing. */
      const speed = delta * (2.65 * Math.pow(1 - t, 1.65)) / (WHEEL_SPIN_MS / 1000);

      rotation.current = from + delta * eased;
      wheel.style.transform = `rotate(${rotation.current}deg)`;
      wheelWhirrUpdate(whirr, speed);

      const boundary = Math.floor(rotation.current / step);
      if (boundary !== lastBoundary) {
        const crossings = Math.min(3, Math.abs(boundary - lastBoundary));
        for (let i = 0; i < crossings; i++) wheelTick(speed, i * 0.006);
        if (speed > 165) {
          pointerState.velocity = Math.min(pointerState.velocity, -(55 + speed * 0.12));
        }
        lastBoundary = boundary;
      }
      const raw = rotation.current / step;
      ratchet(dt, speed, ((raw - Math.floor(raw)) % 1 + 1) % 1);
      previous = now;

      if (t < 1) { raf.current = requestAnimationFrame(frame); return; }

      rotation.current = from + delta;
      wheel.style.transform = `rotate(${rotation.current}deg)`;
      wheelWhirrStop(whirr);

      /* The tongue is still swinging when the wheel stops; the result waits
         for it to come to rest, because that is the moment the answer is
         actually given. */
      const settleStart = performance.now();
      const finish = (time) => {
        const d = Math.min(0.04, (time - previous) / 1000);
        previous = time;
        settle(d);
        if (time - settleStart < 520 || Math.abs(pointerState.angle) > 0.18) {
          raf.current = requestAnimationFrame(finish); return;
        }
        pointerState.angle = 0; pointerState.velocity = 0; paint();
        land();
      };
      raf.current = requestAnimationFrame(finish);
    };
    raf.current = requestAnimationFrame(frame);

    return () => {
      cancelAnimationFrame(raf.current);
      wheelWhirrStop(whirr);
      if (celebRef.current) celebRef.current.replaceChildren();
    };
  }, [landedOn]);

  return (
    <div className={"wheel-stage" + (spinning ? " is-spinning" : "")}>
    <div className={"wheel-rig" + (spinning ? " is-spinning" : "")}>
      <div className="speed-lines" aria-hidden="true" />
      <div className="pointer-assembly" aria-hidden="true">
        <div className="pointer-crown">♛</div>
        <div className="pointer" ref={pointerRef}><span /></div>
        <div className="pointer-pin" />
      </div>
      <div className="wheel-shadow" aria-hidden="true" />
      <div className="wheel" ref={wheelRef}
           role="img" aria-label={`A wheel of ${n} equal divisions`}>
        <svg viewBox="0 0 600 600" aria-hidden="true">
          <defs>
            <path id="ukpx-wheel-inscription"
                  d="M300 300 m-118 0 a118 118 0 1 1 236 0 a118 118 0 1 1 -236 0" />
          </defs>
          <circle cx="300" cy="300" r="292" fill="#533914" />
          {list.map((sl, i) => {
            const a0 = i * step - step / 2, a1 = a0 + step;
            const [x0, y0] = wheelPolar(284, a0), [x1, y1] = wheelPolar(284, a1);
            const [lx, ly] = wheelPolar(205, i * step);
            const [nx, ny] = wheelPolar(264, i * step);
            const [sx, sy] = wheelPolar(290, i * step + step / 2);
            const lines = Array.isArray(sl.label) ? sl.label : [String(sl.label || "")];
            return (
              <g key={i}>
                <path className={"wheel-slice" + (won === i ? " is-won" : "")} fill={WHEEL_COLOURS[sl.id] || "#315e4d"}
                      d={`M300 300 L${x0.toFixed(2)} ${y0.toFixed(2)} A284 284 0 0 1 ${x1.toFixed(2)} ${y1.toFixed(2)} Z`} />
                <text className="wheel-label" x={lx.toFixed(2)} y={(ly - (lines.length - 1) * 7).toFixed(2)}
                      transform={`rotate(${i * step} ${lx.toFixed(2)} ${ly.toFixed(2)})`}>
                  {lines.map((ln, j) => (
                    <tspan key={j} x={lx.toFixed(2)} dy={j === 0 ? 0 : 14}>{ln}</tspan>
                  ))}
                </text>
                <text className="wheel-number" x={nx.toFixed(2)} y={(ny + 3).toFixed(2)}>
                  {String(i + 1).padStart(2, "0")}
                </text>
                <circle className="wheel-stud" cx={sx.toFixed(2)} cy={sy.toFixed(2)} r="5" />
              </g>
            );
          })}
          <circle cx="300" cy="300" r="93" fill="none" stroke="#e8cf6d" strokeWidth="5" opacity=".8" />
          <text fill="#ead47d" fontFamily="'IBM Plex Mono', monospace" fontSize="10"
                letterSpacing="3" opacity=".82">
            <textPath href="#ukpx-wheel-inscription" startOffset="1%">
              EBORACUM · YORK MINSTER · CITY WALLS · CLIFFORD’S TOWER · RIVER OUSE ·
            </textPath>
          </text>
        </svg>
        <div className="wheel-rim rim-outer" aria-hidden="true" />
        <div className="wheel-rim rim-inner" aria-hidden="true" />
        <div className="hub" aria-hidden="true"><span>Y</span><small>FORTUNE</small></div>
      </div>
    </div>
      <div className="celebration-rays" aria-hidden="true" />
      <div className="result-flare" aria-hidden="true" />
      <div className="celebration-layer" ref={celebRef} aria-hidden="true" />

      {/* All three are always here and all three are invisible until the
          panel's outcome class picks one. Putting the verdict OVER the wheel
          is most of what makes a jackpot feel like one — the bench had this
          and the first port left it on the floor. */}
      <div className="award-banner rare-banner" aria-hidden="true">
        <small>GAMING COUNCIL</small><strong>RARE CARD</strong>
        <span>ADDED TO YOUR DECK</span>
      </div>
      <div className="award-banner jackpot-banner" aria-hidden="true">
        <b className="jackpot-crown">♛</b>
        <small>YORK GAMING COUNCIL</small><strong>JACKPOT</strong>
        <span>SUPER RARE, YOUR PICK</span>
        <i className="marquee-lights" />
      </div>
      <div className="award-banner free-spin-banner" aria-hidden="true">
        <small>FORTUNE RETURNS</small><strong>ANOTHER PULL</strong>
        <span>SPIN AGAIN NOW</span>
      </div>
    </div>
  );
}

/* THE CHOICE COMES AFTER THE WIN, NOT BEFORE THE SPIN.

   The panel used to ask "if it lands on the jackpot, take —" before you had
   pulled anything, which is a question about a thing that happens once in
   twenty pulls, asked every single time. It made the moment smaller too: the
   prize was already decided before the wheel moved.

   Now the spin grants an ENTITLEMENT, held server-side so closing the tab
   does not eat it, and the choosing happens in front of the four cards with
   the wheel stopped on claret. */
function WheelPanel({ wheel, onActResult }) {
  const [busy, setBusy] = useState(false);
  const [result, setResult] = useState(null);
  /* The outcome class drives everything the panel itself does — the felt
     flash, the border flare, the shudder. It is set the moment the tongue
     rests, not when the request came back, and it is cleared on the next
     pull so nothing is ever animating under a spinning wheel. */
  const [burst, setBurst] = useState(null);
  const owed = wheel && wheel.owed;

  const spin = async () => {
    if (busy) return;
    setBusy(true); setResult(null); setBurst(null);
    /* First steps holds "You are in" from here until the wheel rests. */
    try { window.dispatchEvent(new CustomEvent("civexio:wheelspin")); } catch {}
    try {
      const r = onActResult ? await onActResult("wheel.spin", {}) : null;
      /* The verdict is withheld until the wheel has stopped and the tongue
         has come to rest — WheelFace calls onSettled at that moment. A timer
         would have to guess, and would be wrong the moment the spin length
         or the settle changes. */
      if (r && r.ok) setResult({ ...r, revealed: false });
      else if (r && r.error) setResult({ error: r.error, revealed: true });
    } finally { setBusy(false); }
  };

  const claim = async (card) => {
    if (busy) return;
    setBusy(true);
    try { if (onActResult) await onActResult("wheel.claim", { card }); }
    finally { setBusy(false); }
  };

  if (!wheel) return null;
  const jackpot = result && result.revealed && result.slice === "super-rare";

  return (
    <section className={"wheel-panel"
        + (result && result.revealed ? " is-result" : "")
        + (burst ? " result-" + burst : "")}>
      <header className="wheel-head">
        <h3>The wheel of fortune</h3>
        <p>Twenty equal divisions. One of them is a super rare card, and it is the
           only place one comes from.</p>
      </header>

      <WheelFace landedOn={result && !result.error ? result.index : null}
                 won={result && result.revealed && !result.error ? result.index : null}
                 spinning={busy} slices={wheel.slices}
                 onSettled={(id) => {
                   /* First steps waits for this before it says "You are in". */
                   try { window.dispatchEvent(new CustomEvent("civexio:wheel", { detail: id })); } catch {}
                   setResult((x) => (x ? { ...x, revealed: true } : x));
                   setBurst(WHEEL_BURSTS[id] ? id : null);
                 }} />

      <div className="wheel-act">
        <button className="btn wheel-go" disabled={busy || !wheel.canSpin || !!owed} onClick={spin}>
          {busy ? "The wheel is in motion"
            : wheel.free > 0 ? `Spin: ${wheel.free} free pull${wheel.free === 1 ? "" : "s"}`
            : `Spin for ${wheel.cost} skill`}
        </button>
        {!wheel.canSpin && !owed && (
          <p className="wheel-note">
            A pull costs {wheel.cost} skill and you have {wheel.skill}. Skill comes from the
            Gaming Council, which is the point: the wheel cannot be bought out by whoever
            is winning.
          </p>
        )}
      </div>

      {/* Owed takes precedence over the spin's own message: an unclaimed
          jackpot is the only thing on this panel that needs answering. But
          NOT WHILE THE WHEEL IS STILL TURNING (Aaron, 28 Sep 2026): the
          server banks the jackpot the instant the spin is decided, so the
          choice used to appear while the wheel was still going round and
          gave the result away. It waits for the tongue to come to rest. */}
      {owed && (!result || result.revealed) ? (
        <div className="wheel-claim">
          <h4>Yours to choose</h4>
          <p>The wheel stopped on the jackpot. Take whichever of the four you want;
             it is added to your deck for good.</p>
          <div className="wheel-choices">
            {(wheel.superRares || []).map((c) => (
              <button key={c.id} className="btn" disabled={busy} onClick={() => claim(c.id)}>
                {c.name}
              </button>
            ))}
          </div>
        </div>
      ) : result && result.revealed && !result.error ? (
        /* THE PRIZE, READ AT A GLANCE. Aaron, 26 Sep 2026: after the spin it
           was a line of small white text. Now the winning slice lights up on
           the wheel and a plaque in its colour rises with the prize in large
           type. Deliberately short of the confetti the rarer outcomes throw:
           a flip, one shine across it, and a glow. */
        <div className={"wheel-prize" + (jackpot ? " is-jackpot" : "")} role="status"
             style={{ "--prize": WHEEL_COLOURS[result.slice] || "#315e4d" }}
             key={`${result.index}:${result.message}`}>
          <span className="wheel-prize-kicker">The wheel stops on</span>
          <strong className="wheel-prize-name">
            {(() => {
              const sl = (wheel.slices || [])[result.index];
              const lines = sl ? (Array.isArray(sl.label) ? sl.label : [String(sl.label)]) : [];
              return lines.length ? lines.join(" ") : "Your prize";
            })()}
          </strong>
          {(() => {
            /* The message only when it adds something: "25 skill points."
               under "25 SKILL POINTS" is the same thing twice. */
            const sl = (wheel.slices || [])[result.index];
            const flat = (t) => String(t || "").toLowerCase().replace(/[^a-z0-9]/g, "");
            const label = sl ? flat(Array.isArray(sl.label) ? sl.label.join(" ") : sl.label) : "";
            const msg = flat(result.message);
            return result.message && msg !== label ? <span className="wheel-prize-msg">{result.message}</span> : null;
          })()}
        </div>
      ) : result && result.revealed ? (
        <p className="wheel-result">{result.error}</p>
      ) : null}
    </section>
  );
}

/* WHAT YOUR MAN INSIDE HAS SEEN.

   Put Someone Inside buys two evenings of knowing what one trader has
   sealed, before it resolves. It stops nothing — there is no counter-order
   and no way to cancel theirs — so the whole value is in the arming and the
   targeting you do with it, which is why this sits at the top of the tactics
   tab rather than in a feed somewhere.

   Shown ONLY to the watcher. The watched player is never told, and there is
   nothing anywhere in their view that could tell them: the row is attached
   to them with the watcher as its source, and only the source can read it.
   That is the card. */
function InsideMan({ informants, game, me }) {
  const rows = informants || [];
  if (!rows.length) return null;
  return (
    <section className="deck-section inside-man">
      <header className="deck-head">
        <h3>Your man inside</h3>
        <p>What they have chosen for tonight, before it resolves. They do not know you are reading it.</p>
      </header>
      {rows.map((r) => (
        <div key={r.on} className="inside-row">
          <div className="inside-who">
            {game.players[r.on]
              ? <TraderName player={game.players[r.on]} size={14} />
              : <b>{r.name}</b>}
            <span className="muted">{spanOf(r.until - Date.now())} left</span>
          </div>
          {r.orders.length === 0 ? (
            <p className="deck-empty">Nothing chosen yet tonight.</p>
          ) : (
            <ul className="inside-orders">
              {r.orders.map((o, i) => {
                const card = DECK.cards[o.card] || { name: o.card };
                return (
                  <li key={i}>
                    <b>{card.name}</b>
                    <span>{o.propertyId ? <PropLink id={o.propertyId} />
                      : o.district ? districtLabel(o.district) || "a district" : "no building named"}</span>
                  </li>
                );
              })}
            </ul>
          )}
        </div>
      ))}
    </section>
  );
}

/* ---- playing a card at yourself ---------------------------------------

   SIX CARDS USED TO END UP IN TONIGHT'S QUEUE. They were sealed like an
   attack, held to 19:00, and resolved by a switch that had no case for any
   of them — so a player spent up to £100,000, pressed "Seal the order", and
   the evening reported nothing at all. The engine resolves them at the
   moment they are played now, and this is the control that does it.

   Each one asks for exactly what it needs and nothing more: Open the Books
   asks for nothing, Gentlemen's Agreement for a name, Ward Boundary Review
   for a district, Night Works and Scrapper for a card out of your own
   discard. The button says "Play it now" rather than "Seal the order",
   because that is the difference. */
function PlayNow({ card, held, deck, traders, act, busy }) {
  const [choice, setChoice] = useState("");
  const [district, setDistrict] = useState("");
  const [trader, setTrader] = useState("");
  useEffect(() => { setChoice(""); setDistrict(""); setTrader(""); }, [held && held.copy]);

  const id = held ? held.id : null;
  const discard = (deck.discard || []).filter((c) => c.copy !== (held && held.copy));
  const needsTrader = id === "truce";
  const needsDistrict = id === "ward";
  const needsDiscard = id === "nightworks";
  const offersDiscard = id === "scrapper";
  const floor = deck.floor || 9;
  const atFloor = (deck.total || 0) - 1 < floor;

  const ready =
    (!needsTrader || trader) && (!needsDistrict || district) && (!needsDiscard || choice);

  const payload = { copy: held && held.copy };
  if (needsTrader) payload.targetUser = Number(trader);
  if (needsDistrict) payload.district = district;
  if (needsDiscard || (offersDiscard && choice)) payload.choice = Number(choice);

  return (
    <div className="order-row">
      {needsTrader && (
        <label className="order-field"><span>Trader</span>
          <select value={trader} onChange={(e) => setTrader(e.target.value)}>
            <option value="">Choose a trader…</option>
            {traders.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
          </select>
        </label>
      )}

      {needsDistrict && (
        <label className="order-field"><span>District</span>
          <select value={district} onChange={(e) => setDistrict(e.target.value)}>
            <option value="">Choose a district…</option>
            {NEIGHBOURHOODS.map((n) => <option key={n.code} value={n.code}>{n.name}</option>)}
          </select>
        </label>
      )}

      {(needsDiscard || offersDiscard) && (
        <label className="order-field">
          <span>{needsDiscard ? "Card to bring back" : "Card to remove for good"}</span>
          <select value={choice} onChange={(e) => setChoice(e.target.value)}>
            <option value="">{offersDiscard ? "Take nothing out" : "Choose from your discard…"}</option>
            {discard.map((c) => <option key={c.copy} value={c.copy}>{c.name}</option>)}
          </select>
        </label>
      )}

      {offersDiscard && atFloor && (
        <p className="order-note">
          Your deck is on its floor of {floor} cards. Nothing more can come out of it. Play
          this and it simply goes to your discard.
        </p>
      )}

      {!discard.length && (needsDiscard || offersDiscard) && (
        <p className="order-note">Your discard pile is empty. There is nothing to reach for.</p>
      )}

      <button className="btn" disabled={!ready || !!busy}
              onClick={() => act("card.use", payload, "use")}>
        Play it now
      </button>

      <p className="order-note">
        {/* THE ONE LINE THAT MATTERS: this does not wait for seven o'clock.
            Every other card in the hand does, so saying nothing would be
            worse than saying it twice. */}
        This one resolves the moment you play it, not at the evening lock.
      </p>
    </div>
  );
}

/* Call the Meeting, half-played: the four cards are open and the only thing
   left is the order they go back in. Click them in the order you want them
   drawn — first click is the next card off the top. */
function ScryPanel({ deck, act, busy }) {
  const cards = deck.scry || [];
  const [order, setOrder] = useState([]);
  useEffect(() => { setOrder([]); }, [cards.length && cards.map((c) => c.copy).join()]);
  if (!cards.length) return null;

  const pick = (copy) =>
    setOrder((o) => (o.includes(copy) ? o.filter((x) => x !== copy) : o.concat(copy)));
  const done = order.length === cards.length;

  return (
    <section className="deck-section scry-open">
      <header className="deck-head">
        <h3>The next {cards.length}</h3>
        <p>Nobody else will ever see these. Click them in the order you want to draw them:
           whatever you click first comes off the top.</p>
      </header>
      <div className="hand-row">
        {cards.map((c) => {
          const at = order.indexOf(c.copy);
          return (
            <div key={c.copy} className={"scry-card" + (at >= 0 ? " is-placed" : "")}>
              <TacticCard id={c.id} size="hand" onClick={() => pick(c.copy)} />
              <span className="scry-seat">{at >= 0 ? at + 1 : "—"}</span>
            </div>
          );
        })}
      </div>
      <div className="order-row">
        <button className="btn" disabled={!done || !!busy}
                onClick={() => act("card.scry", { order }, "scry")}>
          {done ? "Put them back in this order" : `Choose ${cards.length - order.length} more`}
        </button>
        <button className="btn secondary small" onClick={() => setOrder([])}>Start again</button>
      </div>
    </section>
  );
}

function WarRow({ r, players }) {
  const card = DECK.cards[r.card] || { name: r.card };
  const meta = r.property ? metaFor(r.property) : null;
  const stopped = r.status !== "landed";
  const shield = r.blockedBy && DECK.cards[r.blockedBy];

  /* One sentence, and it has to say what actually happened to the money. */
  const what = stopped
    ? (r.blockedBy === "security" ? "turned back by security"
      : r.blockedBy === "listed" ? "the building was listed"
      : r.blockedBy === "trust" ? "the building is Held In Trust"
      : r.blockedBy === "botched" ? "botched, and the owner was told who tried"
      : shield ? `stopped by ${shield.name}`
      : "stopped")
    : r.took ? `${fmt(r.took)} lifted`
    : r.valueOff ? `${fmt(r.valueOff)} off the value`
    /* A figure where there is one, and what the card did where there is
       not. "Landed" on its own told a player nothing about what it cost
       them, which is the only question they were asking. */
    : (r.did || "landed");

  return (
    <li className={"war-row" + (r.mine ? " is-mine" : " is-theirs") + (stopped ? " is-stopped" : "")}>
      <span className="war-dir">{r.mine ? "YOURS" : "AGAINST YOU"}</span>
      <span className="war-card">{card.name}</span>
      <span className="war-where">
        {meta ? <PropLink id={meta.id} name={meta.name} />
          : r.district ? districtLabel(r.district) || "a district" : "—"}
      </span>
      <span className="war-what">{sentence(what)}</span>
      <span className="war-who">
        {/* A name links to the standing behind it. No id means the card
            never gave the attacker away, and the row says so in words
            rather than offering a link to nobody. */}
        {r.whoId && players[r.whoId]
          ? <TraderName player={players[r.whoId]} size={13} />
          : r.who ? r.who : <em>an unnamed trader</em>}
      </span>
      {r.mine && r.spent > 0 && <span className="war-spent">cost you {fmt(r.spent)}</span>}
    </li>
  );
}

/* THE ARCHIVE. Everything OLDER than a day, one line per evening, shut.

   This used to print every night of the week open, directly under the
   market stall, in the market's own colours. Three things were wrong with
   that at once: your own orders appeared here AND in the panel above, so
   every play was written twice; seven nights of open lists is more history
   than anyone reads; and sharing a surface with the bazaar made a record of
   people attacking you look like part of the shopping.

   So: the last day lives above, in its own panel. This holds the rest of
   the week, each evening collapsed to a line you can open — and it sits on
   its own ground, outside the two rooms entirely. */
function WarHistory({ war, players = {}, me = null }) {
  const cutoff = Date.now() - 24 * 3600 * 1000;
  const rows = (war || []).filter((r) => r.at < cutoff);
  if (!rows.length) return null;

  const nights = [];
  for (const r of rows) {
    const key = new Date(r.at).toDateString();
    let night = nights.find((n) => n.key === key);
    if (!night) nights.push((night = { key, at: r.at, rows: [] }));
    night.rows.push(r);
  }

  return (
    <section className="war-archive">
      <header className="war-archive-head">
        <h3>Earlier this week</h3>
        <p>Every evening before last night, shut. Open one to read it.</p>
      </header>
      {nights.slice(0, 7).map((n) => {
        const against = n.rows.filter((r) => !r.mine);
        const landed = against.filter((r) => r.status === "landed").length;
        return (
          <details key={n.key} className="war-night">
            <summary className="war-head">
              <b>{WAR_DAY(n.at)}</b>
              <span>
                {against.length === 0
                  ? `nothing came your way · ${n.rows.length} of yours`
                  : `${against.length} against you · ${landed} landed, ${against.length - landed} stopped`}
              </span>
            </summary>
            <div className="war-night-body">
              <button type="button" className="war-share" title="Save this night as a picture"
                      onClick={() => shareWarNight(n, me && me.name)}>share ↗</button>
              <ul className="war-list">
                {n.rows.map((r) => <WarRow key={r.id} r={r} players={players} />)}
              </ul>
            </div>
          </details>
        );
      })}
    </section>
  );
}

/* THE LAST DAY. Sealed orders, then everything that resolved since
   yesterday evening — both directions in one list, which is what removes
   the duplication: your own plays were being listed here as "your orders"
   and again below as tactical history. */
/* ---- THE EVENING, UNFOLDING ---------------------------------------------

   Aaron, 25 Sep 2026: "the actual visualisation/experience of deploying them
   is lack lustre because it is just text feedback that is instant... we need
   to create more suspense."

   So nothing says how the evening went until you watch it. At seven the
   server files every result as sealed and sends one invitation; this plays
   the evening back one order at a time, both directions, in the order it
   really ran — the card, what stood in its way, and the verdict, in that
   order, because the suspense is in the gap between the second and third.
   It can be paused, run at double speed, or skipped straight to the tally,
   and whichever way it ends the evening is marked as read and the history
   and the feed open up.

   Reduced motion gets the same evening with nothing timed: every order laid
   out in full, and the tally. */
function sealEveningNotes(notes, evening) {
  if (!notes || !evening || evening.seen) return notes;
  return notes.filter((n) => !(n.kind === "evening" && n.evening === evening.at));
}
function sealEveningRows(war, evening) {
  if (!war || !evening || evening.seen) return war;
  return war.filter((r) => r.at !== evening.at);
}

/* card, shield, security, verdict, consequence. Aaron, 26 Sep 2026: under
   "Aimed at" comes whether a shield answered, then the security level, then
   the stamp; the stamp says whether it got through, so no sentence repeats
   it. */
const EVENING_STEP_MS = [0, 900, 1600, 2300, 3000];
const EVENING_BEAT_MS = 4400;

/* A reason the order never came to shields or security at all. */
function eveningStopped(b) {
  if (b.rejected) return b.reason === "truce" ? "An agreement stood between you." : "The queue on that target was already full.";
  if (b.blockedBy === "listed") return "The building is listed. Nothing can touch it.";
  if (b.blockedBy === "trust") return "The building is Held In Trust. No order can take its deed.";
  if (b.blockedBy === "ineligible") return "The building no longer qualifies for it.";
  if (b.blockedBy === "moved") return "The building had changed hands. It cost nothing.";
  return null;
}
function eveningShield(b) {
  const stopped = eveningStopped(b);
  if (stopped) return stopped;
  if (b.bypassed) return "The bell was never rung. No shield answered.";
  if (b.heldOver) return `Shield activated: ${b.blockedByName || "Watchman on the Gate"}. Held over to tomorrow's resolution.`;
  if (b.passedFrom && !b.blockedByName && !b.halvedByName) return `Passed on by ${b.passedFrom} with Pass the Buck. No shield of yours answered it.`;
  if (b.blockedByName) return `${b.passedFrom ? `Passed on by ${b.passedFrom} with Pass the Buck. ` : ""}Shield activated: ${b.blockedByName}.`;
  if (b.halvedByName) return `Shield activated: ${b.halvedByName}. It took the edge off.`;
  if (b.district) return "No shield answers a scheme on a whole district.";
  return "No relevant shield activated.";
}
function eveningSecurity(b) {
  if (eveningStopped(b) || b.district) return null;
  return `Security level ${b.security || 0}`;
}
function eveningVerdict(b) {
  if (b.rejected) return { word: "Turned away", tone: "grey" };
  if (b.landed) return { word: "Landed", tone: b.mine ? "good" : "bad" };
  if (b.blockedBy === "security") return { word: "Turned back", tone: b.mine ? "bad" : "good" };
  if (b.blockedBy === "botched") return { word: "Botched", tone: b.mine ? "bad" : "good" };
  if (b.heldOver) return { word: "Held over", tone: "grey" };
  return { word: "Stopped", tone: b.mine ? "bad" : "good" };
}
function eveningConsequence(b) {
  if (!b.landed && b.heldOver) return "It goes in at tomorrow's resolution.";
  if (!b.landed) return b.rejected ? "Nothing was spent on it."
    : b.blockedBy === "botched" ? "Nothing was taken, and the owner was told who tried." : null;
  const bits = [];
  if (b.did && b.did !== "landed") bits.push(sentence(b.did) + ".");
  if (b.took) bits.push(`${fmt(b.took)} lifted.`);
  if (b.valueOff) bits.push(`${fmt(b.valueOff)} off the value.`);
  if (b.amplified) bits.push("Doubled, because rumours were already spreading.");
  if (b.alsoCondemned && b.alsoCondemned.length) bits.push(`${b.alsoCondemned.join(" and ")} ${b.alsoCondemned.length === 1 ? "was" : "were"} condemned with it.`);
  if (b.alsoFrozen) bits.push(`${b.alsoFrozen} more building${b.alsoFrozen === 1 ? "" : "s"} in the district frozen with it.`);
  if (b.buildDelayed) bits.push("A house going up there lost a day.");
  return bits.join(" ") || null;
}
const EVENING_DIRECTION = (b) => b.mine ? "Your order"
  : b.bystander ? "On your street" : "Against you";

function EveningBeat({ b, step }) {
  const v = eveningVerdict(b);
  return (
    <div className={"eve-beat " + (b.mine ? "is-mine" : b.bystander ? "is-street" : "is-against")}>
      <div className="eve-dir">{EVENING_DIRECTION(b)}</div>
      <div className="eve-card"><TacticCard id={b.card} size="mid" /></div>
      <div className="eve-lines">
        <p className="eve-target">
          Aimed at <b>{b.target}</b>
          {b.mine && b.owner ? <>, owned by <b>{b.owner}</b></> : null}
          {!b.mine && b.attacker ? <>, by <b>{b.attacker}</b></> : null}
        </p>
        <p className={"eve-defence" + (step >= 1 ? " is-on" : "")}>{eveningShield(b)}</p>
        {eveningSecurity(b) ? <p className={"eve-security" + (step >= 2 ? " is-on" : "")}>{eveningSecurity(b)}</p> : null}
        <p className={"eve-verdict tone-" + v.tone + (step >= 3 ? " is-on" : "")}>{v.word}</p>
        <p className={"eve-consequence" + (step >= 4 ? " is-on" : "")}>{eveningConsequence(b) || "\u00a0"}</p>
        {b.mine && b.cost > 0 ? <p className={"eve-cost" + (step >= 4 ? " is-on" : "")}>It cost you {fmt(b.cost)}.</p> : null}
      </div>
    </div>
  );
}

function EveningTally({ ev }) {
  const t = ev.tally;
  const lines = [];
  if (t.sent) lines.push(`You sent ${t.sent} order${t.sent === 1 ? "" : "s"}. ${t.sentLanded} landed.`);
  if (t.turnedAway) lines.push(`${t.turnedAway} ${t.turnedAway === 1 ? "was" : "were"} turned away before the evening began.`);
  if (t.against) lines.push(`${t.against} move${t.against === 1 ? "" : "s"} touched you. ${t.againstLanded} landed${t.shieldsFired ? `, and your shields fired ${t.shieldsFired} time${t.shieldsFired === 1 ? "" : "s"}` : ""}.`);
  if (t.spent) lines.push(`Your orders cost you ${fmt(t.spent)}.`);
  if (t.youTook) lines.push(`You lifted ${fmt(t.youTook)}.`);
  if (t.tookFromYou) lines.push(`${fmt(t.tookFromYou)} was lifted from you.`);
  return (
    <div className="eve-tally">
      <div className="eve-dir">The evening, in sum</div>
      {lines.map((l, i) => <p key={i}>{l}</p>)}
      {ev.city && ev.city.sealed ? (
        <p className="eve-city">{ev.city.sealed} order{ev.city.sealed === 1 ? " was" : "s were"} confirmed across the city tonight. {ev.city.landed} found {ev.city.landed === 1 ? "its" : "their"} mark.</p>
      ) : null}
    </div>
  );
}

function EveningTheatre({ ev, onClose, onSeen }) {
  const reduced = typeof window !== "undefined" && window.matchMedia
    && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const beats = ev.beats || [];
  const [i, setI] = useState(0);                 /* beats.length = the tally */
  const [step, setStep] = useState(0);
  const [paused, setPaused] = useState(false);
  const [speed, setSpeed] = useState(1);
  const marked = useRef(false);
  const mark = () => { if (!marked.current) { marked.current = true; onSeen && onSeen(ev.at); } };
  const atEnd = i >= beats.length;

  useEffect(() => { if (atEnd) mark(); }, [atEnd]);
  useEffect(() => {
    if (reduced || atEnd || paused) return;
    setStep(0);
    const ids = EVENING_STEP_MS.slice(1).map((ms, k) => setTimeout(() => setStep(k + 1), ms / speed));
    ids.push(setTimeout(() => setI((n) => n + 1), EVENING_BEAT_MS / speed));
    return () => ids.forEach(clearTimeout);
  }, [i, paused, speed, reduced, atEnd]);
  useEffect(() => {
    const key = (e) => {
      if (e.key === "Escape") { mark(); onClose(); }
      else if (e.key === "ArrowRight") setI((n) => Math.min(beats.length, n + 1));
      else if (e.key === "ArrowLeft") setI((n) => Math.max(0, n - 1));
      else if (e.key === " ") { e.preventDefault(); setPaused((p) => !p); }
    };
    window.addEventListener("keydown", key);
    return () => window.removeEventListener("keydown", key);
  }, [beats.length]);

  const when = new Date(ev.at).toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" });
  return (
    <div className="res-overlay eve-overlay" role="dialog" aria-modal="true" aria-label="How the evening unfolded">
      <div className="eve-stage">
        <header className="eve-head">
          <div>
            <span className="eve-kicker">The evening of {when}</span>
            <h3>{atEnd ? "How it ended" : reduced ? "How it unfolded" : `Order ${i + 1} of ${beats.length}`}</h3>
          </div>
          <div className="eve-controls">
            {!reduced && !atEnd && (
              <>
                <button type="button" onClick={() => setPaused((p) => !p)}>{paused ? "Play" : "Pause"}</button>
                <button type="button" onClick={() => setSpeed((v) => (v === 1 ? 2 : 1))} aria-pressed={speed === 2}>{speed === 2 ? "Normal speed" : "Double speed"}</button>
                <button type="button" onClick={() => setI(beats.length)}>Skip to the end</button>
              </>
            )}
            <button type="button" className="eve-close" onClick={() => { mark(); onClose(); }}>{atEnd || reduced ? "Done" : "Close"}</button>
          </div>
        </header>
        {!reduced && !atEnd && (
          <div className="eve-progress" aria-hidden="true">
            {beats.map((b, k) => <i key={k} className={k < i ? "is-done" : k === i ? "is-now" : ""} />)}
          </div>
        )}
        {reduced ? (
          <>
            {beats.map((b) => <EveningBeat key={b.id} b={b} step={4} />)}
            <EveningTally ev={ev} />
          </>
        ) : atEnd ? (
          <>
            <EveningTally ev={ev} />
            <div className="eve-again"><button type="button" onClick={() => { setI(0); setPaused(false); }}>Watch it again</button></div>
          </>
        ) : (
          <EveningBeat key={beats[i].id + ":" + i} b={beats[i]} step={step} />
        )}
      </div>
    </div>
  );
}

const eveDate = (at) => new Date(at).toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" });

/* THE WEEK'S EVENINGS, EACH BY ITS DATE. Aaron, 26 Sep 2026: yesterday's
   reveal should not vanish once it has been watched or once a new one
   arrives, and it should be plain which day each one is. Fetched when the
   Tactics tab opens, not pushed: nobody needs last Tuesday on every tick. */
function PastEvenings({ latest }) {
  const [list, setList] = useState(null);
  const [open, setOpen] = useState(null);
  const [err, setErr] = useState("");
  useEffect(() => {
    let live = true;
    api("/api/evenings").then((r) => { if (live) setList(r.evenings || []); }).catch(() => { if (live) setList([]); });
    return () => { live = false; };
  }, [latest]);
  const older = (list || []).filter((e) => e.at !== latest);
  if (!older.length) return null;
  return (
    <div className="eve-past">
      <span className="eve-past-h">Earlier evenings</span>
      <div className="eve-past-list">
        {older.map((e) => (
          <button type="button" key={e.at} onClick={async () => {
            setErr("");
            try { const r = await api(`/api/evenings?at=${e.at}`); setOpen(r.evening); }
            catch (x) { setErr(x.message); }
          }}>
            <b>{eveDate(e.at)}</b>
            <span>{e.orders} order{e.orders === 1 ? "" : "s"}</span>
          </button>
        ))}
      </div>
      {err ? <p className="error-text">{err}</p> : null}
      {open && <EveningTheatre ev={open} onClose={() => setOpen(null)} onSeen={() => {}} />}
    </div>
  );
}

/* The invitation, on every page, until the evening is watched or skipped. */
function EveningBar({ evening, onWatch, onSkip }) {
  if (!evening || evening.seen || !(evening.beats || []).length) return null;
  return (
    <div className="eve-bar" role="status">
      <span className="eve-bar-moon" aria-hidden="true">☾</span>
      <span>The evening has been read. Nothing will tell you how it went until you watch it.</span>
      <button className="eve-bar-go" onClick={onWatch}>See how it unfolded</button>
      <button className="eve-bar-skip" onClick={onSkip}>Just tell me</button>
    </div>
  );
}

function RecentWar({ war, deck, players, onWatch }) {
  const cutoff = Date.now() - 24 * 3600 * 1000;
  const rows = (war || []).filter((r) => r.at >= cutoff);
  const sealed = (deck && deck.sealed) || [];
  if (!rows.length && !sealed.length) return null;

  /* deck.recent carries the replay; the war row carries the writing. Match
     them on card and building so the Watch button lands on the right one. */
  const replayFor = (r) => (deck && deck.recent || []).find(
    (p) => p.card === r.card && p.propertyId === r.property && !p.reason);

  return (
    <section className="war-recent">
      <header className="war-archive-head">
        <h3>The last day</h3>
        <p>What you confirmed for tonight, and everything that resolved yesterday evening,
           yours and theirs in the order it happened.</p>
      </header>

      {sealed.length > 0 && (
        <ul className="war-list war-sealed">
          {sealed.map((p) => (
            <li key={"s" + p.id} className="war-row is-mine is-sealed">
              <span className="war-dir">CONFIRMED</span>
              <span className="war-card">{(DECK.cards[p.card] || {}).name || p.card}</span>
              <span className="war-where">{p.propertyId ? <PropLink id={p.propertyId} />
                : p.district ? districtLabel(p.district) || "a district" : "—"}</span>
              <span className="war-what">resolves at the evening lock</span>
            </li>
          ))}
        </ul>
      )}

      {rows.length > 0 ? (
        <ul className="war-list">
          {rows.map((r) => {
            const replay = r.mine ? replayFor(r) : null;
            return (
              <li key={r.id} className="war-line">
                <WarRow r={r} players={players} />
                {replay && onWatch && (
                  <button className="btn secondary small war-watch"
                          onClick={() => onWatch(replay)}>Watch</button>
                )}
              </li>
            );
          })}
        </ul>
      ) : !sealed.length ? null : null}

      {!rows.length && sealed.length > 0 && (
        <p className="war-empty">Nothing has resolved since yesterday evening.</p>
      )}
    </section>
  );
}

/* ---------------------------------------------------------------------
   THE REGISTER — WHAT IS BEING DONE TO YOU RIGHT NOW

   The tab could show you your hand, your sealed orders, your discard and a
   history of attacks, and nothing at all about the state you were currently
   in. Every card that attaches an effect ran invisibly. A player hit by
   Corrupt the Register on 24 Sep 2026 watched his own figures read a fifth
   out for a day with no surface anywhere in the game that would say so.

   Every row opens. The card face is there when the server knows which card
   left the effect, because "your books are being reported wrong" is worth
   more with the thing that did it beside it.

   It never says who. That is the tactical history's job and it is gated on
   the analytics terminal — see lib/register.js. */
function RegisterPanel({ register }) {
  const [open, setOpen] = useState(null);
  if (!register || !register.length) return null;
  return (
    <section className="reg-panel">
      <header className="reg-head">
        <h4>On the register</h4>
        {/* NOT "against you" — a listed building is an effect running on you and
            it is in your favour. The count is neutral and the dots carry the
            tone. */}
        <span className="reg-count">{register.length} in force</span>
      </header>
      <ul className="reg-list">
        {register.map((r) => (
          <li key={r.id} className={"reg-item tone-" + r.tone + (open === r.id ? " is-open" : "")}>
            <button className="reg-row" onClick={() => setOpen(open === r.id ? null : r.id)}
                    aria-expanded={open === r.id}>
              <span className="reg-dot" aria-hidden="true" />
              <span className="reg-title">{r.title}</span>
              <span className="reg-until"><CountdownTo at={r.until} /></span>
              <span className="reg-chev" aria-hidden="true">{open === r.id ? "\u2212" : "+"}</span>
            </button>
            {open === r.id && (
              <div className="reg-detail">
                <p>{r.detail}</p>
                {r.card && (
                  <div className="reg-card">
                    <b>{r.card.name}</b>
                    <span className="reg-card-rarity">{r.card.rarity}</span>
                    <p>{r.card.text}</p>
                  </div>
                )}
                {!r.card && <p className="reg-nocard">This one was filed before the paper started
                  recording which card left it.</p>}
              </div>
            )}
          </li>
        ))}
      </ul>
    </section>
  );
}

function TacticsTab({ game, me, onAct, onActResult, onEvening }) {
  const deck = game.deck || { hand: [], discard: [], market: [], deckCount: 0, total: 0 };
  const [selected, setSelected] = useState(null);     /* a copy id from the hand */
  const [targetUser, setTargetUser] = useState("");
  const [targetProp, setTargetProp] = useState("");
  /* Who a Pass the Buck is armed against (28 Sep 2026). */
  const [namedPlayer, setNamedPlayer] = useState("");
  const [targetDistrict, setTargetDistrict] = useState("");
  const [showLibrary, setShowLibrary] = useState(false);
  /* The last ruling the player has not watched. Kept as an id rather than a
     boolean so a second resolution arriving while the first is open does not
     silently swap the card underneath them. */
  const [watching, setWatching] = useState(null);
  const [zoom, setZoom] = useState(null);
  const [showDiscard, setShowDiscard] = useState(false);
  const [busy, setBusy] = useState("");
  const [error, setError] = useState("");

  const linked = new Set(me.linkedTo || []);
  /* THE SHELTER. A trader holding fewer buildings than the floor is not a
     legal target, so they are not offered as one - being told "no" after
     choosing a name teaches the rule by refusing you, which is the worst
     way to learn anything. Counted off shared state rather than sent as a
     flag: the owner of every property is already here.

     TradeTab has the identical line and must NOT get this - you may trade
     with anybody, however small. Only orders are sheltered. The server
     enforces it independently in lib/deck.js; this is only the courtesy. */
  const shelter = (game.rules && game.rules.shelterBelow) || 0;
  const holdings = new Map();
  for (const pr of Object.values(game.properties || {})) {
    if (pr.owner) holdings.set(pr.owner, (holdings.get(pr.owner) || 0) + 1);
  }
  const others = Object.values(game.players).filter((p) =>
    p.id !== me.id && !linked.has(p.id) && (holdings.get(p.id) || 0) >= shelter);
  const sheltered = Object.values(game.players).filter((p) =>
    p.id !== me.id && !linked.has(p.id) && (holdings.get(p.id) || 0) < shelter).length;
  /* THE TRADER LIST, GROUPED (Aaron, 29 Sep 2026: the one long drop down was
     getting unwieldy as the city fills). The people you are most likely to
     aim at come first, each trader once, in the first group they belong
     to: your declared rivals, whoever has moved against you in the last
     three days (only where the history names them), the members of other
     alliances, your field, then everybody else from A to Z. */
  const targetGroups = (() => {
    const pool = new Map(others.map((p) => [p.id, p]));
    const taken = new Set();
    const take = (ids) => [...new Set(ids)].map((id) => pool.get(id)).filter((p) => p && !taken.has(p.id) && taken.add(p.id))
      .sort((a, b) => String(a.name).localeCompare(String(b.name)));
    const since = Date.now() - 3 * 24 * 3600 * 1000;
    const dip = game.diplomacy || null;
    const mineAlliance = dip && dip.alliance ? dip.alliance.id : null;
    const groups = [
      ["Your rivals", ((me.rivalries && me.rivalries.mine) || []).map((r) => r.id)],
      ["Moved against you lately", (game.war || []).filter((r) => !r.mine && r.at >= since && r.whoId).map((r) => r.whoId)],
      ["Other alliances", ((dip && dip.table) || []).filter((a) => a.id !== mineAlliance && !a.broken)
        .flatMap((a) => a.members.map((m) => m.id))],
      ["Your field", ((dip && dip.field) || []).map((m) => m.id)],
    ].map(([label, ids]) => ({ label, people: take(ids) }));
    groups.push({ label: groups.some((g) => g.people.length) ? "Everyone else" : "Traders", people: take([...pool.keys()]) });
    return groups.filter((g) => g.people.length);
  })();
  const held = deck.hand.find((c) => c.copy === selected) || null;
  const card = held ? DECK.cards[held.id] : null;
  /* A DISTRICT CARD NAMES A DISTRICT AND NOTHING ELSE. Aaron, 25 Sep 2026:
     it used to borrow the property picker, so laying roadworks on a street
     meant first choosing a trader and then one of their houses. It now has a
     picker of its own, and no trader or building is asked for at all. */
  const needsDistrict = card && card.target === "district";
  const needsProperty = card && card.target === "property";
  const needsPlayer = card && card.target === "player";

  const ownedByMe = ALL_PROPERTIES.filter((m) =>
    game.properties[m.id] && game.properties[m.id].owner === me.id);
  /* ONLY THE DISTRICTS YOU HOLD IN ARE OFFERED. lib/deck.js is where the
     rule is enforced; this is so the player is never offered a district the
     exchange is going to refuse. NEIGHBOURHOODS has no entry for the five
     historic buildings, so they cannot appear here however many you hold.
     The flag is read from `targeting`, which the catalogue sends — not from
     `effect`, which it does not, and which is why the first version of this
     filter never once engaged. */
  const myHoods = new Set(ownedByMe.map((m) => m.neighbourhood));
  const localOnly = !!(card && card.targeting && card.targeting.ownDistrictOnly);
  const districtChoices = needsDistrict
    ? NEIGHBOURHOODS.filter((n) => !localOnly || myHoods.has(n.code))
    : [];
  const theirs = targetUser
    ? ALL_PROPERTIES.filter((m) => game.properties[m.id] &&
        game.properties[m.id].owner === Number(targetUser) && !m.exclusive)
    : [];
  const prop = targetProp ? game.properties[targetProp] : null;

  /* A DEAD QUOTE BLOCK LIVED HERE. It read DECK.costRules, which does not
     exist, and returned null on every path, so it computed nothing and was
     never rendered. orderCost() below is and always was the live quote. */

  async function act(name, payload, tag) {
    if (busy) return;
    setBusy(tag || name); setError("");
    try {
      const res = onActResult ? await onActResult(name, payload) : (onAct(name, payload), null);
      if (res && res.error) setError(res.error);
      else { setSelected(null); setTargetProp(""); setTargetDistrict(""); }
    } finally { setBusy(""); }
  }

  const armable = card && card.kind === "shield";
  /* THE THIRD KIND OF CARD. A shield is armed, an attack is sealed — and a
     self or social card is played on the spot. All six of these used to fall
     through to "Seal the order", which put them in tonight's queue where
     nothing could resolve them. */
  const immediate = card && (card.kind === "self" || card.kind === "social");
  const playable = card && card.kind !== "shield" && !immediate;
  const cost = orderCost(card, targetProp, game.properties, targetDistrict);
  const short = cost > 0 && me.cash < cost;
  const canLock = playable && (!needsProperty || targetProp) && (!needsPlayer || targetUser)
    && (!needsDistrict || targetDistrict);
  /* A truce may be offered to anybody. The shelter guards ORDERS, and an
     agreement is the opposite of one. */
  const everyoneElse = Object.values(game.players).filter((p) => p.id !== me.id);

  return (
    <div className="tactics-deck">
      {showLibrary && <CombatLibrary onClose={() => setShowLibrary(false)} />}
      {watching && <ReckoningTheatre outcome={watching} onClose={() => setWatching(null)} />}
      {zoom && <CardZoom id={zoom} onClose={() => setZoom(null)} />}

      <RegisterPanel register={game.register} />

      {/* THE MASTHEAD (Aaron, 2 Oct 2026: "could be made colourful (in a
          similar style to the portfolio banner)"). The same band as the
          Portfolio's, in the navy of the card backs, each figure with a
          small picture of what it counts. */}
      <div className="deck-strip">
        <div className="ds-tile">
          <span className="ds-label">Hand</span>
          <strong className="ds-value">{deck.hand.length} <em>of {DECK.handSize || 4}</em></strong>
          <i className="ds-pips" aria-hidden="true">
            {Array.from({ length: Math.max(DECK.handSize || 4, deck.hand.length) }, (_, i) => {
              const c = deck.hand[i];
              return <b key={i} className={c ? "on k-" + (c.kind === "social" ? "self" : c.kind) : ""} />;
            })}
          </i>
          <span className="ds-foot">{(() => {
            const n = (k) => deck.hand.filter((c) => (k === "self" ? c.kind === "self" || c.kind === "social" : c.kind === k)).length;
            const parts = [[n("attack"), "attack", "attacks"], [n("shield"), "shield", "shields"], [n("info"), "look", "looks"], [n("self"), "move of your own", "moves of your own"]]
              .filter(([x]) => x > 0).map(([x, one, many]) => `${x} ${x === 1 ? one : many}`);
            return parts.length ? parts.join(" · ") : "a new hand is dealt each evening";
          })()}</span>
        </div>
        <div className="ds-tile">
          <span className="ds-label">Draw pile</span>
          <strong className="ds-value">{deck.deckCount}</strong>
          <i className="ds-stack" aria-hidden="true">{Array.from({ length: Math.min(5, Math.max(1, Math.ceil(deck.deckCount / 3))) }, (_, i) => <b key={i} />)}</i>
        </div>
        <div className="ds-tile">
          <span className="ds-label">Deck</span>
          <strong className="ds-value">{deck.total} <em>cards</em></strong>
          <span className="ds-foot">every card you own</span>
        </div>
        <div className="ds-tile">
          <span className="ds-label">Discard</span>
          <strong className="ds-value">{deck.discard.length}</strong>
          <span className="ds-foot">shuffled back when the pile runs out</span>
        </div>
        <div className="ds-tile ds-feature deck-strip-clock">
          <span className="ds-label">{deck.locked ? "Locked" : "Orders close in"}</span>
          <strong className="ds-value">{deck.locked ? `Resolving at ${eveningAt(deck.resolveAt)}` : <CountdownTo at={deck.locksAt} />}</strong>
          <span className="ds-foot">{deck.locked ? "nothing more can be sealed tonight" : `every order lands at ${eveningAt(deck.resolveAt)}`}</span>
        </div>
        <div className="ds-tile ds-lib">
          <button className="btn small ds-lib-btn" onClick={() => setShowLibrary(true)}>The Combat Library</button>
        </div>
      </div>

      <TacticsHowItWorks onLibrary={() => setShowLibrary(true)} />

      {error ? <p className="error-text deck-error">{error}</p> : null}

      {game.evening && (game.evening.beats || []).length > 0 && onEvening ? (
        <div className="eve-rewatch">
          <span>{game.evening.seen ? `The evening of ${eveDate(game.evening.at)}, played back order by order.` : `The evening of ${eveDate(game.evening.at)} is waiting to be watched.`}</span>
          <button type="button" onClick={onEvening}>{game.evening.seen ? "Watch it again" : "See how it unfolded"}</button>
        </div>
      ) : null}
      <PastEvenings latest={game.evening && game.evening.at} />
      <RecentWar war={game.war} deck={deck} players={game.players} onWatch={setWatching} />

      {/* TWO ROOMS THAT MEET. The table and the stall share an edge rather
          than floating as two panels — you look up from your own hand and the
          market is simply there, in the dark, on the other side of it. */}
      <div className="deck-rooms">
      {/* THE PLAY AREA. Your hand, what you are doing with it, and everything
          you have already played — one green table rather than three sections
          of a form. The discard sits under the hand because that is where it
          goes on a real one, and because a player who has just played a card
          should see where it went. */}
      <div className="deck-table">
        {/* AN OPEN LOOK COMES FIRST. Call the Meeting leaves four cards
            face up and the deck waiting on an answer — putting that under
            the hand would leave a player scrolling past the thing the game
            is asking them for. */}
        <InsideMan informants={game.informants} game={game} me={me} />

        <ScryPanel deck={deck} act={act} busy={busy} />

        {(deck.truces || []).length > 0 && (
          <p className="deck-truce">
            {(deck.truces || []).map((t) => {
              const who = (game.players[t.with] || {}).name || "a trader";
              return `Agreement with ${who}, ${spanOf(t.until - Date.now())} left`;
            }).join(" · ")}
            {": neither of you can move against the other. Breaking it is public and costs skill."}
          </p>
        )}

        <section className="deck-section">
          <header className="deck-head">
            <h3>Your hand</h3>
            {(deck.dealtExtra > 0 && deck.hand.length > (deck.handSize || 4)) && (
              <p className="deck-sub">You hold {deck.hand.length} today: {deck.handSize || 4} dealt and {deck.dealtExtra} extra,
                from the wheel's extra cards prize or an Open the Books card.</p>
            )}
            {deck.extraNext > 0 && (
              <p className="deck-sub">{deck.extraNext} extra card{deck.extraNext === 1 ? "" : "s"} will come with the next deal.</p>
            )}
          </header>
          {deck.hand.length === 0
            ? <p className="deck-empty">Nothing in hand. Four cards are dealt at the evening lock.</p>
            : (
              <div className={"hand-row" + (deck.hand.some((c) => c.armed) ? " has-armed" : "")}>
                {deck.hand.map((c) => (
                  <div key={c.copy} className={"hand-slot" + (c.armed ? " is-armed" : "")}>
                    {/* ABOVE THE CARD, WHERE THE EYE GOES FIRST. Aaron, 28 Sep
                        2026: the old label sat inside the art and was missed. */}
                    {c.armed
                      ? <span className="hand-armed" title="Armed: it fires by itself when you are attacked, and it stays past the evening discard until it fires or you stand it down.">
                          <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1 2.5 3v4.2c0 3.3 2.3 6.3 5.5 7.3 3.2-1 5.5-4 5.5-7.3V3z" fill="currentColor" /></svg>
                          Armed
                        </span>
                      : <span className="hand-armed-space" aria-hidden="true" />}
                    <TacticCard id={c.id} size="hand"
                      armed={c.armed} selected={selected === c.copy}
                      dimmed={selected !== null && selected !== c.copy}
                      onZoom={setZoom}
                      onClick={() => { setSelected(selected === c.copy ? null : c.copy); setTargetProp(""); setTargetDistrict(""); setError(""); }} />
                  </div>
                ))}
              </div>
            )}
        </section>

      {held && (
        <section className="deck-order">
          <header className="deck-head">
            <h3>{card.name}</h3>
            <p>{card.text}</p>
          </header>

          {armable ? (
            <div className="order-row">
              {card.arms === "property" && (
                <label className="order-field"><span>Building to cover</span>
                  <select value={targetProp} onChange={(e) => setTargetProp(e.target.value)}>
                    <option value="">Choose one of yours…</option>
                    {ownedByMe.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
                  </select>
                </label>
              )}
              {card.arms === "property" && (
                <TargetMap candidates={ownedByMe} chosen={targetProp} mine
                           onPick={(id) => setTargetProp(id)} />
              )}
              {card.arms === "player" && (
                <label className="order-field"><span>Pass it to</span>
                  <select value={namedPlayer} onChange={(e) => setNamedPlayer(e.target.value)}>
                    <option value="">Choose a trader…</option>
                    {Object.values(game.players).filter((p) => p.id !== me.id)
                      .sort((a, b) => String(a.name).localeCompare(String(b.name)))
                      .map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
                  </select>
                </label>
              )}
              {card.arms === "property" ? (
                <button className="btn" disabled={!!busy || !targetProp}
                  onClick={() => act("card.arm", { copy: held.copy, propertyId: targetProp || null }, "arm")}>
                  {held.armed ? "Move it to that building" : "Cover that building"}
                </button>
              ) : card.arms === "player" ? (
                <button className="btn" disabled={!!busy || !namedPlayer}
                  onClick={() => act("card.arm", { copy: held.copy, playerId: Number(namedPlayer) }, "arm")}>
                  {held.armed ? "Name them instead" : "Arm it naming them"}
                </button>
              ) : held.armed ? (
                <button className="btn secondary" disabled={!!busy}
                  onClick={() => act("card.disarm", { copy: held.copy }, "disarm")}>
                  Stand it down
                </button>
              ) : (
                <button className="btn" disabled={!!busy}
                  onClick={() => act("card.arm", { copy: held.copy, propertyId: null }, "arm")}>
                  Arm it
                </button>
              )}
              {/* THE WHOLE TRADE, AT THE MOMENT OF CHOOSING. Shields used to
                  arm themselves, so none of this had to be said and the
                  player never made a decision. Now they do, and the cost is
                  as important as the benefit — a note that only sold the
                  upside would be selling them something. */}
              <p className="order-note">
                {card.arms === "property"
                  ? `${card.name} covers one building, so arming it means naming one.`
                  : card.arms === "player"
                  ? `${card.name} passes the next attack on you to the trader you name, at their building nearest in value to yours, and their own shields get their chance to fire. It passes an attack only to somebody who can be attacked: a trader with fewer than ${shelter || 2} buildings, or one with an agreement with the attacker, cannot take it, and it waits for an attack it can pass. They are told it was you.`
                  : "A shield is never played at anybody. Arming it sets it waiting in your hand, and it fires by itself when somebody attacks you, whether or not you are here, and without anybody being able to see you are holding it."}
              </p>
              <p className="order-note">
                <b>It stays in your hand until it fires or you stand it down</b>, which means
                it is not discarded with the rest of your hand tonight, and it costs you a
                draw every day it is armed. Arm two and you draw {DECK.handSize === 4 || !DECK.handSize ? "two" : Math.max(0, DECK.handSize - 2)} new
                cards at the evening turnover instead of {DECK.handSize === 4 || !DECK.handSize ? "four" : DECK.handSize}.
              </p>
              <p className="order-note">
                A shield in your hand answers an attack whether or not you arm it, so you
                are covered tonight even if you never open the game. Arming one puts it
                first in the queue and keeps it past the evening discard; an unarmed one
                goes with the rest of the stale hand.
              </p>
              <p className="order-note">
                One shield answers each attack, armed ones first, and a shield that has
                fired is spent for the rest of the evening, so two shields stop two
                attacks rather than both piling onto the first, and the third attack of
                the night meets nothing.
              </p>
            </div>
          ) : immediate ? (
            <PlayNow card={card} held={held} deck={deck} traders={everyoneElse}
                     act={act} busy={busy} />
          ) : (
            <div className="order-row">
              {needsPlayer || needsProperty ? (
                <label className="order-field"><span>Trader</span>
                  <select value={targetUser} onChange={(e) => { setTargetUser(e.target.value); setTargetProp(""); }}>
                    <option value="">Choose a trader…</option>
                    {targetGroups.map((g) => (
                      <optgroup key={g.label} label={g.label}>
                        {g.people.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
                      </optgroup>
                    ))}
                  </select>
                  {sheltered > 0 && (
                    <em className="order-shelter">
                      {sheltered === 1 ? "One trader is" : `${sheltered} traders are`} not
                      listed: nobody holding fewer than {shelter} buildings can be moved
                      against. A card costs its victim hours of one building's rent, so on a
                      small holding it takes everything.
                    </em>
                  )}
                </label>
              ) : null}
              {needsProperty && (
                <label className="order-field"><span>Building</span>
                  <select value={targetProp} onChange={(e) => setTargetProp(e.target.value)} disabled={!targetUser}>
                    <option value="">{targetUser
                      ? (theirs.length ? "Choose a building…" : "Nothing you can aim this at")
                      : "Choose a trader first"}</option>
                    {theirs.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
                  </select>
                </label>
              )}
              {needsDistrict && (
                <label className="order-field"><span>District</span>
                  <select value={targetDistrict} onChange={(e) => setTargetDistrict(e.target.value)}
                          disabled={!districtChoices.length}>
                    <option value="">{districtChoices.length
                      ? "Choose a district…" : "You hold nothing in any district"}</option>
                    {districtChoices.map((n) => <option key={n.code} value={n.code}>{n.name}</option>)}
                  </select>
                </label>
              )}
              {/* SAY WHY THE LIST IS SHORT. Fifteen districts in the city and
                  perhaps two offered reads as a fault unless the rule that
                  shortened it is stated where the shortening happens. */}
              {needsDistrict && localOnly ? (
                <p className="order-note order-local">
                  {districtChoices.length
                    ? "A scheme has to run past your own door, so only the districts you hold a building in are listed. The historic buildings are not a district."
                    : "A scheme has to run past your own door. Buy a building in a district first, and you can lay one there."}
                </p>
              ) : null}
              {needsProperty && targetUser ? (
                <TargetMap candidates={theirs} chosen={targetProp}
                           onPick={(id) => setTargetProp(id)} />
              ) : null}
              {/* WHAT IT COSTS YOU, above the security reading, because it is the
                  one number a player cannot discover any other way and the one
                  that leaves their own account. */}
              {(prop || (needsDistrict && targetDistrict)) && cost > 0 ? (
                <div className={"order-quote order-cost" + (short ? " is-short" : "")}>
                  <span>YOUR COST</span>
                  <strong>{fmt(cost)}</strong>
                  <em>
                    {card.cost && card.cost.flat
                      ? "A flat fee for this card."
                      : card.cost && card.cost.ofTotal
                        ? `${Math.round((card.cost.pct || 0) * 100)}% of the value of every building in ${(NEIGHBOURHOODS.find((n) => n.code === targetDistrict) || {}).name || "the district"}.`
                        : `${Math.round((card.cost.pct || 0) * 100)}% of this building's value.`}
                    {" "}It leaves your cash when the order resolves at {eveningAt()}, whether it
                    lands or not. You hold {fmt(me.cash)}.
                    {short ? " That is not enough." : ""}
                  </em>
                </div>
              ) : null}
              {prop ? (
                <div className="order-quote">
                  <span>SECURITY</span>
                  <strong>{"·".repeat(Math.max(1, prop.security || 0))} {prop.security || 0} of {SECURITY_MAX_LEVEL}</strong>
                  <em>
                    {prop.security
                      ? `${prop.security * 10}% off the chance it lands, and ${prop.security * 10}% off what it does.`
                      : "Undefended. The card does exactly what it says."}
                  </em>
                </div>
              ) : null}
              <button className="btn" disabled={!canLock || !!busy || deck.locked}
                onClick={() => act("card.lock", needsDistrict
                  ? { copy: held.copy, district: targetDistrict }
                  : { copy: held.copy, propertyId: targetProp || null,
                      targetUser: targetUser ? Number(targetUser) : null }, "lock")}>
                {deck.locked ? "Tonight is locked" : "Confirm the order"}
              </button>
              {/* "FROM THIS BUILDING ALONE" WAS TRUE OF EVERY PRICED CARD BUT ONE.
                  Traffic Scheme bills the district, and this sentence sat under
                  the quote that said so, contradicting it. It now says what is
                  true of the card in hand. */}
              {/* THE FOOT OF THE ORDER ALWAYS STARTS ITS OWN LINE. `.order-note` caps
                  its width at 74ch for reading, which lets flexbox seat it beside
                  the seal button whenever the row has room — invisible while a
                  security reading sat beside the button, and plain once a district
                  order, which has none, left the button alone at the bottom left
                  with this paragraph floating up next to it. */}
              <div className="order-foot"><p className="order-note">
                {card && card.cost && card.cost.ofTotal
                  ? "Your cost is worked out from every building in the district"
                  : card && card.cost && card.cost.flat
                    ? "Your cost is a flat fee"
                    : "Your cost is worked out from this building alone"}
                {" "}and is taken at the
                {" "}{eveningAt()} resolution, not when you confirm, so it leaves your account hours
                later, and it is charged whether the order lands or is turned back.
                Confirmed orders resolve with everybody else's: you will be told where in
                the queue you landed, never how many there were.
              </p></div>
            </div>
          )}
        </section>
      )}

      <AwaitingOrders deck={deck} act={act} busy={busy} players={game.players} />
      <ArmedShields deck={deck} owned={ownedByMe} act={act} busy={busy} players={game.players} />

      <section className="deck-section">
        <header className="deck-head deck-head-row">
          <h3>Your discard</h3>
          <button className="btn secondary small" onClick={() => setShowDiscard(!showDiscard)}>
            {showDiscard ? "Hide" : `Show ${deck.discard.length}`}
          </button>
        </header>
        {showDiscard && (
          <div className="hand-row wrap">
            {deck.discard.length === 0
              ? <p className="deck-empty">Nothing played yet.</p>
              : deck.discard.map((c) => <TacticCard key={c.copy} id={c.id} size="hand" onZoom={setZoom} />)}
          </div>
        )}
      </section>
      </div>

      {/* THE MARKET. A different room: you are buying from somebody, at
          night, and what is on offer today is not what was on offer
          yesterday. Deliberately not the same ground as the table. */}
      <div className="deck-bazaar">
      <section className="deck-section">
        <header className="deck-head">
          <h3>Today's market</h3>
          <p>Three cards, redrawn daily, at most one of them a rare. Buying adds it to
             your deck for good, which makes every other card in your deck rarer.
             That is the whole game.</p>
        </header>
        <div className="hand-row">
          {deck.market.map((c) => (
            <figure key={c.id} className="market-item">
              <TacticCard id={c.id} size="hand" badge={fmt(c.price)} onZoom={setZoom} />
              <button className="btn small" disabled={!!busy || (me.cash || 0) < c.price}
                onClick={() => act("card.buy", { card: c.id }, "buy:" + c.id)}>
                {(me.cash || 0) < c.price ? "Not enough cash" : `Buy · ${fmt(c.price)}`}
              </button>
            </figure>
          ))}
        </div>
      </section>

      </div>
      </div>

      <WarHistory war={game.war} players={game.players} me={me} />
    </div>
  );
}

/* Sandbags and Listed Status name a building; the rest simply arm. */
function c_armLabel(held, card) {
  if (held.armed) return "Already armed";
  return card.arms === "property" ? "Arm it over that building" : "Arm it";
}

/* THE APPEALS BOARD.

   It sits at the top of the trading floor, above the machinery for
   swapping buildings, because a trader who has just been foreclosed on is
   not in the market for a three-way property swap — and because the point
   of putting it where everybody trades is that everybody sees it.

   What a player in trouble sees is one button. What everybody else sees is
   a list of people asking, what they have raised so far, and how long they
   have left. Nobody is listed until they choose to be. */
function AppealsBoard({ game, me, onAct }) {
  const appeals = game.appeals || [];
  const mine = me.appeal || {};
  const [amounts, setAmounts] = useState({});
  const rules = game.rules || {};
  const min = rules.appealMinGift || 25000;
  const hours = rules.appealReliefHours || 24;
  const others = appeals.filter((a) => a.userId !== me.id);

  const left = (t) => {
    const ms = t - Date.now();
    return ms >= 3600000 ? `${spanOf(ms)} left` : "closing within the hour";
  };

  if (!others.length && !mine.open && !mine.eligible) return null;

  return (
    <div className="card appeals">
      <h3>Appeals</h3>
      <div className="meta">
        A trader whose debts have caught them can ask the floor for help. Answering an
        appeal pauses that trader's overheads for {hours} hours, the same {hours} hours
        whatever you give, so there is nothing to be bought here but goodwill.
      </div>

      {mine.open ? (
        <div className="appeal-mine">
          <strong>Your appeal is open.</strong>{" "}
          {fmt(mine.open.raised)} raised · {left(mine.open.expiresAt)}
          {mine.reliefUntil > Date.now()
            ? <div className="chg-up" style={{ marginTop: 4 }}>Your overheads are paused.</div>
            : null}
        </div>
      ) : mine.eligible ? (
        <div className="appeal-mine">
          <div style={{ marginBottom: 6 }}>
            Your debts have caught up with you. You can ask the floor, once a week, for a day at a time.
          </div>
          <button className="btn" onClick={() => onAct("appeal.open")}>Put out an appeal</button>
        </div>
      ) : null}

      {others.map((a) => (
        <div className="appeal-row" key={a.id}>
          <div>
            <b>{a.name}</b>
            <span className="muted">
              {" · "}{fmt(a.raised)} raised from {a.donors} {a.donors === 1 ? "trader" : "traders"} · {left(a.expiresAt)}
            </span>
          </div>
          <div className="appeal-give">
            <input type="number" min={min} step={5000} placeholder={String(min)}
                   value={amounts[a.id] || ""}
                   onChange={(e) => setAmounts({ ...amounts, [a.id]: e.target.value })} />
            <button className="btn secondary"
                    disabled={!me.canInteract || Number(amounts[a.id] || 0) < min}
                    onClick={() => { onAct("appeal.give", { appealId: a.id, amount: Number(amounts[a.id]) }); setAmounts({ ...amounts, [a.id]: "" }); }}>
              Give
            </button>
          </div>
        </div>
      ))}
      <div className="hint">
        Minimum {fmt(min)}. Only money you have earned can be given, and it does not
        become earned money for them: a floor, not a war chest.
      </div>
    </div>
  );
}

/* ONE SECOND OF PAPER PLANE. Folded, launched from the Send offer button,
   dipping as it leaves the hand and then climbing away up and to the right
   along a curve it draws behind it as a dotted line, shrinking as it goes.
   It rides the curve with CSS offset-path, so the nose always points along
   the line; a browser without offset-path gets the same climb as a plain
   transform. Rendered into document.body so no transformed ancestor can
   drag its fixed position about. Reduced motion: no flight at all. */
const PLANE_MS = 1000;
function PaperPlaneFlight({ x, y, onDone }) {
  const reduce = typeof window !== "undefined" && window.matchMedia
    && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  /* Cleared when the flight itself ends (so a paused animation, as the
     review freezes it, holds the plane on screen), with a long backstop in
     case a browser never reports the end. */
  useEffect(() => {
    const t = setTimeout(onDone, reduce ? 0 : PLANE_MS * 3);
    return () => clearTimeout(t);
  }, []);
  if (reduce || typeof document === "undefined") return null;
  const vw = window.innerWidth || 1200;
  /* Up and to the right, as far as the window allows, never off it. */
  const dx = Math.max(140, Math.min(360, vw - x - 48));
  const dy = -Math.max(90, Math.min(230, y - 48));
  const d = `M0 0 C ${(dx * 0.14).toFixed(1)} ${(-dy * 0.07).toFixed(1)}, ${(dx * 0.36).toFixed(1)} ${(-dy * 0.03).toFixed(1)}, ${(dx * 0.56).toFixed(1)} ${(dy * 0.36).toFixed(1)} S ${(dx * 0.86).toFixed(1)} ${(dy * 0.98).toFixed(1)}, ${dx.toFixed(1)} ${dy.toFixed(1)}`;
  const offsetOk = typeof CSS !== "undefined" && CSS.supports && CSS.supports("offset-path", "path('M0 0 L1 1')");
  const node = (
    <div className="plane-flight" aria-hidden="true" style={{ left: x, top: y, "--dx": dx + "px", "--dy": dy + "px" }}>
      <svg className="plane-trail" width="1" height="1" overflow="visible">
        <defs>
          <mask id="plane-trail-mask" maskUnits="userSpaceOnUse" x="-40" y={dy - 40} width={dx + 80} height={-dy + 80}>
            <path d={d} pathLength="1" fill="none" stroke="#fff" strokeWidth="6" className="plane-trail-reveal" />
          </mask>
        </defs>
        <path d={d} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"
              strokeDasharray="1.5 6" mask="url(#plane-trail-mask)" />
      </svg>
      <div className={"plane-body" + (offsetOk ? " on-path" : " no-path")} style={offsetOk ? { offsetPath: `path('${d}')` } : undefined}
           onAnimationEnd={(e) => { if (e.target === e.currentTarget) onDone(); }}>
        <svg viewBox="-14 -10 28 20" width="36" height="26">
          {/* A folded dart seen from above, nose to the right. */}
          <path d="M13 0 L-12 -8.5 L-6 0 Z" fill="#FFFDF7" stroke="#1B2420" strokeWidth=".9" strokeLinejoin="round" />
          <path d="M13 0 L-12 8.5 L-6 0 Z" fill="#E9E2D0" stroke="#1B2420" strokeWidth=".9" strokeLinejoin="round" />
          <path d="M13 0 L-6 0" stroke="#1B2420" strokeWidth=".7" />
          <path d="M13 0 L-9 3.2 L-6 0 Z" fill="#CFC6AF" />
        </svg>
      </div>
    </div>
  );
  /* The live page's React 18 carries createPortal on ReactDOM; the test
     harness's React 19 client entry does not, and there it draws in place. */
  return typeof ReactDOM !== "undefined" && ReactDOM.createPortal ? ReactDOM.createPortal(node, document.body) : node;
}

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 [want, setWant] = useState("");
  const [plane, setPlane] = useState(null);
  const [sent, setSent] = useState(false);
  const [sending, setSending] = useState(false);
  const filtered = others.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()));
  /* PROPERTY FIRST. Aaron, 26 Sep 2026: trade should start from what you
     need, not from who you know. Every building another trader holds (and
     can deal in), matched on its name, district, type or category; picking
     one sets the trader to its holder and asks for it. */
  const tradeable = useMemo(() => ALL_PROPERTIES.filter((m) => {
    const row = game.properties[m.id];
    return row && row.owner && row.owner !== me.id && !linked.has(row.owner) && !row.inAuction
      && game.players[row.owner] && !(m.built && !(game.builder && game.builder.tradeable));
  }), [game.properties, me.id]);
  const wantQ = want.trim().toLowerCase();
  const wanted = !wantQ ? [] : tradeable.filter((m) => [m.name, m.neighbourhoodName, m.stock,
    (CATEGORIES[m.category] || {}).label, (RARITIES[m.rarity] || {}).label]
    .some((t) => String(t || "").toLowerCase().includes(wantQ))).slice(0, 12);
  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>}

      {/* Property first. Picking a holder fills the form below with them and
          the property already requested, so the path from "I need one more in
          Micklegate" to a sealed proposal is one click rather than a search. */}
      <MutualMatches me={me} onPick={(m) => {
        setToId(String(m.who));
        setRequestProps((l) => (l.includes(m.theyHold.id) ? l : [...l, m.theyHold.id]));
        setOfferProps((l) => (l.includes(m.youHold.id) ? l : [...l, m.youHold.id]));
        setSearch("");
      }} />

      <WantedFromMe me={me} game={game} />

      <OneAway game={game} me={me} onPick={(c) => {
        setToId(String(c.ownerId));
        setRequestProps((l) => (l.includes(c.id) ? l : [...l, c.id]));
        setSearch("");
      }} />

      {!others.length ? <div className="empty">No other traders are available to you yet.</div> : (
        <div className="card">
          <h3>Propose a trade</h3>
          <div className="want-box">
            <label htmlFor="trade-want">What do you want?</label>
            <input id="trade-want" className="search-input" value={want} onChange={(e) => setWant(e.target.value)}
                   placeholder="A building, a district or a type, for example Micklegate or Terrace" />
            {wantQ && (
              <div className="want-results">
                {!wanted.length ? <div className="muted">No other trader holds anything matching that.</div> : wanted.map((m) => {
                  const row = game.properties[m.id];
                  const who = game.players[row.owner];
                  const picked = requestProps.includes(m.id) && String(toId) === String(row.owner);
                  return (
                    <button type="button" key={m.id} className={"want-row" + (picked ? " is-picked" : "")}
                            onClick={() => {
                              if (String(toId) !== String(row.owner)) { setToId(String(row.owner)); setRequestProps([m.id]); }
                              else setRequestProps((l) => (l.includes(m.id) ? l : [...l, m.id]));
                              setSearch("");
                            }}>
                      <b>{m.name}</b>
                      <span>{m.neighbourhoodName} · {m.stock}</span>
                      <span>Held by {who.name}</span>
                      <span className="want-price">{fmt(row.currentPrice)}</span>
                    </button>
                  );
                })}
              </div>
            )}
          </div>
          <div className="field-row">
            <label>Or find a 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 send-offer" + (sent ? " is-sent" : "")} disabled={!me.canInteract || !balanced || sending}
                    onClick={async (e) => {
              /* THE PAPER PLANE. Aaron, 27 Sep 2026: a one second flight on
                 "Send offer", like an email going. It flies only once the
                 server has taken the offer, so a refused one never looks
                 sent. Launched from where the button is on screen. */
              const r = e.currentTarget.getBoundingClientRect();
              setSending(true);
              const ok = await onAct("trade.propose", { toId: Number(toId), offerProps, offerCash: Number(offerCash) || 0, requestProps, requestCash: Number(requestCash) || 0 });
              setSending(false);
              if (ok === false) return;
              setOfferProps([]); setOfferCash(0); setRequestProps([]); setRequestCash(0);
              setPlane({ key: Date.now(), x: r.right - 10, y: r.top + r.height / 2 });
              setSent(true);
              setTimeout(() => setSent(false), 1600);
            }}>{sent ? "Offer sent" : "Send offer"}</button>
            {plane && <PaperPlaneFlight key={plane.key} x={plane.x} y={plane.y} onDone={() => setPlane(null)} />}
          </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: <PropList ids={o.offerProps} />{o.offerCash > 0 ? ` + ${fmt(o.offerCash)}` : ""}<br />
              They want: <PropList ids={o.requestProps} />{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: <PropList ids={o.offerProps} />{o.offerCash > 0 ? ` + ${fmt(o.offerCash)}` : ""}<br />
              You want: <PropList ids={o.requestProps} />{o.requestCash > 0 ? ` + ${fmt(o.requestCash)}` : ""}
            </div>
            <button className="btn secondary" onClick={() => onAct("trade.cancel", { tradeId: o.id })}>Cancel</button>
          </div>
        ))}</>}
    </div>
  );
}

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

/* GROWTH IS NO LONGER WORKED OUT HERE.

   It used to be: the server shipped every player's whole net-worth series
   and the browser did the arithmetic. Once the series had to reach back a
   week — for these columns and for the weekly honours — that was seven
   kilobytes a player on every broadcast to deliver two numbers, and it left
   the figure the board printed and the figure the awards were settled on as
   two separate calculations that could drift apart. Both now come down as
   player.growth, from lib/awards.js. */

/* ---- the weekly honours ------------------------------------------------ */

/* A LIFEBUOY FOR SUPPORT. Aaron, 27 Sep 2026, asked for a better mark than
   the diamond: two diamonds (◆ grant, ◈ support) read as two grades of the
   same prize. Support is a hand held out, so it is a ring buoy: a ring with
   four bands, drawn at 1em in the text's own colour. */
const SupportGlyph = () => (
  <svg className="support-glyph" viewBox="0 0 16 16" width="1em" height="1em" aria-hidden="true">
    <circle cx="8" cy="8" r="5.6" fill="none" stroke="currentColor" strokeWidth="3" />
    <g stroke="#F4EFE2" strokeWidth="1.6">
      <path d="M8 1.4v3M8 11.6v3M1.4 8h3M11.6 8h3" />
    </g>
  </svg>
);
/* FAST GROWER replaces the cash grants (Aaron, 28 Sep 2026). "grant"
   stays so last week's history still draws. */
const AWARD_ICON = {
  fastgrower: { glyph: "▲", label: "Fast Grower" },
  grant: { glyph: "◆", label: "Funding grant" },
  support: { glyph: <SupportGlyph />, label: "Social support" },
};

function AwardMarks({ player, kinds = null }) {
  const held = (player.awards || []).filter((a) => !kinds || kinds.includes(a.kind));
  if (!held.length) return null;
  return (
    <>
      {held.map((a, i) => {
        const ic = AWARD_ICON[a.kind];
        if (!ic) return null;
        const what = a.kind === "fastgrower"
          ? `${ic.label}: the best growth of anybody last week`
          : a.kind === "grant"
          ? `${ic.label}: ${["first", "second", "third"][a.rank - 1] || ""} on last week's growth`
          : `${ic.label}: overheads reduced this week`;
        return (
          <span key={i} className={"award-mark am-" + a.kind} title={what} aria-label={what}>
            {ic.glyph}
          </span>
        );
      })}
    </>
  );
}

/* THE CLOCK ON THE PRIZE.

   A weekly award nobody can see coming is a weekly surprise, which is a
   different and much weaker thing: the point of publishing the close is
   that the last day of the week is worth playing. Ticks once a second,
   which is often enough to feel live and rare enough to cost nothing. */
function AwardCountdown({ at, relief }) {
  const [now, setNow] = useState(Date.now());
  useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);
  if (!at) return null;
  const left = Math.max(0, at - now);
  const d = Math.floor(left / 86400000);
  const h = Math.floor((left % 86400000) / 3600000);
  const m = Math.floor((left % 3600000) / 60000);
  const sec = Math.floor((left % 60000) / 1000);
  /* LONDON, EXPLICITLY.

     Left to the browser's own zone this read "Sunday 22:59" to anybody
     outside the UK — the right instant, the wrong number, and one that
     disagrees with the BST clock in the page header. The market keeps
     British hours, so the close is quoted in British hours and says so. */
  const when = new Date(at).toLocaleString("en-GB", {
    timeZone: "Europe/London", weekday: "long", hour: "2-digit", minute: "2-digit",
    hour12: false, timeZoneName: "short",
  });
  return (
    <div className="award-clock">
      <div className="award-clock-face">
        <span className="award-clock-label">The honours are read in</span>
        <b>
          {d > 0 && <><em>{d}</em>d </>}
          <em>{String(h).padStart(2, "0")}</em>h{" "}
          <em>{String(m).padStart(2, "0")}</em>m{" "}
          <em>{String(sec).padStart(2, "0")}</em>s
        </b>
        <span className="award-clock-when">{when}</span>
      </div>
      <p className="award-clock-note">
        <span className="award-mark am-fastgrower">▲</span> Fast Grower, an honour kept on their profile, to the
        trader whose net worth grew most since Monday, the "Since Monday" column below. Win it again and it
        counts up: ×2, ×3.
      </p>
    </div>
  );
}

/* ---- WHAT LEADING A DISTRICT IS WORTH, AND THE LEADER'S CHOICE ---------

   Aaron, 25 Sep 2026. The title used to carry a flat 4% on the leader's own
   rent there — £90 to £137 a day with three buildings, against roughly
   £1,000 a day from ONE building. It was a badge nobody could see the point
   of. The leader now chooses one of four rewards per district, and may
   change it once a week. The figures come from the server's own settings in
   the catalogue, never from a copy typed here. */
/* LEADER_RULES is declared with the other catalogue globals at the top of
   the file, because adoptCatalogue can run before this line is reached. */
const LEADER_REWARDS = ["groundRent", "ownRent", "skill", "security"];
function leaderRewardText(code, where) {
  const r = LEADER_RULES;
  return {
    groundRent: { name: "Ground rent",
      line: `${Math.round(r.groundRentPct * 100)}% of the rent every other owner in ${where} earns, taken from them and paid to you.` },
    ownRent: { name: "Rent premium",
      line: `${Math.round(r.ownRentPct * 100)}% more rent from your own buildings in ${where}.` },
    skill: { name: "Standing",
      line: `${r.skillPerEvening} skill every evening at ${eveningAt()}, for as long as you lead ${where}.` },
    security: { name: "The watch",
      line: `${r.securityLevels === 1 ? "One more security level" : `${r.securityLevels} more security levels`} on each of your buildings in ${where}. The only way a building goes past level 3.` },
  }[code];
}
/* A day's worth of each money option, from the board as it stands, so the
   choice is made on this district's numbers and not on a percentage. */
function leaderRewardValue(code, hood, game, me) {
  let mine = 0, theirs = 0, mineCount = 0;
  for (const m of ALL_PROPERTIES) {
    if (m.neighbourhood !== hood) continue;
    const p = game.properties[m.id];
    if (!p || !p.owner) continue;
    const gross = (yieldPctOf(m) / 100) * (p.currentPrice || 0);
    if (p.owner === me.id) { mine += gross; mineCount++; } else theirs += gross;
  }
  const r = LEADER_RULES;
  return {
    groundRent: `About ${fmt(theirs * r.groundRentPct)} a day now`,
    ownRent: `About ${fmt(mine * r.ownRentPct)} a day now`,
    skill: `${r.skillPerEvening * 7} skill a week`,
    security: `${mineCount} building${mineCount === 1 ? "" : "s"}`,
  }[code];
}

/* The magnate may take everything but ground rent: nobody else owns
   anything there to take it from. */
const MAGNATE_REWARDS = LEADER_REWARDS.filter((c) => c !== "groundRent");

/* One row of reward buttons. `slot` 2 is the magnate's second choice. */
function LeaderRewardRow({ f, hood, codes, slot, game, me, onAct }) {
  const current = slot === 2 ? f.bonus2 : f.bonus;
  const chosen = slot === 2 ? f.bonus2Chosen : f.bonusChosen;
  const locked = chosen && (slot === 2 ? f.bonus2ChangeableAt : f.bonusChangeableAt) > Date.now();
  const other = f.magnate ? (slot === 2 ? f.bonus : f.bonus2) : null;
  return (
    <div className="leader-reward-options" role="radiogroup"
         aria-label={`${slot === 2 ? "Second reward" : "Reward"} for ${f.magnate ? "owning" : "leading"} ${hood.name}`}>
      {codes.map((code) => {
        const t = leaderRewardText(code, hood.name);
        const on = current === code;
        return (
          <button key={code} role="radio" aria-checked={on}
                  className={"leader-reward" + (on ? " is-on" : "")}
                  disabled={on || locked}
                  onClick={() => onAct("leader.bonus", { district: f.neighbourhood, bonus: code, slot })}>
            <b>{t.name}{on ? <em>{chosen ? (other === code ? "In force, twice" : "In force") : "In force until you choose"}</em> : null}</b>
            <span>{t.line}</span>
            <i>{leaderRewardValue(code, f.neighbourhood, game, me)}</i>
          </button>
        );
      })}
    </div>
  );
}

function LeaderRewards({ game, me, onAct }) {
  const mine = (game.freeholds || []).filter((f) => f.user_id === me.id);
  if (!mine.length || !onAct) return null;
  return (
    <div className="leader-rewards">
      {mine.map((f) => {
        const hood = NEIGHBOURHOOD_INDEX.get(f.neighbourhood) || { name: f.neighbourhood };
        const size = f.size || 10;
        const locked = f.bonusChosen && f.bonusChangeableAt > Date.now();
        const locked2 = f.bonus2Chosen && f.bonus2ChangeableAt > Date.now();
        const when = (chosen, lock, at, what) => !chosen
          ? `Choose your ${what}. Until you do, the rent premium applies.`
          : lock ? `You can change your ${what} in ${spanOf(at - Date.now())}.` : `You can change your ${what} now.`;
        return (
          <section key={f.neighbourhood} className={"leader-reward-card" + (f.magnate ? " is-magnate" : "")}>
            <header>
              <span className="leader-crown" aria-hidden="true">♛</span>
              <div>
                <h4>{f.magnate ? `You are the magnate of ${hood.name}` : `You lead ${hood.name}`}</h4>
                {f.magnate ? (
                  <p>All {size} of its buildings are yours. Ground rent has nobody to come from, so choose two
                    rewards from the other three. Choose the same one twice for twice the effect.</p>
                ) : (
                  <p>{f.count} of its {size} buildings are yours. {when(f.bonusChosen, locked, f.bonusChangeableAt, "reward")}
                    {" "}Own all {size} and you become its magnate, with a second reward.</p>
                )}
              </div>
            </header>
            {f.magnate ? (
              <>
                <div className="leader-slot-label">First reward. {when(f.bonusChosen, locked, f.bonusChangeableAt, "first reward")}</div>
                <LeaderRewardRow f={f} hood={hood} codes={MAGNATE_REWARDS} slot={1} game={game} me={me} onAct={onAct} />
                <div className="leader-slot-label">Second reward. {when(f.bonus2Chosen, locked2, f.bonus2ChangeableAt, "second reward")}</div>
                <LeaderRewardRow f={f} hood={hood} codes={MAGNATE_REWARDS} slot={2} game={game} me={me} onAct={onAct} />
              </>
            ) : (
              <LeaderRewardRow f={f} hood={hood} codes={LEADER_REWARDS} slot={1} game={game} me={me} onAct={onAct} />
            )}
          </section>
        );
      })}
    </div>
  );
}

/* A LEADER WHO HAS NOT CHOSEN IS TOLD, everywhere, until they do. Taking a
   district is the achievement; the old version said so in one notification
   that scrolled away and the reward was a number nobody could see. */
function LeaderPrompt({ game, me, onOpen }) {
  const due = (game.freeholds || []).filter((f) => f.user_id === me.id && (!f.bonusChosen || (f.magnate && !f.bonus2Chosen)));
  if (!due.length) return null;
  const names = due.map((f) => (NEIGHBOURHOOD_INDEX.get(f.neighbourhood) || {}).name || f.neighbourhood);
  return (
    <div className="leader-bar" role="status">
      <span className="leader-crown" aria-hidden="true">♛</span>
      <span>You lead {names.join(" and ")}. Choose what leading {due.length === 1 ? "it" : "them"} is worth to you.</span>
      <button className="leader-bar-go" onClick={onOpen}>Choose your reward</button>
    </div>
  );
}

/* Who holds each of the fifteen neighbourhoods. A second board, and
   deliberately a different one: it is scored on properties held rather than
   money, so the cheap end of the city is winnable by somebody who could
   never top the net-worth table. */
function FreeholdBoard({ game, me, onAct }) {
  const held = new Map((game.freeholds || []).map((f) => [f.neighbourhood, f]));
  if (!NEIGHBOURHOODS.length) return null;
  return (
    <div className="card" id="district-leaders">
      <h3>District leaders</h3>
      <div className="meta">
        Hold the most of a district's buildings, at least three, and you lead it. Ties stay with
        the holder, so you have to go one better. A leader chooses one reward for each district
        they lead and can change it once a week: ground rent from the other owners there, a rent
        premium on their own buildings, standing in skill every evening, or an extra level of
        security. Own every building in a district and you are its magnate: no ground rent, but two
        rewards from the other three, and the same one twice for twice the effect. Everybody can see
        which rewards each leader has chosen.
      </div>
      <LeaderRewards game={game} me={me} onAct={onAct} />
      <div className="lb-wrap">
        <table className="lb">
          <thead><tr><th>District</th><th>District leader</th><th>Held</th><th>Reward</th></tr></thead>
          <tbody>
            {NEIGHBOURHOODS.map((n) => {
              const f = held.get(n.code);
              const p = f ? game.players[f.user_id] : null;
              const isMine = f && f.user_id === me.id;
              return (
                <tr key={n.code} className={(isMine ? "you-row" : "") + (f ? " is-claimed" : " is-open")}>
                  <td className="fh-name">{n.name}</td>
                  <td>{p ? <><TraderName player={p} className={f.magnate ? "is-magnate" : "is-leader"} />
                          {f.magnate && <span className="magnate-tag"> (magnate)</span>}
                          {isMine && <span className="muted"> (you)</span>}</>
                        : <span className="muted">unclaimed</span>}</td>
                  <td>{f ? <span className="fh-held">{f.count}/{f.size || 10}<i aria-hidden="true"><b style={{ width: `${Math.round((f.count / (f.size || 10)) * 100)}%` }} /></i></span> : <span className="muted">—</span>}</td>
                  <td>{f && f.bonus ? rewardNames(f, n.name) : <span className="muted">—</span>}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    </div>
  );
}

/* What a district's leader has in force, by name: "Standing", or for a
   magnate both, "Rent premium ×2" when doubled. */
function rewardNames(f, where) {
  const a = (leaderRewardText(f.bonus, where) || {}).name;
  if (!f.magnate || !f.bonus2) return a;
  const b = (leaderRewardText(f.bonus2, where) || {}).name;
  return a === b ? `${a} ×2` : `${a} and ${b}`;
}

function LeaderboardTab({ game, me, onAct }) {
  const [sortBy, setSortBy] = useState("net");
  const [board, setBoard] = useState("all");
  const [addName, setAddName] = useState("");
  const friends = me.friends || { accepted: [], incoming: [], outgoing: [], max: 50 };
  const friendIds = new Set(friends.accepted.map((f) => f.id));
  const rows = Object.values(game.players).map((p) => ({
    id: p.id, p, net: p.netWorth,
    day: p.growth ? p.growth.day : null,
    week: p.growth ? p.growth.week : null,
  }));
  const pool = board === "friends"
    ? rows.filter((r) => friendIds.has(r.id) || r.id === me.id)
    : rows;
  const sorted = [...pool].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, board === "friends" ? 50 : 10);
  const myRank = [...rows].sort((a, b) => b.net - a.net).findIndex((r) => r.id === me.id) + 1;

  /* WHO IS IN LINE, IF THE WEEK ENDED NOW.

     Worked out across the WHOLE field rather than the ten rows on screen —
     the three lowest net worths are, by definition, not in a top-ten table,
     and a "you are in line for support" that only appeared once you were
     already visible would be no warning at all. The ranking is the same one
     lib/awards.js settles on, over the same two published figures, so the
     highlighted rows and the printed numbers cannot disagree. */
  const aw = game.awards || null;
  const leaderIds = new Set((game.freeholds || []).map((f) => f.user_id));
  const magnateIds = new Set((game.freeholds || []).filter((f) => f.magnate).map((f) => f.user_id));
  const enoughPlayers = aw && aw.enabled && rows.length >= (aw.minPlayers || 6);
  const inLine = useMemo(() => {
    if (!enoughPlayers) return { grant: new Map(), support: new Map() };
    /* One Fast Grower a week (28 Sep 2026), not three grants. */
    const grant = new Map([...rows].filter((r) => r.week !== null)
      .sort((a, b) => b.week - a.week).slice(0, 1).map((r, i) => [r.id, i + 1]));
    /* The server's forecast: it knows who has been inactive for over a
       week (not considered) and breaks a tie on the most recent visit,
       neither of which the page is told for other traders. */
    const support = new Map(Array.isArray(aw.supportLine)
      ? aw.supportLine.map((id, i) => [id, i + 1])
      : [...rows].sort((a, b) => a.net - b.net).slice(0, 3).map((r, i) => [r.id, i + 1]));
    return { grant, support };
  }, [rows.map((r) => `${r.id}:${r.net}:${r.week}`).join("|"), enoughPlayers, aw && (aw.supportLine || []).join(",")]);
  const pct = (v) => v === null ? <span className="muted">—</span> : <span className={v >= 0 ? "chg-up" : "chg-down"}>{fmtPct(v)}</span>;
  /* The masthead's figures: where you stand, and who is one place above. */
  const byNet = [...rows].sort((a, b) => b.net - a.net);
  const meRow = byNet.find((r) => r.id === me.id) || null;
  const above = myRank > 1 ? byNet[myRank - 2] : null;
  const topNet = sorted.length ? Math.max(...sorted.map((r) => r.net), 1) : 1;
  const podium = sortBy === "net" ? sorted.slice(0, 3) : [];

  return (
    <div className="lboard">
      <Masthead tone="board" tiles={[
        { label: board === "friends" ? "Your rank overall" : "Your rank", value: myRank ? `#${myRank}` : "—", unit: `of ${rows.length}`,
          foot: myRank === 1 ? "top of the city" : myRank && myRank <= 10 ? "on the board" : "outside the top ten" },
        { label: "Your net worth", value: fmt(meRow ? meRow.net : me.netWorth || 0), foot: `${(meRow && meRow.p.propertyCount) || 0} properties` },
        { label: above ? `To catch ${above.p.name}` : "To catch", value: above ? fmt(Math.max(0, above.net - (meRow ? meRow.net : 0))) : "Nobody", wide: false,
          foot: above ? `the trader at #${myRank - 1}` : "you are the one to catch" },
        { label: "Your growth since Monday", value: meRow && meRow.week !== null ? fmtPct(meRow.week) : "—", feature: true,
          foot: meRow && meRow.day !== null ? `${fmtPct(meRow.day)} over 24 hours` : "shown once you start the week above the opening stake" },
      ]} />
      <p className="intro">
        {board === "friends"
          ? "You and the traders you have added. A smaller field, and the one most people actually care about."
          : "Top ten traders by net worth, with how much each has grown over the last day and since Monday."}
      </p>
      {/* A BOARD THAT CAN LIE, AND SAYS SO.
          A trader can pay the registrar to have their figure published 20%
          out for a day. Hiding the fact that this is possible would make the
          board lie successfully for ever, which is a worse game than one
          where everyone knows the numbers are a claim. WHICH way, and by how
          much, is still theirs. */}
      {Object.values(game.players).some((p) => p.disguised) && (
        <div className="board-caveat">
          Some figures on this board are disguised: a trader can have theirs published
          20% out for a day. A marked name is not the number it claims.
        </div>
      )}
      {aw && aw.enabled && (
        <AwardCountdown at={aw.nextAt} relief={aw.relief} />
      )}
      {aw && aw.enabled && !enoughPlayers && (
        <div className="board-caveat">
          Fast Grower needs {aw.minPlayers} traders on the board before it is awarded;
          there {rows.length === 1 ? "is" : "are"} {rows.length} so far.
        </div>
      )}
      {aw && aw.last && aw.last.rows.some((r) => r.kind !== "support") && (
        <div className="last-honours">
          <span className="lh-label">Last week</span>
          {/* Social support is left off this board (Aaron, 29 Sep 2026): the
              traders on it are not the ones receiving it. */}
          {aw.last.rows.filter((r) => r.kind !== "support").map((r, i) => (
            <span className={"lh-row k-" + r.kind} key={i}>
              <span className={"award-mark am-" + r.kind}>{r.kind === "fastgrower" ? "▲" : r.kind === "grant" ? "◆" : <SupportGlyph />}</span>
              <b>{r.name || "a departed trader"}</b>
              {r.kind === "fastgrower" ? <em>Fast Grower</em> : r.kind === "grant" ? <em>{fmt(r.amount)}</em> : <em>support</em>}
            </span>
          ))}
        </div>
      )}

      <div className="filter-bar">
        <div className="seg">
          <button className={"seg-btn" + (board === "all" ? " on" : "")} onClick={() => setBoard("all")}>Everyone</button>
          <button className={"seg-btn" + (board === "friends" ? " on" : "")} onClick={() => setBoard("friends")}>
            Friends{friends.accepted.length ? ` (${friends.accepted.length})` : ""}
          </button>
        </div>
        <select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
          <option value="net">Rank by net worth</option>
          <option value="day">Rank by growth over 24 hours</option>
          <option value="week">Rank by growth since Monday</option>
        </select>
        <span className="muted" style={{ alignSelf: "center" }}>Your overall rank: #{myRank || "—"} of {rows.length}</span>
      </div>
      {/* THE PODIUM. The top three by net worth, in the board's own colours
          (no gold or silver medals: those are rarities here). */}
      {podium.length >= 3 && (
        <ol className="lb-podium" aria-label="The top three">
          {podium.map((r, i) => (
            <li key={r.id} className={"lbp lbp-" + (i + 1) + (r.id === me.id ? " is-me" : "")}>
              <span className="lbp-rank">{i + 1}</span>
              <span className="lbp-name"><TraderName player={r.p} noCrest className={magnateIds.has(r.id) ? "is-magnate" : leaderIds.has(r.id) ? "is-leader" : ""} /></span>
              <strong className="lbp-net">{fmt(r.net)}{r.p.disguised && <span className="disguised-mark" title="This figure is disguised">✱</span>}</strong>
              <span className="lbp-foot">{r.p.propertyCount} properties{r.week !== null ? ` · ${fmtPct(r.week)} since Monday` : ""}</span>
            </li>
          ))}
        </ol>
      )}
      <div className="lb-wrap">
        <table className="lb">
          <thead><tr><th></th><th>Trader</th><th>Net worth</th><th>24 hours</th><th>Since Monday</th><th>Properties</th></tr></thead>
          <tbody>
            {sorted.map((r, i) => {
              const forGrant = inLine.grant.get(r.id);
              return (
                <tr key={r.id} className={(forGrant ? "in-grant" : "") + (r.id === me.id ? " is-me" : "")}>
                  <td className="rank">{i + 1}</td>
                  {/* Your own row is no longer shaded (Aaron, 27 Sep 2026):
                      your name is in bold instead, so the only washes on the
                      table are the two honours. */}
                  {/* A DISTRICT LEADER'S NAME IN THE MAP'S LEADER COLOUR, and no
                      crest beside it (Aaron, 29 Sep 2026): the key says so. */}
                  <td><TraderName player={r.p} noCrest className={magnateIds.has(r.id) ? "is-magnate" : leaderIds.has(r.id) ? "is-leader" : ""} />{r.id === me.id && <span className="muted"> (you)</span>}
                    <AwardMarks player={r.p} kinds={["fastgrower"]} />
                    {r.p.firmName && <div className="firm-line">{r.p.firmName}</div>}</td>
                  <td className="price">
                    {fmt(r.net)}
                    {r.p.disguised && <span className="disguised-mark" title="This figure is disguised">✱</span>}
                    <span className="lb-bar" aria-hidden="true"><i style={{ width: `${Math.max(2, Math.round((r.net / topNet) * 100))}%` }} /></span>
                  </td>
                  <td>{pct(r.day)}</td>
                  <td className={forGrant ? "lit-grant" : ""}>
                    {pct(r.week)}
                    {forGrant && <span className="in-line-pip is-grant"
                      title="In line for Fast Grower: the best growth since Monday">▲</span>}
                  </td>
                  <td>{r.p.propertyCount}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>

      {/* HOW THE TWO GROWTH COLUMNS ARE MEASURED. Aaron, 28 Sep 2026: true
          growth, and only for a trader who started the period at the
          opening stake or more. Below it a percentage means nothing (every
          account starts at about zero), so the column shows a dash and that
          trader cannot be Fast Grower that week. */}
      <p className="lb-growth-note">
        Growth is the change in a trader's net worth as a percentage of where they started. It is shown
        only for traders who started the period with at least {fmt((game.rules && game.rules.starterLoan) || 500000)};
        below that the column shows a dash and they cannot be Fast Grower that week. 24 hours looks back
        one day. Since Monday runs from the start of the honours week, so early in the week it covers
        less time than 24 hours.
      </p>

      {/* THE KEY. Aaron, 29 Sep 2026: district leaders in the map's leader
          colour, and Fast Grower said plainly: who is in line for it now, by
          name, with their figure, or why nobody is yet. Social support is not
          explained here: the traders on this board are not the ones who get it. */}
      {(() => {
        const lead = [...inLine.grant.keys()][0];
        const leadRow = lead != null ? rows.find((r) => r.id === lead) : null;
        const when = aw && aw.nextAt ? new Date(aw.nextAt).toLocaleString("en-GB", { timeZone: "Europe/London", weekday: "long", hour: "2-digit", minute: "2-digit", hour12: false }) : "Sunday 23:59";
        return (
          <div className="lb-key">
            <span><i className="lb-key-sw is-leader" />District leader: a name in this colour leads at least one district.</span>
            <span><i className="lb-key-sw is-magnate" />Magnate: a name in this colour owns every building in a district.</span>
            {aw && aw.enabled && (leadRow ? (
              <span><i className="lb-key-sw is-grant" />In line for Fast Grower ▲: <TraderName player={leadRow.p} size={13} noCrest /> (up {fmtPct(leadRow.week)} since
                Monday, the best of anybody). If nobody overtakes them by {when}, the honour goes on their profile.</span>
            ) : (
              <span><i className="lb-key-sw is-grant" />Fast Grower ▲ goes on {when} to the trader whose net worth has grown most
                since Monday. {enoughPlayers ? `Nobody is in line yet: it needs a trader who started the week with at least ${fmt((game.rules && game.rules.starterLoan) || 500000)}.` : ""}</span>
            ))}
          </div>
        );
      })()}

      {/* Rivalries used to sit here, under the board. They are intelligence —
          who is coming for you — not a ranking, and the tab whose whole job
          is "what is happening to me" is Intelligence. Moved, not copied:
          the same panel in two places is two places to keep in step. */}
      <AllianceBoard game={game} me={me} />
      <FreeholdBoard game={game} me={me} onAct={onAct} />

      {board === "friends" && (
        <div className="panel" style={{ marginTop: 16 }}>
          <h3>Friends</h3>
          <p className="muted" style={{ fontSize: 12.5, marginTop: 0 }}>
            Both sides have to agree. {friends.accepted.length} of {friends.max} used.
          </p>

          <form className="inline-form" onSubmit={(e) => { e.preventDefault(); if (addName.trim()) { onAct("friend.request", { name: addName.trim() }); setAddName(""); } }}>
            <input value={addName} onChange={(e) => setAddName(e.target.value)}
                   placeholder="Trader name" maxLength={28} />
            <button className="btn small" type="submit" disabled={!addName.trim()}>Send request</button>
          </form>

          {friends.incoming.length > 0 && (
            <div style={{ marginTop: 14 }}>
              <div className="label">Waiting on you</div>
              {friends.incoming.map((f) => (
                <div key={f.id} className="friend-row">
                  <span>{f.name}</span>
                  <span>
                    <button className="btn small" onClick={() => onAct("friend.accept", { id: f.id })}>Accept</button>
                    <button className="btn small ghost" onClick={() => onAct("friend.remove", { id: f.id })}>Decline</button>
                  </span>
                </div>
              ))}
            </div>
          )}

          {friends.outgoing.length > 0 && (
            <div style={{ marginTop: 14 }}>
              <div className="label">Waiting on them</div>
              {friends.outgoing.map((f) => (
                <div key={f.id} className="friend-row">
                  <span className="muted">{f.name}</span>
                  <button className="btn small ghost" onClick={() => onAct("friend.remove", { id: f.id })}>Cancel</button>
                </div>
              ))}
            </div>
          )}

          {friends.accepted.length > 0 && (
            <div style={{ marginTop: 14 }}>
              <div className="label">Your friends</div>
              {friends.accepted.map((f) => (
                <div key={f.id} className="friend-row">
                  <span>{f.name}</span>
                  <button className="btn small ghost" onClick={() => onAct("friend.remove", { id: f.id })}>Remove</button>
                </div>
              ))}
            </div>
          )}

          {!friends.accepted.length && !friends.incoming.length && !friends.outgoing.length && (
            <p className="muted" style={{ fontSize: 13 }}>Nobody yet. Add a trader by the name they play under.</p>
          )}
        </div>
      )}
    </div>
  );
}

/* ---------------------------- Intelligence ------------------------------- */
/* The tab is Intelligence; the card inside it called "Market report" keeps
   that name, because it is the feed of what the market actually did — one
   section of the intelligence you hold, not the whole of it. */

/* The dispatch box: email when another player moves against you. Sold on
   this tab rather than in the shop because it belongs with the other
   "how do I find things out" upgrades, and priced well under the cheapest
   analytics tier — it changes when you hear, never what you are told. */
function DispatchBox({ me, onAct }) {
  const a = me.emailAlerts;
  if (!a) return null;
  const mins = Math.round((a.cooldownMs || 0) / 60000);
  return (
    <div className="card">
      <h3>Dispatch box</h3>
      <div className="meta">An email when a rival moves on you, so a takeover doesn't complete while the tab is shut.</div>
      {!a.owned ? (
        <>
          <div style={{ fontSize: 13, margin: "6px 0 8px" }}>
            Tactics, hostile bids, trade offers, being outbid, and the bank losing patience. Never rent:
            that lands every hour and would be nothing but noise. At most one email every {mins} minutes,
            with everything since the last one gathered into it.
          </div>
          <button className="btn" onClick={() => onAct("alerts.buy")} disabled={me.cash < a.price}>
            Open one for {fmt(a.price)}
          </button>
        </>
      ) : (
        <>
          <div className={"hold-note " + (a.on ? "ok" : "warn")}>
            {a.on ? `On: sent to ${a.address}, at most one every ${mins} minutes.` : "Off: nothing is being sent."}
          </div>
          <button className="btn small" onClick={() => onAct("alerts.set", { on: !a.on })} style={{ marginTop: 8 }}>
            {a.on ? "Stop the emails" : "Start them again"}
          </button>
        </>
      )}
      {a.owned && a.on && !a.ready && (
        <div className="hold-note warn" style={{ marginTop: 8 }}>
          No mail provider is configured on this server yet, so alerts are being written to the server log
          instead of sent. Nothing is lost; they start arriving as soon as it's set up.
        </div>
      )}
    </div>
  );
}

/* THE DESK USED TO BE A LOG, AND A LOG OF AN HOURLY EVENT IS UNREADABLE.

   Rent settles every hour, so does deposit interest, and a player in
   arrears with nothing left to seize is told so on every single settlement.
   Fifty rows later the desk holds about four distinct facts, repeated.

   So the desk groups by what the message IS rather than by each time it
   happened: amounts are masked out to find the shape, and one row stands for
   every entry sharing it — newest time, how many, and for anything carrying
   money, the total. That last part is the bit a log could never give you:
   "Rent settled x24, £48,120 in total" is the number a player actually wants
   and would otherwise have to add up by hand.

   Nothing is thrown away. Each group opens to the individual entries, and
   the toggle above restores the plain log for anyone who wants it. */
const notifShape = (n) =>
  n.kind + "|" + String(n.text).replace(/[\d][\d,.]*/g, "#");

function groupNotifications(list) {
  const groups = new Map();
  for (const n of list) {
    const key = notifShape(n);
    const g = groups.get(key);
    if (g) {
      g.rows.push(n);
      g.total += n.value || 0;
      if (n.t < g.first) g.first = n.t;
    } else {
      groups.set(key, { key, rows: [n], total: n.value || 0, first: n.t, latest: n });
    }
  }
  /* Ordered by the newest member, so the desk still reads newest-first —
     the grouping changes what a row means, not where it sits. */
  return [...groups.values()].sort((a, b) => b.latest.t - a.latest.t);
}

function DeskGroup({ group }) {
  const [open, setOpen] = useState(false);
  const n = group.latest;
  const count = group.rows.length;
  if (count === 1) {
    return (
      <div className="notif-row">
        <span className="notif-dot" />
        <div style={{ flex: 1 }}>{n.text}</div>
        <div className="event-time">{fmtTime(n.t)}</div>
      </div>
    );
  }
  return (
    <>
      <div className="notif-row grouped" onClick={() => setOpen(!open)} role="button"
           tabIndex={0} aria-expanded={open}
           onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen(!open); } }}>
        <span className="notif-dot" />
        <div style={{ flex: 1 }}>
          {n.text}
          <div className="notif-sub">
            <span className="notif-count">&times;{count}</span>
            {group.total > 0 && <> · {fmt(group.total)} in total</>}
            {" · since "}{fmtTime(group.first)}
          </div>
        </div>
        <div className="event-time">{fmtTime(n.t)}<span className="notif-caret">{open ? "\u25B4" : "\u25BE"}</span></div>
      </div>
      {open && group.rows.map((r) => (
        <div className="notif-row child" key={r.id}>
          <div style={{ flex: 1 }}>{r.text}</div>
          <div className="event-time">{fmtTime(r.t)}</div>
        </div>
      ))}
    </>
  );
}

function DeskFeed({ notifications }) {
  const [raw, setRaw] = useState(false);
  const groups = useMemo(() => groupNotifications(notifications), [notifications]);
  const collapsed = notifications.length - groups.length;
  if (!notifications.length) {
    return <div className="empty">Nothing yet. Buy something and the rents will start arriving.</div>;
  }
  return (
    <>
      {collapsed > 0 && (
        <div className="desk-toggle">
          <span className="muted">
            {raw ? `${notifications.length} entries` : `${groups.length} things happened · ${collapsed} repeat${collapsed === 1 ? "" : "s"} folded in`}
          </span>
          <button className="btn small secondary" onClick={() => setRaw(!raw)}>
            {raw ? "Group repeats" : "Show every entry"}
          </button>
        </div>
      )}
      {raw
        ? 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>
          ))
        : groups.map((g) => <DeskGroup key={g.key} group={g} />)}
    </>
  );
}

/* ---- INTELLIGENCE: THE WIRE ROOM ------------------------------------------

   Aaron, 27 Sep 2026: the page wanted a theme, the market report was a mess,
   market sentiment belonged below the desk, and the analytics upgrade read
   as if it still sold the heat map, which has been free for a while.

   So the page is a wire room, top to bottom in the order a trader wants it:
   the morning brief, what happened to you, how the market is leaning, then
   the wire itself, and last what a subscription adds. The wire was one flat
   list in which the hourly "rents settled" line drowned everything; it is
   now sorted into days, filterable by kind, with the routine lines folded
   into one. */
const WIRE_KINDS = [
  { key: "all", label: "Everything" },
  { key: "deal", label: "Deals", tag: "Deal" },
  { key: "auction", label: "Auctions", tag: "Auction" },
  { key: "tactic", label: "Tactics", tag: "Tactic" },
  { key: "pact", label: "Pacts and honours", tag: "Pact" },
  { key: "city", label: "The city", tag: "City" },
];
function wireKind(e) {
  const t = e.text || "";
  if (e.kind === "tactic") return "tactic";
  if (/auction|foreclos|vendue|drew no bids/i.test(t)) return "auction";
  if (e.kind === "social" || /alliance|truce|walked out|earned /i.test(t)) return "pact";
  if (e.kind === "player") return "deal";
  return "city";
}
const ROUTINE_RE = /^Rents settled on (\d+) /;
function wireDays(news, filter) {
  const days = [];
  const byDay = new Map();
  for (const e of news) {
    const kind = wireKind(e);
    if (filter !== "all" && kind !== filter) continue;
    const d = new Date(e.t);
    const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
    if (!byDay.has(key)) { const day = { key, t: e.t, items: [], routine: null }; byDay.set(key, day); days.push(day); }
    const day = byDay.get(key);
    const m = (e.text || "").match(ROUTINE_RE);
    if (m) {
      if (!day.routine) { day.routine = { id: e.id, t: e.t, n: 0, last: +m[1] }; day.items.push({ routine: day.routine, t: e.t, id: "r" + e.id }); }
      day.routine.n += 1;
      continue;
    }
    day.items.push({ e, kind, t: e.t, id: e.id });
  }
  return days;
}
function dayLabel(t) {
  const d = new Date(t), now = new Date();
  const same = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
  if (same(d, now)) return "Today";
  const y = new Date(now); y.setDate(now.getDate() - 1);
  if (same(d, y)) return "Yesterday";
  return d.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" });
}

function SentimentGauge({ label, value }) {
  const v = Math.max(-1, Math.min(1, value || 0));
  const word = v > 0.35 ? "Strong" : v > 0.1 ? "Firm" : v < -0.35 ? "Weak" : v < -0.1 ? "Soft" : "Steady";
  return (
    <div className={"intel-gauge" + (v < -0.1 ? " is-neg" : v > 0.1 ? " is-pos" : "")}>
      <div className="intel-gauge-top"><span>{label}</span><b>{word}</b></div>
      <div className="intel-gauge-track">
        <i className="intel-gauge-mid" />
        <i className="intel-gauge-fill" style={{ left: v >= 0 ? "50%" : `${50 + v * 50}%`, width: `${Math.abs(v) * 50}%` }} />
      </div>
      <div className="intel-gauge-num">{(() => { const n = Math.round(v * 100) || 0; return n > 0 ? `+${n}` : String(n); })()}</div>
    </div>
  );
}

function EventsTab({ game, me, onAct }) {
  const level = me.analyticsLevel || 0;
  const nextTier = ANALYTICS_TIERS[level + 1];
  const currentTier = ANALYTICS_TIERS[level];
  const [filter, setFilter] = useState("all");
  const lead = game.gazette && game.gazette.leader;
  const days = useMemo(() => wireDays(game.news || [], filter), [game.news, filter]);
  const counts = useMemo(() => {
    const c = { all: 0 };
    for (const e of game.news || []) { if (ROUTINE_RE.test(e.text || "")) continue; const k = wireKind(e); c[k] = (c[k] || 0) + 1; c.all += 1; }
    return c;
  }, [game.news]);

  return (
    <div className="intel">
      <header className="intel-mast">
        <div>
          <span className="intel-kicker">Intelligence</span>
          <h2>The wire room</h2>
        </div>
        <span className="intel-feed-chip" title={currentTier.desc}>
          <i aria-hidden="true" />{currentTier.name}
        </span>
      </header>

      {/* THE BRIEF. The Gazette's leader, in plain type, first on the page:
          what the city did, before what happened to you. */}
      {lead && (
        <section className="intel-brief">
          <div className="intel-brief-tag">Morning brief{game.gazette.night != null && NIGHT_NAME[game.gazette.night] ? ` · ${NIGHT_NAME[game.gazette.night]}` : ""}</div>
          <p className="intel-brief-head">{lead.headline}</p>
          {lead.leader && <p className="intel-brief-body">{lead.leader}</p>}
        </section>
      )}

      <section className="card intel-desk">
        <h3>Your desk</h3>
        <div className="meta">Everything that happened to you, whether or not it made the wire.</div>
        <DeskFeed notifications={game.notifications} />
      </section>

      {/* Below the desk, as Aaron asked: how the market is leaning. */}
      <section className="card intel-sentiment">
        <h3>Market sentiment</h3>
        <div className="meta">The drift behind every price. The bank rate outlook moves the whole city; each area leans on its own buildings.</div>
        <div className="intel-gauges">
          <SentimentGauge label="Bank rate outlook" value={game.market.economicIndex} />
          {Object.entries(REGIONS).map(([code, r]) => (
            <SentimentGauge key={code} label={r.label} value={game.market.regionTrend[code] || 0} />
          ))}
        </div>
      </section>

      <section className="card intel-wire">
        <div className="intel-wire-head">
          <h3>The wire</h3>
          <div className="intel-filters" role="tablist" aria-label="Filter the wire">
            {WIRE_KINDS.map((k) => (
              <button key={k.key} role="tab" aria-selected={filter === k.key}
                      className={"intel-filter" + (filter === k.key ? " on" : "")} onClick={() => setFilter(k.key)}>
                {k.label}{counts[k.key] ? <em>{counts[k.key]}</em> : null}
              </button>
            ))}
          </div>
        </div>
        {!days.length && <div className="empty">Nothing on the wire{filter === "all" ? " yet" : " of that kind"}.</div>}
        {days.map((day) => (
          <div className="intel-day" key={day.key}>
            <h4 className="intel-day-h">{dayLabel(day.t)}</h4>
            <ul className="intel-lines">
              {day.items.map((it) => it.routine ? (
                <li key={it.id} className="intel-line is-routine">
                  <time>{fmtTime(it.t)}</time>
                  <span className="intel-kind k-city">Rents</span>
                  <span>Rents settled {it.routine.n === 1 ? "once" : `${it.routine.n} times`}; the latest paid {it.routine.last} maintained propert{it.routine.last === 1 ? "y" : "ies"}.</span>
                </li>
              ) : (
                <li key={it.id} className={"intel-line k-" + it.kind}>
                  <time>{fmtTime(it.t)}</time>
                  <span className={"intel-kind k-" + it.kind}>{(WIRE_KINDS.find((k) => k.key === it.kind) || {}).tag}</span>
                  <span>{it.e.text}</span>
                </li>
              ))}
            </ul>
          </div>
        ))}
        {game.hiddenNews > 0 && (
          <div className="intel-hidden">
            {game.hiddenNews} smaller deal{game.hiddenNews === 1 ? " is" : "s are"} off your wire.
            {nextTier ? ` The ${nextTier.name} reports ${level === 0 ? "deals of £250,000 and more" : "every deal"}.` : ""}
          </div>
        )}
      </section>

      {/* WHAT A SUBSCRIPTION ADDS. The heat map is free, and says so here,
          so nobody pays thinking they are buying it. */}
      <section className="card intel-sub">
        <h3>Analytics subscription</h3>
        <div className="meta">The Market Heat map is free to everyone. A subscription changes how much of the wire you see, and how much the map and a rival's profile tell you.</div>
        <div className="intel-tiers">
          {ANALYTICS_TIERS.map((t) => (
            <div key={t.level} className={"intel-tier" + (t.level === level ? " is-current" : "") + (t.level < level ? " is-past" : "")}>
              <div className="intel-tier-name">{t.name}</div>
              <div className="intel-tier-cost">{t.cost ? fmt(t.cost) : "Free"}</div>
              <p>{t.desc}</p>
              {t.level === level ? <span className="intel-tier-now">Your plan</span>
                : t.level === level + 1 ? <button className="btn small" onClick={() => onAct("analytics")} disabled={me.cash < t.cost}>Upgrade for {fmt(t.cost)}</button>
                : t.level > level ? <span className="muted">After the {ANALYTICS_TIERS[t.level - 1].name}</span> : null}
            </div>
          ))}
        </div>
      </section>

      <DispatchBox me={me} onAct={onAct} />
    </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;
}

/* YOUR PROFILE PAGE, IN PERSONALISE. Locked, it says what a Keystone buys
   and shows it; unlocked, every choice previews live before it is saved. */
function ProfileEditor({ me, onAct }) {
  const ed = me.personalise && me.personalise.profile;
  const [draft, setDraft] = useState(() => (ed ? { ...ed.current } : null));
  useEffect(() => { if (ed) setDraft({ ...ed.current }); }, [ed && JSON.stringify(ed.current), ed && ed.unlocked]);
  if (!ed || !draft) return null;
  const o = ed.options;
  const set = (k, v) => setDraft({ ...draft, [k]: v });
  const player = { id: me.id, name: me.name, joinedAt: me.joinedAt || Date.now(), firmName: (me.cosmetics || {}).firmName };
  const shown = ed.unlocked ? draft : { ...draft, avatar: "crown", tint: "plum", flag: "GB", border: "silver", trim: "marble",
    bio: "Buying in Micklegate, selling nothing. Ask about the Shambles." };
  const dirty = ed.unlocked && JSON.stringify(draft) !== JSON.stringify(ed.current);
  return (
    <div className="card pf-editor">
      <h3>Your profile page</h3>
      <div className="meta">
        What every trader sees when they open your name.
        {ed.unlocked ? " Yours for good; change it whenever you like."
          : ` One Keystone unlocks it for good. You have ${ed.keystones}.`}
      </div>
      <div className="pf-editor-grid">
        <div className={"pf-preview modal-box pf-box" + (shown.border !== "none" ? " pf-border pf-border-" + shown.border : "")} aria-label="Preview">
          <div className="modal-inner">
            <ProfileHead player={player} profile={shown} isMe />
            <div className={"pf-preview-trim" + (shown.trim !== "none" ? " pf-trim pf-trim-" + shown.trim : "")}>
              {[0, 1, 2].map((i) => <span key={i} className="pf-preview-tile" />)}
            </div>
          </div>
          {!ed.unlocked && <span className="pf-sample">Example</span>}
        </div>
        {ed.unlocked ? (
          <div className="pf-controls">
            <div className="pf-field"><span className="stat-label">Avatar</span>
              <div className="pf-swatches">
                {o.avatars.map((a) => (
                  <button key={a} type="button" className={"pf-pick" + (draft.avatar === a ? " on" : "")} title={AVATAR_NAMES[a] || a}
                          aria-label={AVATAR_NAMES[a] || a} aria-pressed={draft.avatar === a} onClick={() => set("avatar", a)}>
                    <ProfileAvatar profile={{ avatar: a, tint: draft.tint }} name={me.name} size={30} />
                  </button>
                ))}
              </div>
            </div>
            <div className="pf-field"><span className="stat-label">Colour</span>
              <div className="pf-swatches">
                {o.tints.map((t) => (
                  <button key={t} type="button" className={"pf-tint" + (draft.tint === t ? " on" : "")} style={{ background: PROFILE_TINTS[t] }}
                          aria-label={t} aria-pressed={draft.tint === t} onClick={() => set("tint", t)} />
                ))}
              </div>
            </div>
            <div className="pf-field"><span className="stat-label">Border</span>
              <div className="pf-swatches">
                {o.borders.map((b) => (
                  <button key={b} type="button" className={"pf-chip" + (draft.border === b ? " on" : "")} aria-pressed={draft.border === b}
                          onClick={() => set("border", b)}><i className={"pf-chip-sw pf-sw-" + b} />{BORDER_NAMES[b] || b}</button>
                ))}
              </div>
            </div>
            <div className="pf-field"><span className="stat-label">Frame round your buildings</span>
              <div className="pf-swatches">
                {o.trims.map((t) => (
                  <button key={t} type="button" className={"pf-chip" + (draft.trim === t ? " on" : "")} aria-pressed={draft.trim === t}
                          onClick={() => set("trim", t)}><i className={"pf-chip-sw pf-tsw-" + t} />{TRIM_NAMES[t] || t}</button>
                ))}
              </div>
            </div>
            <div className="pf-field">
              <label className="stat-label" htmlFor="pf-flag">Flag</label>
              <select id="pf-flag" value={draft.flag} onChange={(e) => set("flag", e.target.value)}>
                <option value="">No flag</option>
                {o.flags.filter(Boolean).sort((a, b) => (FLAG_NAMES[a] || a).localeCompare(FLAG_NAMES[b] || b))
                  .map((f) => <option key={f} value={f}>{FLAG_NAMES[f] || f}</option>)}
              </select>
            </div>
            <div className="pf-field">
              <label className="stat-label" htmlFor="pf-bio">A line to your readers</label>
              <textarea id="pf-bio" rows={2} maxLength={ed.bioMax} value={draft.bio}
                        placeholder="What you are building, buying or after."
                        onChange={(e) => set("bio", e.target.value)} />
              <span className="pf-count">{draft.bio.length} / {ed.bioMax}. No links or handles.</span>
            </div>
            <div className="pf-actions">
              <button type="button" className="btn" disabled={!dirty} onClick={() => onAct("profile.save", draft)}>Save your profile</button>
              {dirty && <button type="button" className="btn secondary" onClick={() => setDraft({ ...ed.current })}>Undo changes</button>}
            </div>
          </div>
        ) : (
          <div className="pf-controls">
            <ul className="pf-sell">
              <li><b>An avatar</b> from a set of emblems, or your initials, on a colour of your choice.</li>
              <li><b>A border</b> round your profile in silver, rose, copper, jet or emerald.</li>
              <li><b>Your flag</b>, beside your name.</li>
              <li><b>A line to your readers</b>, up to {ed.bioMax} characters.</li>
              <li><b>A frame</b> round the buildings shown on your profile, in slate or marble.</li>
            </ul>
            <button type="button" className="btn" disabled={ed.keystones < ed.cost} onClick={() => onAct("profile.unlock", {})}>
              Unlock for {ed.cost} Keystone
            </button>
            {ed.keystones < ed.cost && <div className="muted" style={{ marginTop: 6 }}>Keystones are in the shop below: 4 for £10.</div>}
          </div>
        )}
      </div>
    </div>
  );
}

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", "#B08C1E"];
  };

  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>

      <ProfileEditor me={me} onAct={onAct} />

      {me.personalise && (
        <div className="card">
          <h3>Keystones</h3>
          <div className="meta">
            You have <strong>{me.personalise.tokens}</strong> Keystone{me.personalise.tokens === 1 ? "" : "s"}.
            {" "}{KEYSTONE_NOTE} Every trader starts with one free.
            {me.personalise.enabled && <> Open any property you own and look for "Personalise this property" to spend one on a fresh photograph;
            you choose whether to keep it before anyone else sees it.</>}
          </div>
          {me.personalise.request && (
            <div className="muted" style={{ marginTop: 8 }}>
              {me.personalise.request.status === "pending"
                ? "A photograph is being prepared right now. Open that property to check on it."
                : "A photograph is ready to review. Open that property to keep it or discard it."}
            </div>
          )}
        </div>
      )}

      <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);
              /* A locked crest has two quite different reasons for being
                 locked, and one of them is not a thing to buy. Saying
                 "Not owned yet" to a player three properties off leading
                 Acomb hides the fact that they are three properties off
                 having it. */
              const earned = (shop.leaderCrests || []).includes(sku);
              return (
                <button key={sku} className={"crest-btn" + (worn.crest === sku ? " active" : "")}
                  disabled={!owned} onClick={() => onEquip("crest", sku)}
                  title={owned ? sku.split(".")[1]
                    : earned ? "Lead a district to earn this one"
                    : "In House Crests, in the shop below"}>
                  <svg width="100%" height="100%" viewBox="0 0 24 24">{CREST_SHAPES[sku]("currentColor")}</svg>
                </button>
              );
            })}
          </div>
          <div className="hint">
            The white rose, the keys and the tower are earned: lead a district and
            they are yours for good. The other five are in the shop.
          </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">Single 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. */
/* One app-level opener rather than a prop threaded through every call site.
   TraderName appears in the leaderboard, on property cards, in auctions, in
   trades and in the news, and none of those has any other reason to know
   that profiles exist. Bound to real state by Exchange on mount. */
let openTraderProfile = () => {};

function TraderName({ player, size = 16, plain = false, noCrest = false, className = "" }) {
  if (!player) return <span>a trader</span>;
  const body = (
    <>
      {player.crest && !noCrest && <Crest id={player.crest} size={size} />}
      {player.name}
      {player.founder && <span className="founder-mark" title="Founding trader">✦</span>}
    </>
  );
  if (plain) return <span>{body}</span>;
  return (
    <button type="button" className={"trader-link" + (className ? " " + className : "")} title={`${player.name}: see their standing`}
      onClick={(e) => { e.stopPropagation(); openTraderProfile(player.id); }}>
      {body}
    </button>
  );
}

/* Another trader, as far as you are allowed to see them.

   The holdings are shown as pictures before they are shown as facts. Who
   owns what is already public — the market grid says so on every card — so
   nothing here is a new disclosure; what the Full terminal buys is the
   ability to read the collection rather than merely look at it. Below that
   tier you get a wall of houses you can count, which is the honest shape of
   "I know they are big, I don't know exactly where".

   It also gives cosmetics somewhere to be seen. A facade or a personalised
   photograph only its owner ever looks at is a poor thing to sell; here it
   is on display to everyone who comes to size them up. */
/* Honours, as a shelf of crests.

   These are permanent and public, which is what makes them worth drawing at
   all: the point is that opening a rival's profile tells you what they have
   actually done, not what they happen to hold this hour. Net worth already
   answers the second question and is already honest about going down.

   Drawn rather than lettered — a wall of names reads as a list, a row of
   crests reads as a shelf — with the name and the date it was earned on
   hover, and the tier deciding how loud each one is. */
const BADGE_TIER = {
  1: { ring: "#8E979F", ink: "#3C423E" },
  2: { ring: "#B08C1E", ink: "#5C4A1F" },
  3: { ring: "#B4342E", ink: "#6B2420" },
};

/* THE MEDALS (Aaron, 1 Oct 2026: "the honours need to look more impressive
   on the profile page"). Each honour is struck as a medal on a ribbon: a
   silver one for the first steps, gold for the middle, and crimson enamel in
   a gold rim for the rare ones. The tier colours are the honours' own and
   sit apart from the rarity and ownership colours. */
const MEDAL = {
  1: { name: "Silver", rimA: "#E3E7EA", rimB: "#7F888F", faceA: "#F4F6F7", faceB: "#C3C9CD", ink: "#38403F",
       ribbon: ["#4D5A66", "#C9D0D5", "#4D5A66"] },
  2: { name: "Gold", rimA: "#F6E08E", rimB: "#9C7A14", faceA: "#FBEFC4", faceB: "#D9B24A", ink: "#4E3E12",
       ribbon: ["#1E3553", "#D9B24A", "#1E3553"] },
  3: { name: "Crimson", rimA: "#F3D57A", rimB: "#80600F", faceA: "#C8443C", faceB: "#701A16", ink: "#FBEBC4",
       ribbon: ["#7E1F1B", "#E2BF55", "#7E1F1B"] },
};

function Medal({ tier = 1, glyph, size = 56, locked = false }) {
  const m = MEDAL[tier] || MEDAL[1];
  const g = `md${tier}${locked ? "x" : ""}`;
  return (
    <svg className="medal-svg" viewBox="0 0 48 64" width={size * 0.75} height={size} aria-hidden="true">
      <defs>
        <linearGradient id={g + "r"} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor={m.rimA} /><stop offset="1" stopColor={m.rimB} />
        </linearGradient>
        <radialGradient id={g + "f"} cx="0.38" cy="0.32" r="0.8">
          <stop offset="0" stopColor={m.faceA} /><stop offset="1" stopColor={m.faceB} />
        </radialGradient>
      </defs>
      {/* The ribbon: two tails meeting behind the medal. */}
      <path d="M11 0h11l4 22h-11Z" fill={m.ribbon[0]} />
      <path d="M15 0h3l4 22h-3Z" fill={m.ribbon[1]} />
      <path d="M26 0h11l-4 22H22Z" fill={m.ribbon[2]} />
      <path d="M30 0h3l-4 22h-3Z" fill={m.ribbon[1]} />
      <rect x="17" y="19" width="14" height="5" rx="1" fill={`url(#${g}r)`} />
      {/* The medal: a milled rim, a face, the honour's own mark. */}
      <circle cx="24" cy="42" r="19" fill={`url(#${g}r)`} />
      <circle cx="24" cy="42" r="19" fill="none" stroke={m.rimB} strokeWidth="0.8" strokeDasharray="1.2 1.4" opacity="0.6" />
      <circle cx="24" cy="42" r="14.5" fill={`url(#${g}f)`} stroke={m.rimB} strokeWidth="0.6" />
      {glyph && (
        <g transform="translate(14.4 32.4) scale(0.8)">
          <path d={glyph} fill="none" stroke={m.ink} strokeWidth="1.9" strokeLinejoin="round" strokeLinecap="round" />
        </g>
      )}
      <path d="M11 34a14 14 0 0 1 10-9" fill="none" stroke="#fff" strokeOpacity="0.55" strokeWidth="1.4" strokeLinecap="round" />
    </svg>
  );
}

const FAST_GROWER_GLYPH = "M3 19 9 13l4 4 8-9M15 8h6v6";

function BadgeShelf({ badges, fastGrower = 0 }) {
  const [showAll, setShowAll] = useState(false);
  const list = [...(badges || [])].sort((a, b) => (b.tier || 1) - (a.tier || 1) || (b.at || 0) - (a.at || 0));
  const earned = new Set(list.map((b) => b.id));
  const still = HONOURS.filter((h) => !earned.has(h.id)).sort((a, b) => a.tier - b.tier);
  const total = list.length + (fastGrower ? 1 : 0);
  const of = HONOURS.length + 1;
  const count = (t) => list.filter((b) => (b.tier || 1) === t).length + (t === 3 && fastGrower ? 1 : 0);
  const tally = [3, 2, 1].map((t) => [t, count(t)]).filter(([, n]) => n);
  return (
    <section className={"honours" + (total ? "" : " none")} aria-label="Honours">
      <div className="honours-head">
        <h3>Honours</h3>
        <span className="honours-count">{total} of {of}</span>
        {tally.length > 0 && (
          <span className="honours-tally">
            {tally.map(([t, n]) => <span key={t} className={"ht ht" + t}><i />{n} {MEDAL[t].name.toLowerCase()}</span>)}
          </span>
        )}
      </div>
      {!total && <p className="honours-empty">No honours yet. The first comes with the first deed.</p>}
      {total > 0 && (
        <div className="medal-row">
          {fastGrower > 0 && (
            <div className="medal t3" title={`Fast Grower: the best growth of anybody in a week, ${fastGrower === 1 ? "once" : `${fastGrower} times`}.`}>
              <Medal tier={3} glyph={FAST_GROWER_GLYPH} />
              {fastGrower > 1 && <b className="medal-times">×{fastGrower}</b>}
              <span className="medal-name">Fast Grower</span>
              <span className="medal-when">{fastGrower === 1 ? "Once" : `${fastGrower} weeks`}</span>
            </div>
          )}
          {list.map((b) => (
            <div className={"medal t" + (b.tier || 1)} key={b.id} title={`${b.name}: ${b.blurb}\nEarned ${fmtDate(b.at)}`}>
              <Medal tier={b.tier || 1} glyph={b.glyph} />
              <span className="medal-name">{b.name}</span>
              <span className="medal-when">{fmtDate(b.at)}</span>
            </div>
          ))}
        </div>
      )}
      {still.length > 0 && (
        <>
          <button type="button" className="honours-more" onClick={() => setShowAll((v) => !v)} aria-expanded={showAll}>
            {showAll ? "Hide the honours still to earn" : `${still.length} still to earn`}
          </button>
          {showAll && (
            <div className="medal-row locked">
              {still.map((h) => (
                <div className={"medal locked t" + h.tier} key={h.id} title={`${h.name}: ${h.blurb}`}>
                  <Medal tier={h.tier} glyph={h.glyph} locked />
                  <span className="medal-name">{h.name}</span>
                  <span className="medal-when">{h.blurb}</span>
                </div>
              ))}
            </div>
          )}
        </>
      )}
    </section>
  );
}

/* ---- THE PROFILE PAGE A KEYSTONE UNLOCKS (27 Sep 2026) --------------------

   Drawn, never uploaded: emblems on a 24 grid, flags on a 30 by 20 grid,
   borders and trims in CSS. The keys are checked by lib/profile.js. */
const PROFILE_TINTS = {
  forest: "#1F4B3F", navy: "#23395D", brick: "#A23E27", slate: "#4A5A63",
  plum: "#5E3A5C", teal: "#1F6B6B", ochre: "#9C6B1E", ink: "#1B2420",
};
const PROFILE_AVATAR_ART = {
  rose: <g fill="#F4F1EA"><circle cx="12" cy="7.2" r="3.2" /><circle cx="16.6" cy="10.5" r="3.2" /><circle cx="14.8" cy="15.8" r="3.2" /><circle cx="9.2" cy="15.8" r="3.2" /><circle cx="7.4" cy="10.5" r="3.2" /><circle cx="12" cy="12" r="2.4" fill="#E5C35A" /></g>,
  key: <g stroke="#F4F1EA" strokeWidth="2" fill="none" strokeLinecap="round"><circle cx="8" cy="8" r="3.4" /><path d="M10.5 10.5 L18 18 M15.5 18 h2.5 M18 15.5 v2.5 M13.5 13.5 l2 -2" /></g>,
  tower: <g fill="#F4F1EA"><rect x="8" y="9" width="8" height="11" /><path d="M6.5 9 h11 v-3.5 h-2 v1.8 h-1.6 v-1.8 h-2.2 v1.8 h-1.6 v-1.8 h-2.1 v1.8 h-1.5 z" /><rect x="11" y="14" width="2" height="6" fill="#00000033" /></g>,
  crown: <g fill="#F4F1EA"><path d="M4.5 17 L4.5 8 L9 12 L12 5.5 L15 12 L19.5 8 L19.5 17 Z" /><rect x="4.5" y="17.6" width="15" height="2" /></g>,
  anchor: <g stroke="#F4F1EA" strokeWidth="1.9" fill="none" strokeLinecap="round"><circle cx="12" cy="5.4" r="1.9" /><path d="M12 7.3 v12.4 M7.5 11.5 h9 M5 15.5 q7 6.8 14 0" /></g>,
  ship: <g fill="#F4F1EA"><path d="M4 15 h16 l-2.5 4 h-11 z" /><path d="M11.5 4 v10 h-6 z" /><path d="M12.5 6 v8 h5 z" opacity=".8" /></g>,
  sun: <g fill="#F4F1EA"><circle cx="12" cy="12" r="4" /><g stroke="#F4F1EA" strokeWidth="1.7" strokeLinecap="round"><path d="M12 3.5v2.4M12 18.1v2.4M3.5 12h2.4M18.1 12h2.4M6 6l1.7 1.7M16.3 16.3L18 18M6 18l1.7-1.7M16.3 7.7L18 6" /></g></g>,
  crescent: <path d="M15.5 4.5 a8 8 0 1 0 3.6 13.4 a6.4 6.4 0 1 1 -3.6 -13.4 z" fill="#F4F1EA" />,
  star: <path d="M12 3.6 l2.5 5.6 6 .6 -4.5 4 1.3 6 -5.3 -3.1 -5.3 3.1 1.3 -6 -4.5 -4 6 -.6 z" fill="#F4F1EA" />,
  bridge: <g fill="none" stroke="#F4F1EA" strokeWidth="1.9"><path d="M3 10 h18" /><path d="M4 18 q4 -8 8 0 q4 -8 8 0" /><path d="M8 10 v4.5 M16 10 v4.5 M12 10 v8" /></g>,
  minster: <g fill="#F4F1EA"><rect x="5" y="7" width="4" height="13" /><rect x="15" y="7" width="4" height="13" /><rect x="9" y="11" width="6" height="9" /><path d="M9 11 l3 -3.5 3 3.5 z" /><path d="M5 7 h4 v-2 h-1 v1 h-.8 v-1 h-.8 v1 h-.6 v-1 h-.8 z M15 7 h4 v-2 h-.8 v1 h-.8 v-1 h-.8 v1 h-.8 v-1 h-.8 z" /><circle cx="12" cy="14" r="1.6" fill="#00000040" /></g>,
  quill: <g fill="#F4F1EA"><path d="M18.5 4 C 12 5, 8 10, 7 16 L 9 16 C 10 11, 13.5 7.5, 18.5 4 z" /><path d="M7 16 L 5 20.5 L 6 20.8 L 8.2 16.4 z" /></g>,
};
function initialsOf(name) {
  const w = String(name || "?").trim().split(/\s+/).filter(Boolean);
  return ((w[0] || "?")[0] + (w.length > 1 ? w[w.length - 1][0] : (w[0] || "")[1] || "")).toUpperCase();
}
function ProfileAvatar({ profile, name, size = 56 }) {
  const tint = PROFILE_TINTS[profile && profile.tint] || PROFILE_TINTS.forest;
  const art = profile && PROFILE_AVATAR_ART[profile.avatar];
  return (
    <svg className="pf-avatar" viewBox="0 0 24 24" width={size} height={size} aria-hidden="true">
      <circle cx="12" cy="12" r="12" fill={tint} />
      <circle cx="12" cy="12" r="11" fill="none" stroke="#F4F1EA" strokeOpacity=".35" strokeWidth=".6" />
      {art || <text x="12" y="15.6" textAnchor="middle" fontSize="10" fontWeight="700" fill="#F4F1EA" fontFamily="'Fraunces', Georgia, serif">{initialsOf(name)}</text>}
    </svg>
  );
}

/* FLAGS, in SVG on a 30 by 20 grid. Windows draws a flag emoji as two
   letters, so every one is drawn. Simplified where the real flag carries
   fine detail: at the size a profile shows them, the shape is what reads. */
const FLAG_H = (cols) => cols.map((c, i) => <rect key={i} x="0" y={(20 / cols.length) * i} width="30" height={20 / cols.length + 0.05} fill={c} />);
const FLAG_V = (cols) => cols.map((c, i) => <rect key={i} x={(30 / cols.length) * i} y="0" width={30 / cols.length + 0.05} height="20" fill={c} />);
const FLAG_NORDIC = (bg, cross, inner) => <><rect width="30" height="20" fill={bg} /><rect x="8" width="6" height="20" fill={cross} /><rect y="7" width="30" height="6" fill={cross} />
  {inner && <><rect x="9.5" width="3" height="20" fill={inner} /><rect y="8.5" width="30" height="3" fill={inner} /></>}</>;
const FLAG_UNION = <><rect width="30" height="20" fill="#012169" /><path d="M0 0 L30 20 M30 0 L0 20" stroke="#fff" strokeWidth="4" /><path d="M0 0 L30 20 M30 0 L0 20" stroke="#C8102E" strokeWidth="1.4" />
  <rect x="12" width="6" height="20" fill="#fff" /><rect y="7" width="30" height="6" fill="#fff" /><rect x="13.2" width="3.6" height="20" fill="#C8102E" /><rect y="8.2" width="30" height="3.6" fill="#C8102E" /></>;
const FLAG_STAR = (cx, cy, r, fill) => {
  const pts = [];
  for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + (i * Math.PI) / 5; const rr = i % 2 ? r * 0.42 : r; pts.push(`${(cx + rr * Math.cos(a)).toFixed(2)},${(cy + rr * Math.sin(a)).toFixed(2)}`); }
  return <polygon points={pts.join(" ")} fill={fill} />;
};
const PROFILE_FLAG_ART = {
  GB: FLAG_UNION,
  ENG: <><rect width="30" height="20" fill="#fff" /><rect x="12.5" width="5" height="20" fill="#CE1124" /><rect y="7.5" width="30" height="5" fill="#CE1124" /></>,
  SCT: <><rect width="30" height="20" fill="#005EB8" /><path d="M0 0 L30 20 M30 0 L0 20" stroke="#fff" strokeWidth="3.4" /></>,
  WLS: <><rect width="30" height="10" fill="#fff" /><rect y="10" width="30" height="10" fill="#00AB39" />
    <path d="M7 13 C 8 9, 12 7.5, 15 8.5 L 18 6 L 19 8.5 L 22 8 L 20.5 10.5 C 21.5 12, 21 14, 19 14.5 L 20 16.5 L 17.5 15.5 L 16 17 L 14.5 15 L 11.5 15.5 L 10 17 L 9.5 14.5 Z" fill="#D30731" /></>,
  IE: FLAG_V(["#169B62", "#fff", "#FF883E"]),
  US: <>{Array.from({ length: 13 }, (_, i) => <rect key={i} y={(20 / 13) * i} width="30" height={20 / 13 + 0.05} fill={i % 2 ? "#fff" : "#B22234"} />)}
    <rect width="13" height="10.8" fill="#3C3B6E" />{Array.from({ length: 12 }, (_, i) => <circle key={i} cx={1.8 + (i % 4) * 3.1} cy={1.9 + Math.floor(i / 4) * 3.3} r=".7" fill="#fff" />)}</>,
  CA: <><rect width="30" height="20" fill="#fff" /><rect width="7.5" height="20" fill="#D52B1E" /><rect x="22.5" width="7.5" height="20" fill="#D52B1E" />
    <path d="M15 4 L16.2 7 L18.4 6.2 L17.6 9.6 L20 9 L19.4 11 L21 12 L17 14.2 L17.5 15.6 L15.4 15.2 L15.4 17.6 L14.6 17.6 L14.6 15.2 L12.5 15.6 L13 14.2 L9 12 L10.6 11 L10 9 L12.4 9.6 L11.6 6.2 L13.8 7 Z" fill="#D52B1E" /></>,
  AU: <><rect width="30" height="20" fill="#012169" /><svg x="0" y="0" width="15" height="10" viewBox="0 0 30 20">{FLAG_UNION}</svg>{FLAG_STAR(7.5, 15, 2.6, "#fff")}{FLAG_STAR(22.5, 5, 1.3, "#fff")}{FLAG_STAR(19, 10, 1.3, "#fff")}{FLAG_STAR(25.5, 9, 1.3, "#fff")}{FLAG_STAR(22.5, 16, 1.4, "#fff")}</>,
  NZ: <><rect width="30" height="20" fill="#012169" /><svg x="0" y="0" width="15" height="10" viewBox="0 0 30 20">{FLAG_UNION}</svg>{FLAG_STAR(22.5, 5, 1.5, "#C8102E")}{FLAG_STAR(19.5, 10, 1.5, "#C8102E")}{FLAG_STAR(25.5, 9, 1.3, "#C8102E")}{FLAG_STAR(22.5, 16, 1.6, "#C8102E")}</>,
  IN: <>{FLAG_H(["#FF9933", "#fff", "#138808"])}<circle cx="15" cy="10" r="2.6" fill="none" stroke="#000080" strokeWidth=".7" /><circle cx="15" cy="10" r=".6" fill="#000080" /></>,
  PK: <><rect width="30" height="20" fill="#01411C" /><rect width="7.5" height="20" fill="#fff" /><circle cx="19" cy="10" r="5.2" fill="#fff" /><circle cx="20.4" cy="8.9" r="4.4" fill="#01411C" />{FLAG_STAR(22.3, 7.6, 1.6, "#fff")}</>,
  BD: <><rect width="30" height="20" fill="#006A4E" /><circle cx="13.5" cy="10" r="5.5" fill="#F42A41" /></>,
  LK: <><rect width="30" height="20" fill="#FFBE29" /><rect x="1.2" y="1.2" width="3.4" height="17.6" fill="#00534E" /><rect x="4.6" y="1.2" width="3.4" height="17.6" fill="#EB7400" /><rect x="9.2" y="1.2" width="19.6" height="17.6" fill="#8D153A" /></>,
  NG: FLAG_V(["#008751", "#fff", "#008751"]),
  GH: <>{FLAG_H(["#CE1126", "#FCD116", "#006B3F"])}{FLAG_STAR(15, 10, 3, "#000")}</>,
  KE: <>{FLAG_H(["#000", "#BB0000", "#006600"])}<rect y="6.3" width="30" height=".9" fill="#fff" /><rect y="12.8" width="30" height=".9" fill="#fff" /><ellipse cx="15" cy="10" rx="2.6" ry="5.5" fill="#BB0000" stroke="#000" strokeWidth=".6" /></>,
  ZA: <><rect width="30" height="10" fill="#E03C31" /><rect y="10" width="30" height="10" fill="#001489" /><path d="M0 0 L12 10 L0 20 Z" fill="#fff" /><path d="M0 0 H 4 L 15 8 H 30 V 12 H 15 L 4 20 H 0 Z" fill="#fff" />
    <path d="M0 1.8 L 12.5 10 L 0 18.2 Z" fill="#FFB81C" /><path d="M0 3.2 L 10.5 10 L 0 16.8 Z" fill="#000" /><path d="M2.4 0 H 4.6 L 15.4 8.6 H 30 V 11.4 H 15.4 L 4.6 20 H 2.4 L 14 10 Z" fill="#007749" /></>,
  JM: <><rect width="30" height="20" fill="#009B3A" /><path d="M0 0 L15 10 L0 20 Z M30 0 L15 10 L30 20 Z" fill="#000" /><path d="M0 0 L30 20 M30 0 L0 20" stroke="#FED100" strokeWidth="2.6" /></>,
  FR: FLAG_V(["#0055A4", "#fff", "#EF4135"]),
  DE: FLAG_H(["#000", "#DD0000", "#FFCE00"]),
  ES: <><rect width="30" height="20" fill="#AA151B" /><rect y="5" width="30" height="10" fill="#F1BF00" /></>,
  IT: FLAG_V(["#009246", "#fff", "#CE2B37"]),
  PT: <><rect width="30" height="20" fill="#FF0000" /><rect width="12" height="20" fill="#006600" /><circle cx="12" cy="10" r="3.4" fill="#FFCC00" /><circle cx="12" cy="10" r="2" fill="#FF0000" /></>,
  NL: FLAG_H(["#AE1C28", "#fff", "#21468B"]),
  BE: FLAG_V(["#000", "#FAE042", "#ED2939"]),
  CH: <><rect width="30" height="20" fill="#D52B1E" /><rect x="13.3" y="4.5" width="3.4" height="11" fill="#fff" /><rect x="9.5" y="8.3" width="11" height="3.4" fill="#fff" /></>,
  AT: FLAG_H(["#ED2939", "#fff", "#ED2939"]),
  PL: FLAG_H(["#fff", "#DC143C"]),
  CZ: <><rect width="30" height="10" fill="#fff" /><rect y="10" width="30" height="10" fill="#D7141A" /><path d="M0 0 L15 10 L0 20 Z" fill="#11457E" /></>,
  HU: FLAG_H(["#CD2A3E", "#fff", "#436F4D"]),
  RO: FLAG_V(["#002B7F", "#FCD116", "#CE1126"]),
  UA: FLAG_H(["#0057B7", "#FFD700"]),
  GR: <>{Array.from({ length: 9 }, (_, i) => <rect key={i} y={(20 / 9) * i} width="30" height={20 / 9 + 0.05} fill={i % 2 ? "#fff" : "#0D5EAF"} />)}
    <rect width="11.1" height="11.1" fill="#0D5EAF" /><rect x="4.44" width="2.22" height="11.1" fill="#fff" /><rect y="4.44" width="11.1" height="2.22" fill="#fff" /></>,
  TR: <><rect width="30" height="20" fill="#E30A17" /><circle cx="11.5" cy="10" r="5" fill="#fff" /><circle cx="12.8" cy="10" r="4" fill="#E30A17" />{FLAG_STAR(18, 10, 2.2, "#fff")}</>,
  SE: FLAG_NORDIC("#006AA7", "#FECC02"),
  NO: FLAG_NORDIC("#BA0C2F", "#fff", "#00205B"),
  DK: FLAG_NORDIC("#C8102E", "#fff"),
  FI: FLAG_NORDIC("#fff", "#002F6C"),
  CN: <><rect width="30" height="20" fill="#DE2910" />{FLAG_STAR(5, 5, 3, "#FFDE00")}{FLAG_STAR(10, 2, 1, "#FFDE00")}{FLAG_STAR(12, 4, 1, "#FFDE00")}{FLAG_STAR(12, 7, 1, "#FFDE00")}{FLAG_STAR(10, 9, 1, "#FFDE00")}</>,
  JP: <><rect width="30" height="20" fill="#fff" /><circle cx="15" cy="10" r="6" fill="#BC002D" /></>,
  KR: <><rect width="30" height="20" fill="#fff" /><circle cx="15" cy="10" r="5" fill="#0047A0" /><path d="M10 10 a5 5 0 0 1 10 0 a2.5 2.5 0 0 1 -5 0 a2.5 2.5 0 0 0 -5 0 z" fill="#CD2E3A" />
    <g stroke="#000" strokeWidth="1"><path d="M4 4 l3 -2 M4.8 5.2 l3 -2 M5.6 6.4 l3 -2 M22.4 13.6 l3 2 M23.2 12.4 l3 2 M24 11.2 l3 2 M4 16 l3 2 M4.8 14.8 l3 2 M22.4 6.4 l3 -2 M23.2 5.2 l3 -2" /></g></>,
  PH: <><rect width="30" height="10" fill="#0038A8" /><rect y="10" width="30" height="10" fill="#CE1126" /><path d="M0 0 L17 10 L0 20 Z" fill="#fff" /><circle cx="5.5" cy="10" r="2.2" fill="#FCD116" /></>,
  AE: <>{FLAG_H(["#00732F", "#fff", "#000"])}<rect width="8" height="20" fill="#FF0000" /></>,
  SA: <><rect width="30" height="20" fill="#006C35" /><rect x="7" y="7" width="16" height="3" rx="1" fill="#fff" /><rect x="8" y="12.5" width="14" height="1.2" fill="#fff" /></>,
  EG: <>{FLAG_H(["#CE1126", "#fff", "#000"])}<circle cx="15" cy="10" r="1.8" fill="#C09300" /></>,
  BR: <><rect width="30" height="20" fill="#009C3B" /><path d="M15 2.5 L27.5 10 L15 17.5 L2.5 10 Z" fill="#FFDF00" /><circle cx="15" cy="10" r="4.4" fill="#002776" /><path d="M10.8 9 q4.4 -1.2 8.4 1.4" stroke="#fff" strokeWidth=".8" fill="none" /></>,
  MX: <>{FLAG_V(["#006847", "#fff", "#CE1126"])}<circle cx="15" cy="10" r="2" fill="#8C6B2A" /></>,
  AR: <>{FLAG_H(["#74ACDF", "#fff", "#74ACDF"])}<circle cx="15" cy="10" r="1.8" fill="#F6B40E" /></>,
};
const FLAG_NAMES = {
  GB: "United Kingdom", ENG: "England", SCT: "Scotland", WLS: "Wales", IE: "Ireland", US: "United States", CA: "Canada",
  AU: "Australia", NZ: "New Zealand", IN: "India", PK: "Pakistan", BD: "Bangladesh", LK: "Sri Lanka", NG: "Nigeria",
  GH: "Ghana", KE: "Kenya", ZA: "South Africa", JM: "Jamaica", FR: "France", DE: "Germany", ES: "Spain", IT: "Italy",
  PT: "Portugal", NL: "Netherlands", BE: "Belgium", CH: "Switzerland", AT: "Austria", PL: "Poland", CZ: "Czechia",
  HU: "Hungary", RO: "Romania", UA: "Ukraine", GR: "Greece", TR: "Turkey", SE: "Sweden", NO: "Norway", DK: "Denmark",
  FI: "Finland", CN: "China", JP: "Japan", KR: "South Korea", PH: "Philippines", AE: "United Arab Emirates",
  SA: "Saudi Arabia", EG: "Egypt", BR: "Brazil", MX: "Mexico", AR: "Argentina",
};
function FlagMark({ code, height = 14 }) {
  const art = PROFILE_FLAG_ART[code];
  if (!art) return null;
  return (
    <svg className="pf-flag" viewBox="0 0 30 20" width={height * 1.5} height={height} role="img" aria-label={FLAG_NAMES[code] || code}>
      <title>{FLAG_NAMES[code] || code}</title>{art}
    </svg>
  );
}
const BORDER_NAMES = { none: "None", silver: "Silver", rose: "Rose", copper: "Copper", jet: "Jet", emerald: "Emerald" };
const TRIM_NAMES = { none: "None", slate: "Slate", marble: "Marble" };
const AVATAR_NAMES = { initials: "Initials", rose: "Rose", key: "Key", tower: "Tower", crown: "Crown", anchor: "Anchor",
  ship: "Ship", sun: "Sun", crescent: "Moon", star: "Star", bridge: "Bridge", minster: "Minster", quill: "Quill" };

/* The profile's head: avatar, name, flag, firm and the line to readers. */
function ProfileHead({ player, profile, isMe }) {
  return (
    <div className={"pf-head" + (profile ? " is-dressed" : "")}>
      {profile && <ProfileAvatar profile={profile} name={player.name} />}
      <div className="pf-head-text">
        <h3 className="pf-name">
          <TraderName player={player} size={20} plain />
          {profile && profile.flag && <FlagMark code={profile.flag} height={15} />}
        </h3>
        {player.firmName && <div className="firm-line">{player.firmName}</div>}
        <div className="meta">Trading since {fmtDate(player.joinedAt)}{isMe ? " · this is you" : ""}</div>
      </div>
      {profile && profile.bio && <blockquote className="pf-bio">{profile.bio}</blockquote>}
    </div>
  );
}

function PlayerProfile({ game, me, playerId, onClose, onOpen, onAct, onGoBuild }) {
  const p = game.players[playerId];
  const detailed = (me.analyticsLevel || 0) >= 2;
  if (!p) return null;

  const owned = ALL_PROPERTIES
    .map((meta) => ({ meta, prop: game.properties[meta.id] }))
    .filter((x) => x.prop && x.prop.owner === playerId);
  const holds = (game.freeholds || []).filter((f) => f.user_id === playerId);
  const myRival = ((me.rivalries && me.rivalries.mine) || []).find((r) => r.id === playerId);
  const theirRival = ((me.rivalries && me.rivalries.against) || []).find((r) => r.id === playerId);
  const isMe = playerId === me.id;

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className={"modal-box pf-box" + (p.profile && p.profile.border !== "none" ? " pf-border pf-border-" + p.profile.border : "")}
           onClick={(e) => e.stopPropagation()}>
        <button className="modal-close" onClick={onClose} aria-label="Close">×</button>
        <div className="modal-inner">
          <ProfileHead player={p} profile={p.profile} isMe={isMe} />

          <div className="portfolio-total" style={{ marginBottom: 12 }}>
            <div className="block"><div className="label">Net worth</div><div className="value">{fmt(p.netWorth)}</div></div>
            <div className="block"><div className="label">Properties</div><div className="value">{p.propertyCount}</div></div>
            <div className="block"><div className="label">Districts led</div><div className="value">{holds.length}</div></div>
          </div>

          {holds.length > 0 && (
            <div className="muted" style={{ marginBottom: 10 }}>
              District leader of {holds.map((h) => (NEIGHBOURHOOD_INDEX.get(h.neighbourhood) || {}).name).filter(Boolean).join(", ")}.
            </div>
          )}

          {(myRival || theirRival) && (
            <div className="hold-note warn" style={{ marginBottom: 10 }}>
              {myRival ? `Your declared rival since ${fmtDate(myRival.since)}.` : ""}
              {myRival && theirRival ? " " : ""}
              {theirRival ? `They declared you a rival on ${fmtDate(theirRival.since)}.` : ""}
            </div>
          )}
          {/* FRIENDS, FROM THE PROFILE. Aaron, 28 Sep 2026: you should be able
              to befriend someone from their profile, not only by typing their
              name on the Leaderboard's Friends board. */}
          {!isMe && onAct && (() => {
            const fr = me.friends || { accepted: [], incoming: [], outgoing: [] };
            const has = (list) => (list || []).some((f) => f.id === playerId);
            if (has(fr.accepted)) return <div className="pf-friend is-on">A friend of yours. They are on your Friends board on the Leaderboard.</div>;
            if (has(fr.outgoing)) return <div className="pf-friend">Friend request sent. Waiting on them.</div>;
            if (has(fr.incoming)) return (
              <button type="button" className="btn small" style={{ marginBottom: 10, marginRight: 8 }}
                      onClick={() => onAct("friend.accept", { id: playerId })}>Accept their friend request</button>
            );
            return (
              <button type="button" className="btn small" style={{ marginBottom: 10, marginRight: 8 }}
                      onClick={() => onAct("friend.request", { name: p.name })}>Add as a friend</button>
            );
          })()}
          {!isMe && !myRival && onAct && ((me.rivalries && me.rivalries.mine) || []).length < ((me.rivalries && me.rivalries.max) || 2)
            && !(me.linkedTo || []).includes(playerId) && (
            <button type="button" className="btn small secondary" style={{ marginBottom: 10 }}
                    onClick={() => onAct("rival.declare", { rival: playerId })}>
              Declare a rival (they will be told)
            </button>
          )}

          <BadgeShelf badges={p.badges} fastGrower={p.fastGrower || 0} />

          {/* THEIR FLOATS: how each listed building has done since it listed,
              which is the market's verdict on the owner. */}
          {(() => {
            const fl = ((game.stocks && game.stocks.list) || []).filter((r) => Number(r.owner) === Number(playerId) && metaFor(r.id));
            if (!fl.length) return null;
            return (
              <div className="pf-floats">
                <span className="pf-floats-h">On the Exchange</span>
                <ul>
                  {fl.map((r) => (
                    <li key={r.id}>
                      <b>{metaFor(r.id).name}</b>
                      <span className={r.sinceList >= 0 ? "up" : "down"}>{Math.abs(r.sinceList) < 0.05 ? "Unchanged since listing" : `${r.sinceList > 0 ? "Up" : "Down"} ${Math.abs(r.sinceList).toFixed(1)}% since listing`}</span>
                    </li>
                  ))}
                </ul>
              </div>
            );
          })()}

          <BuildsUnderway builds={p.building} own={isMe} onGo={onGoBuild} />

          <h3 style={{ fontFamily: "'Fraunces', serif", fontSize: 14, margin: "14px 0 6px" }}>Their holdings</h3>
          {!owned.length ? <div className="empty">No properties.</div> : (
            <>
              <div className={"holding-grid" + (p.profile && p.profile.trim !== "none" ? " pf-trim pf-trim-" + p.profile.trim : "")}>
                {owned.map(({ meta, prop }) => {
                  const photo = artFor(meta, prop);
                  const rarity = meta.rarity || "common";
                  /* Rarity is shown to everybody, unlike the name and
                     district. What a rival owns is private; how good it is
                     is the thing worth advertising — it makes a collection
                     legible at a glance, and it is the best argument the
                     Full terminal has for itself: you can see they hold two
                     ultra rares and still not know which. */
                  return (
                    <div key={meta.id} className={"holding-tile q-" + rarity + (detailed ? " open" : "")}
                      onClick={detailed ? () => { onClose(); onOpen(meta.id); } : undefined}
                      title={detailed
                        ? `${meta.name}, ${(RARITIES[rarity] || {}).label || "Common"}`
                        : `A ${((RARITIES[rarity] || {}).label || "Common").toLowerCase()} property in their portfolio`}>
                      <span className="holding-rarity" aria-hidden="true" />
                      {photo
                        ? <img src={photo} alt="" loading="lazy" onError={(e) => { e.target.style.visibility = "hidden"; }} />
                        /* No fixed height: the tile sets its own shape. A
                           height of 150 here stretched a built house (the
                           one holding with no photograph) to twice the
                           height of its row (Aaron, 1 Oct 2026). */
                        : <PropertyArt meta={meta} facade={ownerFacade(game, prop)} height={null} />}
                      {detailed && (
                        <div className="holding-cap">
                          <span>{meta.name}</span>
                          <span className="muted">{meta.neighbourhoodName || ""}</span>
                        </div>
                      )}
                    </div>
                  );
                })}
              </div>
              {!detailed && (
                <div className="muted" style={{ marginTop: 8 }}>
                  {owned.length} propert{owned.length === 1 ? "y" : "ies"}, shown without their names or districts.
                  The Full terminal analytics subscription reads the collection rather than just counting it.
                </div>
              )}
            </>
          )}
        </div>
      </div>
    </div>
  );
}

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 ------------------------------ */

/* ---- THE ROADMAP, IN TWO PLACES ---------------------------------------

   Rendered on the way in and again inside the Account panel, from the one
   list in lib/roadmap.js. A stranger reads it as evidence that the thing
   works and is still moving; somebody who already plays reads it as what is
   coming next. Same words serve both, which is why it carries no dates —
   see the note in that file. */
function Roadmap({ compact = false }) {
  const cols = ROADMAP || [];
  if (!cols.length) return null;
  return (
    <div className={"road" + (compact ? " is-compact" : "")}>
      {cols.map((col) => (
        <section key={col.status} className={"road-col road-" + col.status}>
          <h4>
            <span className="road-dot" aria-hidden="true" />
            {col.label}
          </h4>
          <ul>
            {col.items.map((it) => (
              <li key={it.title}>
                <b>{it.title}</b>
                <span>{it.text}</span>
              </li>
            ))}
          </ul>
        </section>
      ))}
    </div>
  );
}

/* THE GUEST CITY'S DOOR (lib/guest.js). One button, no form: civexio.com's
   "Play as a guest" arrives as /?guest and goes straight in. */
function GuestEntry({ onAuthed }) {
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  const live = (CAT && CAT.guest && CAT.guest.liveOrigin) || "https://play.civexio.com";
  const go = async () => {
    setBusy(true); setError("");
    try {
      await api("/api/guest", { method: "POST", body: { deviceId: deviceId() } });
      try { history.replaceState({}, "", "/"); } catch {}
      await onAuthed();
    } catch (e) { setError(e.message); setBusy(false); }
  };
  useEffect(() => {
    try { if (new URL(location.href).searchParams.has("guest")) go(); } catch {}
  }, []);
  return (
    <div className="ukpx-root city-york">
      <div className="land land-simple">
        <header className="land-top">
          <h1>Civexio <span className="brand-city">York</span> <span className="guest-tag">Guest city</span></h1>
          <a className="land-more" href="https://civexio.com">About the game at civexio.com</a>
        </header>
        <div className="land-grid">
          <main className="land-pitch">
            <p className="land-lede">
              Try every part of the game in a copy of the real city, with no sign up. Buy buildings, play your
              tactics cards, trade shares and take on the traders already here.
            </p>
            <ul className="land-steps">
              <li><b>Kept apart.</b> Nothing you do here touches the real game or its players.</li>
              <li><b>Faster.</b> The evening comes every hour on the hour, so you see your cards land in one visit.</li>
              <li><b>Not kept.</b> The guest city goes back to the start every night.</li>
            </ul>
          </main>
          <aside className="land-join">
            <div className="auth-card">
              <h2 className="auth-h">Play as a guest</h2>
              <p className="lede">You will be given a guest name and {fmt(500000)} to start, as every new trader is.</p>
              {error && <div className="error-text" style={{ marginTop: 10 }}>{error}</div>}
              <button className="btn" type="button" disabled={busy} onClick={go} style={{ width: "100%", marginTop: 14 }}>
                {busy ? "Opening the city…" : "Play as a guest"}
              </button>
              <div className="auth-switch">
                Ready for the real thing? <a href={`${live}/?join`}>Open an account</a>
              </div>
            </div>
          </aside>
        </div>
      </div>
    </div>
  );
}

/* Across the top of every page in the guest city, so nobody mistakes it for
   the real one. */
function GuestBanner() {
  if (!CAT || !CAT.guest) return null;
  const live = CAT.guest.liveOrigin || "https://play.civexio.com";
  return (
    <div className="guest-banner" role="note">
      <span><b>Guest city.</b> Nothing here touches the real game, and it starts afresh every night. The evening comes every hour.</span>
      <a href={`${live}/?join`}>Join the real game</a>
    </div>
  );
}

function AuthScreen({ onAuthed }) {
  return CAT && CAT.guest ? <GuestEntry onAuthed={onAuthed} /> : <AuthForm onAuthed={onAuthed} />;
}

function AuthForm({ onAuthed }) {
  /* civexio.com's "Open an account" arrives as /?join, so it lands on the
     form it promised rather than on sign in. */
  const [mode, setMode] = useState(() => {
    try { return new URL(location.href).searchParams.has("join") ? "register" : "login"; } catch { return "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(), acq: readAcq() } });
        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>
    );
  }

  /* THE WAY IN, KEPT SIMPLE. Aaron, 28 Sep 2026: with the pitch, the
     pictures and the roadmap now on civexio.com, play.civexio.com goes back
     to what it was: a sign in box beside a short account of the game, and a
     link to the full page for anyone who wants more. */
  return (
    <div className="ukpx-root city-york">
      <div className="land land-simple">
        <header className="land-top">
          <h1>Civexio <span className="brand-city">York</span></h1>
          <a className="land-more" href="https://civexio.com">About the game at civexio.com</a>
        </header>

        <div className="land-grid">
          <main className="land-pitch">
            <p className="land-lede">
              A live property market game set in the real city of York. Buy real buildings, collect
              the rent, and outplay the other traders.
            </p>
            <ul className="land-steps">
              <li><b>Trade</b> 155 real buildings. Prices move every hour.</li>
              <li><b>Lead a district</b> by holding the most in it, and collect ground rent from everyone else there.</li>
              <li><b>Choose your tactics cards</b> by day and confirm them. They all land together at {eveningAt()} every evening.</li>
              <li><b>Play your field</b> of five other traders in the Gaming Council for skill.</li>
            </ul>
            <p className="land-foot">Free, in a browser, on a phone. A few minutes an evening is enough.</p>
          </main>

          <aside className="land-join">
        <form className="auth-card" onSubmit={submit}>
          <h2 className="auth-h">{mode === "register" ? "Open an account" : "Sign in"}</h2>

          <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>
          </aside>
        </div>

        <footer className="land-bottom">
          Civexio · York. <a href="https://civexio.com">civexio.com</a>
        </footer>
      </div>
    </div>
  );
}

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

/* Merge one stream patch into the state we already hold. Kept pure and at
   module scope so it can be tested on its own — the wire protocol is the
   part of this app most likely to break quietly.

   Every value in a patch is absolute rather than an increment, so applying
   the same patch twice, or applying one to a base that is already slightly
   newer, both land in the same place. Sections the server left out did not
   change and are carried over untouched. */
/* A tick arrives in one of two shapes. `p` is the compact form: prices as a
   bare array in catalogue order, which is the order /api/catalogue already
   gave us, so the ids never have to travel. `prices` is the older id-keyed
   object, still sent to any page that connected without asking for v2.

   The length check is the safety catch on positional data: if this page's
   catalogue and the server's have drifted — which can only really happen to
   a tab left open across a deploy that changed the property list — the
   prices would land on the wrong properties. Returning null instead leaves
   the last known prices in place until the next full state resyncs them,
   which is wrong by a few seconds rather than wrong about which house. */
function tickPrices(payload, order) {
  if (payload && payload.prices) return payload.prices;
  if (!payload || !Array.isArray(payload.p) || !order) return null;
  if (payload.p.length !== order.length) return null;
  const out = {};
  for (let i = 0; i < order.length; i++) out[order[i].id] = payload.p[i];
  return out;
}

/* Extracted from the stream handler so tools/protocol-test.mjs applies
   exactly what the browser applies. Prices now reach the client only here —
   the patch stream stopped repeating them — so if this drifts, every price
   in the game silently freezes between resyncs. */
function mergeTick(prev, payload, order) {
  if (!prev) return prev;
  const prices = tickPrices(payload, order);
  if (!prices) return prev;
  const properties = { ...prev.properties };
  for (const id of Object.keys(prices)) {
    if (properties[id]) properties[id] = { ...properties[id], currentPrice: prices[id] };
  }
  const economicIndex = payload.economicIndex !== undefined ? payload.economicIndex : payload.e;
  const regionTrend = payload.regionTrend !== undefined ? payload.regionTrend : payload.r;
  return { ...prev, properties, market: { ...prev.market, economicIndex, regionTrend } };
}

function mergePatch(prev, p) {
  if (!prev || !p) return prev;
  const next = { ...prev, t: p.t || prev.t };
  for (const key of ["players", "properties"]) {
    const d = p[key];
    if (!d) continue;
    const map = { ...prev[key] };
    for (const id of Object.keys(d.changed || {})) {
      map[id] = map[id] ? { ...map[id], ...d.changed[id] } : d.changed[id];
    }
    for (const id of d.removed || []) delete map[id];
    next[key] = map;
  }
  if (p.self) next.self = { ...prev.self, ...p.self };
  if (p.market) next.market = { ...prev.market, ...p.market };
  /* Whole-value keys: a patch replaces them rather than merging into them.
     `deck` belongs here — a hand is a list whose shortening is the point, so
     merging a three-card hand over a four-card one would leave the played
     card on screen. This list is also why the tactics deck rendered empty
     the first time: a key that is not named here never reaches the client at
     all, however faithfully the server sends it. */
  for (const key of ["auctions", "freeholds", "trades", "tactics", "news", "notifications", "awards", "deck", "war", "informants", "openingWeek", "wheel", "appeals", "assess", "tontine", "gazette", "myWants", "wantedFromMe", "matches", "register", "evening", "diplomacy", "builder", "built", "city", "stocks"]) {
    if (p[key] !== undefined) next[key] = p[key];
  }
  if (p.city !== undefined) adoptCity(p.city);
  if (p.built !== undefined) adoptBuilt(p.built);
  if (p.hiddenNews !== undefined) next.hiddenNews = p.hiddenNews;
  return next;
}

/* Which cue a completed action plays. Actions not listed here are silent
   on purpose: a sound for every click is noise, and noise is what a player
   turns off — taking the sounds that were worth hearing with it. */
const CUE_FOR = {
  buy: "buy",
  sell: "sell",
  draw: "buy",
  "auction.bid": "sell",
  "trade.respond": "sell",
  "takeover.open": "tactic.takeover",
};

function Exchange() {
  const [booted, setBooted] = useState(false);
  const [state, setState] = useState(null);
  const [authed, setAuthed] = useState(false);
  /* THE MAP OPENS THE GAME.

     The market list is the spreadsheet; the city is the reason anyone is
     here. A new trader who lands on a table of 155 rows has to be told what
     York is before they can want any of it — landing on the city itself
     does that work without a word. */
  const [tab, setTab] = useState("map");
  /* The evening theatre is the App's, not a tab's: it is opened from the
     invitation bar on any page and from the tactical history. */
  const [eveOpen, setEveOpen] = useState(false);
  /* A grant reveal closes the moment it is answered, not when the server
     has confirmed it was seen. */
  const [grantClosed, setGrantClosed] = useState(null);
  const [notice, setNotice] = useState("");
  const [now, setNow] = useState(Date.now());
  const [detailId, setDetailId] = useState(null);
  const [detailFocus, setDetailFocus] = useState(null);   /* "look": open the deed at Personalise */
  const [showCashFlow, setShowCashFlow] = useState(false);
  const [showKeystones, setShowKeystones] = useState(false);
  /* The property the map spotlight is pointing at, or null. */
  const [spotId, setSpotId] = useState(null);
  const [profileId, setProfileId] = 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.

     ONLY WHEN THE PRICE ACTUALLY MOVED. This used to append a point every
     time any state arrived — and state arrives after every action, not just
     every tick. So toggling a watchlist star, or making an offer, or doing
     anything at all, pushed a duplicate reading onto all 155 charts at once
     and shifted every line to the left. The graph was not a chart of price
     over time, it was a chart of price per thing-you-clicked: an idle
     player's never moved, a busy one's scrolled away in a minute, and
     "this session" compared against a window of forty clicks rather than
     forty ticks.

     Recording a point only when the value differs from the last one we
     recorded makes it a series of real movements. A -> B -> A still records
     all three, because each is compared against what was last stored. */
  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 (typeof price !== "number") continue;
      if (!h[id]) { h[id] = [price]; continue; }
      if (h[id][h[id].length - 1] === price) continue;      /* nothing happened */
      h[id].push(price);
      if (h[id].length > 40) h[id].shift();
    }
  }, []);

  const applyState = useCallback((s) => {
    if (!s) return;
    GAME_HOUR_MS = (s.rules && s.rules.gameHourMs) || GAME_HOUR_MS;
    GAME_DAY_MS = (s.rules && s.rules.gameDayMs) || 24 * GAME_HOUR_MS;
    recordPrices(s.properties);
    adoptCity(s.city);
    adoptBuilt(s.built);
    setState(s);
    setAuthed(true);
  }, [recordPrices]);

  /* Merge a patch from the stream. Every value in a patch is absolute, not
     an increment, so applying one twice or applying it to a slightly newer
     base both land in the same place. Sections the server left out did not
     change and are kept as they are. */
  const applyPatch = useCallback((p) => {
    if (!p) return;
    /* Price history is fed by the tick stream, which sees every price every
       15 seconds. Recording here as well would double-count into the
       sparklines. */
    setState((prev) => mergePatch(prev, p));
  }, []);

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

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

  /* Bound here so TraderName can open a profile from wherever it happens to
     be rendered, without every intervening component having to pass a
     handler down for it.

     It must sit with the other hooks, ABOVE the `!booted` and `!authed`
     early returns below. It was originally further down, next to the modal
     it drives, which meant a signed-out render ran one fewer hook than a
     signed-in one — React error #310, and a game that would not open for
     anybody who was actually logged in. */
  useEffect(() => {
    openTraderProfile = (id) => setProfileId(id);
    return () => { openTraderProfile = () => {}; };
  }, []);

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

  /* One event stream carries everything: a full state on connect and as a
     periodic resync, a patch of just what changed after anyone acts, and a
     small price message on each tick. */
  useEffect(() => {
    if (!authed) return;
    /* Asking for the compact tick format by name. A build that does not know
       how to read it simply never asks, and the server keeps sending that
       connection the older shape — which is what makes this safe to deploy
       under tabs that are already open. */
    const es = new EventSource("/api/stream?tick=v2");
    esRef.current = es;
    es.addEventListener("state", (ev) => { setConnected(true); applyState(JSON.parse(ev.data)); });
    es.addEventListener("patch", (ev) => { setConnected(true); applyPatch(JSON.parse(ev.data)); });
    es.addEventListener("tick", (ev) => {
      setConnected(true);
      const payload = JSON.parse(ev.data);
      const prices = tickPrices(payload, ALL_PROPERTIES);
      if (prices) recordPrices(prices);
      setState((prev) => mergeTick(prev, payload, ALL_PROPERTIES));
    });
    es.onerror = () => setConnected(false);
    return () => { es.close(); esRef.current = null; };
  }, [authed, applyState, applyPatch, recordPrices]);

  /* Tell the server which section is open, so the pilot can see which parts
     of the game people actually use. Fire-and-forget: a failure here must
     never interrupt play. */
  useEffect(() => {
    if (!authed || !tab) return;
    const id = setTimeout(() => {
      fetch("/api/telemetry", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ tab }),
        keepalive: true,
      }).catch(() => {});
    }, 1200);
    return () => clearTimeout(id);
  }, [authed, tab]);

  /* ONE PLACE DECIDES WHAT A MOVE SOUNDS LIKE.

     Every action in the game comes through here, so the cue is chosen from
     the action name rather than wired into each of the forty buttons that
     can fire one — which is the only version of this that stays correct
     when a button moves. A refused action gets the refusal cue, so "not
     enough cash" is heard as well as read. */
  const runAct = useCallback(async (action, payload) => {
    try {
      const res = await act(action, payload);
      if (res.message) flash(res.message);
      if (res.state) applyState(res.state);
      /* Every tactic arrives as the one action "tactic" with its kind in the
         payload — so the cue is chosen from payload.type, not from the
         action name. Guessing at "tactic.<name>" as an action would have
         silently played nothing at all. */
      cue(action === "tactic" ? `tactic.${payload && payload.type}`
        : action === "shares.trade" ? (payload && payload.side === "sell" ? "sell" : "buy")
        : CUE_FOR[action]);
      return true;
    } catch (e) {
      flash(e.message);
      cue("denied");
      return false;
    }
  }, [applyState, flash]);

  /* SPENDING REAL MONEY GETS A SECOND PRESS.

     A purchase is the largest single thing a player can do by accident, and
     Buy sits inches from Open on a card and inside a modal that a stray tap
     can reach. The guard lives here rather than on each button so that every
     route to a purchase — the market grid, the property tile, anything added
     later — is covered by one rule instead of each remembering for itself.

     Only buying is guarded. Selling can be undone by buying back; an
     improvement is small and reversible in effect; a tactic already has its
     own confirmation because it names a target. A confirmation on everything
     is a confirmation nobody reads. */
  const [confirming, setConfirming] = useState(null);
  const onAct = useCallback(async (action, payload) => {
    if (action === "buy") {
      const meta = metaFor(payload.propertyId);
      const prop = state && state.properties ? state.properties[payload.propertyId] : null;
      if (meta && prop) {
        setConfirming({ action, payload, meta, price: prop.currentPrice });
        return;
      }
    }
    return runAct(action, payload);
  }, [runAct, state]);

  /* Same as onAct, but hands the whole response back instead of swallowing
     it, and stays quiet: the market draw needs the result payload to drive
     its reveal, and a flash message would spoil it a second before the deed
     turns over. */
  const onActResult = useCallback(async (action, payload) => {
    try {
      const res = await act(action, payload);
      if (res.state) applyState(res.state);
      return res;
    } catch (e) { return { error: e.message }; }
  }, [applyState]);

  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);
  }

  /* The account panel and the opening guide. Both live here rather than in a
     tab, because they are about the person rather than about the city — and
     because a player looking for either is usually looking for a way OUT of
     whatever they are stuck in. */
  const [showAccount, setShowAccount] = useState(false);
  const [showGuide, setShowGuide] = useState(false);
  const [showTour, setShowTour] = useState(false);
  /* First steps (FirstSteps): on for a new trader until done or skipped,
     remembered in this browser so a reload keeps the place. */
  const [fsOn, setFsOn] = useState(false);
  const selfId = state && state.self ? state.self.id : null;
  useEffect(() => { if (selfId && fsGet(selfId) === "on") setFsOn(true); }, [selfId]);
  /* Read off `state` rather than off `me`, because `me` is declared AFTER the
     early return for the sign-in screen and hooks must run unconditionally on
     every render — reaching for it here is a temporal-dead-zone error that
     takes the whole client down before React mounts. Null until the state has
     arrived; strictly false only once we know the answer. */
  const guideSeen = state && state.self ? !!state.self.guideSeen : null;
  useEffect(() => {
    /* First sight of the game. Marked on the server the moment it OPENS
       rather than when it is finished, so somebody who skips it is not shown
       it again on their next visit — skipping is an answer. */
    if (guideSeen === false) {
      /* Straight into the game (Aaron, 2 Oct 2026): first steps, not a
         guide to read. The picture tour and the text are both in Account. */
      setFsOn(true);
      if (state.self && state.self.id) fsSet(state.self.id, "on");
      api("/api/action", { method: "POST", body: { action: "guide.seen", payload: { release: GUIDE_RELEASE } } })
        .catch(() => {});
    }
  }, [guideSeen]);
  const closeGuide = useCallback(() => setShowGuide(false), []);
  /* LATER CARDS: a new batch for a trader who read the guide before it was
     written, and the cards released at £1.5m. Each once, marked as it
     opens, and never on top of another notice. */
  const [extraGuide, setExtraGuide] = useState(null);
  const extraShown = useRef(new Set());
  const guideNewPending = !!(state && state.self && state.self.guideSeen && state.self.guideNew !== GUIDE_RELEASE);
  const guideRichPending = !!(state && state.self && state.self.guideSeen && !state.self.guideRich
    && state.self.shares && state.self.shares.rates && state.self.shares.rates.due);
  useEffect(() => {
    if (extraGuide || showGuide || showTour || fsOn || (selfId && fsGet(selfId) === "on")) return;
    const part = guideNewPending && !extraShown.current.has("new") ? "new"
      : guideRichPending && !extraShown.current.has("rich") ? "rich" : null;
    if (!part) return;
    extraShown.current.add(part);
    setExtraGuide(part);
    api("/api/action", { method: "POST", body: { action: "guide.seen", payload: { part, release: GUIDE_RELEASE } } }).catch(() => {});
  }, [guideNewPending, guideRichPending, extraGuide, showGuide, showTour, fsOn]);

  /* THE OPENING WEEK OPENS ITSELF, ONCE A DAY.

     A reward a player has to go looking for is not a reason to come back,
     which is the entire job of this mechanic. It opens on the first look of
     each day and remembers the day it was shown, so dismissing it does not
     bring it back on the next tab change. */
  const [weekOpen, setWeekOpen] = useState(false);
  /* Remembered as the week's start and the day, not the day alone: a week
     restarted by a reset or by starting over has a day three of its own,
     and remembering only "3" hid it behind the old week's (26 Sep 2026). */
  const weekDay = state && state.openingWeek ? state.openingWeek.day : 0;
  const weekKey = weekDay ? `${state.openingWeek.since || 0}:${weekDay}` : "";
  useEffect(() => {
    /* Not over the first minute's tour: it waits until the tour is done. */
    /* Read from the browser as well as the state: on a reload the state
       arrives a render before first steps has switched itself back on. */
    const firstSteps = fsOn || (selfId && fsGet(selfId) === "on");
    if (!weekKey || showTour || firstSteps || guideSeen === false) return;
    let seen = null;
    try { seen = window.localStorage.getItem(OPENING_SEEN_KEY); } catch { /* fine */ }
    if (String(seen) === weekKey) return;
    setWeekOpen(true);
    try { window.localStorage.setItem(OPENING_SEEN_KEY, weekKey); } catch { /* fine */ }
  }, [weekKey, showTour, fsOn, guideSeen, selfId]);

  /* ---- the soundtrack follows the room -------------------------------

     AUTOPLAY IS NOT NEGOTIABLE WITH THE BROWSER. No audio context may start
     without a gesture, and a rejected start is silent — so a player who
     turned the music on yesterday would come back to nothing and conclude it
     was broken. The engine is therefore armed on the FIRST click or key the
     player makes, whatever it was for, and the listener takes itself off
     afterwards. Nothing starts for a player who has not asked for music. */
  useEffect(() => {
    const m = music();
    /* A SWALLOWED FAILURE IS WHY THIS WAS HARD TO FIND ONCE.

       Every one of these paths used to end in .catch(() => {}), which is
       right for the player — a browser that refuses audio should not throw a
       dialogue at somebody who never asked for music — and useless for
       anybody trying to work out why a live site is silent. The reason is
       now written to window.__civexioMusic, which costs nothing and turns
       "no music" into one line in a console. */
    const why = (v) => { try { window.__civexioMusic = v; } catch { /* fine */ } };
    if (!m) return why("the engine never loaded: /soundtrack.js did not run");
    if (!m.available) return why("this browser has no AudioContext");
    if (!musicShouldPlay()) return why("switched off: music=" + musicPreferred() + " sound=" + soundPreferred());
    why("armed: waiting for the first click or keypress");
    let done = false;
    const arm = () => {
      if (done) return;
      done = true;
      off();
      if (!musicShouldPlay()) return why("switched off between arming and the first click");
      m.start()
        .then((ok) => why(ok ? "playing" : "start() refused: the audio context would not resume"))
        .catch((e) => why("start() threw: " + (e && e.message ? e.message : String(e))));
    };
    const off = () => {
      window.removeEventListener("pointerdown", arm);
      window.removeEventListener("keydown", arm);
    };
    window.addEventListener("pointerdown", arm);
    window.addEventListener("keydown", arm);
    return off;
  }, []);

  /* The scene is set on every tab change whether or not the music is
     playing: the engine keeps it as its resting scene, so turning the music
     on from the account panel starts the piece that belongs to wherever the
     player already is rather than the market by default. */
  useEffect(() => {
    const m = music();
    if (m) { try { m.setScene(sceneForTab(tab)); } catch { /* fine */ } }
  }, [tab]);

  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;
  /* `self` belongs in here even though `me` is passed separately, because two
     things reach for it through `game` and neither can see `me`:
     abilityRentMult(game, …), which takes only the game, and the takeover
     check in TacticsTab. Leaving it out cost us both — the tactics tab threw
     "Cannot read properties of undefined (reading 'takeovers')" the moment a
     target actually owned something (which is why it never reproduced against
     an empty test world), and abilityRentMult, being defensively guarded,
     failed silently instead: every ability rent bonus read as 1.0 in the UI
     while the server was really paying it. */
  const game = {
    self: state.self,
    players: state.players, properties: state.properties, auctions: state.auctions,
    trades: state.trades, tactics: state.tactics, news: state.news, deck: state.deck,
    appeals: state.appeals || [],
    /* THESE LIVE ON `game`, NOT ON `me`.

       privateState returns them as siblings of `self`, and `me` is built
       from `self` alone — so a panel reading me.assess or me.tontine finds
       undefined, returns null, and renders nothing at all. That is exactly
       what happened to the assessors' round: the payload was correct, the
       wire was correct, the panel simply asked the wrong object. */
    assess: state.assess || null,
    tontine: state.tontine || null,
    gazette: state.gazette || null,
    /* THE SAME MISTAKE, TWICE MORE.

       `freeholds` is the game's own record of who leads each district. It
       was on the wire, it was in the merge whitelist, it was correct in
       /api/me — and it was missing from this literal, so the map's district
       labels never named a holder, the dashed freehold rings never drew,
       the freehold board read empty and a player's profile showed them
       holding nothing. `war` went the same way the day it was written: the
       tactical history rendered an empty panel against a full table.

       Anything privateState returns as a sibling of `self` has to be copied
       across HERE or no component can see it. tools/gamekeys.mjs now fails
       the build if app.jsx reads a game.X this object does not define. */
    freeholds: state.freeholds || [],
    /* SEALED UNTIL WATCHED. The evening's rows in the tactical history and
       its messages in the feed are held back until the player has watched it
       unfold or skipped it — see lib/evening.js. The game's figures are not:
       cash and rent moved at seven like always. */
    war: sealEveningRows(state.war, state.evening) || null,
    register: state.register || [],
    evening: state.evening || null,
    diplomacy: state.diplomacy || null,
    builder: state.builder || null,
    built: state.built || [],
    city: state.city || null,
    stocks: state.stocks || null,
    informants: state.informants || [],
    myWants: state.myWants || [],
    wantedFromMe: state.wantedFromMe || [],
    matches: state.matches || [],
    openingWeek: state.openingWeek || null,
    wheel: state.wheel || null,
    notifications: sealEveningNotes(state.notifications, state.evening), hiddenNews: state.hiddenNews,
    market: state.market, rules: state.rules, awards: state.awards,
  };

  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);
  /* CORRUPT THE REGISTER, WHILE IT IS RUNNING. The server has always shipped
     this flag and nothing ever read it, so the card's own promise — "they
     are told when it lifts" — was never kept by anything. Now it marks the
     two figures the card moves, live, which is what makes it fog rather
     than a fault the player reports. */
  const suspect = (me.falsifiedUntil || 0) > now;

  const openDetail = (id, focus = null) => { setDetailId(id); setDetailFocus(typeof focus === "string" ? focus : null); };
  /* From a build under way, on the portfolio or your own profile, to the
     Build screen with the build in view. */
  const goToBuild = () => {
    setProfileId(null);
    setTab("build");
    setTimeout(() => document.querySelector(".bld-progress")?.scrollIntoView({ behavior: "smooth", block: "start" }), 120);
  };

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

  return (
    <SpotlightCtx.Provider value={setSpotId}>
    <DeedCtx.Provider value={openDetail}>
    <div className={"ukpx-root city-york" + themeClass}>
      <div className="topbar">
        <div>
          <h1>Civexio <span className="brand-city">York</span></h1>
          {/* Aaron, 27 Sep 2026: the line is the game's, not the city's, so a
              second city only changes the name beside the wordmark. */}
          <div className="sub">A live property market game of the UK, open to all</div>
        </div>
        <div className="topbar-right">
          <CityClock />
          <div className="sub">
            {Object.keys(state.players).length} trader{Object.keys(state.players).length === 1 ? "" : "s"} registered ·{" "}
            <button className="switch-link" onClick={() => setShowAccount(true)}
                    style={{ color: "#c9d6cb" }}>Account</button>
            {" · "}
            <button className="switch-link" onClick={signOut} style={{ color: "#c9d6cb" }}>Sign out</button>
          </div>
        </div>
      </div>

      {showCashFlow && (
        <CashFlowModal cf={me.cashFlow} me={me} now={now} onClose={() => setShowCashFlow(false)} />
      )}
      {showKeystones && (
        <KeystoneModal me={me} onClose={() => setShowKeystones(false)}
                       onPersonalise={() => { setShowKeystones(false); setTab("personalise"); }} />
      )}
      {fsOn && !showTour && !showGuide && (
        <FirstSteps game={game} me={me} tab={tab} setTab={setTab}
                    onText={() => setShowGuide(true)}
                    onDone={() => { setFsOn(false); if (me.id) fsSet(me.id, "done"); }} />
      )}
      {showTour && (
        <FirstTour onClose={() => setShowTour(false)}
                   onText={() => { setShowTour(false); setShowGuide(true); }}
                   onDraw={() => {
                     setShowTour(false); setTab("market");
                     try { window.scrollTo(0, 0); } catch {}
                     setTimeout(() => { try { window.dispatchEvent(new CustomEvent("civexio:draw")); } catch {} }, 120);
                   }} />
      )}
      {showGuide && <GuideOverlay rules={state.rules} onClose={closeGuide}
                                  cards={state.self && state.self.guideRich ? GUIDE.concat(GUIDE_RICH) : GUIDE} />}
      {!showGuide && !weekOpen && extraGuide === "new" && (
        <GuideOverlay rules={state.rules} onClose={() => setExtraGuide(null)} cards={GUIDE_NEW}
                      kicker="NEW IN CIVEXIO" finish="Back to the exchange" />
      )}
      {!showGuide && !weekOpen && extraGuide === "rich" && (
        <GuideOverlay rules={state.rules} onClose={() => setExtraGuide(null)} cards={GUIDE_RICH}
                      kicker="PAST £1.5M" finish="Back to the exchange" />
      )}
        {weekOpen && game.openingWeek && (
          <OpeningWeek week={game.openingWeek} onAct={onActResult}
                       onClose={() => setWeekOpen(false)} />
        )}

        {showAccount && (
        <AccountPanel me={me} rules={state.rules} flash={flash} onAct={onAct}
                      week={game.openingWeek || null}
                      onOpenWeek={game.openingWeek ? () => setWeekOpen(true) : null}
                      onClose={() => setShowAccount(false)}
                      onGuide={() => setShowGuide(true)}
                      onTour={() => setShowTour(true)}
                      onSignedOut={() => { setShowAccount(false); signOut(); }} />
      )}

      <BuyConfirm pending={confirming} me={me}
                  onNo={() => setConfirming(null)}
                  onYes={() => { const c = confirming; setConfirming(null); if (c) runAct(c.action, c.payload); }} />

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

      <LockBar deck={state.deck} onOpen={() => setTab("tactics")} onCouncil={() => setTab("council")} />
      <EveningBar evening={state.evening} onWatch={() => setEveOpen(true)}
                  onSkip={() => { runAct("evening.seen", { at: state.evening.at }); setTab("tactics"); }} />
      {eveOpen && state.evening && (
        <EveningTheatre ev={state.evening} onClose={() => setEveOpen(false)}
                        onSeen={(at) => runAct("evening.seen", { at })} />
      )}
      <LeaderPrompt game={game} me={me} onOpen={() => {
        setTab("leaderboard");
        setTimeout(() => document.getElementById("district-leaders")?.scrollIntoView({ behavior: "smooth", block: "start" }), 120);
      }} />

      <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>}
        {/* MARKED WHILE THE REGISTER IS CORRUPTED. The card moves this figure
            and the cash flow forecast and nothing else, so those two carry a
            blur and a title and the rest of the bar stays crisp. A player who
            can see WHICH numbers are lying can still play; one who cannot is
            just filing a bug report. */}
        <div><span className="stat-label">Net worth</span>
          <span className={"stat-value" + (suspect ? " is-suspect" : "")}
                title={suspect ? SUSPECT_NOTE : null}
                style={me.netWorth < 0 ? { color: "var(--loss)" } : null}>{fmt(me.netWorth)}</span></div>
        {/* What the next settlement will do, not what the last one did. A
            player could see what they had and what they owned but nothing
            about what was about to happen to either, so an hour that took
            money off them arrived as a surprise every single time. */}
        {me.skill && (
          <div title="Skill points: earned in the Gaming Council, spent on edges the market cannot sell">
            <span className="stat-label">Skill</span>
            <span className="stat-value" style={{ color: "var(--gold)" }}>{me.skill.points}</span>
          </div>
        )}
        {/* Every trader starts with one (Aaron, 28 Sep 2026), so it is on the
            bar; hovering or clicking says they are cosmetic only. */}
        {me.personalise && (
          <button className="stat-button" title={KEYSTONE_NOTE + " Click for more."}
                  onClick={() => setShowKeystones(true)}>
            <span className="stat-label">Keystones</span>
            <span className="stat-value is-silver">{me.personalise.tokens}</span>
          </button>
        )}
        {me.cashFlow && me.cashFlow.net === null && (
          <div title="The forecast could not be computed. This is a fault, not a figure">
            <span className="stat-label">Cash flow / hour</span>
            <span className="stat-value" style={{ color: "var(--loss)" }}>—</span>
          </div>
        )}
        {me.cashFlow && me.cashFlow.net !== null && (
          /* THE WHOLE TILE IS THE CONTROL, not a small link beside it. The
             thing people want to interrogate is the number, so the number
             is what they click. */
          <button className="stat-button"
                  title={(suspect ? SUSPECT_NOTE + " " : "") + cashFlowNote(me.cashFlow) + ". Click for the breakdown."}
                  onClick={() => setShowCashFlow(true)}>
            <span className="stat-label">Cash flow / hour</span>
            <span className={"stat-value" + (suspect ? " is-suspect" : "")}
                  style={{ color: me.cashFlow.net >= 0 ? "var(--gain)" : "var(--loss)" }}>
              {me.cashFlow.net >= 0 ? "+" : "−"}{fmt(Math.abs(me.cashFlow.net))}
            </span>
          </button>
        )}
      </div>

      <GuestBanner />

      <nav className="tabs">
        {[["map", "Map"], ["market", "Market"], ["portfolio", "Portfolio"],
          /* ONE FLOOR. Auctions and private treaty are the same errand —
             getting a building off another player — and splitting them put
             an auction you were losing one tab away from the trade you
             would have taken instead. The badge counts offers waiting on
             you, because that is the only thing here with somebody else's
             clock on it; the live auction count lives on the sub-tab. */
          /* CITY TASKS (1 Oct 2026): only for traders below £1.5m, or one
             still finishing the task they held when they crossed it. */
          ...(me.cityTasks && me.cityTasks.visible
            ? [["tasks", `City tasks${me.cityTasks.mine ? " ●" : ""}`]] : []),
          ["floor", `Trading floor${incoming.length ? ` (${incoming.length})` : ""}`],
          ["build", "Build"],
          /* Bank / Stocks once there is an account to trade from. */
          ["bank", `${(me.bankLevel || 0) >= 1 && game.stocks ? "Bank / Stocks" : "Bank"}${bankAlert ? " ●" : ""}`],
          ["tactics", "Tactics"],
          ["council", "Gaming Council"],
          ["leaderboard", "Leaderboard"], ["events", "Intelligence"], ["personalise", "Personalise"]].map(([id, label]) => (
          <button key={id} data-tab={id} className={"tab-btn" + (tab === id ? " active" : "")} onClick={() => setTab(id)}>{label}</button>
        ))}
      </nav>

      <div className="content">
        <TabErrorBoundary key={tab}>
          {tab === "market" && <MarketTab game={game} me={me} histories={histories.current} onAct={onAct} onActResult={onActResult} onOpen={openDetail} />}
          {tab === "map" && <MapTab game={game} me={me} onOpen={openDetail} onEquip={onEquip} onAct={onAct} flash={flash} />}
          {tab === "portfolio" && <PortfolioTab game={game} me={me} now={now} histories={histories.current} onAct={onAct} onOpen={openDetail} onGoBuild={goToBuild} />}
          {tab === "floor" && <FloorTab game={game} me={me} onAct={onAct} />}
          {tab === "tasks" && <CityTasksTab me={me} now={now} onAct={onAct} onOpen={openDetail} onGo={setTab} />}
          {tab === "bank" && <BankTab game={game} me={me} now={now} onAct={onAct} onOpen={openDetail} />}
          {tab === "build" && <BuildTab game={game} me={me} now={now} onAct={onAct} onOpen={openDetail} />}
          {tab === "tactics" && <TacticsTab game={game} me={me} onAct={onAct} onActResult={onActResult}
                                            onEvening={() => setEveOpen(true)} />}
          {tab === "leaderboard" && <LeaderboardTab game={game} me={me} onAct={onAct} />}
          {tab === "council" && <CouncilTab game={game} me={me} onAct={onAct} onActResult={onActResult} />}
          {tab === "events" && <EventsTab game={game} me={me} onAct={onAct} />}
          {tab === "personalise" && <PersonaliseTab me={me} onAct={onAct} onEquip={onEquip} onFirmName={onFirmName} flash={flash} />}
        </TabErrorBoundary>
      </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)} focus={detailFocus}
          onAct={(a, p) => { onAct(a, p); }} />
      )}

      {(() => {
        /* The reveal waits until the evening has been watched. */
        const eveWaiting = state.evening && !state.evening.seen && (state.evening.beats || []).length;
        const won = game.builder && (game.builder.plots || []).find((p) => p.granted && !p.seen);
        /* And for the guide and the opening week, so it never sits under
           or over another notice. */
        if (!won || won.id === grantClosed || eveOpen || eveWaiting || showGuide || weekOpen
          || extraGuide === "new" || extraGuide === "rich") return null;
        return <GrantReveal key={won.id} plot={won}
          onGo={() => { setGrantClosed(won.id); runAct("ballot.seen", { plot: won.id }); setTab("build"); }}
          onLater={() => { setGrantClosed(won.id); runAct("ballot.seen", { plot: won.id }); }} />;
      })()}

      {profileId && game.players[profileId] && (
        <PlayerProfile game={game} me={me} playerId={profileId}
          onClose={() => setProfileId(null)} onOpen={openDetail} onAct={onAct} onGoBuild={goToBuild} />
      )}

      {/* Mounted last and keyed on the property, so asking for a second one
          while the first is open starts a fresh flight rather than leaving
          the map parked where the last answer was. */}
      {spotId && (
        <MapSpotlight key={spotId} id={spotId} game={game} me={me} onOpen={openDetail}
          onEquip={onEquip} onAct={onAct} flash={flash} onClose={() => setSpotId(null)} />
      )}
    </div>
    </DeedCtx.Provider>
    </SpotlightCtx.Provider>
  );
}

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