// game.js — REIKHELM: DESCENT front-end. A thin view over game-wasm: every
// input calls into the Game and re-renders from the returned state payload;
// the only client-side state is presentation (timers, animations, the log).

import init, { Game } from './pkg/game_wasm.js';
import { sfx } from './audio.js';

const CAMPAIGN = [
  { seed: 7,  name: 'The Hollow Threshold' },
  { seed: 69, name: 'The Drowned Halls' },
  { seed: 59, name: 'The Burning Deep' },
];
const ROMAN = ['I', 'II', 'III'];
const TICK_MS = 100;

// ------------------------------------------------------------------ //
// DOM
// ------------------------------------------------------------------ //
const $ = (id) => document.getElementById(id);
const screens = { title: $('title'), game: $('game'), death: $('death'), victory: $('victory') };
const frame = $('frame');
const frameFade = $('frame-fade');
const sprites = $('sprites');
const floaters = $('floaters');
const banner = $('banner');
const enemiesBox = $('enemies');
const logBox = $('log');
const actionbar = $('actionbar');
const minimap = $('minimap');
const mctx = minimap.getContext('2d');

// ------------------------------------------------------------------ //
// Session
// ------------------------------------------------------------------ //
let configText = null;
let manifests = [];          // parsed, in floor order (minimap needs tiles)
let manifestsText = null;    // raw JSON array string for the wasm constructor
let game = null;             // wasm Game
let st = null;               // latest state payload
let abilities = [];          // player loadout views
let seed = roll();
let lastArch = 'duelist';
let ticker = null;
let target = 1;
let castMax = {};            // actor id -> longest cast seen (telegraph scale)
let lastGrowl = 0;

function roll() { return 1 + Math.floor(Math.random() * 999_999); }

function show(name) {
  for (const k in screens) screens[k].classList.toggle('show', k === name);
}

// ------------------------------------------------------------------ //
// Boot
// ------------------------------------------------------------------ //
async function boot() {
  await init();
  configText = await (await fetch('config.json')).text();
  manifests = await Promise.all(
    CAMPAIGN.map(async (f) => (await fetch(`/out/atlas/${f.seed}/manifest.json`)).json()),
  );
  manifestsText = JSON.stringify(manifests);

  $('seed-label').textContent = String(seed).padStart(6, '0');
  $('reroll').onclick = () => {
    seed = roll();
    $('seed-label').textContent = String(seed).padStart(6, '0');
    sfx.unlock(); sfx.turn();
  };
  document.querySelectorAll('.arch-card').forEach((card) => {
    card.onclick = () => { sfx.unlock(); startRun(card.dataset.arch); };
  });
  $('btn-retry').onclick = () => { seed = roll(); startRun(lastArch); };
  $('btn-again').onclick = () => { seed = roll(); startRun(lastArch); };
  $('btn-auto').onclick = () => toggleAuto();
  $('btn-flee').onclick = () => flee();
  document.body.dataset.ready = '1';
}

function startRun(arch) {
  lastArch = arch;
  stopTicker();
  game = new Game(configText, manifestsText, arch, BigInt(seed));
  abilities = JSON.parse(game.abilities());
  buildActionbar();
  logBox.innerHTML = '';
  show('game');
  sfx.ambientOn();
  floorCard(0);
  log(`the descent begins — fate ${String(seed).padStart(6, '0')}`);
  apply(game.state());
}

// ------------------------------------------------------------------ //
// State pump: every interaction funnels through apply()
// ------------------------------------------------------------------ //
function apply(json) {
  st = JSON.parse(json);
  for (const ev of st.events) onEvent(ev);
  render();
}

function onEvent(ev) {
  switch (ev.t) {
    case 'moved': sfx.step(); dip(110); break;
    case 'bump': sfx.bump(); break;
    case 'rested': sfx.rest(); showRest(); break;
    case 'loot': {
      sfx.loot();
      flash('belt-gold');
      let s = `you find ${ev.gold} gold`;
      if (ev.item === 'draught') { s += ' and a vigor draught'; flash('belt-draught'); }
      if (ev.item === 'tonic') { s += ' and a stamina tonic'; flash('belt-tonic'); }
      if (ev.item === 'relic') s += '…';
      log(s, 'good');
      break;
    }
    case 'relic':
      sfx.relic();
      flash('belt-relic');
      log(`☥ a RELIC of the old kings — your pool widens (${ev.total})`, 'good');
      break;
    case 'item_used':
      if (ev.ok) { sfx.potion(); log(ev.kind === 'draught' ? 'the draught burns going down' : 'the tonic steadies your limbs', 'good'); }
      else if (st.mode === 'combat') log('no drinking while something wants you dead', 'bad');
      else log(`no ${ev.kind} left on your belt`, 'bad');
      break;
    case 'encounter': {
      sfx.encounter();
      bannerShow(ev.monsters.join('  &  '));
      log(`${ev.monsters.join(' and ')} bars the way!`, 'bad');
      // let the lunge land before the swing timers start
      setTimeout(() => { if (st && st.mode === 'combat') startTicker(); }, 750);
      break;
    }
    case 'win_fight': {
      sfx.monsterDeath();
      let s = `the room falls silent — ${ev.gold} gold`;
      if (ev.drops.length) s += `, dropped: ${ev.drops.join(', ')}`;
      log(s, 'good');
      flash('belt-gold');
      break;
    }
    case 'fled':
      sfx.fled();
      log('you break and run — the effort marks you', 'bad');
      break;
    case 'descend':
      sfx.descend();
      floorCard(ev.floor);
      break;
    case 'player_died':
      sfx.death();
      setTimeout(() => endDeath(ev.by), 1700);
      break;
    case 'victory':
      sfx.victory();
      setTimeout(endVictory, 1900);
      break;
  }
}

// ------------------------------------------------------------------ //
// Render
// ------------------------------------------------------------------ //
function render() {
  const floorMeta = CAMPAIGN[st.floor];
  $('floor-label').innerHTML = `floor <b>${ROMAN[st.floor]}</b> · ${floorMeta.name}`;
  $('compass').textContent = `✦ ${st.facing}`;

  setFrame(`/out/atlas/${st.floor_seed}/r${st.room}_${st.facing}.png`);
  preloadAround();

  renderPlayer();
  renderBelt();
  drawMinimap();

  const inCombat = st.mode === 'combat' || (st.combat && st.combat.outcome);
  renderSprites(inCombat);
  renderEnemies(inCombat);
  actionbar.classList.toggle('on', inCombat);
  $('combat-controls').classList.toggle('on', inCombat);
  if (st.combat) renderCombat();

  $('descend-prompt').classList.toggle(
    'show',
    st.mode === 'explore' && st.at_exit && st.floor + 1 < st.floors_total,
  );
  $('danger-vignette').classList.toggle('on', st.mode === 'explore' && st.adjacent_danger);
  if (st.mode === 'explore' && st.adjacent_danger && Date.now() - lastGrowl > 6000) {
    lastGrowl = Date.now();
    sfx.growl();
  }
}

let frameUrl = null;
function setFrame(url) {
  if (frameUrl === url) return;
  frameUrl = url;
  frame.src = url;
}
function dip(ms) {
  frameFade.classList.add('dip');
  setTimeout(() => frameFade.classList.remove('dip'), ms);
}

const preloaded = new Set();
function preloadAround() {
  const m = manifests[st.floor];
  const here = m.rooms.find((r) => r.id === st.room);
  const want = [st.room];
  if (here) for (const d of ['N', 'E', 'S', 'W']) if (here.nav[d] != null) want.push(here.nav[d]);
  for (const id of want) {
    for (const d of ['N', 'E', 'S', 'W']) {
      const u = `/out/atlas/${st.floor_seed}/r${id}_${d}.png`;
      if (!preloaded.has(u)) { preloaded.add(u); new Image().src = u; }
    }
  }
}

// ---------------- player HUD ---------------- //
function setVbar(el, a) {
  const capPct = (a.cap / a.max_vigor) * 100;
  el.querySelector('.vbar-cap').style.width = `${capPct}%`;
  el.querySelector('.vbar-fill').style.width = `${(a.vigor / a.max_vigor) * 100}%`;
  el.querySelector('.vbar-ceiling').style.left = `calc(${capPct}% - 1px)`;
  el.querySelector('.vbar-num').textContent =
    `${Math.round(a.vigor)} · cap ${Math.round(a.cap)} / ${Math.round(a.max_vigor)}`;
}

function renderPlayer() {
  const me = st.combat ? st.combat.actors.find((a) => a.id === 0) : st.player;
  setVbar($('player-vbar'), me);
  const cast = st.combat && st.combat.actors[0].casting;
  const bar = $('player-cast');
  bar.classList.toggle('on', !!cast);
  bar.classList.add('player');
  if (cast) {
    castMax[0] = Math.max(castMax[0] || 1, cast.remaining);
    bar.firstElementChild.style.width = `${(1 - cast.remaining / castMax[0]) * 100}%`;
  } else castMax[0] = 1;
}

function renderBelt() {
  $('belt-gold').querySelector('b').textContent = st.player.gold;
  $('belt-draught').querySelector('b').textContent = st.player.draughts;
  $('belt-tonic').querySelector('b').textContent = st.player.tonics;
  $('belt-relic').querySelector('b').textContent = st.player.relics;
}

function flash(id) {
  const el = $(id);
  el.classList.remove('flash');
  void el.offsetWidth;
  el.classList.add('flash');
}

// ---------------- monsters ---------------- //
let spriteIds = null; // signature of current sprite set
function renderSprites(inCombat) {
  if (!inCombat) {
    if (sprites.childElementCount) { sprites.innerHTML = ''; spriteIds = null; }
    return;
  }
  const foes = st.combat ? st.combat.actors.filter((a) => a.team === 1) : [];
  const sig = foes.map((f) => f.id).join(',');
  if (sig !== spriteIds) {
    spriteIds = sig;
    sprites.innerHTML = '';
    const lanes = foes.length === 1 ? [50] : [37, 63];
    foes.forEach((f, i) => {
      const div = document.createElement('div');
      div.className = `sprite enter slot-${i}`;
      div.style.left = `${lanes[i]}%`;
      div.dataset.actor = f.id;
      div.innerHTML = `<img src="portraits/${f.archetype || 'skeleton'}.png" alt=""><span class="tgt">▼</span>`;
      div.onclick = () => setTarget(f.id);
      sprites.appendChild(div);
      setTimeout(() => div.classList.remove('enter'), 500);
    });
  }
  for (const f of foes) {
    const div = sprites.querySelector(`[data-actor="${f.id}"]`);
    if (!div) continue;
    div.classList.toggle('dazed', f.stagger === 'dazed');
    if ((f.stagger === 'dead' || f.stagger === 'unconscious') && !div.classList.contains('dead')) {
      div.classList.add('dead');
    }
    div.classList.toggle('targeted', f.id === target && f.stagger !== 'dead');
  }
}

let panelIds = null;
function renderEnemies(inCombat) {
  if (!inCombat) {
    if (enemiesBox.childElementCount) { enemiesBox.innerHTML = ''; panelIds = null; }
    return;
  }
  const foes = st.combat ? st.combat.actors.filter((a) => a.team === 1) : [];
  const sig = foes.map((f) => f.id).join(',');
  if (sig !== panelIds) {
    panelIds = sig;
    enemiesBox.innerHTML = '';
    for (const f of foes) {
      const div = document.createElement('div');
      div.className = 'epanel';
      div.dataset.actor = f.id;
      div.innerHTML = `
        <div class="ehead"><span class="ename">${f.name}</span><span class="elvl">lvl ${f.level}</span></div>
        <div class="vbar"><div class="vbar-cap"></div><div class="vbar-fill"></div><div class="vbar-ceiling"></div><div class="vbar-num"></div></div>
        <div class="castbar"><div></div></div>
        <div class="estatus"></div>`;
      div.onclick = () => setTarget(f.id);
      enemiesBox.appendChild(div);
    }
  }
  for (const f of foes) {
    const panel = enemiesBox.querySelector(`[data-actor="${f.id}"]`);
    if (!panel) continue;
    setVbar(panel.querySelector('.vbar'), f);
    panel.classList.toggle('dead', f.stagger === 'dead' || f.stagger === 'unconscious');
    panel.classList.toggle('targeted', f.id === target && f.stagger !== 'dead');
    const cb = panel.querySelector('.castbar div');
    if (f.casting) {
      castMax[f.id] = Math.max(castMax[f.id] || 1, f.casting.remaining);
      cb.style.width = `${(1 - f.casting.remaining / castMax[f.id]) * 100}%`;
    } else { cb.style.width = '0%'; castMax[f.id] = 1; }
    const chips = [];
    if (f.stagger === 'dazed') chips.push('<span class="chip daze">DAZED</span>');
    for (const s of f.statuses) chips.push(`<span class="chip">${s.replace('_', ' ')}</span>`);
    panel.querySelector('.estatus').innerHTML = chips.join('');
  }
}

// ---------------- combat ---------------- //
function startTicker() {
  if (ticker) return;
  ticker = setInterval(() => {
    if (!game) return;
    apply(game.combatTick());
  }, TICK_MS);
}
function stopTicker() {
  if (ticker) { clearInterval(ticker); ticker = null; }
}

function renderCombat() {
  // retarget if the focus dropped
  const foes = st.combat.actors.filter((a) => a.team === 1);
  const focus = foes.find((f) => f.id === target);
  if (!focus || focus.stagger === 'dead' || focus.stagger === 'unconscious') {
    const alive = foes.find((f) => f.stagger === 'normal' || f.stagger === 'dazed');
    if (alive) setTarget(alive.id, true);
  }

  for (const ev of st.combat.events) onCombatEvent(ev);
  updateActionbar();
  $('btn-auto').classList.toggle('lit', st.player.auto_attack);

  if (st.combat.outcome) stopTicker();
}

function onCombatEvent(ev) {
  switch (ev.t) {
    case 'hit': {
      const big = ev.dmg >= 18;
      if (ev.tgt === 0) { sfx.playerHit(); floatAt('player', `-${ev.dmg.toFixed(0)}`, 'ouch'); }
      else {
        (big ? sfx.heavyHit : sfx.hit)();
        floatAt(ev.tgt, `${ev.dmg.toFixed(0)}`, big ? 'big' : 'dmg');
        hurt(ev.tgt);
      }
      if (ev.outcome === 'dazed') { sfx.daze(); floatAt(ev.tgt, 'DAZED', 'curse'); }
      if (ev.tgt === 0) flashBar();
      break;
    }
    case 'ceiling':
      floatAt(ev.tgt === 0 ? 'player' : ev.tgt, `⌄${ev.fatigue.toFixed(0)}`, 'curse');
      break;
    case 'recovery':
      sfx.recovery();
      floatAt(ev.tgt === 0 ? 'player' : ev.tgt, `+${ev.heal.toFixed(0)}`, 'heal');
      break;
    case 'cast': {
      sfx.cast();
      const name = (abilities.find((a) => a.id === ev.ability) || {}).name || ev.ability.replace(/_/g, ' ');
      if (ev.actor !== 0) floatAt(ev.actor, name, 'info');
      break;
    }
    case 'fizzle':
      sfx.fizzle();
      floatAt(ev.actor === 0 ? 'player' : ev.actor, 'fizzle!', 'info');
      break;
    case 'interrupt':
      floatAt(ev.actor === 0 ? 'player' : ev.actor, 'interrupted!', 'info');
      break;
    case 'death':
      if (ev.actor !== 0) sfx.monsterDeath();
      break;
  }
}

function hurt(actorId) {
  const div = sprites.querySelector(`[data-actor="${actorId}"]`);
  if (!div || div.classList.contains('dead')) return;
  div.classList.remove('hurt');
  void div.offsetWidth;
  div.classList.add('hurt');
}

function flashBar() {
  const bar = $('player-vbar');
  bar.classList.remove('hurtflash');
  void bar.offsetWidth;
  bar.classList.add('hurtflash');
}

function floatAt(where, text, cls) {
  const f = document.createElement('div');
  f.className = `floater ${cls}`;
  f.textContent = text;
  if (where === 'player') {
    f.style.left = `${44 + Math.random() * 12}%`;
    f.style.bottom = '24%';
  } else {
    const div = sprites.querySelector(`[data-actor="${where}"]`);
    const lane = div ? parseFloat(div.style.left) : 50;
    f.style.left = `${lane - 4 + Math.random() * 8}%`;
    f.style.bottom = `${46 + Math.random() * 8}%`;
  }
  floaters.appendChild(f);
  setTimeout(() => f.remove(), 1150);
}

function setTarget(id, silent) {
  target = id;
  if (game) game.setTarget(id);
  if (!silent) sfx.turn();
  renderSprites(true);
  if (st && st.combat) renderEnemies(true);
}

// ---------------- action bar ---------------- //
function buildActionbar() {
  actionbar.innerHTML = '';
  abilities.forEach((ab, i) => {
    const b = document.createElement('button');
    b.className = 'slot';
    b.dataset.kind = ab.kind;
    b.dataset.id = ab.id;
    const cost = ab.fill_cost > 0 ? `${ab.fill_cost}◈` : ab.fatigue_cost > 0 ? `${ab.fatigue_cost}⌄` : 'free';
    b.innerHTML = `<span class="num">${i + 1}</span><span class="nm">${ab.name}</span><span class="cost">${cost}</span><div class="cd"></div>`;
    b.onclick = () => useAbility(ab.id);
    actionbar.appendChild(b);
  });
}

function updateActionbar() {
  const me = st.combat.actors[0];
  const cds = Object.fromEntries(me.cooldowns.map((c) => [c.id, c.remaining]));
  for (const slot of actionbar.children) {
    const ab = abilities.find((a) => a.id === slot.dataset.id);
    const remaining = cds[ab.id] || 0;
    slot.querySelector('.cd').style.transform = `scaleY(${ab.cooldown ? Math.min(1, remaining / ab.cooldown) : 0})`;
    slot.classList.toggle('locked', me.vigor < ab.fill_cost || me.stagger !== 'normal');
  }
}

function useAbility(id) {
  if (!game || !st || st.mode !== 'combat') return;
  game.useAbility(id, target);
}

function toggleAuto() {
  if (!game) return;
  game.setAutoAttack(!st.player.auto_attack);
  st.player.auto_attack = !st.player.auto_attack;
  $('btn-auto').classList.toggle('lit', st.player.auto_attack);
}

function flee() {
  if (!game || !st || st.mode !== 'combat') return;
  stopTicker();
  apply(game.flee());
}

// ---------------- banner / floor card / log ---------------- //
function bannerShow(text) {
  banner.textContent = text;
  banner.classList.remove('show');
  void banner.offsetWidth;
  banner.classList.add('show');
}

function floorCard(idx) {
  $('floorcard-depth').textContent = `· floor ${ROMAN[idx]} ·`;
  $('floorcard-name').textContent = CAMPAIGN[idx].name;
  const fc = $('floorcard');
  fc.classList.remove('show');
  void fc.offsetWidth;
  fc.classList.add('show');
  log(`floor ${ROMAN[idx]} — ${CAMPAIGN[idx].name}`);
}

function log(text, cls = '') {
  const line = document.createElement('div');
  line.className = `line ${cls}`;
  line.textContent = text;
  logBox.appendChild(line);
  while (logBox.childElementCount > 4) logBox.firstElementChild.remove();
}

// ---------------- minimap ---------------- //
function drawMinimap() {
  const m = manifests[st.floor];
  const { tiles, width, height } = m;
  const s = Math.max(2, Math.floor(Math.min(196 / width, 124 / height)));
  minimap.width = width * s;
  minimap.height = height * s;
  const visited = new Set(st.visited);
  const danger = new Set(st.known_monsters);

  // ghost layer: the bones of the floor, barely visible
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      if (tiles[y][x] !== 0) {
        mctx.fillStyle = 'rgba(70, 64, 78, 0.16)';
        mctx.fillRect(x * s, y * s, s, s);
      }
    }
  }
  // visited rooms: lit
  for (const r of m.rooms) {
    if (!visited.has(r.id)) continue;
    for (let y = r.bounds.y; y < r.bounds.y + r.bounds.h; y++) {
      for (let x = r.bounds.x; x < r.bounds.x + r.bounds.w; x++) {
        const t = tiles[y] && tiles[y][x];
        if (t == null || t === 0) continue;
        mctx.fillStyle =
          t === 2 ? 'rgba(202, 162, 74, 0.85)' :
          t === 4 ? 'rgba(70, 120, 140, 0.8)' :
          t === 5 ? 'rgba(190, 90, 40, 0.85)' :
          'rgba(120, 112, 100, 0.55)';
        mctx.fillRect(x * s, y * s, s, s);
      }
    }
  }
  // markers
  for (const r of m.rooms) {
    if (!visited.has(r.id)) continue;
    const [vx, vy] = r.vantage;
    if (r.id === st.exit_room) {
      mctx.fillStyle = '#d4a54a';
      mctx.font = `${s * 3}px monospace`;
      mctx.fillText('▼', vx * s - s, vy * s + s * 1.5);
    }
    if (danger.has(r.id) && r.id !== st.room) {
      mctx.fillStyle = '#b03a2e';
      mctx.fillRect(vx * s - 1, vy * s - 1, s + 2, s + 2);
    }
  }
  // the player
  const here = m.rooms.find((r) => r.id === st.room);
  if (here) {
    const [vx, vy] = here.vantage;
    const cx = vx * s + s / 2, cy = vy * s + s / 2;
    mctx.fillStyle = '#e8dcc8';
    mctx.beginPath();
    mctx.arc(cx, cy, Math.max(2, s * 0.8), 0, Math.PI * 2);
    mctx.fill();
    const vec = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] }[st.facing];
    mctx.strokeStyle = '#d4a54a';
    mctx.lineWidth = Math.max(1.5, s * 0.5);
    mctx.beginPath();
    mctx.moveTo(cx, cy);
    mctx.lineTo(cx + vec[0] * s * 2.2, cy + vec[1] * s * 2.2);
    mctx.stroke();
  }
}

// ---------------- end screens ---------------- //
function statsHTML() {
  const s = st.stats;
  const rows = [
    [s.kills, 'slain'],
    [s.gold_looted, 'gold'],
    [st.floor + 1, 'floors'],
    [s.rooms_explored, 'rooms'],
    [s.fights, 'battles'],
    [`${Math.round(s.combat_ticks / 10)}s`, 'in combat'],
  ];
  return rows
    .map(([v, k]) => `<div class="stat"><div class="v">${v}</div><div class="k">${k}</div></div>`)
    .join('');
}

function endDeath(by) {
  stopTicker();
  sfx.ambientOff();
  $('death-by').textContent = by ? `${by} stands over your body on floor ${ROMAN[st.floor]}` : `floor ${ROMAN[st.floor]} keeps you`;
  $('death-stats').innerHTML = statsHTML();
  show('death');
}

function endVictory() {
  stopTicker();
  sfx.ambientOff();
  $('victory-stats').innerHTML = statsHTML();
  show('victory');
}

// ------------------------------------------------------------------ //
// Input
// ------------------------------------------------------------------ //
let lastRest = 0;
let restHide = null;
const restOverlay = $('rest-overlay');

// Shown by the rested event, self-hiding — robust even without a keyup.
function showRest() {
  restOverlay.classList.add('show');
  clearTimeout(restHide);
  restHide = setTimeout(() => restOverlay.classList.remove('show'), 400);
}

window.addEventListener('keydown', (e) => {
  if (!game || !st) return;
  if (e.metaKey || e.ctrlKey || e.altKey) return;
  const k = e.key.toLowerCase();

  if (st.mode === 'explore') {
    if (k === 'w' || k === 'arrowup') apply(game.step(true));
    else if (k === 's' || k === 'arrowdown') apply(game.step(false));
    else if (k === 'a' || k === 'arrowleft') { sfx.turn(); apply(game.turn(-1)); }
    else if (k === 'd' || k === 'arrowright') { sfx.turn(); apply(game.turn(1)); }
    else if (k === 'r') {
      const now = Date.now();
      if (now - lastRest > 130) { lastRest = now; apply(game.rest()); }
    } else if (k === 'g') apply(game.useItem('draught'));
    else if (k === 't') apply(game.useItem('tonic'));
    else if (k === 'enter') apply(game.descend());
    else return;
    e.preventDefault();
    return;
  }

  if (st.mode === 'combat') {
    const n = parseInt(k, 10);
    if (n >= 1 && n <= abilities.length) useAbility(abilities[n - 1].id);
    else if (k === ' ') toggleAuto();
    else if (k === 'f') flee();
    else if (k === 'tab') {
      const foes = st.combat ? st.combat.actors.filter((a) => a.team === 1 && a.stagger !== 'dead') : [];
      if (foes.length > 1) {
        const i = foes.findIndex((f) => f.id === target);
        setTarget(foes[(i + 1) % foes.length].id);
      }
    } else return;
    e.preventDefault();
  }
});

window.addEventListener('keyup', (e) => {
  if (e.key.toLowerCase() === 'r') restOverlay.classList.remove('show');
});

// Scripting/debug handle (same spirit as the playground's window.reikhelm).
window.descent = {
  get st() { return st; },
  cmd(name, ...args) { if (game) apply(game[name](...args)); },
  start(arch, s) { if (s != null) seed = s; startRun(arch); },
};

boot().catch((e) => {
  document.body.innerHTML = `<pre style="color:#b03a2e;padding:40px">boot failed: ${e}\n${e.stack || ''}</pre>`;
});
