/* global React, GraphNode, NT */
const { useState: useStateExp, useEffect: useEffectExp, useRef: useRefExp } = React;

const TRAVEL_MS = 600;          // vessel glide along an edge (either direction now)
const HUSK_STEP_MS = 280;       // husk flees fast, edge-by-edge (manifestation-clock §end-of-run)
const RIGHTWARD_TOL = 8;        // px tolerance for the build-time LAYOUT lint only (x = author order)
const TRAVEL_SLOTS = 1;         // the move is the clock — a move advances the ToD one step (route length = ToD, §3)
const OFFOPTION_SLOTS = 1;      // primary action is FREE (the move paid it); only special-marked off-options add a slot (§2, nodes.md)
// Condition keeps ticking on expedition (needs tick in town & field alike — day-and-rest.md).
const FULLNESS_DRAIN_SLOT = [12, 16, 22, 28];   // per-slot appetite drain by rank (mirrors the port)
// Fatigue is now a single per-slot BASELINE charged centrally in app.jsx tickSlot
// (FATIGUE_PER_SLOT, plus the sleep-skip penalty). The field adds no per-action
// exertion of its own — a move/extra just spends the slot and inherits that baseline.
const FATIGUE_SLEEP_SKIP_HINT = 30;   // display mirror of app.jsx FATIGUE_SLEEP_SKIP

// Warning thresholds ONLY (the real starvation model lives in app.jsx bodyTick /
// autoAscend, shared with the town). Kept in sync with app.jsx by name.
const HUNGER_AT = 8;       // fullness below this = going hungry (wasting Weight)
const WEIGHT_DEATH = 3;    // Weight ≤ this = starvation death

// Starvation / exhaustion / death now live ENTIRELY in the reducer (app.jsx:
// bodyTick + autoAscend), shared with the town. The field just dispatches the
// real care actions — no local condition model.

// ====================================================================
// ZONE 0 — "The Whispering Strata" (Aether Web). First-launch zone.
// BAKED from the Aether Web Shaper board (2026-06-10) + the ToD rule schema.
// Behavior per HANDOFF_CHECKS_Z0.md v0 + the three GLOBAL ToD rules:
//   • R1 IN-TIME — element checks swing ±ToD (todSwing); off-options surface
//     ONLY in their slot (OFFERING_AT[].slot — the shaper's affinity badges).
//   • R2 DAWN-PICK — the web regrows overnight: Morning forage yields +1.
//   • R3 NIGHTSIDE — at Night hazard bands shift up and fails bite +1 Hull.
//   Global rules touch NUMBERS only; the hand-placed TOD_FACE headlines are
//   the only things that change what a node IS (tide-gate · squall · predator
//   · ice-glint · dusk rescue).
//   • Edges are authored-directed; ONE-WAY edges restrict the un-DAG roam.
//   • per-ToD MAGNITUDE drives marker size (magK) — louder when awake.
// ALL numbers first-pass / tunable — kept in the tables below, not inlined.
// ====================================================================
const Z0 = {
  id: 'whispering-strata',
  space: { w: 1300, h: 340 },
  coords: {
    launch: [50, 168],
    a: [217, 14], d: [219, 130], b: [362, 298], h: [417, 143], c: [442, 15],
    f: [650, 27], g: [680, 223], j: [756, 125], i: [859, 17], k: [877, 194],
    n1: [822, 303], end1: [1196, 57], end2: [1187, 172], end3: [1187, 279],
  },
  // BASE action role per node — the identity it wears most of the day.
  // TOD_FACE (below) overrides role/key/band in specific slots (the headlines).
  roles: {
    launch: 'vessel',
    a: 'forage', c: 'forage', f: 'forage', g: 'forage', h: 'forage',
    i: 'forage', j: 'forage', k: 'forage', end2: 'forage',
    d: 'gate', b: 'danger', n1: 'danger', end1: 'recruit', end3: 'recruit',
  },
  // progression band [min,max] for the BASE identity (faces carry their own).
  // T2-divergence fixes kept: n1 force-floor 16 (B1) · end3 flat gate (B2).
  bands: {
    a: [2, 10], c: [2, 10], f: [2, 10], g: [2, 10], h: [2, 10],
    i: [2, 10], j: [2, 10], k: [2, 10],
    d: [12, 12],            // tide-gate — flat Earth requirement (gates take NO ToD swing)
    b: [4, 20], end2: [6, 20], n1: [16, 36],
    // end1 reads the SUM of Water+Nature (a themed PAIR) — variable, moderate.
    end1: [20, 70],
    // end3 GATEWAY reads the SUM of ALL elements — a flat BREADTH gate.
    end3: [200, 200],
  },
  // per-ToD MAGNITUDE (night-indexed [N, M, A, E]) — presentation loudness:
  // the marker swells at the hour its content wakes (shaper mag 1–6).
  mags: {
    launch: 2, a: [1, 2, 1, 1], d: 2, b: 2, h: [1, 1, 1, 3], c: 2,
    f: [2, 2, 4, 2], g: [4, 2, 2, 2], j: [3, 2, 2, 2], i: 2, k: 2,
    n1: 5, end1: 3, end2: [3, 3, 3, 4], end3: 6,
  },
  params: {
    b:    { hullFail: 3 },                                            // danger · gentle (the paid shortcut into depth)
    n1:   { hullFail: 6 },                                            // danger · real (guards the gateway tail)
    h:    { citizen: 'nav', rewardLabel: 'navigator lead' },           // recruit 1 → first facility (Evening face)
    end1: { citizen: 'supply', terminal: true, rewardLabel: 'crew lead' }, // recruit 2 (crew)
    end2: { dest: true, rich: true, oneTime: { coins: 4 } },           // rich corner (2 ways in) · blooms at dusk
    end3: { dest: true, citizen: 'mech', unlockKeyed: true, rewardLabel: 'gateway crew' }, // recruit 3 + UNLOCK keyed zones
  },
  // authored-DIRECTED edges (the shaper board). Two-way unless listed one-way.
  rawEdges: [
    ['launch', 'a'], ['launch', 'd'], ['launch', 'b'],
    ['a', 'c'], ['d', 'c'], ['d', 'h'], ['h', 'g'],
    ['c', 'f'], ['f', 'i'], ['f', 'j'],
    ['b', 'j'], ['b', 'n1'],
    ['g', 'j'], ['g', 'k'], ['i', 'j'],
    ['i', 'end1'], ['i', 'end2'], ['j', 'end2'],
    ['k', 'n1'], ['n1', 'end3'],
  ],
  // one-way lines (travel a → b only — restricts the bidirectional roam):
  // the b shortcut runs one way; i drops into the basin, no climbing back.
  oneWayEdges: [['b', 'j'], ['b', 'n1'], ['i', 'j']],
};
const T1_START = 'launch';
const DESTS = new Set(['end2', 'end3']);

// ---- tunable data tables -------------------------------------------
const SLOT_ORDER = ['Night', 'Morning', 'Afternoon', 'Evening'];
// home elements per ToD (§3) — drives the ±swing AND the UI spotlight.
const TOD_HOME = {
  Night:     ['ice', 'holy'],
  Morning:   ['fire', 'earth'],
  Afternoon: ['lightning', 'wind'],
  Evening:   ['water', 'nature'],
};
const TOD_BONUS = 12, TOD_PENALTY = -8;   // the "both" swing (§3)
const BOND_MOD = 0;                        // bond modifier — STUB at 0 for v0 (§1/§6)

// ---- ToD GLOBAL RULES (R2/R3) — numbers only, never what a node IS ----
const DAWN_PICK_BONUS = 1;        // R2 — Morning forage finds the overnight regrowth (+1 feed)
const NIGHT_HAZARD_SHIFT = 4;     // R3 — at Night, hazard bands shift up …
const NIGHT_HULL_EXTRA = 1;       // … and a hazard fail bites +1 extra Hull

// ---- ToD FACES — the hand-placed HEADLINES (a node's TYPE changes by slot).
// Rare on purpose (~one per slot); each tells a story. Shaper per-slot types.
const TOD_FACE = {
  d: { slots: ['Morning'],   role: 'forage',  key: 'nature', band: [2, 10],
       why: 'the tide-gate stands open at dawn' },
  f: { slots: ['Afternoon'], role: 'danger',  key: { max: ['wind', 'earth'] }, band: [6, 24], hullFail: 2,
       name: 'Weather the Squall', verb: 'Weather', why: 'wind-keyed — its force path opens with the Wind unlock' },
  g: { slots: ['Night'],     role: 'danger',  key: { max: ['fire', 'earth'] }, band: [6, 24], hullFail: 3,
       name: 'Face the Predator', verb: 'Face', why: 'a predator wakes on the calm ground' },
  j: { slots: ['Night'],     role: 'event',
       name: 'Witness the Ice-Glint', verb: 'Witness', why: 'one-time — the frozen vein’s rumor (the T2 hint)' },
  h: { slots: ['Evening'],   role: 'recruit', key: 'water', band: [2, 18],
       why: 'the foundered soul answers only at dusk' },
};
const faceAt = (id, slot) => { const f = TOD_FACE[id]; return (f && slot != null && f.slots.includes(slot)) ? f : null; };
const roleAt = (id, slot) => { const f = faceAt(id, slot); return (f && f.role) || Z0.roles[id] || 'forage'; };
function bandAt(id, slot) {
  const f = faceAt(id, slot);
  let band = (f && f.band) || Z0.bands[id] || [0, 0];
  if (slot === 'Night' && roleAt(id, slot) === 'danger') band = [band[0] + NIGHT_HAZARD_SHIFT, band[1] + NIGHT_HAZARD_SHIFT];   // R3
  return band;
}
function hullFailAt(id, slot) {
  const f = faceAt(id, slot);
  const base = (f && f.hullFail) != null ? f.hullFail
    : (Z0.params[id] && Z0.params[id].hullFail) != null ? Z0.params[id].hullFail : 3;
  return base + (slot === 'Night' ? NIGHT_HULL_EXTRA : 0);                                                                     // R3
}
const magAt = (id, slot) => {
  const m = Z0.mags[id];
  const v = Array.isArray(m) ? m[Math.max(0, SLOT_ORDER.indexOf(slot))] : m;
  return v == null ? 2 : v;
};
const magKAt = (id, slot) => 0.82 + 0.085 * magAt(id, slot);   // shaper magScale

// reward tables (first-pass / tunable — §5/§6)
const REWARD = {
  forageStaple: 3,        // +staple on a forage success
  forageRich: 6,          // end2 — rich forage
  forageFail: 1,          // −staple on a forage fail
  danger: { coins: 0 },   // danger "reward" is passage, not loot (§5)
  recruitHull: 3,         // securing a citizen steadies the harness
  offeringBand: [8, 30],  // offering action band (§5)
  offeringMin: 1, offeringMax: 2,
};
// Off-options (the shaper's affinity badges): each surfaces ONLY in its slot —
// R1's "in-time". A badge's slot IS its element's home slot, so a live off-option
// is always ToD-favored by construction. The locked teasers (Wind/Ice/Lightning)
// were CUT (human call 2026-06-10) — the Ice hint lives in j's Night event instead.
const OFFERING_AT = {
  a:    { el: 'fire',   slot: 'Morning' },
  c:    { el: 'water',  slot: 'Evening' },
  d:    { el: 'earth',  slot: 'Morning' },     // only live while the tide stands open
  g:    { el: 'water',  slot: 'Evening' },
  i:    { el: 'fire',   slot: 'Morning' },
  end2: { el: 'nature', slot: 'Evening' },     // the rich corner blooms at dusk
};

// ---- role → visual glyph (NT taxonomy in expedition-graph.jsx) ------
// wreck/geode "destination" visuals DROPPED (human call): end2 reads as rich
// forage, end3 as the gateway recruit. Magnitude carries the size emphasis.
const ROLE_VISUAL = { vessel: 'launch', forage: 'forage', danger: 'hazard', recruit: 'rescue', gate: 'gate', event: 'event' };
function visualType(id) {
  return ROLE_VISUAL[Z0.roles[id]] || 'forage';
}
const visualTypeAt = (id, slot) => ROLE_VISUAL[roleAt(id, slot)] || 'forage';
const roleOf = (id) => Z0.roles[id] || 'forage';
const bandOf = (id) => Z0.bands[id] || [0, 0];

// ---- per-node check KEYING (wiki/zone0.md "Keying \u2014 soft, not locked") -------
// Each progression check reads a SPECIFIC element \u2014 or the higher of a themed
// PAIR \u2014 not the spirit's global highest, so build + routing actually matter
// (the old "everything reads highest" is what made Z0 feel unkeyed). ToD swing
// then applies to whichever element the node reads, so routing-for-timing bites.
//   \u2022 forages (incl. rich end2) \u2192 Nature, lenient \u2014 the never-a-wall staple.
//   \u2022 hazards \u2192 the HIGHER of their two themed paths (force / finesse).
//   \u2022 the moderate recruit end1 \u2192 the SUM of a themed pair (reward two paths).
//   \u2022 the gateway end3 \u2192 the SUM of ALL elements \u2014 a BREADTH gate (see below).
// Intentionally LEFT on highest: the gentle early rescue `h` (the build-flexible
// teaching rescue \u2014 "rescue 1"). Edit NODE_KEY to re-theme a node.
//
// A key is one of three shapes (read by keyDescriptor):
//   'nature'                       \u2192 SINGLE element
//   { max: ['fire','water'] }       \u2192 the HIGHER of a pair (spirit runs its stronger path)
//   { sum: ['water','nature'] }     \u2192 the SUM of the listed elements
//   { sum: '*' }                    \u2192 the SUM of EVERY element (breadth)
// SUM keys are ToD-independent (no single element to be "home" \u2014 a flat total).
const NODE_KEY = {
  a: 'nature', c: 'nature', f: 'nature', g: 'nature', h: 'nature',
  i: 'nature', j: 'nature', k: 'nature', end2: 'nature',
  d: 'earth',                         // tide-gate \u2014 flat req, read RAW (gates take no swing)
  b:  { max: ['fire', 'water'] },   // hazard · gentle \u2014 force Fire / finesse Water (higher)
  n1: { max: ['earth', 'nature'] }, // hazard · deep  \u2014 force Earth / finesse Nature (higher)
  end1: { sum: ['water', 'nature'] }, // recruit · moderate \u2014 SUM of a themed pair
  end3: { sum: '*' },                 // gateway \u2014 SUM of ALL elements (breadth gate)
};
// All element ids (for sum:'*'). Locked elements sit at mastery 0, so summing
// every key == summing the unlocked ones in Z0 (4 starters) \u2014 no special-casing.
const ALL_ELEMENTS = ['fire', 'water', 'earth', 'nature', 'ice', 'lightning', 'wind', 'holy'];

// Resolve a node's check KEY into a descriptor the rest of the engine consumes:
//   { mode:'single', els:[el], rep:el }
//   { mode:'max',    els:[a,b], rep: the higher of a,b for THIS spirit }
//   { mode:'sum',    els:[...],  rep:null }   \u2014 a flat total, no ToD home
// absent key \u2192 falls back to the spirit's highest (single). `slot` (optional)
// lets a TOD_FACE override the key in its slots (the dusk rescue reads Water).
function keyDescriptor(id, state, hi, slot) {
  const mastery = (el) => (state.elements[el] && state.elements[el].mastery) || 0;
  const face = slot != null ? faceAt(id, slot) : null;
  const k = (face && face.key) || NODE_KEY[id];
  if (!k) return { mode: 'single', els: [hi.id], rep: hi.id };
  if (typeof k === 'string') return { mode: 'single', els: [k], rep: k };
  if (k.max) {
    const rep = k.max.reduce((best, el) => mastery(el) > mastery(best) ? el : best);
    return { mode: 'max', els: k.max.slice(), rep };
  }
  if (k.sum) {
    const els = k.sum === '*' ? ALL_ELEMENTS.slice() : k.sum.slice();
    return { mode: 'sum', els, rep: null };
  }
  return { mode: 'single', els: [hi.id], rep: hi.id };
}
// Effective check value for a key descriptor. SINGLE/MAX read the representative
// element WITH its ToD swing (routing-for-timing). SUM totals its elements with
// NO swing \u2014 a flat breadth total (zone0.md: the gateway gate is ToD-independent).
function effForKey(state, desc, slotLabel) {
  if (desc.mode === 'sum') {
    const total = desc.els.reduce((t, el) =>
      t + ((state.elements[el] && state.elements[el].mastery) || 0), 0);
    return total + BOND_MOD;
  }
  return effFor(state, desc.rep, slotLabel);
}
// The shown "your value" for a key (no ToD swing) \u2014 sum total, or rep mastery.
function keyMastery(state, desc) {
  if (desc.mode === 'sum') {
    return desc.els.reduce((t, el) =>
      t + ((state.elements[el] && state.elements[el].mastery) || 0), 0);
  }
  return (state.elements[desc.rep] && state.elements[desc.rep].mastery) || 0;
}
// A SUM key has no single "home" element, so it is never ToD-favored.
function keyFavored(desc, slotLabel) {
  return desc.mode !== 'sum' && isHomeToD(desc.rep, slotLabel);
}
// short note shown when the vessel arrives at a node of each role
const ARRIVE_HINT = {
  forage: 'A forage drift. Comb it for stores, farm an offering, or press on.',
  danger: 'A hazard rides the thermals — clear it to advance, or it bars the way.',
  recruit: 'A soul adrift. Secure the citizen (the place then yields like a forage perch), or press on.',
  gate: 'A pressure-gate athwart the current — meet its requirement, or come back when it stands open.',
  event: 'Something stirs here that the daylight never shows.',
};

// ---- forage-freshness helpers (Z0) — the freshness map is town-owned, living
// in app state (state.forageFresh, keyed `zone:node`); the reducer draws it down
// on a harvest and regenerates it on day-wrap. These read it for yield + intel. --
const FORAGE_DEFAULTS = { START: 0.5, FLOOR: 0, DEPLETE: 0.5, REGEN: 0.04 };
const forageKeyZ0 = (id) => 'whispering-strata:' + id;
function forageFreshZ0(state, id) {
  const F = window.SH_FORAGE || FORAGE_DEFAULTS;
  const v = state && state.forageFresh && state.forageFresh[forageKeyZ0(id)];
  return (v != null) ? v : F.START;
}

// ---- per-node SCAN INTEL (shown only on KNOWN nodes; fog hides it) ---
// Surfaces what the radial-gauge node needs:
//   · keyed-by — element rune(s) the node's MAIN check reads (learned by visiting;
//     a dashed ? before that). Plus the optional offering (a secondary draw).
//   · STOCK — a 0..1 ring fraction + its colour (the staleness read at a glance).
//   · draw — 'live' | 'spent' | 'cleared': is there still a reason to come (back)?
function nodeIntel(n, state, hi, role, visited, run, slot) {
  const PE = window.PORT_ELEM || {};
  const elIcon  = (e) => (PE[e] && PE[e].icon) || null;

  const worked  = !!(run && run.worked[n.id]);
  const cleared = !!(run && run.cleared[n.id]);
  const off     = OFFERING_AT[n.id] || null;
  const offEl   = off ? off.el : null;
  const offKey  = offEl ? 'offer:' + offEl : null;
  const offerConsumed = offKey ? !!(run && run.consumed[offKey]) : false;
  const offerAvail = !!offEl && !offerConsumed && off.slot === slot;   // R1: in-time only

  // A · the element(s) the MAIN check is made by — element RUNES, and ONLY for
  // nodes you've actually VISITED before (you learn a node's keying by going there).
  // Known-but-unvisited shows a dashed ? rune. RESCUES never surface a key.
  let keyIcons = null, offerIcon = null, keyUnknown = false;
  if (role !== 'recruit' && role !== 'event') {
    if (visited) {
      if (role === 'forage') {
        keyIcons = [elIcon('nature')].filter(Boolean);
      } else {
        const desc = keyDescriptor(n.id, state, hi, slot);
        const els = desc.mode === 'single' ? [desc.rep] : desc.els;   // single rep, or the themed pair
        keyIcons = els.map(elIcon).filter(Boolean);
      }
      offerIcon = offEl ? elIcon(offEl) : null;
    } else {
      keyUnknown = true;     // a surveyed location whose keying we've not yet learned
    }
  }

  // B · stores → the radial STOCK gauge (fraction + colour) and the draw status
  let storeLabel, storeColor, fresh = null, stock = 0, ringColor = 'var(--ink-faded)', draw = 'live';
  if (role === 'forage') {
    fresh = forageFreshZ0(state, n.id);
    const base = n.rich ? REWARD.forageRich : REWARD.forageStaple;
    const est = Math.round(base * fresh);
    if (worked) {
      // drawn down FOR THIS RUN — empty ring; an unspent offering still draws you back
      storeLabel = 'worked'; storeColor = 'var(--ink-faded)';
      stock = 0; ringColor = 'var(--sepia)';
      draw = offerAvail ? 'live' : 'spent';
    } else {
      storeLabel = est <= 0 ? 'empty' : `~${est} feed`;
      if (est > 0 && n.rich && n.oneTime && n.oneTime.coins) storeLabel += ` · ${n.oneTime.coins}c`;
      stock = Math.max(0, Math.min(1, fresh));
      storeColor = ringColor = fresh < 0.45 ? 'var(--sepia)' : 'var(--el-earth)';
      draw = est <= 0 ? (offerAvail ? 'live' : 'spent') : 'live';
    }
  } else if (role === 'danger') {
    if (cleared) { storeLabel = 'passage open'; storeColor = 'var(--sepia)'; stock = 0; ringColor = 'var(--ink-faded)'; draw = 'cleared'; }
    else { storeLabel = 'bars the way'; storeColor = 'var(--crit)'; stock = 1; ringColor = 'var(--crit)'; draw = 'live'; }
  } else if (role === 'recruit') {
    storeLabel = 'soul adrift'; storeColor = 'var(--el-air)'; stock = 1; ringColor = 'var(--el-air)'; draw = 'live';
  } else if (role === 'gate') {
    if (cleared) { storeLabel = 'gate parted'; storeColor = 'var(--sepia)'; stock = 0; ringColor = 'var(--ink-faded)'; draw = 'cleared'; }
    else { storeLabel = 'sealed'; storeColor = 'var(--el-air)'; stock = 1; ringColor = 'var(--el-air)'; draw = 'live'; }
  } else if (role === 'event') {
    if (cleared) { storeLabel = 'witnessed'; storeColor = 'var(--ink-faded)'; stock = 0; ringColor = 'var(--sepia)'; draw = 'spent'; }
    else { storeLabel = 'a glimmer'; storeColor = 'var(--el-air)'; stock = 1; ringColor = 'var(--el-air)'; draw = 'live'; }
  } else {
    storeLabel = '—'; storeColor = 'var(--ink-faded)';
  }

  return { role, keyIcons, keyUnknown, offerIcon, offerAvail, worked, storeLabel, storeColor, fresh, stock, ringColor, draw };
}

// ---- graph load: authored direction + lint (EXPEDITIONS §1) ---------
// Direction is AUTHORED now (the shaper board): two-way edges traverse both
// ways, one-way edges only a → b. The lint walks the TRAVERSAL adjacency.
function buildTerritory(T) {
  const byId = {};
  Object.entries(T.coords).forEach(([id, [x, y]]) => {
    byId[id] = { id, x, y, role: T.roles[id], type: visualType(id), ...(T.params[id] || {}) };
  });
  const oneWay = new Set((T.oneWayEdges || []).map(([a, b]) => a + '>' + b));
  const edges = T.rawEdges.map(([a, b]) => [a, b]);
  const nodes = Object.values(byId);
  lintTerritory(T.id, nodes, edges, byId, oneWay);
  return { nodes, edges, byId, oneWay };
}
function lintTerritory(id, nodes, edges, byId, oneWay) {
  const warn = (m) => console.warn(`[territory:${id}] ${m}`);
  // back-edge: only PREDOMINANTLY-leftward two-way lines warn (near-vertical
  // lines get the tolerance band; one-way lines are exempt — angle is the point).
  edges.forEach(([a, b]) => {
    if (oneWay.has(a + '>' + b)) return;
    const dx = byId[b].x - byId[a].x, dy = Math.abs(byId[b].y - byId[a].y);
    if (dx < -RIGHTWARD_TOL && -dx > dy) warn(`back-edge ${a}->${b} violates rightward+TOL`);
  });
  const adj = {}; nodes.forEach(n => { adj[n.id] = []; });
  edges.forEach(([a, b]) => { adj[a].push(b); if (!oneWay.has(a + '>' + b)) adj[b].push(a); });
  const walk = (start) => {
    const seen = new Set([start]); const stack = [start];
    while (stack.length) adj[stack.pop()].forEach(n => { if (!seen.has(n)) { seen.add(n); stack.push(n); } });
    return seen;
  };
  const fromVessel = walk(T1_START);
  nodes.forEach(n => { if (n.id !== T1_START && !fromVessel.has(n.id)) warn(`node ${n.id} unreachable from Vessel`); });
  nodes.forEach(n => {
    if (DESTS.has(n.id) || n.terminal) return;
    if (![...walk(n.id)].some(c => DESTS.has(c))) warn(`node ${n.id} cannot reach any destination`);
  });
  // one-ways must never trap: every node must be able to walk HOME
  nodes.forEach(n => { if (!walk(n.id).has(T1_START)) warn(`node ${n.id} cannot walk home (one-way trap)`); });
}

const TERR = buildTerritory(Z0);
const fwd = (id) => TERR.edges.filter(e => e[0] === id).map(e => e[1]);
const reachableFrom = (id, resolved) => fwd(id).filter(t => !resolved[t]);

// ---- UN-DAG (2026-06-03, human-log) -------------------------------------------
// The map is no longer one-way. Every edge connects BOTH ways, and the vessel is
// the single launch AND exit. Traversal + reveal walk a bidirectional adjacency;
// `fwd` survives only for the build-time layout lint above. You push out as far
// as the clock allows, then come back — the return is REAL navigation (it costs
// slots, you pick the path), not the retired magic 3× retrace. Backtracking is
// allowed; what keeps routing honest is the clock + per-run forage depletion
// (a worked perch pays nothing on the way back — the "cooldown").
const ADJ = {};
const ONE_WAY = TERR.oneWay;                  // 'a>b' keys — travel a → b only
TERR.nodes.forEach(n => { ADJ[n.id] = []; });
TERR.edges.forEach(([a, b]) => { ADJ[a].push(b); if (!ONE_WAY.has(a + '>' + b)) ADJ[b].push(a); });
const neighbors = (id) => ADJ[id] || [];
// Shortest hop-path back to the vessel (BFS over the bidirectional graph). The
// auto-husk flees home along this; already-passed gates stay open so it is never
// walled in (manifestation-clock §end-of-run).
function pathHome(from, to = T1_START) {
  if (from === to) return [from];
  const prev = { [from]: null }, q = [from];
  while (q.length) {
    const c = q.shift();
    if (c === to) break;
    for (const n of neighbors(c)) if (!(n in prev)) { prev[n] = c; q.push(n); }
  }
  if (!(to in prev)) return [from, to];
  const path = []; let c = to;
  while (c != null) { path.unshift(c); c = prev[c]; }
  return path;
}

// ====================================================================
// CHECK RESOLUTION — the core (HANDOFF_CHECKS_Z0 §1).
// A check is an `element value` vs a difficulty RANGE [min,max]. There is NO
// die on the spirit side — the randomness is in the obstacle:
//     P_success = clamp((eff − min) / (max − min), 0, 1)
//     success   = random() < P_success
// Element values are RAW, on a 0–999 scale (no √, no mod). A requirement gate
// is the same mechanic at min == max (hard threshold).
// ====================================================================
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
function pSuccess(eff, band) {
  const [min, max] = band;
  if (max <= min) return eff >= max ? 1 : 0;     // requirement gate
  return clamp01((eff - min) / (max - min));     // linear between min and max
}
function rollCheck(eff, band) { return Math.random() < pSuccess(eff, band); }

// ToD swing (§3): +12 if the checked element is home in the current ToD, else −8.
function isHomeToD(elementId, slotLabel) { return (TOD_HOME[slotLabel] || []).includes(elementId); }
function todSwing(elementId, slotLabel) { return isHomeToD(elementId, slotLabel) ? TOD_BONUS : TOD_PENALTY; }
// Effective check value: raw mastery + ToD swing + bond (stub 0). §1/§3
function effFor(state, elementId, slotLabel) {
  const e = state.elements[elementId];
  const raw = e ? (e.mastery || 0) : 0;
  return raw + todSwing(elementId, slotLabel) + BOND_MOD;
}
// The spirit's HIGHEST element (its specialty) — progression checks use this. §2/§4
function highestElement(state) {
  let id = 'fire', v = -1;
  for (const k in state.elements) {
    const m = state.elements[k].mastery || 0;
    if (m > v) { v = m; id = k; }
  }
  return { id, value: v < 0 ? 0 : v };
}
const slotLabelFor = (departIdx, slot) => SLOT_ORDER[((departIdx + slot) % 4 + 4) % 4];

// ====================================================================
// THE ACTION MODEL (§2). A node offers 1–3 actions. Move-is-the-clock: the
// move already paid for the node's PRIMARY action (free); only the special-
// marked off-option adds a slot. Two kinds:
//   • progression (forage / danger / recruit) — the node's PRIMARY action,
//     checked vs the spirit's HIGHEST element. FREE (the move paid it). Runs
//     the zone; never locks out.
//   • offering (bonus loot) — the special-marked OFF-OPTION, checked vs a
//     SPECIFIC element. Costs +1 slot. Build-variety / farming; off-element
//     spirits can't pass (route around).
// Returns descriptors; live P is computed against the current ToD + spirit.
// ====================================================================
function actionsFor(id, slot) {
  const role = roleAt(id, slot);
  if (role === 'vessel') return [];
  const out = [{ key: 'prog', kind: 'progression', role, band: role === 'event' ? [0, 0] : bandAt(id, slot) }];
  const o = OFFERING_AT[id];
  if (o && o.slot === slot && role === 'forage') out.push({ key: 'offer:' + o.el, kind: 'offering', role: 'offering', element: o.el, band: REWARD.offeringBand });
  return out;
}
// The check KEY for an action: an offering reads its OWN element (single); a
// progression check reads the node's keyed descriptor (single / max-of-pair /
// sum). Everything downstream consumes the descriptor via effForKey / keyMastery
// / keyFavored, so single-element and sum checks share one path.
function checkKeyFor(action, hi, id, state, slot) {
  if (action.kind === 'offering') return { mode: 'single', els: [action.element], rep: action.element };
  return keyDescriptor(id, state, hi, slot);
}

// ---- DEV ASSERTS (§6). Now descriptor-aware (single / max-of-pair / sum). A
// FULLY-maxed spirit (every element 110) clears every node on-ToD; a FRESH spirit
// (starters at 20) fails the end3 breadth gate; and a LONE specialist (one maxed +
// ring-leak, ~160 total) falls SHORT of end3 — that shortfall IS the breadth
// lesson. If any breaks, the Z0 bands/keys have drifted. Fails loudly.
(function devAssertZ0() {
  const STARTERS = ['fire', 'water', 'earth', 'nature'];
  const mkState = (m) => ({ elements: Object.fromEntries(
    ALL_ELEMENTS.map(el => [el, { mastery: m[el] || 0, unlocked: true }])) });
  const pFor = (id, st, onToD) => {
    const desc = keyDescriptor(id, st, highestElement(st));
    // pick a home ToD for the rep element when we want the +swing (sums ignore ToD)
    const slot = onToD && desc.rep
      ? (Object.keys(TOD_HOME).find(s => TOD_HOME[s].includes(desc.rep)) || 'Morning')
      : 'Night';
    return pSuccess(effForKey(st, desc, slot), bandOf(id));
  };
  // (1) a fully-maxed spirit clears EVERY node on-ToD
  const MAX = mkState(Object.fromEntries(ALL_ELEMENTS.map(el => [el, 110])));
  Object.keys(Z0.bands).forEach(id => {
    const p = pFor(id, MAX, true);
    if (p < 0.999) console.error(`[Z0 assert] fully-maxed spirit on-ToD at "${id}" → P=${p.toFixed(3)} (expected 1.0) — bands/keys drifted`);
  });
  // (2) a FRESH spirit (starters 20) fails the end3 breadth gate
  const FRESH = mkState(Object.fromEntries(STARTERS.map(el => [el, 20])));
  const e3fresh = pFor('end3', FRESH, true);
  if (e3fresh > 0.0001) console.error(`[Z0 assert] fresh spirit at end3 → P=${e3fresh.toFixed(3)} (expected 0) — gateway sum-gate drifted`);
  // (3) a LONE specialist (fire 110 + water-leak 50, ~160) is SHORT of end3 (needs breadth)
  const SPEC = mkState({ fire: 110, water: 50 });
  const e3spec = pFor('end3', SPEC, true);
  if (e3spec > 0.0001) console.error(`[Z0 assert] lone specialist (~160 total) clears end3 → P=${e3spec.toFixed(3)} (expected 0) — breadth gate too low`);
})();

// ====================================================================
// EXPEDITION PHASE — interactive node-graph (Zone 0, the Whispering Strata)
// ====================================================================
function ExpeditionPhase({ state, dispatch }) {
  const hi = highestElement(state);              // the spirit's specialty (highest element)
  const elemColor = (e) => (window.PORT_ELEM && window.PORT_ELEM[e] ? window.PORT_ELEM[e].color : 'var(--gold)');

  // The spirit's condition is the REAL town condition — never a copy. Read it
  // from state.care / state.hull; mutate it ONLY by dispatching (CARE_ACTION for
  // rest/pet/feed, FIELD_CARE for moves/work). stateRef gives timeouts a fresh read.
  const stateRef = React.useRef(state);
  stateRef.current = state;

  const [run, setRun] = useStateExp(() => ({
    vessel: T1_START,
    visited: { [T1_START]: true },   // nodes the vessel has occupied (no backtrack / no revisit)
    seen: { [T1_START]: { [SLOT_ORDER[((state.slotIdx % 4) + 4) % 4]]: true } },   // node × slot faces WITNESSED (knowledge accrues by being there)
    cleared: {},                     // danger passed / recruit secured / one-time bonus spent
    worked: {},                      // forage progression attempted (visual "worked")
    tried: {},                       // progression attempted at least once (retry = +slot, §3)
    consumed: {},                    // offer:<id> spent this visit
    converted: {},                   // recruit → forage after pickup (§7)
    revealed: new Set([T1_START, ...neighbors(T1_START)]),
    visitedPath: [T1_START],
    husk: false,                     // true once the spirit de-manifests — the auto-husk flight home
    haul: { staple: 0, coins: 0, offerings: {} },
    rescues: [],                     // citizen leads secured this run
    unlockedKeyed: false,            // end3 → unlock the keyed zones (§5)
    phase: 'choosing',               // choosing|traveling|husking|success|husked|failure
    floaty: null,
    note: `Launched into the Strata at ${SLOT_ORDER[state.slotIdx % 4]}. Roam the currents both ways, work each node, and steer back to the Vessel before the clock runs out.`,
  }));

  const rank = state.rank || 0;
  const FULL_DRAIN = FULLNESS_DRAIN_SLOT[rank] ?? 16;       // per-slot appetite drain on expedition
  const [zoomIx, setZoomIx] = useStateExp(0);              // map camera zoom (0 = Fit the whole map)
  const ZOOMS = [1, 1.42, 1.9];
  const mapWrapRef = useRefExp(null);
  const [mapW, setMapW] = useStateExp(1300);               // measured map-viewport width — Fit must use the REAL width, not a guess
  useEffectExp(() => {
    const el = mapWrapRef.current;
    if (!el || typeof ResizeObserver === 'undefined') return;
    const update = () => setMapW(el.clientWidth || 1300);
    update();
    const ro = new ResizeObserver(update); ro.observe(el);
    return () => ro.disconnect();
  }, []);
  const timers = useRefExp([]);
  const push = (fn, ms) => { const id = setTimeout(fn, ms); timers.current.push(id); return id; };
  useEffectExp(() => () => timers.current.forEach(clearTimeout), []);

  useEffectExp(() => {
    if (!run.floaty) return;
    const id = setTimeout(() => setRun(r => (r.floaty && r.floaty.key === run.floaty.key ? { ...r, floaty: null } : r)), 950);
    return () => clearTimeout(id);
  }, [run.floaty?.key]);

  // ToD is the SHARED day clock — read live from global state, advanced via ADVANCE_SLOT.
  const todLabel = SLOT_ORDER[((state.slotIdx % 4) + 4) % 4];

  // CLOCK-OUT → AUTO-HUSK. The spirit's lifespan hit zero mid-run; it de-manifests
  // and the husk flees home on its own (manifestation-clock §end-of-run). app.jsx
  // holds back the central clock-death while phase==='expedition' so the husk plays
  // out; the expired spirit then passes on in town once the haul is banked.
  // Gate strictly on 'choosing' (not 'traveling'): a move can be what zeroes the
  // clock, and its pending arrival timeout would otherwise clobber the husk phase
  // back to 'choosing'. Waiting for the move to settle avoids that race.
  useEffectExp(() => {
    if (state.manifestDays <= 0 && !run.husk && run.phase === 'choosing') {
      beginHusk('clock');
    }
  }, [state.manifestDays, run.phase, run.husk]);

  // WITNESSING — you chart a node by WALKING it; you learn its hours by being
  // there as they turn. Standing somewhere marks (node × current slot) as seen.
  useEffectExp(() => {
    setRun(r => {
      const cur = r.seen[r.vessel];
      if (cur && cur[todLabel]) return r;
      return { ...r, seen: { ...r.seen, [r.vessel]: { ...(cur || {}), [todLabel]: true } } };
    });
  }, [todLabel, run.vessel]);

  // ---- travel one edge (a move spends a slot on the shared clock). Bidirectional
  //      now: any adjacent node, backtracking allowed. Stepping back onto the
  //      Vessel IS the exit — a safe return that banks the haul (no 3× retrace). ----
  function travel(id) {
    if (run.phase !== 'choosing') return;
    const vRole = roleAt(run.vessel, todLabel);
    const barred = (vRole === 'danger' || vRole === 'gate') && !run.cleared[run.vessel];
    if (barred && !run.visited[id]) return;   // bars the way FORWARD only — retreat over charted ground stays open
    if (!neighbors(run.vessel).includes(id)) return;                          // must be adjacent (either direction)
    const goingHome = id === T1_START;                                        // back onto the Vessel = exit / safe return
    // the move spends a slot — FIELD_CARE reuses tickSlot (slot + appetite drain +
    // body model + the baseline fatigue, same as town). No per-move exertion: the
    // slot's baseline tiredness is the single source. Collapse/Hull-death are handled
    // centrally; clock-out triggers the auto-husk (watched below).
    const willHunger = stateRef.current.care.fullness - FULL_DRAIN < HUNGER_AT;
    dispatch({ type: 'FIELD_CARE', tick: true });
    setRun(r => ({ ...r, phase: 'traveling', vessel: id,
      visited: { ...r.visited, [id]: true },
      visitedPath: r.visitedPath[r.visitedPath.length - 1] === id ? r.visitedPath : [...r.visitedPath, id],
      floaty: willHunger ? { key: Date.now(), text: 'hungry · wasting', color: 'var(--crit)', node: id } : r.floaty }));
    push(() => setRun(r => {
      if (goingHome) return { ...r, phase: 'success', note: 'Home — the run is yours. Cargo and crew bank at the Hearth.' };
      const revealed = new Set(r.revealed); neighbors(id).forEach(n => revealed.add(n));
      // arrival reads the slot you LANDED on — the move just advanced the clock
      const slotNow = SLOT_ORDER[((stateRef.current.slotIdx % 4) + 4) % 4];
      const role = r.converted[id] ? 'forage' : roleAt(id, slotNow);
      const note = DESTS.has(id)
        ? `${NT[visualTypeAt(id, slotNow)].label} reached — the zone’s deep edge. Work it, then steer back to the Vessel to bank.`
        : ARRIVE_HINT[role] || 'Bearing resolved.';
      return { ...r, revealed, phase: 'choosing', note };
    }), TRAVEL_MS);
  }

  // ---- attempt an action at the current node (§2). Move-is-the-clock: the
  //      primary action is FREE (the move paid it); only the special-marked
  //      off-option (offering) adds a slot. Resolves the dieless range check
  //      at the CURRENT time of day. ----
  function attemptAction(action) {
    if (run.phase !== 'choosing') return;
    const id = run.vessel, node = TERR.byId[id];
    if (action.kind === 'offering' && run.consumed[action.key]) return;
    // A forage perch is a DEPLETABLE store: working it once spends it for the run
    // (no re-farm). Dangers/recruits block once cleared/secured, as before.
    if (action.kind === 'progression' && action.role === 'forage' && run.worked[id]) return;
    if (action.kind === 'progression' && action.role !== 'forage' && run.cleared[id]) return;

    // Resolve the dieless check ONCE (outside setRun) so the same outcome drives
    // both the map state and the shared condition dispatch.
    const cKey = checkKeyFor(action, hi, id, state, todLabel);
    // flat gates (tide-gate d, the breadth gateway) read RAW — ToD-independent;
    // variable bands take the ±ToD swing (R1).
    const flatGate = action.band[0] >= action.band[1];
    const eff = flatGate ? (keyMastery(state, cKey) + BOND_MOD) : effForKey(state, cKey, todLabel);
    const ok = rollCheck(eff, action.band);
    // Only the special-marked off-option (offering) spends a slot; the forage
    // primary is always free (the flight here paid it) and one-time. A RETRY of a
    // failed progression (danger/recruit — the only re-attemptable kinds) costs a
    // slot: the move bought the FIRST attempt, never the second (checks.md §3).
    const isRetry = action.kind === 'progression' && action.role !== 'forage' && action.role !== 'event' && !!run.tried[id];
    const spendsSlot = action.kind === 'offering' || isRetry;
    let hullDelta = 0;
    if (action.role === 'danger' && !ok) hullDelta = -hullFailAt(id, todLabel);   // R3: night bites harder
    if (action.role === 'recruit' && ok) hullDelta = REWARD.recruitHull;
    const newHull = Math.max(0, Math.min(20, stateRef.current.hull + hullDelta));
    const failure = newHull <= 0;
    // apply the REAL spirit condition through the shared reducer path. TOWN RULE:
    // fatigue (and the fullness drain) are charged ONLY when a slot is spent — every
    // town fatigue gain rides a tickSlot, never a free action. So exertion tires the
    // spirit only on a slot-spending action; the FREE primary action costs nothing
    // apply the REAL spirit condition through the shared reducer path. A spent slot
    // reuses tickSlot (drain + body model + the BASELINE fatigue, same as town); no
    // per-action exertion — the slot's baseline is the single source. The hull delta
    // patches/breaks the harness regardless. autoAscend handles collapse + death.
    // A successful forage harvest also DRAWS DOWN this perch's freshness (the
    // reducer applies it; it regenerates only as clock-days pass).
    const forageDraw = (action.role === 'forage' && ok)
      ? forageKeyZ0(id) : undefined;
    dispatch({ type: 'FIELD_CARE', tick: spendsSlot, hull: hullDelta, forageDraw });
    setRun(r => {
      const haul = { staple: r.haul.staple, coins: r.haul.coins, offerings: { ...r.haul.offerings } };
      const cleared = { ...r.cleared }, worked = { ...r.worked }, consumed = { ...r.consumed }, converted = { ...r.converted };
      const tried = { ...r.tried, [id]: true };
      const rescues = [...r.rescues];
      let unlockedKeyed = r.unlockedKeyed;
      let fText = '', fColor = 'var(--gold)', note = r.note;

      if (action.kind === 'offering') {
        consumed[action.key] = true;
        if (ok) {
          const amt = REWARD.offeringMin + Math.floor(Math.random() * (REWARD.offeringMax - REWARD.offeringMin + 1));
          haul.offerings[action.element] = (haul.offerings[action.element] || 0) + amt;
          fText = `+${amt} ${action.element}`; fColor = elemColor(action.element);
          note = `Coaxed ${amt} ${action.element} offering from the drift.`;
        } else {
          haul.staple = Math.max(0, haul.staple - REWARD.forageFail);
          fText = 'offering slips'; fColor = 'var(--crit)';
          note = 'The drift would not yield that element — a ration spent for nothing.';
        }
      } else if (action.role === 'forage') {
        worked[id] = true;
        if (ok) {
          // Yield scales with the perch's CURRENT freshness (drawn-down perches
          // pay less, floored at ~1). Read pre-depletion — this harvest reflects
          // the perch as it was on arrival; the draw lands in the reducer.
          // A single freshness-scaled hit, which CAN reach 0 once a perch is fully
          // drawn down. Nature only sets the success CHANCE, never the amount.
          const fresh = forageFreshZ0(state, id);
          const dawn = todLabel === 'Morning' ? DAWN_PICK_BONUS : 0;   // R2: dawn-pick
          const g = Math.round((node.rich ? REWARD.forageRich : REWARD.forageStaple) * fresh) + dawn;
          haul.staple += g;
          if (node.rich && node.oneTime && !r.cleared[id + ':one']) {
            if (node.oneTime.coins) haul.coins += node.oneTime.coins;
            cleared[id + ':one'] = true; fText = `+${g} feed · cache`;
          } else fText = `+${g} feed${dawn ? ' · dawn' : ''}`;
          note = node.rich ? 'A rich field — the hold sits heavy.' : 'Stores gathered.';
        } else {
          haul.staple = Math.max(0, haul.staple - REWARD.forageFail);
          fText = `\u2212${REWARD.forageFail} feed`; fColor = 'var(--crit)';
          note = 'Empty perch — a ration burned for sore wings.';
        }
      } else if (action.role === 'danger') {
        if (ok) { cleared[id] = true; fText = 'cleared'; fColor = 'var(--ok)'; note = 'Drove the patrol off the thermals — the way opens.'; }
        else {
          fText = `\u2212${hullFailAt(id, todLabel)} Hull`; fColor = 'var(--crit)';
          note = 'The patrol drew blood on the rigging. Brace and try again — or the run ends if the hull gives.';
        }
      } else if (action.role === 'gate') {
        if (ok) { cleared[id] = true; fText = 'the gate parts'; fColor = 'var(--ok)'; note = 'The pressure-locks read the spirit\u2019s Earth and part — and stay open for the run.'; }
        else { fText = 'refused'; fColor = 'var(--crit)'; note = `Sealed — the tide-gate wants Earth ≥ ${action.band[1]}, raw. Route around, or come back at Morning when it stands open.`; }
      } else if (action.role === 'event') {
        cleared[id] = true;
        haul.coins += 2;
        fText = '✦ ice-glint'; fColor = 'var(--el-air)';
        note = 'Night parts the drift — a frozen vein glints far below. A rumor of Ice, worth coin to the right ears. One-time; gone by day.';
      } else if (action.role === 'recruit') {
        if (ok) {
          cleared[id] = true; converted[id] = true;        // the place becomes a forage site (§7)
          if (node.citizen) rescues.push(node.citizen);
          if (node.unlockKeyed) { unlockedKeyed = true; note = 'Gateway crew secured — the keyed zones beyond now open to you.'; fText = 'gateway open'; }
          else { note = 'Secured. This site now yields like a forage perch.'; fText = 'secured'; }
          fColor = 'var(--el-air)';
        } else { fText = 'no answer'; fColor = 'var(--crit)'; note = 'They would not come — not yet. Try again; the citizen is never lost.'; }
      }

      const phase = failure ? 'failure' : 'choosing';
      if (failure) note = 'Hull integrity lost — the vessel breaks apart.';
      return { ...r, haul, cleared, worked, tried, consumed, converted, rescues, unlockedKeyed, phase,
        floaty: { key: Date.now(), text: fText, color: fColor, node: id }, note };
    });
  }

  // ---- AUTO-HUSK (manifestation-clock §end-of-run) ------------------------------
  // Clock-out (or a voluntary recall): the spirit de-manifests and the empty
  // harness flees home along the shortest path — FAST, NO control, and it CANNOT
  // pass checks (forages nothing, disarms nothing). Re-arming hazards on its arc
  // still bite Hull. Survive the flight → home with the haul, but the people aboard
  // STRAND (Art. VIII). Hull → 0 mid-flight → Hull-death — the run's only true loss.
  function beginHusk(reason) {
    if (run.husk || run.phase !== 'choosing') return;
    const hops = pathHome(run.vessel).slice(1);          // nodes the husk flees through, ending at the Vessel
    // Simulate Hull locally (dispatch is async) to find any Hull-death point.
    const dmgOf = (id) => roleAt(id, todLabel) === 'danger' ? hullFailAt(id, todLabel) : 0;
    let hull = stateRef.current.hull, deathAt = -1;
    for (let i = 0; i < hops.length; i++) { hull -= dmgOf(hops[i]); if (hull <= 0) { deathAt = i; break; } }
    // DEATH FIRST. The spirit de-manifests IN PLACE — the avatar swaps to the bare
    // airship husk (run.husk) and we hold a beat so the loss reads — THEN the empty
    // husk begins its flight home. The flight is offset by DEATH_BEAT so it never
    // starts before the de-manifestation has landed.
    const DEATH_BEAT = 750;
    setRun(r => ({ ...r, husk: true, phase: 'husking',
      floaty: { key: Date.now(), text: reason === 'clock' ? '✦ de-manifests' : '✦ recalled', color: 'var(--el-air)', node: r.vessel },
      note: reason === 'clock'
        ? 'The Manifestation Clock runs dry — the spirit de-manifests. The empty husk will flee home on its own; it cannot pass checks. Pray the hazards bite light.'
        : 'Recalled — the spirit de-manifests early. The empty husk flees home; it cannot pass checks.' }));
    const last = deathAt >= 0 ? deathAt : hops.length - 1;
    for (let i = 0; i <= last; i++) {
      const id = hops[i], dmg = dmgOf(id), dead = (i === deathAt);
      push(() => {
        if (dmg) dispatch({ type: 'FIELD_CARE', hull: -dmg });
        setRun(r => {
          const revealed = new Set(r.revealed); revealed.add(id);   // the husk's flight charts what it crosses
          return { ...r, vessel: id, revealed,
            floaty: dmg ? { key: Date.now() + i, text: `\u2212${dmg} Hull`, color: 'var(--crit)', node: id } : r.floaty,
            note: dead ? 'The husk shears apart on a re-armed hazard.' : r.note };
        });
      }, DEATH_BEAT + HUSK_STEP_MS * (i + 1));
    }
    push(() => setRun(r => (deathAt >= 0
      ? { ...r, phase: 'failure', note: 'Hull integrity lost — the husk breaks apart. Everything aboard scatters to the winds.' }
      : { ...r, phase: 'husked', note: 'The husk drifts home on dead reckoning. The haul is saved — but the souls aboard strand.' })),
      DEATH_BEAT + HUSK_STEP_MS * (last + 1) + 200);
  }

  // outcome: 'safe' (banked + crew) · 'husk' (haul kept, people strand) · 'lost' (Hull-death, all gone)
  function returnToPort(outcome) {
    const empty = { staple: 0, coins: 0, offerings: {} };
    if (outcome === 'lost') {
      dispatch({ type: 'RETURN_PORT', success: false, haul: empty, rescues: [],
        unlockedKeyed: false, daysLeft: state.manifestDays });
    } else if (outcome === 'husk') {
      // clock-out husk: the cargo comes home, the PEOPLE strand — so no gateway unlock either.
      dispatch({ type: 'RETURN_PORT', success: true, haul: run.haul, rescues: [],
        unlockedKeyed: false, daysLeft: state.manifestDays });
    } else {
      dispatch({ type: 'RETURN_PORT', success: true, haul: run.haul, rescues: run.rescues,
        unlockedKeyed: run.unlockedKeyed, daysLeft: state.manifestDays });
    }
  }

  // ---- Rest at the current node: dispatches the TOWN's own rest action
  //      (CARE_ACTION 'rest' → tickSlot + −fatigue + mend hull). Same function. ----
  function restHere() {
    if (run.phase !== 'choosing') return;
    dispatch({ type: 'CARE_ACTION', kind: 'rest' });
    setRun(r => ({ ...r, floaty: { key: Date.now(), text: 'rest · fatigue eases', color: 'var(--ok)', node: r.vessel } }));
  }

  // ---- Pet the spirit: the TOWN's own 'bond' care action (CARE_ACTION 'bond' →
  //      tickSlot + +12 Bond + +5 fatigue). Literally the same reducer case. ----
  function petSpirit() {
    if (run.phase !== 'choosing' || state.care.bondPct >= 100) return;
    dispatch({ type: 'CARE_ACTION', kind: 'bond' });
    setRun(r => ({ ...r, floaty: { key: Date.now(), text: '+12 Bond', color: 'var(--gold)', node: r.vessel } }));
  }

  // ---- use a stored item mid-run (provisions / offerings) ----
  const canUseStores = run.phase === 'choosing';
  function useProvision(food) {
    if (!canUseStores) return;
    if (food === 'staple') {
      if (state.care.fullness >= 90) return;
      // Road-feed-first: the spirit eats what was scavenged on THIS run before the
      // town's own stores are touched — an expedition lives off the land, and the
      // home larder is only tapped once the road haul runs dry.
      if (run.haul.staple >= 1) {
        dispatch({ type: 'FIELD_CARE', fill: 22 });   // +fullness, no town staple spent
        setRun(r => ({ ...r,
          haul: { ...r.haul, staple: Math.max(0, r.haul.staple - 1) },
          floaty: { key: Date.now(), text: 'road feed — belly settles', color: 'var(--ok)', node: r.vessel },
        }));
        return;
      }
      if (state.resources.staple < 1) return;
      dispatch({ type: 'CARE_ACTION', kind: 'feed', food: 'staple' });   // fall back to town stores: −1 staple, +fullness
      setRun(r => ({ ...r,
        floaty: { key: Date.now(), text: 'town stores — belly settles', color: 'var(--ok)', node: r.vessel },
      }));
    } else {
      if ((state.resources.offerings[food] || 0) < 1) return;
      dispatch({ type: 'CONSUME_PROVISION', food });
      dispatch({ type: 'FIELD_CARE', bond: 8, hull: 2 });   // field-only: burn an offering for morale + a harness patch
      setRun(r => ({ ...r,
        floaty: { key: Date.now(), text: 'offering burned — bond deepens', color: 'var(--el-air)', node: r.vessel },
      }));
    }
  }

  // ---- node + edge display state (bidirectional / un-DAG) ----
  const vRoleNow = roleAt(run.vessel, todLabel);
  const blockingDanger = (vRoleNow === 'danger' || vRoleNow === 'gate') && !run.cleared[run.vessel];
  // Reach = every neighbour (backtracking allowed). An uncleared hazard/gate bars
  // the way FORWARD only — retreat to already-charted ground stays open (a hazard
  // punishes, it doesn't capture — checks.md). A worked perch stays passable; it
  // just pays nothing on the way back (the per-run cooldown).
  const reach = run.phase !== 'choosing' ? []
    : blockingDanger ? neighbors(run.vessel).filter(n => run.visited[n])
    : neighbors(run.vessel);
  const reachSet = new Set(reach);
  const atVessel = run.vessel === T1_START;
  const homeReachable = reachSet.has(T1_START);   // the Vessel is one hop away — safe exit in reach
  function nodeState(n) {
    if (n.id === run.vessel) return 'active';
    if (reachSet.has(n.id)) return 'reachable';       // reachable wins so backtrack targets stay clickable
    if (run.cleared[n.id] || run.worked[n.id]) return 'success';
    if (run.revealed.has(n.id)) return 'revealed';
    return 'fog';
  }
  function edgeState(e) {
    const [a, b] = e;
    // FULL fog-of-war: a line is charted only once you've STOOD on one of its
    // endpoints (visiting a node reveals its spokes). Unexplored links: hidden.
    if (!(run.visited[a] || run.visited[b])) return 'hidden';
    if (run.visited[a] && run.visited[b]) return 'traveled';
    if ((a === run.vessel && reachSet.has(b)) || (b === run.vessel && reachSet.has(a))) return 'open';
    return 'dim';
  }
  // FORECAST + KNOWLEDGE RENDERING:
  //   • reachable nodes preview their ARRIVAL slot (the move advances the clock);
  //   • a node's IDENTITY is known only once WALKED ('uncharted' disc before);
  //   • a walked node's per-slot FACES are known only for hours you've stood
  //     through (run.seen) — unwitnessed hours show the base identity, so the
  //     first predator-night still surprises; the second you see coming;
  //   • MAGNITUDE is always live — something can LOOM from afar, unidentified.
  const nextSlot = SLOT_ORDER[(SLOT_ORDER.indexOf(todLabel) + 1) % 4];
  const dispSlot = (nid) => (reachSet.has(nid) && nid !== run.vessel) ? nextSlot : todLabel;
  const witnessed = (nid, slot) => !!(run.seen[nid] && run.seen[nid][slot]);
  const renderNode = (n) => {
    const slot = dispSlot(n.id);
    if (!run.visited[n.id]) return { ...n, type: 'uncharted', magK: magKAt(n.id, slot) };
    const conv = run.converted[n.id];
    const face = (s) => conv ? 'forage' : (witnessed(n.id, s) ? visualTypeAt(n.id, s) : visualType(n.id));
    const type = face(slot);
    const forecast = slot !== todLabel && witnessed(n.id, slot) && type !== face(todLabel) ? slot.toUpperCase() : null;
    return { ...n, type, magK: magKAt(n.id, slot), forecast };
  };

  const vNode = TERR.byId[run.vessel];
  const slotLabel = todLabel;
  // Sleep-window "hold" gate — spending a slot AT the spirit's sleep moment is
  // brutal (+ sleep-skip fatigue), so any slot-consuming action (a move, an
  // extra/offering, a retry) is marked "have to hold it" here, the same way the
  // Rest button reads off its sleep window. Rest itself lights up instead.
  const atSleep = (((state.slotIdx % 4) + 4) % 4) === ((((state.sleepSlot ?? 0) % 4) + 4) % 4);

  // Live field API for the click-by-click PLAYTHROUGH harness (Z0) — the REAL
  // travel/attempt handlers the chart + action buttons call, invoked by id.
  useEffectExp(() => {
    window.SH_field = {
      kind: 'z0', zoneId: 'whispering-strata', vessel: run.vessel, phase: run.phase,
      reach, blockingDanger, run, hull: state.hull, edges: TERR.edges,
      actions: (id) => actionsFor(id || run.vessel, todLabel),
      role: (id) => roleAt(id || run.vessel, todLabel),
      cleared: (id) => !!run.cleared[id],
      dests: Array.from(DESTS),
      home: T1_START, homeReachable, husk: run.husk,
      travel, attempt: attemptAction, recall: () => beginHusk('recall'),
      // compat for the playthrough harnesses: "extract" now means steer home if the
      // Vessel is one hop away, else de-manifest (auto-husk) so the run still ends.
      extract: () => (homeReachable ? travel(T1_START) : beginHusk('recall')),
    };
    return () => { if (window.SH_field && window.SH_field.kind === 'z0') window.SH_field = null; };
  });

  return (
    <div className="canvas-content" style={{ padding: '24px 32px', display: 'flex', flexDirection: 'column', gap: 12 }}>
      {/* HEADER */}
      <header style={{ display: 'grid', gridTemplateColumns: '420px 1fr', gap: 24, alignItems: 'center', paddingRight: 104 }}>
        <div className="col gap-4">
          <div className="label">Hull Integrity</div>
          <HullMeter hull={state.hull} max={20} />
        </div>
        <div className="col" style={{ alignItems: 'center', gap: 3 }}>
          <div className="fell" style={{ fontSize: 22, lineHeight: 1.1 }}>The Whispering Strata</div>
          <TimeOfDay slot={state.slotIdx} sleepSlot={state.sleepSlot ?? 0} />
          {(() => {
            const d = Math.max(0, state.manifestDays), m = state.manifestMax || d || 1;
            const crit = d <= 1;
            return (
              <div className="row gap-6" title="Manifestation Clock — at zero the spirit de-manifests and the husk flees home"
                style={{ alignItems: 'center', marginTop: 1 }}>
                <span className="label" style={{ fontSize: 8, letterSpacing: '0.18em', color: 'var(--sepia)' }}>LIFESPAN</span>
                <div className="row gap-3" style={{ alignItems: 'center' }}>
                  {Array.from({ length: m }).map((_, i) => (
                    <span key={i} style={{ width: 9, height: 9, borderRadius: '50%',
                      border: `1.5px solid ${i < d ? (crit ? 'var(--crit)' : 'var(--ink)') : 'var(--ink-faded)'}`,
                      background: i < d ? (crit ? 'var(--crit)' : 'var(--stamp-amber)') : 'transparent' }} />
                  ))}
                </div>
                <span className="mono" style={{ fontSize: 9, color: crit ? 'var(--crit)' : 'var(--sepia)' }}>{d}d left</span>
              </div>
            );
          })()}
        </div>
      </header>

      <hr className="divider-h" style={{ borderColor: 'var(--ink)', margin: 0 }} />

      {/* GRAPH PANEL */}
      <div className="panel brackets" style={{ padding: '12px 24px 14px', height: 400, position: 'relative', overflow: 'hidden' }}>
        <div className="row" style={{ justifyContent: 'space-between', marginBottom: 6, position: 'relative', zIndex: 3 }}>
          <span className="label label-ink">Aether Current Chart · Zone 0 · Whispering Strata</span>
          <span className="stamp stamp-blue" style={{ fontSize: 9 }}>{slotLabel.toUpperCase()}</span>
        </div>

        <svg viewBox="0 0 1300 380" preserveAspectRatio="none" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.15, pointerEvents: 'none' }}>
          {[60, 130, 200, 270, 340].map((base, r) => (
            <path key={r} fill="none" stroke={r % 2 ? 'var(--el-air)' : 'var(--stamp-amber)'} strokeWidth="1.2"
              d={`M 0 ${base} ${Array.from({ length: 13 }).map((_, i) => `Q ${i * 100 + 50} ${base + (i % 2 ? -22 : 22)} ${(i + 1) * 100} ${base}`).join(' ')}`} />
          ))}
        </svg>

        {(() => {
          // Camera: "Fit" (zoom 0) centers the whole map; zoom in to follow the
          // active node (pans left→right, clamped). Controls bottom-right.
          const VIEW_W = mapW, VIEW_H = 348, PAD = 16;
          const KFIT = Math.min((VIEW_W - 2 * PAD) / 1300, (VIEW_H - 2 * PAD) / 340);
          const K = KFIT * ZOOMS[zoomIx];
          const WORLD_W = 1300 * K, WORLD_H = 340 * K;
          const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
          const cx = (vNode ? vNode.x : 650) * K, cy = (vNode ? vNode.y : 170) * K;
          const ox = WORLD_W <= VIEW_W ? (VIEW_W - WORLD_W) / 2 : clamp(VIEW_W / 2 - cx, VIEW_W - WORLD_W, 0);
          const oy = WORLD_H <= VIEW_H ? (VIEW_H - WORLD_H) / 2 : clamp(VIEW_H / 2 - cy, VIEW_H - WORLD_H, 0);
          return (
          <div ref={mapWrapRef} style={{ position: 'relative', zIndex: 2, width: '100%', height: VIEW_H, overflow: 'hidden' }}>
            <div style={{ position: 'absolute', top: 0, left: 0, width: 1300, height: 340, transformOrigin: 'top left', transform: `translate(${ox}px, ${oy}px) scale(${K})`, transition: 'transform 620ms cubic-bezier(0.4,0,0.2,1)' }}>
            <svg viewBox="0 0 1300 340" width={1300} height={340} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
              {[...TERR.edges].filter(e => edgeState(e) !== 'hidden').sort((a, b) => ({ dim: 0, open: 1, traveled: 2 }[edgeState(a)] - { dim: 0, open: 1, traveled: 2 }[edgeState(b)])).map((e, i) => {
                const a = TERR.byId[e[0]], b = TERR.byId[e[1]]; if (!a || !b) return null;
                const st = edgeState(e);
                const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
                const stroke = st === 'traveled' ? 'var(--stamp-amber)' : st === 'open' ? 'var(--ink)' : 'var(--ink-faded)';
                const oneWay = ONE_WAY.has(e[0] + '>' + e[1]);
                const ang = Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
                return <g key={i}>
                  <path d={`M ${a.x} ${a.y} Q ${mx} ${my} ${b.x} ${b.y}`} fill="none"
                    stroke={stroke} strokeWidth={st === 'traveled' ? 2.4 : st === 'open' ? 1.8 : 1}
                    strokeDasharray={st === 'dim' ? '4 6' : ''} opacity={st === 'dim' ? 0.6 : 0.9} strokeLinecap="round" />
                  {oneWay && <g transform={`translate(${mx}, ${my}) rotate(${ang})`}>
                    <path d="M -5 -4.5 L 6 0 L -5 4.5 Z" fill={stroke} opacity={st === 'dim' ? 0.55 : 0.85} />
                  </g>}
                </g>;
              })}
            </svg>
            {TERR.nodes.map(n => {
              const s = nodeState(n);
              if (s === 'fog') return null;   // FULL fog-of-war — unexplored nodes don't exist yet (no ? discs)
              const ds = dispSlot(n.id);      // reachable nodes preview their ARRIVAL slot
              const known = !!(run.seen[n.id] && run.seen[n.id][ds]);
              const eRole = run.converted[n.id] ? 'forage' : (known ? roleAt(n.id, ds) : roleOf(n.id));
              return <GraphNode key={n.id} node={renderNode(n)} state={s} hideShip
                labelSide={n.y > 228 ? 'above' : 'below'}
                intel={(s === 'fog' || eRole === 'vessel' || !run.visited[n.id]) ? null : nodeIntel(n, state, hi, eRole, true, run, ds)}
                holdWarn={s === 'reachable' && atSleep}
                onClick={s === 'reachable' ? () => travel(n.id) : undefined} />;
            })}
            <div style={{ position: 'absolute', left: vNode.x, top: vNode.y, transform: 'translate(-50%, calc(-50% - 30px))', transition: `left ${run.husk ? HUSK_STEP_MS : TRAVEL_MS}ms cubic-bezier(0.4,0,0.6,1), top ${run.husk ? HUSK_STEP_MS : TRAVEL_MS}ms cubic-bezier(0.4,0,0.6,1)`, zIndex: 8, pointerEvents: 'none' }}>
              {run.husk
                ? <div style={{ filter: 'drop-shadow(0 4px 9px rgba(74,54,38,0.45))' }} title="The empty husk — the spirit has de-manifested"><Vessel size={56} /></div>
                : <div style={{ position: 'relative' }}>
                    <img src={window.spiritIconSrc ? window.spiritIconSrc(state) : 'assets/drift-mote.png'} alt=""
                      style={{ width: 60, height: 60, objectFit: 'contain',
                        filter: 'url(#sh-dropwhite) drop-shadow(0 4px 9px rgba(74,54,38,0.45))' }} />
                    {/* need-markers — appear only when something needs tending */}
                    {(() => {
                      const c = state.care;
                      const starving = c.fullness < HUNGER_AT, hungry = c.fullness < 35;
                      const worn = c.fatigue >= 80;
                      const badges = [];
                      if (starving || hungry) badges.push({ g: '◦', col: starving ? 'var(--crit)' : 'var(--stamp-amber)', crisis: starving,
                        t: starving ? 'Starving — feed now, Weight is burning' : 'Hungry — feed soon' });
                      if (atSleep || worn) badges.push({ g: '☾', col: worn ? 'var(--crit)' : 'var(--el-air)', crisis: worn,
                        t: worn ? 'Worn down — collapse nears; rest' : 'Sleep window — rest now, or push (+fatigue)' });
                      if (!badges.length) return null;
                      return (
                        <div style={{ position: 'absolute', top: -6, right: -14, display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'center' }}>
                          {badges.map((b, i) => (
                            <span key={i} title={b.t} style={{
                              width: b.crisis ? 28 : 18, height: b.crisis ? 28 : 18, borderRadius: '50%',
                              background: b.crisis ? 'rgba(168,42,42,0.14)' : 'var(--paper)',
                              border: `${b.crisis ? 2.5 : 1.5}px solid ${b.col}`, color: b.col,
                              display: 'flex', alignItems: 'center', justifyContent: 'center',
                              fontSize: b.crisis ? 17 : 10.5, lineHeight: 1, fontWeight: b.crisis ? 700 : 400,
                              boxShadow: b.crisis ? '0 0 0 4px rgba(168,42,42,0.18), 0 1px 4px rgba(20,16,10,0.3)' : '0 1px 3px rgba(20,16,10,0.25)',
                              animation: b.crisis ? 'marker-pulse 0.8s ease-in-out infinite' : 'none' }}>{b.g}</span>
                          ))}
                        </div>
                      );
                    })()}
                  </div>}
            </div>
            {run.floaty && run.floaty.text && (
              <div key={run.floaty.key} className="floaty" style={{ left: TERR.byId[run.floaty.node].x, top: TERR.byId[run.floaty.node].y - 40, transform: 'translateX(-50%)', fontSize: 22, color: run.floaty.color }}>{run.floaty.text}</div>
            )}
            </div>
            <div className="row gap-4" style={{ position: 'absolute', right: 8, bottom: 8, zIndex: 12, alignItems: 'center', background: 'var(--paper)', border: '1px solid var(--ink-faded)', padding: '2px 4px' }}>
              <button className="btn" disabled={zoomIx === 0} style={{ padding: '1px 8px', fontSize: 13, lineHeight: 1, background: 'var(--ink)', color: 'var(--paper-light)', opacity: zoomIx === 0 ? 0.4 : 1 }} onClick={() => setZoomIx(i => Math.max(0, i - 1))}>−</button>
              <span className="mono" style={{ fontSize: 9.5, color: 'var(--sepia)', minWidth: 28, textAlign: 'center' }}>{zoomIx === 0 ? 'Fit' : ZOOMS[zoomIx].toFixed(1) + '×'}</span>
              <button className="btn" disabled={zoomIx === ZOOMS.length - 1} style={{ padding: '1px 8px', fontSize: 13, lineHeight: 1, background: 'var(--ink)', color: 'var(--paper-light)', opacity: zoomIx === ZOOMS.length - 1 ? 0.4 : 1 }} onClick={() => setZoomIx(i => Math.min(ZOOMS.length - 1, i + 1))}>+</button>
            </div>
          </div>
          );
        })()}
      </div>

      {/* BOTTOM */}
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 410px', gap: 12, flex: 1, minHeight: 0 }}>
        <NodeConsole run={run} state={state} hi={hi} reach={reach} slotLabel={slotLabel} atSleep={atSleep}
          atVessel={atVessel} homeReachable={homeReachable} blockingDanger={blockingDanger}
          onAttempt={attemptAction} onTravelHome={() => travel(T1_START)} onRest={restHere}
          onFeed={() => useProvision('staple')}
          onPet={petSpirit}
          onRecall={() => beginHusk('recall')} />
        <ShipSystemsPanel state={state} hull={state.hull} runFatigue={state.care.fatigue}
          fullness={state.care.fullness} bondPct={state.care.bondPct} haul={run.haul} rescues={run.rescues}
          canUse={canUseStores} onUse={useProvision}
          onRest={restHere} onFeed={() => useProvision('staple')} />
      </div>

      {run.phase === 'success' && <SuccessModal onReturn={() => returnToPort('safe')} haul={run.haul} rescues={run.rescues} />}
      {run.phase === 'husked' && <HuskModal onReturn={() => returnToPort('husk')} haul={run.haul} rescues={run.rescues} />}
      {run.phase === 'failure' && <FailureModal onReturn={() => returnToPort('lost')} haul={run.haul} />}
    </div>
  );
}

function noteFor(type, res) {
  if (type === 'forage') return res === 'fail' ? 'Empty perch — sore wings, no stores.' : 'Stores gathered. The hold sits heavier.';
  if (type === 'hazard') return res === 'fail' ? 'The patrol drew blood on the rigging.' : 'Drove the patrol off the thermals.';
  if (type === 'event') return 'A drifting cache, cracked open.';
  if (type === 'gate') return 'The pressure gate parts — currents beyond now scried.';
  if (type === 'rescue') return 'Secured. This site now yields like a forage perch.';
  return 'Bearing resolved.';
}

// ====================================================================
// Time-of-day clock (within-run; primary content driver, §2/§5)
// ====================================================================
function TimeOfDay({ slot, sleepSlot = 0 }) {
  // Delegate to the shared SleepSchedule strip (defined in port.jsx) so the
  // expedition ToD row and the city screen read identically.
  const S = window.SleepSchedule;
  return S ? <S slotIdx={slot} sleepSlot={sleepSlot} /> : null;
}

// ====================================================================
// Field Console — the per-node action menu (§2) + onward bearings. Replaces
// the old single-check Bearing Inspector. At a node you may attempt its 1–3
// actions (primary is free — the move paid it; only the special-marked
// off-option adds a slot; resolves the dieless range check at the current
// ToD), then commit to an outgoing edge.
// ====================================================================
const pctText = (p) => `${Math.round(p * 100)}%`;

function PBar({ p, favored }) {
  const col = p >= 0.66 ? 'var(--ok)' : p >= 0.33 ? 'var(--stamp-amber)' : 'var(--crit)';
  return (
    <div className="row gap-6" style={{ alignItems: 'center' }}>
      <div style={{ width: 64, height: 6, background: 'var(--ink-trace)', border: '0.5px solid var(--ink-faded)' }}>
        <div style={{ height: '100%', width: `${Math.round(p * 100)}%`, background: col, opacity: 0.9 }} />
      </div>
      <span className="mono" style={{ fontSize: 11, color: col, width: 34, textAlign: 'right' }}>{pctText(p)}</span>
    </div>
  );
}

// ---- day-track pip badge — cost shown as the day advancing (E2 language) ---
//   mode 'covered' : the current slot is marked ✓ (paid by the flight here)
//   mode 'extra'   : current slot spent + the NEXT pip lights red (+1 slot)
//   mode 'hold'    : nothing lights — a quick gesture, the day holds
function SlotPips({ slotLabel, mode }) {
  const cur = ((SLOT_ORDER.indexOf(slotLabel)) % 4 + 4) % 4;
  const nxt = (cur + 1) % 4;
  return (
    <div className="col" style={{ gap: 2, alignItems: 'flex-end' }}>
      <div className="row" style={{ gap: 3, alignItems: 'center' }}>
        {SLOT_ORDER.map((s, i) => {
          const spent = mode !== 'hold' && i === cur;
          const next = mode === 'extra' && i === nxt;
          const bw = spent ? 'var(--stamp-amber)' : next ? 'var(--crit)' : 'var(--ink-faded)';
          return (
            <span key={s} style={{ width: 12, height: 12, display: 'flex', alignItems: 'center', justifyContent: 'center',
              borderWidth: '1.5px', borderStyle: next ? 'dashed' : 'solid', borderColor: bw,
              background: spent ? 'var(--stamp-amber)' : 'transparent' }}>
              {spent && <span style={{ color: 'var(--paper-light)', fontSize: 7.5, lineHeight: 1 }}>✓</span>}
              {next && <span style={{ color: 'var(--crit)', fontSize: 9, lineHeight: 1 }}>+</span>}
            </span>
          );
        })}
      </div>
      <span className="mono" style={{ fontSize: 7.5, letterSpacing: '0.05em',
        color: mode === 'extra' ? 'var(--crit)' : mode === 'covered' ? 'var(--stamp-amber)' : 'var(--ink-faded)' }}>
        {mode === 'extra' ? `+1 SLOT · → ${SLOT_ORDER[nxt].toUpperCase()}` : mode === 'covered' ? 'COVERED BY TRAVEL' : 'NO SLOT · DAY HOLDS'}
      </span>
    </div>
  );
}

// bottom toggle tab
function ConsoleTab({ label, sub, active, alert, onClick }) {
  return (
    <button onClick={onClick} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1,
      padding: '6px 8px', borderRadius: 0, cursor: 'pointer', transition: 'none',
      border: `1.5px solid ${active ? 'var(--ink)' : 'var(--ink-faded)'}`,
      backgroundColor: active ? '#1a2e4a' : 'transparent', color: active ? '#f4ecd6' : 'var(--sepia)' }}>
      <span className="fell" style={{ fontSize: 14 }}>{label}</span>
      <span className="mono" style={{ fontSize: 8, letterSpacing: '0.06em',
        color: alert ? 'var(--crit)' : active ? '#f4ecd6' : 'var(--sepia)', opacity: active && !alert ? 0.8 : 1 }}>{sub}</span>
    </button>
  );
}

// ---- WORK pane: the TRAVEL ACTION (the flight here bought it) sits above the
//      recessed EXTRA ACTIONS (each spends a slot). Strong hierarchy. ----
function WorkPane({ travel, extras, slotLabel, atSleep, onAttempt, hi }) {
  const PE = window.PORT_ELEM || {};
  const elName = (e) => (PE[e] ? PE[e].name : e);
  const elColor = (e) => (PE[e] ? PE[e].color : 'var(--gold)');
  // "have to hold it": this action would burn a slot THROUGH the sleep window
  // (+sleep-skip fatigue). Same hold treatment as the Rest button off its window.
  const holdable = (a) => !!(a && a.spendsSlot && atSleep);

  return (
    <div className="col gap-12">
      {/* TRAVEL ACTION — the hero */}
      <div>
        <div className="row gap-8" style={{ alignItems: 'center', marginBottom: 7 }}>
          <span className="label label-ink" style={{ fontSize: 11, letterSpacing: '0.26em' }}>Travel Action</span>
          <span className="mono" style={{ fontSize: 8.5, color: 'var(--sepia)' }}>— THE FLIGHT HERE BOUGHT THIS</span>
        </div>
        {!travel ? (
          <div style={{ border: '1.5px dashed var(--ink-faded)', padding: '14px 16px', textAlign: 'center' }}>
            <span className="elite" style={{ fontSize: 12, color: 'var(--sepia)' }}>The Vessel — no field actions. Set a bearing below.</span>
          </div>
        ) : (() => {
          const done = travel.done;                           // forage depleted / danger cleared / recruit secured
          const hold = !done && holdable(travel);
          return (
            <div style={{ position: 'relative', border: `2px solid ${done ? 'var(--ink-faded)' : 'var(--ink)'}`,
              background: done ? 'transparent' : 'rgba(184,99,42,0.06)', padding: '13px 16px', opacity: done ? 0.6 : 1 }}>
              {!done && (
                <div style={{ position: 'absolute', top: 12, right: 14 }}>
                  <SlotPips slotLabel={slotLabel} mode={travel.spendsSlot ? 'extra' : 'covered'} />
                </div>
              )}
              <div className="row gap-8" style={{ alignItems: 'baseline', marginBottom: 6, paddingRight: 134 }}>
                <span className="fell" style={{ fontSize: 21, lineHeight: 1 }}>{travel.meta.name}</span>
                <span className="mono" style={{ fontSize: 9.5, color: travel.keyColor }}>{travel.isGate ? 'req' : 'vs'} {travel.keyLabel} {travel.cMastery}{travel.isGate ? '/' + travel.band[1] : ''}</span>
                {travel.favored && !done && <span className="stamp stamp-amber" style={{ fontSize: 6.5 }}>FAVOURED +{TOD_BONUS}</span>}
                {travel.isSum && <span className="stamp stamp-blue" style={{ fontSize: 6.5 }}>SUM</span>}
              </div>
              <div className="row" style={{ justifyContent: 'space-between', alignItems: 'flex-end' }}>
                <div className="col gap-6" style={{ minWidth: 0 }}>
                  <span className="elite" style={{ fontSize: 10.5, color: 'var(--sepia)' }}>
                    <span style={{ color: 'var(--gold)' }}>{travel.meta.reward}</span> · <span style={{ color: 'var(--crit)' }}>{travel.meta.risk}</span>
                  </span>
                  {!done && <PBar p={travel.p} />}
                  {hold && <span className="mono" style={{ fontSize: 9, color: 'var(--sepia)', letterSpacing: '0.04em' }}>⧖ have to hold it — acting now wakes the spirit (+fatigue)</span>}
                </div>
                {done
                  ? <span className="mono" style={{ fontSize: 11, color: 'var(--ok)' }}>✓ {travel.role === 'recruit' ? 'SECURED' : travel.role === 'forage' ? 'DEPLETED' : 'CLEARED'}</span>
                  : hold
                    ? <button className="btn" style={{ padding: '9px 22px', fontSize: 15, background: 'rgba(244,236,214,0.4)', color: 'var(--sepia)', border: '1.5px dashed var(--ink-faded)', letterSpacing: '0.08em' }}
                        onClick={() => onAttempt(travel)}>⧖ {travel.meta.verb} ▸</button>
                    : <button className="btn" style={{ padding: '9px 24px', fontSize: 15, background: 'var(--ink)', color: 'var(--paper-light)', letterSpacing: '0.10em' }}
                        onClick={() => onAttempt(travel)}>{travel.meta.verb} ▸</button>}
              </div>
            </div>
          );
        })()}
      </div>

      {/* EXTRA ACTIONS — recessed beneath the Travel Action */}
      <div>
        <div className="row gap-8" style={{ alignItems: 'center', marginBottom: 5 }}>
          <span className="label" style={{ fontSize: 10, letterSpacing: '0.22em' }}>Extra Actions</span>
          <span className="leader" style={{ borderColor: 'var(--ink-faded)' }} />
          <span className="mono" style={{ fontSize: 8.5, color: 'var(--sepia)' }}>EACH SPENDS A SLOT</span>
        </div>
        {extras.length === 0 ? (
          <div style={{ borderLeft: '2px solid var(--ink-faded)', padding: '8px 12px', background: 'rgba(26,46,74,0.025)' }}>
            <span className="elite" style={{ fontSize: 10.5, color: 'var(--sepia)' }}>None at this drift — the Travel Action is all it offers.</span>
          </div>
        ) : (
          <div style={{ borderLeft: '2px solid var(--ink-faded)', background: 'rgba(26,46,74,0.025)' }}>
            {extras.map((a, i) => (
              <div key={a.key} className="row" style={{ justifyContent: 'space-between', alignItems: 'center',
                padding: '8px 12px', opacity: a.done ? 0.5 : 1,
                borderBottom: i < extras.length - 1 ? '1px solid var(--ink-ghost)' : 'none' }}>
                <div className="row gap-8" style={{ alignItems: 'baseline', minWidth: 0 }}>
                  <span className="fell" style={{ fontSize: 15, whiteSpace: 'nowrap' }}>{a.meta.name}</span>
                  <span className="mono" style={{ fontSize: 9, color: elColor(a.element) }}>vs {elName(a.element)}</span>
                  {a.favored && !a.done && <span className="stamp stamp-amber" style={{ fontSize: 6 }}>+{TOD_BONUS}</span>}
                  <span className="elite" style={{ fontSize: 9.5, color: 'var(--gold)', whiteSpace: 'nowrap' }}>{a.meta.reward}</span>
                </div>
                <div className="row gap-12" style={{ alignItems: 'center' }}>
                  {a.done
                    ? <span className="mono" style={{ fontSize: 10, color: 'var(--ok)' }}>✓ DRAWN</span>
                    : <>
                        <PBar p={a.p} />
                        <SlotPips slotLabel={slotLabel} mode="extra" />
                        {holdable(a)
                          ? <button className="btn" title="Holds through the sleep window — wakes the spirit (+fatigue)"
                              style={{ padding: '3px 11px', fontSize: 11.5, background: 'rgba(244,236,214,0.35)', color: 'var(--sepia)', border: '1px dashed var(--ink-faded)', letterSpacing: '0.05em' }}
                              onClick={() => onAttempt(a)}>⧖ {a.meta.verb} ▸</button>
                          : <button className="btn" style={{ padding: '3px 12px', fontSize: 11.5, background: 'transparent', color: 'var(--ink)', letterSpacing: '0.05em' }}
                              onClick={() => onAttempt(a)}>{a.meta.verb} ▸</button>}
                      </>}
                </div>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

// ---- TEND pane: the town care actions, mirrored in-run. Rest & Pet spend a
//      slot; Feed is interstitial (no slot). (app.jsx CARE_ACTION). ----
function TendPane({ run, state, slotLabel, starving, exhausted, onRest, onFeed, onPet }) {
  const roadStock = run.haul.staple;            // feed scavenged this run — eaten first
  const townStock = state.resources.staple;     // home larder — tapped only when road runs dry
  const stapleStock = roadStock + townStock;
  const c = state.care;
  const canFeed = stapleStock >= 1 && c.fullness < 90;
  const canPet = c.bondPct < 100;
  // Starvation ramp (mirrors town): hunger burns Weight; the floor is death.
  const hungry = c.fullness < HUNGER_AT;
  const frail  = c.weight <= WEIGHT_DEATH + 6;     // near the death floor
  const tiring = c.fatigue >= 80;                  // approaching forced-rest collapse
  const warn = frail
    ? '⚠ Wasting away — Weight is critical. Feed now, or the vessel dies where it stands.'
    : hungry
      ? '⚠ Hungry — the body burns Weight every slot. Feed to stop the wasting.'
      : tiring
        ? '⚠ Worn down — at full Fatigue the spirit collapses (clears, but −Bond). Rest soon.'
        : null;
  return (
    <div className="col gap-9">
      <div className="row gap-8" style={{ alignItems: 'center' }}>
        <span className="label label-ink" style={{ fontSize: 11, letterSpacing: '0.26em' }}>Tend the Spirit</span>
        <span className="mono" style={{ fontSize: 8.5, color: 'var(--sepia)' }}>— FEED IS QUICK · REST SPENDS A SLOT</span>
      </div>
      {warn && (
        <div style={{ padding: '6px 10px', border: '1px solid var(--crit)', background: 'rgba(168,42,42,0.07)' }}>
          <span className="mono" style={{ fontSize: 9.5, color: 'var(--crit)', letterSpacing: '0.03em', lineHeight: 1.45 }}>
            {warn}
          </span>
        </div>
      )}
      <TendRow glyph="☾" name="Rest" slotLabel={slotLabel} mode="extra" meter="Fatigue"
        value={c.fatigue} col={c.fatigue > 70 ? 'var(--crit)' : 'var(--stamp-amber)'}
        mend="−most fatigue · wakes sharp" can onAct={onRest} />
      <TendRow glyph="◦" name="Feed" slotLabel={slotLabel} mode="hold" meter="Fullness"
        value={c.fullness} col={hungry ? 'var(--crit)' : 'var(--stamp-amber)'}
        mend={hungry ? `ration · +22 fullness · stops the wasting · road ${roadStock} · stores ${townStock}`
                     : `ration · +22 fullness · road ${roadStock} · stores ${townStock}`}
        can={canFeed} note={c.fullness >= 90 ? 'FULL — CAN’T EAT' : 'NO RATIONS'} onAct={onFeed} />
      <TendRow glyph="◆" name="Weight" slotLabel={slotLabel} mode="none" meter="Reserve"
        value={c.weight} col={frail ? 'var(--crit)' : 'var(--ink)'}
        mend={frail ? 'survival reserve — hunger is draining it toward death'
                    : 'survival reserve — lost to hunger, rebuilt by good feeding'}
        readout onAct={onFeed} />
    </div>
  );
}

function TendRow({ glyph, name, slotLabel, mode, meter, value, col, mend, can, note, onAct, readout }) {
  const pct = Math.max(0, Math.min(100, value));
  return (
    <div style={{ border: '1.5px solid var(--ink-faded)', padding: '9px 12px', opacity: (readout || can) ? 1 : 0.55 }}>
      <div className="row" style={{ justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 7 }}>
        <div className="row gap-10" style={{ alignItems: 'flex-start' }}>
          <span className="fell" style={{ fontSize: 16, lineHeight: 1.1 }}>{glyph} {name}</span>
          {!readout && <SlotPips slotLabel={slotLabel} mode={mode} />}
        </div>
        {readout
          ? <span className="mono" style={{ fontSize: 13, color: col, fontWeight: 600 }}>{Math.round(value)}</span>
          : <button className="btn" disabled={!can} onClick={can ? onAct : undefined}
              style={{ padding: '5px 16px', fontSize: 12.5, background: can ? 'var(--ink)' : 'transparent',
                color: can ? 'var(--paper-light)' : 'var(--ink-faded)', borderColor: can ? 'var(--ink)' : 'var(--ink-faded)',
                letterSpacing: '0.06em', cursor: can ? 'pointer' : 'default' }}>{name} ▸</button>}
      </div>
      <div className="row gap-10" style={{ alignItems: 'center' }}>
        <span className="mono" style={{ fontSize: 8.5, color: 'var(--sepia)', width: 52 }}>{meter}</span>
        <div style={{ flex: 1, height: 6, background: 'var(--ink-trace)', border: '0.5px solid var(--ink-faded)' }}>
          <div style={{ height: '100%', width: `${pct}%`, background: col, opacity: 0.85 }} />
        </div>
        <span className="elite" style={{ fontSize: 9.5, color: (readout || can) ? 'var(--sepia)' : 'var(--crit)', textAlign: 'right', maxWidth: 260 }}>{(readout || can) ? mend : note}</span>
      </div>
    </div>
  );
}

function NodeConsole({ run, state, hi, reach, slotLabel, atSleep, atVessel, homeReachable, blockingDanger,
  onAttempt, onTravelHome, onRecall, onRest, onFeed, onPet }) {
  const PE = window.PORT_ELEM || {};
  const elName = (e) => (PE[e] ? PE[e].name : e);
  const elColor = (e) => (PE[e] ? PE[e].color : 'var(--gold)');

  // ---- transient / terminal phases — just the note ----
  if (run.phase !== 'choosing') {
    const tag = run.phase === 'traveling' ? 'UNDERWAY' : run.phase === 'husking' ? 'HUSK · FLEEING' : 'COMPLETE';
    return (
      <div className="panel" style={{ display: 'flex', flexDirection: 'column' }}>
        <div className="row" style={{ padding: '8px 14px', borderBottom: '1px solid var(--ink-faded)', justifyContent: 'space-between' }}>
          <span className="label label-ink">Field Console</span>
          <span className="mono" style={{ fontSize: 10, color: 'var(--sepia)' }}>{tag}</span>
        </div>
        <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 18 }}>
          <span className="elite" style={{ fontSize: 13, color: 'var(--sepia)', textAlign: 'center', maxWidth: 360 }}>{run.note}</span>
        </div>
      </div>
    );
  }

  // ---- choosing: derive the node's actions, split Travel vs Extra ----
  const id = run.vessel, node = TERR.byId[id], isDest = DESTS.has(id);
  const actions = actionsFor(id, slotLabel).map(a => {
    const cKey = checkKeyFor(a, hi, id, state, slotLabel);
    const isEvent = a.role === 'event';
    const flatGate = !isEvent && a.band[0] >= a.band[1];
    const eff = (isEvent || flatGate) ? keyMastery(state, cKey) : effForKey(state, cKey, slotLabel);
    const p = isEvent ? 1 : pSuccess(eff, a.band);
    const favored = !isEvent && !flatGate && keyFavored(cKey, slotLabel);
    const cMastery = keyMastery(state, cKey);
    // legible label/color for the checked thing: sum → "ALL"/"a+b" (neutral gold);
    // single/max → the representative element's name + color.
    const isSum = cKey.mode === 'sum';
    const keyLabel = isEvent ? 'THE NIGHT'
      : isSum
      ? (cKey.els.length >= ALL_ELEMENTS.length ? 'ALL ELEMENTS' : cKey.els.map(e => elName(e)).join(' + ').toUpperCase())
      : elName(cKey.rep).toUpperCase();
    const keyColor = isEvent ? 'var(--el-air)' : isSum ? 'var(--gold)' : elColor(cKey.rep);
    const isGate = flatGate;   // flat requirement (tide-gate d · gateway end3)
    // forage perches deplete on first work; dangers/recruits finish on clear/secure
    const done = a.kind === 'offering' ? !!run.consumed[a.key]
      : a.role === 'forage' ? !!run.worked[id]
      : !!run.cleared[id];
    // does this action tick a slot? offerings always do; a retry of a danger/recruit
    // does (the move bought only the first attempt); the forage primary is free.
    const isRetry = a.kind === 'progression' && a.role !== 'forage' && a.role !== 'event' && !!run.tried[id];
    const spendsSlot = a.kind === 'offering' || isRetry;
    return { ...a, cKey, eff, cMastery: isEvent ? '' : cMastery, keyLabel, keyColor, isSum, isGate, p, favored, done, spendsSlot, meta: actionMeta(a, node, hi, state, slotLabel) };
  });
  const travel = actions.find(a => a.kind === 'progression') || null;
  const extras = actions.filter(a => a.kind === 'offering');

  return (
    <div className="panel" style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}>
      <div className="row" style={{ padding: '8px 14px', borderBottom: '1px solid var(--ink-faded)', justifyContent: 'space-between', alignItems: 'center' }}>
        <span className="label label-ink">Field Console</span>
        <span className="mono" style={{ fontSize: 10, color: 'var(--sepia)' }}>{slotLabel} · top {elName(hi.id).toUpperCase()} {hi.value}</span>
      </div>

      <div style={{ padding: '13px 14px', overflow: 'auto', flex: 1, minHeight: 0 }}>
        <WorkPane travel={travel} extras={extras} slotLabel={slotLabel} atSleep={atSleep} onAttempt={onAttempt} hi={hi} />
      </div>

      {/* BOTTOM — onward / extract */}
      <div style={{ borderTop: '1px solid var(--ink-faded)', flexShrink: 0 }}>
        <div className="row" style={{ padding: '7px 14px', justifyContent: 'space-between', alignItems: 'center' }}>
          <span className="label" style={{ color: blockingDanger ? 'var(--crit)' : 'var(--sepia)' }}>
            {blockingDanger ? (reach.length ? `Barred ahead — clear it, or fall back (${reach.length} charted)` : 'Barred — no retreat from here; clear it or recall')
              : atVessel ? `At the Vessel · ${reach.length} bearings out`
              : homeReachable ? `Free roam · ${reach.length} bearings · Vessel one hop away`
              : `Free roam · ${reach.length} bearings — steer back to the Vessel to bank`}
          </span>
          <span className="elite" style={{ fontSize: 10, color: 'var(--sepia)' }}>
            {reach.length ? 'pick a node on the chart ▸' : 'clear the way'}
          </span>
        </div>

        {/* Step back onto the Vessel to bank (real navigation, no 3× retrace). */}
        {homeReachable && (
          <div style={{ padding: '8px 14px', borderTop: '1px dashed var(--ink-faded)' }}>
            <button className="btn" style={{ width: '100%', padding: '9px 14px', fontSize: 13.5, background: 'var(--ink)', color: 'var(--paper-light)', letterSpacing: '0.10em' }} onClick={onTravelHome}>Return to the Vessel · Bank Haul ▸</button>
          </div>
        )}
        {/* Voluntary de-manifest — the auto-husk on demand. Strands any souls aboard. */}
        {!atVessel && (
          <div style={{ padding: '6px 14px 8px', borderTop: '1px dashed var(--ink-faded)' }}>
            <button className="btn" title="De-manifest now — the empty husk flees home. The haul comes back; any rescued souls aboard strand. Hull → 0 en route loses everything."
              style={{ width: '100%', padding: '6px 12px', fontSize: 11, background: 'transparent', color: 'var(--crit)', border: '1px solid var(--crit)', letterSpacing: '0.06em' }}
              onClick={onRecall}>⚠ Recall the Husk — flee home ▸</button>
          </div>
        )}
      </div>
    </div>
  );
}


// reward / risk / label copy for a node's action (§2/§5)
function actionMeta(a, node, hi, state, slot) {
  const face = faceAt(node.id, slot);
  if (a.kind === 'offering') {
    const nm = (window.PORT_ELEM && window.PORT_ELEM[a.element]) ? window.PORT_ELEM[a.element].name : a.element;
    return { name: `Draw ${nm}`, reward: `+${REWARD.offeringMin}–${REWARD.offeringMax} ${nm}`, risk: `−${REWARD.forageFail} feed`, verb: 'Draw' };
  }
  if (a.role === 'forage') {
    // Reward reflects the perch's CURRENT freshness (matches the map intel + the
    // actual harvest yield) — plus the R2 dawn-pick at Morning.
    const base = node.rich ? REWARD.forageRich : REWARD.forageStaple;
    const fresh = forageFreshZ0(state, node.id);
    const dawn = slot === 'Morning' ? DAWN_PICK_BONUS : 0;
    const est = Math.round(base * fresh) + dawn;
    return { name: node.rich ? 'Rich Forage' : 'Forage',
      reward: est <= 0 ? 'empty · +0 feed' : `+${est} feed${dawn ? ' · dawn-pick' : ''}${node.rich && node.oneTime ? ' · cache' : ''}`,
      risk: `−${REWARD.forageFail} feed`, verb: 'Forage' };
  }
  if (a.role === 'danger') return { name: (face && face.name) || 'Brave the Hazard', reward: 'opens the way',
    risk: `−${hullFailAt(node.id, slot)} Hull${slot === 'Night' ? ' · night bites' : ''}`, verb: (face && face.verb) || 'Brave' };
  if (a.role === 'gate') return { name: 'Pass the Tide-Gate', reward: 'the way parts · stays open', risk: 'refused · no harm', verb: 'Pass' };
  if (a.role === 'event') return { name: (face && face.name) || 'Witness', reward: '+2 coins · a rumor of Ice', risk: 'one-time · Night only', verb: (face && face.verb) || 'Witness' };
  if (a.role === 'recruit') return node.unlockKeyed
    ? { name: 'Hail Gateway Crew', reward: 'crew + UNLOCK keyed zones', risk: 'retry · citizen safe', verb: 'Hail' }
    : { name: 'Reach Out', reward: `crew lead +${REWARD.recruitHull} Hull`, risk: 'retry · citizen safe', verb: 'Reach' };
  return { name: 'Act', reward: '—', risk: '—', verb: 'Act' };
}

// themed name for a node by role / destination
function bearingName(id) {
  if (id === T1_START) return 'The Vessel';
  if (id === 'end3') return 'Gateway Skywright';
  if (id === 'end2') return 'Rich Drift-Field';
  const role = roleOf(id);
  return role === 'danger' ? 'Wyrm Patrol' : role === 'gate' ? 'Tide-Gate' : role === 'recruit' ? 'Foundered Soul' : 'Forage Drift';
}
function DetailRow({ k, v, accent }) {
  return (
    <div className="row" style={{ justifyContent: 'space-between', alignItems: 'baseline', padding: '2px 0', borderBottom: '1px dotted var(--ink-ghost)' }}>
      <span className="label" style={{ fontSize: 9 }}>{k}</span>
      <span className="mono" style={{ fontSize: 11.5, color: accent || 'var(--ink)', textAlign: 'right' }}>{v}</span>
    </div>
  );
}

// ====================================================================
// Hull Meter
// ====================================================================
function HullMeter({ hull, max }) {
  const SEG = 20, filled = Math.ceil((hull / max) * SEG);
  return (
    <div className="row gap-12">
      <div className="meter" style={{ width: 280, height: 22 }}>
        {Array.from({ length: SEG }).map((_, i) => (
          <div key={i} className="meter-segment" style={{ background: i < filled ? (hull < 7 ? 'var(--crit)' : 'var(--ok)') : 'rgba(20,16,10,0.55)' }} />
        ))}
      </div>
      <span className="fell odometer" style={{ fontSize: 24, width: 86 }}>
        {hull.toString().padStart(2, '0')}<span className="label" style={{ marginLeft: 4, fontSize: 10 }}>/ {max}</span>
      </span>
    </div>
  );
}

function ShipIconSmall() {
  return (
    <svg viewBox="0 0 60 50" style={{ width: 56, height: 46, overflow: 'visible' }}>
      <ellipse cx="30" cy="18" rx="26" ry="11" fill="var(--paper-light)" stroke="var(--ink)" strokeWidth="1.4" />
      <circle cx="30" cy="18" r="4" stroke="var(--el-air)" strokeWidth="1" fill="none" />
      <path d="M14 30 L46 30 L42 38 L18 38 Z" fill="var(--paper-light)" stroke="var(--ink)" strokeWidth="1.4" />
      <circle cx="22" cy="34" r="1.5" fill="var(--ink)" /><circle cx="30" cy="34" r="1.5" fill="var(--ink)" /><circle cx="38" cy="34" r="1.5" fill="var(--ink)" />
      <line x1="14" y1="30" x2="20" y2="22" stroke="var(--ink)" strokeWidth="0.7" /><line x1="46" y1="30" x2="40" y2="22" stroke="var(--ink)" strokeWidth="0.7" />
    </svg>
  );
}

// ====================================================================
// Ship Systems Panel
// ====================================================================
function bondLabel(pct) {
  if (pct >= 80) return 'Devoted';
  if (pct >= 60) return 'Settled';
  if (pct >= 40) return 'Wary';
  if (pct >= 20) return 'Restless';
  return 'Fraying';
}

function ShipSystemsPanel({ state, hull, runFatigue, fullness, bondPct, haul, rescues, canUse, onUse, onRest, onFeed }) {
  const [storesOpen, setStoresOpen] = React.useState(false);
  const hunger = Math.max(0, 100 - (fullness ?? 0));
  return (
    <div className="panel" style={{ display: 'flex', flexDirection: 'column', position: 'relative' }}>
      <div style={{ padding: 14, flex: 1 }}>
        {window.CareGlance && <window.CareGlance state={state} bare />}
        {window.CareActionBar && (
          <div style={{ marginTop: 10 }}>
            <window.CareActionBar state={state} disabled={!canUse} extraFeed={haul.staple} onFeed={onFeed} onRest={onRest} />
          </div>
        )}
        <hr className="divider-h-dashed" />
        <div className="label" style={{ marginBottom: 6 }}>Run Haul</div>
        <div className="row" style={{ justifyContent: 'space-between', marginBottom: 4, fontSize: 12 }}>
          <span className="elite">Feed</span><span className="mono fell" style={{ fontSize: 14 }}>{haul.staple.toString().padStart(2, '0')}</span>
        </div>
        <div className="row" style={{ justifyContent: 'space-between', fontSize: 12 }}>
          <span className="elite">Coins</span><span className="mono fell" style={{ fontSize: 14, color: 'var(--gold)' }}>{haul.coins.toString().padStart(2, '0')}</span>
        </div>
        {rescues && rescues.length > 0 && (
          <>
            <hr className="divider-h-dashed" />
            <div className="label" style={{ marginBottom: 4 }}>Secured</div>
            <div className="elite" style={{ fontSize: 11, color: 'var(--el-air)' }}>✚ {rescues.length} specialist lead{rescues.length > 1 ? 's' : ''}</div>
          </>
        )}
      </div>
      {/* Provisions on hand + Stores access — pinned to the panel foot */}
      <div className="row" style={{ padding: '8px 14px', borderTop: '1px solid var(--ink-faded)', justifyContent: 'space-between', alignItems: 'center' }}>
        {(() => {
          const road = haul.staple;                        // road feed — eaten first
          const town = state.resources.staple;             // home stores — the fallback
          const stock = road + town;                       // total rations on hand to eat mid-run
          const low = stock <= 0;
          return (
            <span className="row gap-4" title={`Feed on hand — eaten road-first (${road} road · ${town} stores)`}
              style={{ alignItems: 'center', fontFamily: 'Special Elite, monospace', fontSize: 9.5,
                letterSpacing: '0.12em', color: low ? 'var(--crit)' : 'var(--ink-soft)' }}>
              <span style={{ fontSize: 11 }}>◦</span>
              <span className="fell" style={{ fontSize: 13, lineHeight: 1, color: low ? 'var(--crit)' : 'var(--ink)' }}>{stock.toString().padStart(2, '0')}</span>
              <span style={{ opacity: 0.7 }}>FOOD</span>
              {road > 0 && <span style={{ opacity: 0.55, fontSize: 8.5 }}>· {road} ROAD</span>}
            </span>
          );
        })()}
        <button onClick={() => setStoresOpen(o => !o)}
          style={{ fontFamily: 'Special Elite, monospace', fontSize: 9.5, letterSpacing: '0.16em',
            padding: '2px 8px', cursor: 'pointer', textTransform: 'uppercase',
            border: `1px solid ${storesOpen ? 'var(--stamp-amber)' : 'var(--ink-faded)'}`,
            color: storesOpen ? 'var(--stamp-amber)' : 'var(--ink-soft)',
            background: storesOpen ? 'rgba(184,99,42,0.08)' : 'transparent' }}>
          {storesOpen ? 'Stores ×' : 'Stores ▸'}
        </button>
      </div>
      {storesOpen && (
        <StoresFlyout state={state} canUse={canUse} hunger={hunger} fatigue={runFatigue}
          roadFeed={haul.staple} onUse={onUse} onClose={() => setStoresOpen(false)} />
      )}
    </div>
  );
}

// ---- Ship's Stores: use provisions / burn offerings mid-run ----
function StoresFlyout({ state, canUse, hunger, fatigue, roadFeed = 0, onUse, onClose }) {
  const PE = window.PORT_ELEM || {};
  const staple = roadFeed + state.resources.staple;   // road feed eaten first, then home stores
  const offerings = Object.entries(state.resources.offerings)
    .filter(([k, n]) => n > 0 && state.elements[k]?.unlocked);
  const rationDead = staple < 1 || (fatigue <= 0 && hunger <= 0);
  return (
    <div className="panel" style={{
      position: 'absolute', bottom: 46, right: 8, width: 248, zIndex: 30,
      background: 'var(--paper)', border: '1.5px solid var(--ink)',
      boxShadow: '0 10px 26px rgba(40,28,16,0.30)', padding: '12px 13px',
    }}>
      <div className="row" style={{ justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
        <span className="fell" style={{ fontSize: 16 }}>Ship's Stores</span>
        <span className="label label-faded" style={{ fontSize: 8 }}>loaded at hearth</span>
      </div>
      {!canUse && (
        <div className="mono" style={{ fontSize: 9.5, color: 'var(--crit)', marginBottom: 8, letterSpacing: '0.06em' }}>
          ◌ Underway — set a bearing before breaking out stores.
        </div>
      )}

      {/* Provisions */}
      <div className="label" style={{ marginBottom: 5 }}>Provisions</div>
      <StoreRow glyph="◦" color="var(--ink)" name="Staple ration" stock={staple}
        note="−14 fatigue · sates hunger" actLabel="Ration"
        disabled={!canUse || rationDead} onUse={() => onUse('staple')} />

      <hr className="divider-h-dashed" style={{ margin: '10px 0 8px' }} />

      {/* Offerings */}
      <div className="label" style={{ marginBottom: 5 }}>Elemental offerings</div>
      {offerings.length === 0 ? (
        <div className="mono" style={{ fontSize: 10, color: 'var(--sepia)', lineHeight: 1.4 }}>
          ◇ No offerings stowed — gather them in the outward zones.
        </div>
      ) : (
        <div className="col gap-6">
          {offerings.map(([k, n]) => (
            <StoreRow key={k} glyph={PE[k]?.glyph || '◆'} icon={PE[k]?.icon} color={PE[k]?.color || 'var(--ink)'}
              name={`${PE[k]?.name || k} offering`} stock={n}
              note="+8 bond · +2 hull" actLabel="Burn"
              disabled={!canUse} onUse={() => onUse(k)} />
          ))}
        </div>
      )}
    </div>
  );
}

function StoreRow({ glyph, icon, color, name, stock, note, actLabel, disabled, onUse }) {
  return (
    <div className="row gap-8" style={{ alignItems: 'center', padding: '5px 0' }}>
      <span style={{ width: 22, height: 22, flexShrink: 0, border: `1.5px solid ${color}`, color,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        fontFamily: 'IM Fell English SC, serif', fontSize: 14 }}>
        {icon ? <img src={icon} alt="" style={{ width: 13, height: 13, objectFit: 'contain' }} /> : glyph}
      </span>
      <div className="col" style={{ flex: 1, minWidth: 0, gap: 1 }}>
        <div className="row gap-6" style={{ alignItems: 'baseline' }}>
          <span className="fell" style={{ fontSize: 12.5, lineHeight: 1.05 }}>{name}</span>
          <span className="mono" style={{ fontSize: 9.5, color: 'var(--sepia)' }}>×{stock}</span>
        </div>
        <span className="mono" style={{ fontSize: 9, color: 'var(--sepia)', letterSpacing: '0.02em' }}>{note}</span>
      </div>
      <button onClick={onUse} disabled={disabled}
        style={{ fontFamily: 'Special Elite, monospace', fontSize: 10, letterSpacing: '0.1em',
          padding: '4px 9px', textTransform: 'uppercase', flexShrink: 0,
          border: `1px solid ${disabled ? 'var(--ink-faded)' : 'var(--ink)'}`,
          color: disabled ? 'var(--ink-faded)' : 'var(--ink)',
          background: disabled ? 'transparent' : 'rgba(184,99,42,0.08)',
          cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.55 : 1 }}>
        {actLabel} ▸
      </button>
    </div>
  );
}

function SystemRow({ label, value, max, accent }) {
  const pct = Math.min(1, value / max);
  return (
    <div style={{ marginBottom: 10 }}>
      <div className="row" style={{ justifyContent: 'space-between', marginBottom: 3 }}>
        <span className="label label-ink">{label}</span>
        <span className="mono fell" style={{ fontSize: 14 }}>{value.toString().padStart(2, '0')}<span style={{ fontSize: 10, color: 'var(--sepia)', marginLeft: 2 }}>/{max}</span></span>
      </div>
      <div style={{ height: 6, background: 'var(--ink-trace)', border: '0.5px solid var(--ink-faded)' }}>
        <div style={{ height: '100%', width: `${pct * 100}%`, background: accent, opacity: 0.85, transition: 'width 300ms ease' }} />
      </div>
    </div>
  );
}

// ====================================================================
// Modals
// ====================================================================
function HaulTable({ staple, coins }) {
  return (
    <div style={{ border: '1.5px solid var(--ink)', padding: '14px 18px' }}>
      <div className="row" style={{ padding: '8px 0', borderBottom: '1px dashed var(--ink-faded)', alignItems: 'baseline' }}>
        <span className="fell" style={{ fontSize: 17, width: 200 }}>Feed gathered</span>
        <span className="leader" style={{ flex: 1 }} />
        <span className="odometer fell" style={{ fontSize: 26 }}>×{staple.toString().padStart(2, '0')}</span>
        <span className="label" style={{ width: 80, textAlign: 'right' }}>units</span>
      </div>
      <div className="row" style={{ padding: '8px 0', alignItems: 'baseline' }}>
        <span className="fell" style={{ fontSize: 17, width: 200, color: 'var(--gold)' }}>Coins cached</span>
        <span className="leader" style={{ flex: 1 }} />
        <span className="odometer fell" style={{ fontSize: 26, color: 'var(--gold)' }}>×{coins.toString().padStart(2, '0')}</span>
        <span className="label" style={{ width: 80, textAlign: 'right' }}>coin</span>
      </div>
    </div>
  );
}

function SuccessModal({ onReturn, haul, rescues }) {
  return (
    <div className="modal-backdrop" style={{ position: 'absolute', inset: 0, background: 'rgba(20, 16, 10, 0.6)', zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div className="panel brackets modal-card-up" style={{ width: 720, background: 'var(--paper)', padding: '36px 44px', border: '2px solid var(--ink)' }}>
        <div style={{ textAlign: 'center', marginBottom: 18 }}>
          <span className="stamp stamp-amber" style={{ fontSize: 11 }}>SAFE RETURN · VESSEL REACHED</span>
          <div className="fell" style={{ fontSize: 36, marginTop: 10 }}>Run Reckoning</div>
          <div className="elite" style={{ fontSize: 12, color: 'var(--sepia)' }}>Steered back to the Vessel alive · cargo & crew banked at the Hearth</div>
        </div>
        <HaulTable staple={haul.staple} coins={haul.coins} />
        {rescues && rescues.length > 0 && (
          <div className="row gap-8" style={{ marginTop: 12, padding: '8px 12px', border: '1.5px solid var(--el-air)', background: 'rgba(42,111,138,0.06)' }}>
            <span style={{ color: 'var(--el-air)' }}>✚</span>
            <span className="elite" style={{ fontSize: 12, color: 'var(--ink)' }}>{rescues.length} specialist lead{rescues.length > 1 ? 's' : ''} carried home safely.</span>
          </div>
        )}
        <div className="row" style={{ justifyContent: 'space-between', marginTop: 24, alignItems: 'center' }}>
          <div className="col">
            <div className="label">Manifestation Clock</div>
            <span className="elite" style={{ fontSize: 12, color: 'var(--sepia)' }}>The road spends lifespan — beat the clock home and the haul is yours</span>
          </div>
          <button className="btn btn-launch" onClick={onReturn}>Return to Hearth ▸</button>
        </div>
      </div>
    </div>
  );
}

function HuskModal({ onReturn, haul, rescues }) {
  const stranded = (rescues && rescues.length) || 0;
  return (
    <div className="modal-backdrop" style={{ position: 'absolute', inset: 0, background: 'rgba(30, 24, 12, 0.66)', zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div className="panel brackets modal-card-up" style={{ width: 720, background: 'var(--paper)', padding: '36px 44px', border: '2px solid var(--stamp-amber)' }}>
        <div style={{ textAlign: 'center', marginBottom: 18 }}>
          <span className="stamp stamp-amber" style={{ fontSize: 11 }}>MANIFESTATION · EXPIRED · AUTO-HUSK</span>
          <div className="fell" style={{ fontSize: 32, marginTop: 10 }}>The Husk Comes Home</div>
          <div className="elite" style={{ fontSize: 12, color: 'var(--sepia)' }}>The spirit's clock ran dry mid-run. The empty husk drifted back on dead reckoning — the cargo is saved.</div>
        </div>
        <HaulTable staple={haul.staple} coins={haul.coins} />
        {stranded > 0 && (
          <div style={{ border: '1.5px solid var(--crit)', padding: '12px 18px', marginTop: 12 }}>
            <div className="label" style={{ color: 'var(--crit)', marginBottom: 6 }}>STRANDED · NOT CARRIED HOME</div>
            <div className="fell" style={{ fontSize: 15, color: 'var(--ink-soft)' }}>✚ {stranded} rescued soul{stranded > 1 ? 's' : ''} left in the Strata — recoverable on a later run (Art. VIII).</div>
          </div>
        )}
        <div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
          <button className="btn btn-launch" onClick={onReturn}>Bank the Haul ▸</button>
        </div>
      </div>
    </div>
  );
}

function FailureModal({ onReturn, haul }) {
  return (
    <div className="modal-backdrop" style={{ position: 'absolute', inset: 0, background: 'rgba(40, 16, 10, 0.7)', zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div className="panel modal-card-up" style={{ width: 720, background: 'var(--paper)', padding: '36px 44px', border: '3px double var(--crit)' }}>
        <div style={{ textAlign: 'center', marginBottom: 18 }}>
          <span className="stamp" style={{ borderColor: 'var(--crit)', color: 'var(--crit)', fontSize: 11 }}>VESSEL STRUCTURAL FAILURE</span>
          <div className="fell" style={{ fontSize: 32, marginTop: 10, color: 'var(--crit)' }}>Critical Crash Initiated</div>
          <div className="elite" style={{ fontSize: 12, color: 'var(--sepia)' }}>Hull integrity reached zero · Run haul scattered to sky currents</div>
        </div>
        <div style={{ border: '1.5px solid var(--crit)', padding: '14px 18px' }}>
          <div className="label" style={{ color: 'var(--crit)', marginBottom: 8 }}>LOST TO THE WINDS</div>
          <div className="fell strike-out" style={{ fontSize: 16, color: 'var(--ink-faded)', padding: '4px 0' }}>Feed ×{haul.staple || 0}</div>
          <div className="fell strike-out" style={{ fontSize: 16, color: 'var(--ink-faded)', padding: '4px 0' }}>Coins ×{haul.coins || 0}</div>
        </div>
        <div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
          <button className="btn btn-launch" onClick={onReturn}>Return to Hearth ▸</button>
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { ExpeditionPhase });

// Expose Z0's REAL resolution data + the divergence-fixed bands so the conformance
// harness can assert B1 (deep-hazard force-floor 16), B2 (end3 pure gate ≥100), and
// A1a (linear model) against the code the player's Z0 runs actually execute.
window.SkyhearthZ0 = { Z0, pSuccess, bandOf, TOD_BONUS, TOD_PENALTY, DESTS, TOD_FACE, faceAt, roleAt, bandAt, hullFailAt, magAt };
