// levels/index.js (Lane C) — level registry, loader, schema selfcheck, pacing simulator.
//
// Runs in BOTH the browser (boot.js: `import('./levels/index.js').getLevel(id)`) and node
// (`node web/js/levels/index.js --selfcheck`, called by tools/qa.sh). That dual life is why
// this file imports no THREE and touches no DOM: it is data and arithmetic, nothing else.
//
//   node web/js/levels/index.js --selfcheck        validate every registered level (qa gate)
//   node web/js/levels/index.js --sim              pacing report for every level
//   node web/js/levels/index.js --sim L2_esophagus pacing report for one level
//
// Schema of record: docs/TECH.md §Level data schema v1 (Lane C owns it; changes go through
// LANE_C_NOTES.md + F referee, never silently).

import { ENEMIES, getEnemy, ARCHETYPES, PICKUPS, HAZARDS } from './enemies.js';
import { applyDifficulty, getDifficulty, DEFAULT_DIFFICULTY } from './difficulty.js';

export { ENEMIES, getEnemy, ARCHETYPES, PICKUPS, HAZARDS };
export { applyDifficulty, getDifficulty, DIFFICULTIES, DEFAULT_DIFFICULTY } from './difficulty.js';

const isNode = typeof process !== 'undefined' && !!process.versions?.node;

/** Schema version this module speaks. Levels declare `"schema": N`. */
export const SCHEMA = 1;

/** Campaign order. L4/L5/LS land in later rounds; the registry is the source of truth. */
export const CAMPAIGN = ['L1_mouth', 'L2_esophagus', 'L3_stomach'];

/** Booting with no ?lvl= gives you the vertical slice. Lane F: see LANE_C_NOTES §→ Lane F. */
export const DEFAULT_LEVEL = 'L2_esophagus';

/**
 * Playability constants — Lane C's model of "is there room to fly here".
 *
 * These MIRROR the other lanes rather than assert anything: A's world/index.js computes
 * `wallRho = radiusAt(s) - waveMaxAt(s) - SKIN` with `waveMaxAt = wave.amp + wave.breathe`
 * (per-biome, read live from A's registry — see waveMargin()), and B's flight/tuning.js
 * sets the hull collision sphere to 0.9. Only MIN_CLEARANCE is Lane C's own call.
 *
 * If A or B move their numbers, this module notices on the next qa run and the levels get
 * re-tuned — which is not hypothetical: A raised the esophagus amp 0.9 -> 1.4 during round 1
 * and it cost the diaphragmatic hiatus 0.5 units of flyable radius. See LANE_C_NOTES.md.
 */
export const PLAY = Object.freeze({
  PERISTALSIS_AMP: 0.9,   // fallback only, used when A's registry is unreachable (stub value)
  SKIN: 0.6,              // collision safety margin — matches A's world/index.js SKIN
  SHIP_R: 0.9,            // ENDO-1 collision sphere — from B's flight/tuning.js ship.radius
  MIN_CLEARANCE: 2.5,     // C'S LAW: the tightest point must still leave a 2.5-unit free disc
                          // (~2.8x the hull's collision radius). Below that a constriction
                          // stops being a challenge and becomes a toll you simply pay.
});

/**
 * Fallback biome list, used only when Lane A's registry cannot be loaded.
 * Mirrors world/biomes.js §Canonical ids. Lane A's registry is AUTHORITATIVE when present —
 * biome = the visual/tissue family, `segments[].name` carries the anatomy. That division is
 * why C needs no 'cardia'/'pylorus'/'pharynx' ids: those are names, not looks.
 */
export const BIOMES_EXPECTED = [
  'oral', 'esophagus', 'stomach', 'small_intestine', 'large_intestine', 'appendix',
];

/** Throttle profiles for the pacing sim. GDD: throttle gives +/-40% of biome flow. */
export const PROFILES = Object.freeze({ cautious: 0.85, par: 1.15, speedrun: 1.4 });

/** A lethal chase forces the player's hand — no one dawdles in front of an acid wall. */
const CHASE_THROTTLE = 1.35;

// ---------------------------------------------------------------------------------------
// loading
// ---------------------------------------------------------------------------------------

const cache = new Map();

async function loadJSON(rel) {
  const url = new URL(rel, import.meta.url);   // resolves against THIS file in node + browser
  if (isNode) {
    const { readFile } = await import('node:fs/promises');
    return JSON.parse(await readFile(url, 'utf8'));
  }
  const res = await fetch(url);
  if (!res.ok) throw new Error(`level fetch failed: ${rel} (${res.status})`);
  return res.json();
}

/**
 * → the level object for `id` (defaults to the vertical slice), validated.
 * Throws on unknown id or on validation errors — a broken level must never reach the world.
 */
export async function getLevel(id = DEFAULT_LEVEL, { difficulty = DEFAULT_DIFFICULTY } = {}) {
  const key = id ?? DEFAULT_LEVEL;
  if (!CAMPAIGN.includes(key)) throw new Error(`unknown level "${key}" (have: ${CAMPAIGN.join(', ')})`);
  if (!cache.has(key)) cache.set(key, loadJSON(`./${key}.json`));
  const level = await cache.get(key);
  const { errors } = await validate(level, { expectId: key });
  if (errors.length) throw new Error(`level "${key}" failed validation:\n  - ${errors.join('\n  - ')}`);
  return applyDifficulty(level, difficulty);
}

/** → [{ id, name, tagline, length, par }] for menus (Lane E). */
export async function listLevels() {
  const out = [];
  for (const id of CAMPAIGN) {
    const l = await loadJSON(`./${id}.json`);
    out.push({ id, name: l.name, tagline: l.tagline, length: totalLength(l), par: l.par, skeleton: isSkeleton(l) });
  }
  return out;
}

/**
 * Lane A's biome registry, or C's fallback. Cached module-level so the sync clearance
 * helpers can read wave amplitudes without every call site going async.
 * We consume A's `wave.amp` rather than assuming the stub's 0.9: peristalsis amplitude is
 * per-biome (small_intestine is 1.1), and it is subtracted from the flyable radius, so a
 * level that is comfortable in the esophagus could be a wall in the ileum.
 */
let REG = null;

async function biomeRegistry() {
  if (REG) return REG;
  try {
    const m = await import('../world/biomes.js');            // Lane A's, when it lands
    if (m.BIOMES && Object.keys(m.BIOMES).length) {
      REG = {
        ids: new Set(Object.keys(m.BIOMES)),
        // Mirror A's waveMaxAt() exactly: amp + breathe, not amp alone.
        marginFor: (id) => {
          const w = m.BIOMES[id]?.wave;
          return w ? w.amp + (w.breathe ?? 0) : PLAY.PERISTALSIS_AMP;
        },
        authoritative: true,
      };
      return REG;
    }
  } catch { /* Lane A hasn't landed yet — fall back to C's expectation */ }
  REG = { ids: new Set(BIOMES_EXPECTED), marginFor: () => PLAY.PERISTALSIS_AMP, authoritative: false };
  return REG;
}

// ---------------------------------------------------------------------------------------
// geometry / pacing helpers (shared by validate + pace)
// ---------------------------------------------------------------------------------------

export function totalLength(level) {
  return level.segments.reduce((a, s) => a + s.length, 0);
}

/** → the segment containing s, plus its start offset. */
export function segmentAt(level, s) {
  let s0 = 0;
  for (const seg of level.segments) {
    if (s < s0 + seg.length || seg === level.segments[level.segments.length - 1]) return { seg, s0 };
    s0 += seg.length;
  }
  return { seg: level.segments[0], s0: 0 };
}

const isSkeleton = (level) => /^SKELETON/.test(level.design?.status ?? '');

/**
 * Lane B's combat balance, or false. Same guarded-import pattern as the biome registry:
 * Lane C's registry CONSUMES the other lanes' contracts and owns only composition, so
 * score lives in exactly one place (B's balance.js, keyed by archetype) and this module
 * reads it rather than restating it.
 */
let BAL = null;

async function combatBalance() {
  if (BAL !== null) return BAL;
  try {
    const m = await import('../combat/balance.js');            // Lane B's, when it lands
    if (m.BALANCE?.enemies) { BAL = m.BALANCE; return BAL; }
  } catch { /* Lane B hasn't landed yet */ }
  BAL = false;
  return BAL;
}

/** → the score Lane B awards for killing `enemyId`, or null if B's balance isn't loaded. */
export function scoreFor(enemyId) {
  const spec = getEnemy(enemyId);
  if (!spec || !BAL) return null;
  return BAL.enemies?.[spec.archetype]?.score ?? null;
}

/** → total points available from kills, or null. Used to sanity-check par.score. */
export function scoreBudget(level) {
  if (!BAL) return null;
  let total = 0;
  for (const e of level.events ?? []) {
    if (e.type !== 'spawn') continue;
    const s = scoreFor(e.enemy);
    if (s == null) return null;
    total += s * (e.count ?? 1);
  }
  return total;
}

/** What the wall eats into the lumen — Lane A's per-biome (amp + breathe) when available. */
function waveMargin(seg) {
  return REG?.marginFor ? REG.marginFor(seg.biome) : PLAY.PERISTALSIS_AMP;
}

/** Static free-flight radius of a segment at its narrowest wobble trough. */
function staticClearance(seg) {
  const r = seg.radius.base * (1 - (seg.radius.wobble ?? 0));
  return r - waveMargin(seg) - PLAY.SKIN - PLAY.SHIP_R;
}

/**
 * Clearance including span hazards that eat into the tube.
 * A one-sided squeeze (aortic) does NOT halve the tube: it bites `amplitude*R` out of one
 * arc, so the largest circle you can still fly through is wallRho - intrusion/2.
 * A ring_gate is exempt — it seals on purpose and is a timing gate, not a corridor.
 */
function hazardClearance(level, seg, s0) {
  let clear = staticClearance(seg);
  const rStatic = seg.radius.base * (1 - (seg.radius.wobble ?? 0));
  for (const e of level.events) {
    if (e.type !== 'hazard' || !e.span) continue;
    const overlaps = e.s < s0 + seg.length && e.s + e.span > s0;
    if (!overlaps) continue;
    if (e.kind === 'aortic_squeeze') {
      const intrusion = (e.amplitude ?? 0) * rStatic;
      clear = Math.min(clear, staticClearance(seg) - intrusion / 2);
    }
  }
  return clear;
}

/** How fast the player is actually moving at s, under a throttle profile. */
function speedAt(level, s, k) {
  const { seg } = segmentAt(level, s);
  let throttle = k;
  for (const e of level.events) {
    if (e.type === 'hazard' && e.kind === 'reflux_surge' && e.lethal && e.span &&
        s >= e.s && s <= e.s + e.span) throttle = Math.max(throttle, CHASE_THROTTLE);
  }
  return Math.max(0.1, seg.flow * throttle);
}

/** Seconds to travel [a, b] under profile k. Numeric integration; 2-unit steps. */
export function travelTime(level, a, b, k) {
  const STEP = 2;
  let t = 0;
  for (let s = a; s < b; s += STEP) {
    const ds = Math.min(STEP, b - s);
    t += ds / speedAt(level, s + ds / 2, k);
  }
  return t;
}

// ---------------------------------------------------------------------------------------
// the pacing simulator — Lane C's evidence (you cannot screenshot data)
// ---------------------------------------------------------------------------------------

/**
 * Models the level as a kinematic ride and reports the encounter curve.
 * This is a MODEL, not a playtest: it assumes a player who holds a constant throttle and is
 * never slowed by combat (true-ish in tube mode — the current carries you and you cannot
 * stop). Treat times as lower bounds on a real run and pressure as a relative index only.
 *
 * pressure = density x tightness x hazard, where
 *   density   = enemies per 100 units in the segment
 *   tightness = widest segment radius / this segment radius (how little room you have)
 *   hazard    = 1 + a per-kind term (see hazardWeight)
 */
export function pace(level, { profile = 'par' } = {}) {
  const k = PROFILES[profile] ?? PROFILES.par;
  const total = totalLength(level);
  const widest = Math.max(...level.segments.map((s) => s.radius.base));

  const beats = [];
  let s0 = 0, tAcc = 0;
  for (const seg of level.segments) {
    const s1 = s0 + seg.length;
    const inSeg = (e) => e.s >= s0 && e.s < s1;
    const enemies = level.events.filter((e) => e.type === 'spawn' && inSeg(e))
      .reduce((a, e) => a + (e.count ?? 1), 0);
    const hazards = level.events.filter((e) => e.type === 'hazard' && inSeg(e));
    const density = (enemies / seg.length) * 100;
    const tightness = widest / seg.radius.base;
    const hazard = hazards.reduce((a, h) => a * hazardWeight(h), 1);
    const dt = travelTime(level, s0, s1, k);
    tAcc += dt;
    beats.push({
      name: seg.name ?? seg.biome, s0, s1, length: seg.length, flow: seg.flow,
      enemies, density, tightness, hazard,
      pressure: density * tightness * hazard,
      clearance: hazardClearance(level, seg, s0),
      seconds: dt, cumulative: tAcc,
      hazardKinds: hazards.map((h) => h.kind),
    });
    s0 = s1;
  }

  const checkpoints = level.events.filter((e) => e.type === 'checkpoint');
  const gaps = checkpoints.map((cp, i) => {
    const next = checkpoints[i + 1]?.s ?? total;
    return { from: cp.s, to: next, name: cp.name ?? '', units: next - cp.s, seconds: travelTime(level, cp.s, next, PROFILES.par) };
  });

  return { id: level.id, profile, total, beats, checkpoints: gaps, seconds: tAcc };
}

function hazardWeight(h) {
  switch (h.kind) {
    case 'aortic_squeeze': return 1 + (h.amplitude ?? 0);
    case 'ring_gate':      return 1 + (1 - (h.open ?? 1) / (h.period ?? 1));
    case 'reflux_surge':   return h.lethal ? 1.8 : 1.15;
    default:               return 1.1;
  }
}

// ---------------------------------------------------------------------------------------
// validate — the schema selfcheck (qa.sh gate)
// ---------------------------------------------------------------------------------------

const TAU = Math.PI * 2;

/**
 * → { errors, warnings }. Errors block qa; warnings are advisory.
 * Skeleton levels (design.status starts "SKELETON") downgrade CONTENT rules (pacing,
 * samples, checkpoint coverage) to warnings — a level that has not been designed yet must
 * not fail the gate for not being designed yet. STRUCTURAL rules always apply.
 */
export async function validate(level, { expectId = null } = {}) {
  const errors = [], warnings = [];
  const E = (m) => errors.push(m);
  const W = (m) => warnings.push(m);
  const skel = isSkeleton(level);
  const C = (m) => (skel ? W(`${m} [skeleton: downgraded]`) : E(m));

  // --- structural -----------------------------------------------------------------
  if (level.schema !== SCHEMA) E(`schema is ${level.schema}, this module speaks ${SCHEMA}`);
  if (typeof level.id !== 'string' || !level.id) E('missing id');
  if (expectId && level.id !== expectId) E(`id "${level.id}" does not match filename "${expectId}"`);
  if (!Number.isInteger(level.seed)) E(`seed must be an integer (got ${JSON.stringify(level.seed)})`);
  if (typeof level.name !== 'string' || !level.name) E('missing name');
  if (!Array.isArray(level.segments) || !level.segments.length) { E('segments must be a non-empty array'); return { errors, warnings }; }
  if (!Array.isArray(level.events)) E('events must be an array');
  if (!Array.isArray(level.arenas)) E('arenas must be an array');

  const total = totalLength(level);
  const { ids: biomes, authoritative } = await biomeRegistry();
  if (!authoritative) W(`Lane A's world/biomes.js not present — biome ids checked against Lane C's expected list (${BIOMES_EXPECTED.length} ids)`);
  const bal = await combatBalance();
  if (!bal) W("Lane B's combat/balance.js not present — archetype rows and score budget unchecked");

  // --- segments -------------------------------------------------------------------
  let s0 = 0;
  for (const [i, seg] of level.segments.entries()) {
    const at = `segments[${i}] "${seg.name ?? seg.biome}"`;
    if (!biomes.has(seg.biome)) E(`${at}: unknown biome "${seg.biome}"`);
    if (!(seg.length > 0)) E(`${at}: length must be > 0`);
    if (!(seg.radius?.base > 0)) E(`${at}: radius.base must be > 0`);
    const wob = seg.radius?.wobble ?? 0;
    if (wob < 0 || wob >= 1) E(`${at}: radius.wobble must be in [0,1) (got ${wob})`);
    if (seg.curviness < 0 || seg.curviness > 1) E(`${at}: curviness must be in [0,1] (got ${seg.curviness})`);
    if (!(seg.flow >= 0)) E(`${at}: flow must be >= 0`);

    // C's playability invariant — the check that catches "I made a cool tube you can't fly".
    const clear = hazardClearance(level, seg, s0);
    if (clear < PLAY.MIN_CLEARANCE) {
      E(`${at}: free radius ${clear.toFixed(2)} < MIN_CLEARANCE ${PLAY.MIN_CLEARANCE} ` +
        `(base ${seg.radius.base}, wobble ${wob}, minus wave ${waveMargin(seg).toFixed(2)} + skin ${PLAY.SKIN} + hull ${PLAY.SHIP_R}` +
        `${clear < staticClearance(seg) ? ' + span hazard' : ''}) — unflyable`);
    }
    s0 += seg.length;
  }

  // --- events ---------------------------------------------------------------------
  let prev = -Infinity;
  for (const [i, e] of (level.events ?? []).entries()) {
    const at = `events[${i}] ${e.type}@${e.s}`;
    if (typeof e.s !== 'number') { E(`${at}: missing numeric s`); continue; }
    if (e.s < prev) E(`${at}: events must be sorted by s (previous was ${prev})`);
    prev = e.s;
    if (e.s < 0 || e.s > total) E(`${at}: s outside level (0..${total})`);
    if (e.span != null && e.s + e.span > total) E(`${at}: span runs past the end of the level`);

    const thetas = Array.isArray(e.theta) ? e.theta : (e.theta != null ? [e.theta] : []);
    for (const t of thetas) if (!(t >= 0 && t < TAU)) E(`${at}: theta ${t} outside [0, 2pi)`);
    if (e.rho != null && !(e.rho >= 0 && e.rho <= 1)) E(`${at}: rho ${e.rho} outside [0,1] (rho is a FRACTION of the safe radius, not a distance)`);

    switch (e.type) {
      case 'spawn': {
        const spec = getEnemy(e.enemy);
        if (!spec) { E(`${at}: unknown enemy "${e.enemy}" (not in enemies.js catalogue)`); break; }
        if (!ARCHETYPES.includes(spec.archetype)) E(`${at}: enemy "${e.enemy}" has archetype "${spec.archetype}" which Lane B does not implement (${ARCHETYPES.join('/')})`);
        else if (bal && !bal.enemies?.[spec.archetype]) E(`${at}: Lane B's balance.js has no row for archetype "${spec.archetype}" — the contract drifted`);
        if (spec.placeholder) W(`${at}: spawns bare archetype "${e.enemy}" — placeholder with no fiction; give it a real catalogue entry`);
        if (spec.wall && !thetas.length) E(`${at}: "${e.enemy}" is wall-mounted and needs an explicit theta`);
        if (Array.isArray(e.theta) && e.theta.length !== (e.count ?? 1)) E(`${at}: theta array has ${e.theta.length} entries but count is ${e.count ?? 1}`);
        break;
      }
      case 'pickup':
        if (!PICKUPS[e.kind]) E(`${at}: unknown pickup kind "${e.kind}"`);
        break;
      case 'hazard': {
        const h = HAZARDS[e.kind];
        if (!h) { E(`${at}: unknown hazard kind "${e.kind}"`); break; }
        for (const p of h.needs) if (e[p] == null) E(`${at}: hazard "${e.kind}" requires "${p}"`);
        if (e.kind === 'ring_gate') {
          const duty = (e.open ?? 0) / (e.period ?? 1);
          if (duty < 0.3) W(`${at}: ring_gate open only ${(duty * 100) | 0}% of the time — below C's 30% fairness floor`);
        }
        if (e.warn != null && e.warn < 2 && e.lethal !== false) W(`${at}: warn ${e.warn}s is under the 2s telegraph law for lethal hazards`);
        break;
      }
      case 'checkpoint': case 'boss': case 'gate': break;
      default: W(`${at}: unknown event type "${e.type}" — B/E will ignore it`);
    }
  }

  // --- arenas ---------------------------------------------------------------------
  for (const [i, a] of (level.arenas ?? []).entries()) {
    const at = `arenas[${i}]`;
    if (!(a.radius > 0)) E(`${at}: radius must be > 0`);
    if (a.at - a.radius < 0 || a.at + a.radius > total) E(`${at}: sphere (at ${a.at} r ${a.radius}) spans ${a.at - a.radius}..${a.at + a.radius}, outside 0..${total}`);
    if (a.biome && !biomes.has(a.biome)) E(`${at}: unknown biome "${a.biome}"`);
  }

  // --- content laws (downgraded for skeletons) ------------------------------------
  const cps = (level.events ?? []).filter((e) => e.type === 'checkpoint');
  for (const e of level.events ?? []) {
    if (e.type === 'boss' && !cps.some((c) => c.s <= e.s && e.s - c.s < 200))
      C(`boss "${e.id}"@${e.s} has no checkpoint within 200 units before it`);
    if (e.type === 'hazard' && e.lethal && !cps.some((c) => c.s <= e.s && e.s - c.s < 120))
      C(`lethal hazard "${e.kind}"@${e.s} has no checkpoint within 120 units before it`);
  }

  // death costs <= 30s of progress (charter). Measured at par throttle.
  for (const [i, cp] of cps.entries()) {
    const next = cps[i + 1]?.s ?? total;
    const secs = travelTime(level, cp.s, next, PROFILES.par);
    if (secs > 30) C(`checkpoint gap ${cp.s}..${next} ("${cp.name ?? ''}") = ${secs.toFixed(1)}s at par throttle — over the 30s death-cost law`);
  }

  const samples = (level.events ?? []).filter((e) => e.type === 'pickup' && e.kind === 'biopsy_sample')
    .reduce((a, e) => a + (e.count ?? 1), 0);
  if (samples !== 3) C(`${samples} biopsy samples — the GDD says exactly 3 per level`);

  // par sanity: an unbeatable par is a bug, a free par is a waste.
  if (level.par?.time > 0) {
    const fast = pace(level, { profile: 'speedrun' }).seconds;
    const slow = pace(level, { profile: 'cautious' }).seconds;
    if (level.par.time < fast) E(`par.time ${level.par.time}s is faster than a full-throttle run (${fast.toFixed(0)}s) — unachievable`);
    else if (level.par.time > slow) C(`par.time ${level.par.time}s is slower than a timid run (${slow.toFixed(0)}s) — free medal`);
  } else C('no par.time');

  if (level.par?.score > 0 && (level.events ?? []).some((e) => e.type === 'spawn')) {
    const budget = scoreBudget(level);
    if (budget != null && level.par.score > budget)
      W(`par.score ${level.par.score} exceeds the kill budget ${budget} (Lane B's scores x C's counts) — only reachable via pickups and combo multipliers`);
  }

  // gate wiring
  for (const e of level.events ?? []) {
    if (e.type === 'gate' && e.to && !CAMPAIGN.includes(e.to)) W(`gate "${e.id}" points at "${e.to}" which is not in the campaign registry yet`);
  }
  if (level.next && !CAMPAIGN.includes(level.next)) W(`next "${level.next}" is not in the campaign registry yet`);

  return { errors, warnings };
}

// ---------------------------------------------------------------------------------------
// CLI (node only — guarded so importing this in the browser has zero side effects)
// ---------------------------------------------------------------------------------------

function bar(v, max, width = 22) {
  const n = Math.max(0, Math.min(width, Math.round((v / max) * width)));
  return '#'.repeat(n) + '.'.repeat(width - n);
}

async function cliSelfcheck() {
  let bad = 0, warned = 0;
  for (const id of CAMPAIGN) {
    let level;
    try { level = await loadJSON(`./${id}.json`); }
    catch (err) { console.error(`  x ${id}: cannot load — ${err.message}`); bad++; continue; }
    const { errors, warnings } = await validate(level, { expectId: id });
    const tag = isSkeleton(level) ? ' (skeleton)' : '';
    if (errors.length) {
      bad++;
      console.error(`  x ${id}${tag}: ${errors.length} error(s)`);
      for (const e of errors) console.error(`      ERROR  ${e}`);
    } else {
      console.log(`  . ${id}${tag}: ok — ${level.segments.length} segments, ${totalLength(level)} units, ${level.events.length} events`);
    }
    for (const w of warnings) { warned++; console.log(`      warn   ${w}`); }
  }
  console.log(`levels selfcheck: ${CAMPAIGN.length - bad}/${CAMPAIGN.length} valid, ${warned} warning(s)`);
  return bad === 0;
}

async function cliSim(only) {
  const reg = await biomeRegistry();   // so clearance uses Lane A's real wave amplitudes
  const bal = await combatBalance();   // so the score budget uses Lane B's real scores
  console.log(`biome registry: ${reg.authoritative ? "Lane A's world/biomes.js" : 'Lane C fallback list (Lane A not present)'}`);
  console.log(`combat balance: ${bal ? "Lane B's combat/balance.js" : 'not present (score budget unavailable)'}`);
  for (const id of CAMPAIGN) {
    if (only && id !== only) continue;
    const level = await loadJSON(`./${id}.json`);
    const p = pace(level, { profile: 'par' });
    const fast = pace(level, { profile: 'speedrun' }).seconds;
    const slow = pace(level, { profile: 'cautious' }).seconds;
    const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}`;

    console.log(`\n=== ${id} — ${level.name}${isSkeleton(level) ? '  [SKELETON]' : ''} ===`);
    const budget = scoreBudget(level);
    console.log(`${p.total} units · par.time ${level.par?.time ?? '?'}s (${mmss(level.par?.time ?? 0)}) · par.score ${level.par?.score ?? '?'}` +
      (budget != null ? ` of ${budget} kill budget (${((level.par.score / budget) * 100) | 0}%)` : ''));
    console.log(`sim: speedrun ${mmss(fast)} (${fast.toFixed(0)}s) · par ${mmss(p.seconds)} (${p.seconds.toFixed(0)}s) · cautious ${mmss(slow)} (${slow.toFixed(0)}s)`);
    const maxP = Math.max(...p.beats.map((b) => b.pressure), 0.001);
    console.log('\n  beat                        s-range      len flow  en  dens  tight haz   PRESSURE  clear   t     cum');
    for (const b of p.beats) {
      console.log(
        `  ${(b.name ?? '').padEnd(26).slice(0, 26)} ${String(b.s0).padStart(4)}-${String(b.s1).padStart(4)} ` +
        `${String(b.length).padStart(5)} ${String(b.flow).padStart(4)} ${String(b.enemies).padStart(3)} ` +
        `${b.density.toFixed(2).padStart(5)} ${b.tightness.toFixed(2).padStart(5)} ${b.hazard.toFixed(2).padStart(4)} ` +
        `${b.pressure.toFixed(2).padStart(6)} ${bar(b.pressure, maxP, 10)} ${b.clearance.toFixed(2).padStart(5)} ` +
        `${b.seconds.toFixed(0).padStart(4)}s ${mmss(b.cumulative).padStart(5)}`);
    }
    const totEn = p.beats.reduce((a, b) => a + b.enemies, 0);
    console.log(`  ${'TOTAL'.padEnd(26)} ${String(0).padStart(4)}-${String(p.total).padStart(4)} ${String(p.total).padStart(5)} ${'-'.padStart(4)} ${String(totEn).padStart(3)}`);

    if (p.checkpoints.length) {
      console.log('\n  checkpoint gaps (death cost at par throttle; law: <= 30s)');
      for (const g of p.checkpoints) {
        const flag = g.seconds > 30 ? '  <-- OVER' : '';
        console.log(`    ${String(g.from).padStart(4)} -> ${String(g.to).padStart(4)}  ${String(g.units).padStart(4)}u  ${g.seconds.toFixed(1).padStart(5)}s  ${g.name}${flag}`);
      }
    }
  }
}

if (isNode && (process.argv.includes('--selfcheck') || process.argv.includes('--sim'))) {
  if (process.argv.includes('--sim')) {
    const i = process.argv.indexOf('--sim');
    await cliSim(process.argv[i + 1]?.startsWith('--') ? null : process.argv[i + 1]);
  }
  if (process.argv.includes('--selfcheck')) {
    const ok = await cliSelfcheck();
    process.exit(ok ? 0 : 1);
  }
}
