<!-- SOLIDIVISM · part 60 -->
 "./lib/" } }
</script>

<script type="module">
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';

/* =====================================================================
 * EPISTLES — TACTICAL CITY
 * Every page of epistles.xyz as an enterable building on a tactical
 * grid. Single file, no backend, no bundler. Three.js r186 by CDN.
 *
 * HOW TO ADD A NEW PAGE / BUILDING (3 lines):
 *   1. Publish the page on epistles.xyz as usual.
 *   2. Add ONE line to EXTRA_PAGES below:   { path: '/my-page' },
 *   3. Reload. The city raises the building itself.
 * (If the site ever gains a real /sitemap.xml, the city reads it
 *  live at boot and this list becomes only the fallback.)
 * ===================================================================== */

/* ---------------- THE PAGE LIST ----------------
 * epistles.xyz serves no /sitemap.xml (verified 2026-10-14: the
 * address returns the site's 404 page). This list was enumerated
 * from the server's own route table + static pages that day, minus
 * admin machinery, parameter instances, and transaction endpoints.
 * ================================================================ */
const EXTRA_PAGES = [
  /* { path: '/my-new-page' },   <-- your new building goes here */
];
const FALLBACK_PAGES = [
  '/', '/ai', '/archive', '/archives', '/archives/chats', '/archives/code', '/archives/muse',
  '/beacon', '/beauty', '/book-of-law', '/boss-mode', '/build-for-ai', '/calc', '/call/provolta',
  '/chat', '/chatlog', '/city', '/coders100', '/codex', '/debates', '/deck', '/deck/observatory',
  '/deck/roster', '/deck/status', '/deck/vault', '/defense', '/digest', '/drops', '/economy',
  '/economy/arcade', '/economy/creator', '/economy/guide', '/economy/hub', '/emojis', '/events',
  '/experimental', '/feed', '/hawaiian-compass', '/hunts', '/kingdom', '/leaderboard', '/learn',
  '/legal/continuum', '/legal/privacy', '/live', '/live/bridge', '/login', '/members', '/missions',
  '/newworld', '/panel', '/path', '/playa', '/playaspace', '/polls', '/portal', '/portal/builder',
  '/practice', '/privacy', '/programs', '/provolta', '/pulleys', '/quarters', '/radio', '/radio/booth',
  '/register', '/room', '/room/aegis', '/ruby', '/rulers', '/sable', '/saga', '/saga-solid',
  '/salvage', '/scroll', '/search', '/security', '/shop', '/solid', '/solid/playtime', '/solid-talk',
  '/solidability', '/solidability/how-it-works', '/solidivism', '/ss', '/ss/store', '/starmap',
  '/ste', '/studio', '/tables', '/terms', '/theking', '/transmissions', '/voidfall', '/warp',
  '/warpspeed', '/world'
].map(path => ({ path }));

/* Sections of the site, color-coded on the buildings and the legend. */
const CATEGORIES = [
  { id: 'mind',    name: 'MINDS',     color: 0x39c2d7, match: ['/ai', '/sable', '/provolta', '/theking', '/solid', '/solid-talk', '/ste', '/beacon'] },
  { id: 'play',    name: 'PLAY',      color: 0xe5484d, match: ['/saga', '/ruby', '/voidfall', '/warp', '/warpspeed', '/boss-mode', '/pulleys', '/hunts', '/missions', '/solid/playtime', '/practice', '/world', '/newworld'] },
  { id: 'trade',   name: 'TRADE',     color: 0x53b981, match: ['/shop', '/ss', '/economy'] },
  { id: 'commons', name: 'COMMONS',   color: 0x9d7bea, match: ['/chat', '/room', '/feed', '/members', '/debates', '/polls', '/events', '/chatlog', '/transmissions', '/radio', '/playa'] },
  { id: 'works',   name: 'WORKS',     color: 0xe8912d, match: ['/codex', '/studio', '/build-for-ai', '/deck', '/coders100', '/archive', '/scroll'] },
  { id: 'civic',   name: 'CIVIC',     color: 0xd8a833, match: ['/', '/kingdom', '/panel', '/book-of-law', '/legal', '/privacy', '/terms', '/security', '/defense', '/rulers', '/leaderboard'] },
];
function categoryOf(path) {
  for (const c of CATEGORIES) for (const m of c.match) if (path === m || path.startsWith(m + '/')) return c;
  return { id: 'misc', name: 'THE CITY', color: 0x5b7290 };
}
function titleOf(path) {
  if (path === '/') return 'EPISTLES — HOME';
  return path.split('/').pop().replace(/-/g, ' ').toUpperCase();
}
function depthOf(path) { return path === '/' ? 0 : path.split('/').filter(Boolean).length; }
/* deterministic small hash so heights are stable between visits */
function hashOf(s) { let h = 7; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h; }

/* ---------------- SITE MAP: live sitemap first, baked list as law ---- */
async function loadPages() {
  try {
    const res = await fetch('/sitemap.xml', { credentials: 'omit' });
    if (res.ok) {
      const text = await res.text();
      const locs = [...text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map(m => {
        try { return { path: new URL(m[1]).pathname.replace(/\/$/, '') || '/' }; } catch (e) { return null; }
      }).filter(Boolean);
      if (locs.length >= 8) return dedupe(locs.concat(EXTRA_PAGES));
    }
  } catch (e) { /* no sitemap on this site — the baked list is the map */ }
  