// Pixel Lords entry: wire map + sim + renderer + HUD + build menu.

import { createMap, Terrain } from './map';
import { createSprites } from './sprites';
import { portraitURL } from './portraits';
import { loadArt } from './art';
import { Renderer, TILE } from './render';
import { TUTORIAL_STEPS, TutorialCtx } from './tutorial';
import { Sim, DAY_SECONDS, SEASONS, SKILL_NAMES, ATTR_NAMES, PERKS, TROOP_TIERS, TROOP_TIER_NAMES, SkillKey, AttrKey, FOUND_TOWN_COST, BUILD_PROJECTS, SettlementProject, TRADE_GOODS, GoodId, Needs, VASSAL_RANKS, POLICY_DEFS } from './sim';
import { ROOM_DEFS, RoomType, roomCostText, roomAffordable, roomCostParts } from './rooms';
import { startTacticalBattle, tickTactical, resolveTactical, battleTap, BattleFighter } from './battle';
import { Person } from './dynasty';
import {
  saveGame,
  loadGame,
  listSlots,
  deleteSlot,
  applySave,
  exportJSON,
  importJSON,
  LoadResult,
} from './save';
import { sfx, startMusic } from './sfx';
import { Fx } from './fx';
import { drawWorldOverlay, OverlayCtx } from './worldview';
import { showLoading, showMainMenu, showIntro, paintYield } from './menu';

const BASE = import.meta.env.BASE_URL;
const portraitSrc = (p: Person): string => portraitURL(p.id, p.gender);

const canvas = document.getElementById('game') as HTMLCanvasElement;
const hud = document.getElementById('hud') as HTMLDivElement;
const buildMenu = document.getElementById('buildmenu') as HTMLDivElement;
const roomPanel = document.getElementById('roompanel') as HTMLDivElement;
const settlePanel = document.getElementById('settlementpanel') as HTMLDivElement;
const villagePanel = document.getElementById('villagepanel') as HTMLDivElement;
const battlePromptEl = document.getElementById('battleprompt') as HTMLDivElement;
const battleBarEl = document.getElementById('battlebar') as HTMLDivElement;
// UI tasks 46-65: new elements.
const minimapEl = document.getElementById('minimap') as HTMLCanvasElement;
const notifeedEl = document.getElementById('notifeed') as HTMLDivElement;
const questTrackerEl = document.getElementById('questtracker') as HTMLDivElement;
const settlerPanel = document.getElementById('settlerpanel') as HTMLDivElement;
const techPanel = document.getElementById('techpanel') as HTMLDivElement;
const tradePanel = document.getElementById('tradepanel') as HTMLDivElement;
const achPanel = document.getElementById('achpanel') as HTMLDivElement;
const battleOverlay = document.getElementById('battleoverlay') as HTMLCanvasElement;
const pauseMenuEl = document.getElementById('pausemenu') as HTMLDivElement;

/** Escape user/sim text before injecting into HTML. */
function esc(s: string): string {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

// WORLD/POLISH: the whole game boots inside this async function (no top-level
// await — the es2020 build target rejects it). The loading screen paints
// between build steps via paintYield().
async function boot(): Promise<void> {
// WORLD/POLISH: loading screen paints while the world builds.
const loading = showLoading();
loading.setProgress(6, 'Shaping the island…');
await paintYield();
const map = createMap(256, 256, 20261012); // 2x map (was 128)
loading.setProgress(42, 'Raising kingdoms…');
await paintYield();
const sim = new Sim(map);
loading.setProgress(68, 'Waking the settlers…');
await paintYield();
const renderer = new Renderer(canvas, createSprites());
renderer.centerOn(sim.stock.x + 1, sim.stock.y + 1);
renderer.attachInput(sim);
// Swap in the baked pixel art when it arrives; the game runs on
// code-drawn sprites until then (and if the PNGs fail to load).
loadArt(renderer.sprites).then((art) => {
  renderer.sprites = art;
});
loading.setProgress(92, 'Sharpening pixels…');
await paintYield();

// ---------- WORLD/POLISH: overlay context, particles, menu gating ----------
const overlayCtx: CanvasRenderingContext2D = canvas.getContext('2d')!;
const fx = new Fx();
const octx: OverlayCtx = { ctx: overlayCtx, canvas, renderer, fx };
let gameStarted = false;
let smoothTarget: { x: number; y: number } | null = null;
let shakeT = 0; // seconds of shake remaining

/** Smoothly pan the camera to a world position (task 93). */
function smoothFocus(x: number, y: number): void {
  renderer.followPlayer = false;
  smoothTarget = { x, y };
}

function startGameLoop(): void {
  if (gameStarted) return;
  gameStarted = true;
  requestAnimationFrame(frame);
  startMusic(() => ((sim.time % DAY_SECONDS) / DAY_SECONDS) * 24);
  // Autosave every 5 minutes to the 'auto' slot.
  setInterval(() => {
    if (gameStarted && !sim.dynasty.gameOver) {
      saveGame(sim, 'auto').catch((e) => console.error('autosave failed:', e));
    }
  }, 5 * 60 * 1000);
}

// Ruin exploration: click a ruin to send your explorer's curiosity.
// (Renderer handles its own taps; this listener only cares about ruins.)
canvas.addEventListener('click', (e) => {
  if (!gameStarted) return;
  const scale = TILE * renderer.cam.zoom;
  const tx = Math.floor((e.clientX - canvas.width / 2) / scale + renderer.cam.x);
  const ty = Math.floor((e.clientY - canvas.height / 2) / scale + renderer.cam.y);
  const ri = map.ruins.findIndex((r) => Math.hypot(r.x - tx, r.y - ty) < 1.6);
  if (ri >= 0) {
    if (sim.exploreRuin(ri)) {
      fx.spark(tx + 0.5, ty + 0.5, 14, '#ffd97a');
      sfx('coin');
    } else {
      sfx('error');
    }
  }
});

// UI click sounds.
document.addEventListener('click', (e) => {
  const t = e.target as HTMLElement;
  if (t && (t.tagName === 'BUTTON' || t.closest('button'))) sfx('click');
});
// 'C' recenters on camp with a smooth pan.
window.addEventListener('keydown', (e) => {
  if (e.key.toLowerCase() === 'c' && gameStarted) {
    smoothFocus(sim.stock.x + 1, sim.stock.y + 1);
  }
});

// Sound hooks on sim events (polled cheaply each frame).
let lastLogs = 0;
let lastIron = 0;
let lastStone = 0;
let lastLevel = 1;
let lastPlayerHp = 100;
function pollSfx(): void {
  if (sim.totalLogs > lastLogs) {
    fx.leaf(sim.stock.x + 1, sim.stock.y + 1, 4);
    sfx('chop');
  }
  if (sim.iron > lastIron || sim.stone > lastStone) {
    fx.spark(sim.stock.x + 1, sim.stock.y - 1, 6, '#c9c9d2');
  }
  if (sim.playerLevel > lastLevel) {
    const p = sim.settlers.find((s) => s.id === sim.dynasty.playerSettlerId);
    if (p) fx.spark(p.x, p.y, 16, '#7ae87a');
    sfx('levelup');
  }
  const p = sim.settlers.find((s) => s.id === sim.dynasty.playerSettlerId);
  if (p && lastPlayerHp - p.hp > 15) {
    shakeT = 0.3; // big hit: shake the camera
    sfx('hit');
  }
  if (p) lastPlayerHp = p.hp;
  lastLogs = sim.totalLogs;
  lastIron = sim.iron;
  lastStone = sim.stone;
  lastLevel = sim.playerLevel;
  // Meteor / ruin rumbles set sim.shakeUntil — mirror into the camera shake.
  if (sim.time < sim.shakeUntil) {
    shakeT = Math.max(shakeT, 0.25);
    sfx('explosion');
  }
}

// ---------- build menu ----------
const SEASON_ICONS = ['🌱', '☀️', '🍂', '❄️'] as const;

// ---------- build menu: categories + search + tooltips ----------
const BUILD_CATS: { id: string; name: string; icon: string; types: RoomType[] }[] = [
  { id: 'housing', name: 'Housing', icon: '🏠', types: ['house'] },
  {
    id: 'production', name: 'Production', icon: '⚒️',
    types: ['farm', 'woodcutter', 'well', 'smelter', 'claypit', 'sawmill', 'potter', 'blacksmith', 'tailor', 'herbalist', 'workshop'],
  },
  { id: 'military', name: 'Military', icon: '🛡', types: ['guardpost'] },
  { id: 'services', name: 'Services', icon: '🛕', types: ['tavern', 'shrine'] },
];
let buildSearch = '';
let buildCat: string | null = null;
let buildChrome = false;
let buildListEl: HTMLDivElement | null = null;

function renderBuildMenu(): void {
  // Persistent chrome (search + category tabs); only the button list re-renders.
  if (!buildChrome) {
    buildMenu.innerHTML = '';
    const row = document.createElement('div');
    row.className = 'bm-row';
    const search = document.createElement('input');
    search.placeholder = '🔍 filter buildings…';
    search.title = 'Type to filter buildings by name';
    search.value = buildSearch;
    search.addEventListener('input', () => {
      buildSearch = search.value.toLowerCase();
      renderBuildList();
    });
    search.addEventListener('keydown', (e) => e.stopPropagation()); // don't walk while typing
    row.appendChild(search);
    const tabs = document.createElement('div');
    tabs.className = 'bm-row';
    const mkTab = (label: string, cat: string | null): HTMLButtonElement => {
      const b = document.createElement('button');
      b.className = 'bmtab' + (buildCat === cat ? ' on' : '');
      b.textContent = label;
      b.title = cat ? `Show ${label} buildings` : 'Show all buildings';
      b.onclick = () => {
        buildCat = cat;
        tabs.querySelectorAll('.bmtab').forEach((t) => (t as HTMLElement).classList.remove('on'));
        b.classList.add('on');
        renderBuildList();
      };
      return b;
    };
    tabs.appendChild(mkTab('All', null));
    for (const c of BUILD_CATS) tabs.appendChild(mkTab(`${c.icon} ${c.name}`, c.id));
    row.appendChild(tabs);
    buildListEl = document.createElement('div');
    buildListEl.className = 'bm-list';
    buildMenu.appendChild(row);
    buildMenu.appendChild(buildListEl);
    buildChrome = true;
  }
  renderBuildList();
}

function renderBuildList(): void {
  if (!buildListEl) return;
  buildListEl.innerHTML = '';
  if (sim.buildMode) {
    const btn = document.createElement('button');
    btn.textContent = `✕ Cancel ${ROOM_DEFS[sim.buildMode].name}`;
    btn.title = 'Cancel placement (Esc)';
    btn.onclick = () => {
      sim.buildMode = null;
      renderBuildList();
    };
    buildListEl.appendChild(btn);
    return;
  }
  let keys = Object.keys(ROOM_DEFS) as RoomType[];
  if (buildCat) {
    const cat = BUILD_CATS.find((c) => c.id === buildCat);
    if (cat) keys = keys.filter((k) => cat.types.includes(k));
  }
  if (buildSearch) keys = keys.filter((k) => ROOM_DEFS[k].name.toLowerCase().includes(buildSearch));
  for (const key of keys) {
    const def = ROOM_DEFS[key];
    const btn = document.createElement('button');
    const costTxt = roomCostText(def);
    btn.textContent = `${def.icon} ${def.name} (${costTxt})`;
    btn.title = `${def.name}: ${def.desc}${def.recipe ? ' — ' + def.recipe : ''} [${BUILD_CATS.find((c) => c.types.includes(key))?.name ?? ''}]`;
    btn.disabled = !roomAffordable(sim, def);
    btn.onclick = () => {
      beep(440, 0.06);
      sim.buildMode = key;
      sim.inspectedRoom = null;
      renderer.inspected = null;
      renderBuildList();
    };
    buildListEl.appendChild(btn);
  }
  // P4: found your own town at the camp.
  const foundBtn = document.createElement('button');
  foundBtn.textContent = `🏘️ Found town (${FOUND_TOWN_COST}🪙)`;
  foundBtn.title = 'Found your own town at your camp. It pays daily taxes.';
  foundBtn.disabled = sim.gold < FOUND_TOWN_COST || sim.playerTownIndex !== null;
  foundBtn.onclick = () => {
    beep(520, 0.08);
    sim.foundTown();
    renderBuildList();
  };
  buildListEl.appendChild(foundBtn);
}
renderBuildMenu();

// ---------- room panel ----------
function renderRoomPanel(): void {
  const room = sim.inspectedRoom;
  if (!room) {
    roomPanel.style.display = 'none';
    return;
  }
  const def = ROOM_DEFS[room.type];
  roomPanel.style.display = 'block';
  let html = `<b>${def.name}</b> <span class="dim">${room.built ? 'built' : `building ${Math.round(room.progress * 100)}%`}</span>`;
  if (room.built && room.level >= 2) html += ` <span style="color:#ffd97a">⭐Lv2</span>`;
  html += ` <button id="rp-close" title="Close">✕</button><br/>`;
  // Production rate: recipe (real timing) or description, plus staffing effect.
  if (room.built) {
    const rate = def.recipe ?? def.desc;
    const lvl = room.level >= 2 ? ' <b style="color:#ffd97a">2× speed (Lv2)</b>' : '';
    const staff = def.maxWorkers > 0 ? ` — staffed ${room.workers.length}/${def.maxWorkers}` : '';
    html += `<span class="dim" title="Production rate">⚙ ${rate}${lvl}${staff}</span><br/>`;
  }
  // P10: recipe line + upgrade + blacksmith toggle.
  if (room.built && def.recipe) {
    const recipeLine = room.type === 'blacksmith' && room.recipe === 'tools'
      ? '2🔩 → 3🔨 tools / 60s'
      : def.recipe;
    html += `<span class="dim">${recipeLine}</span><br/>`;
    if (room.type === 'blacksmith') {
      const making = room.recipe === 'tools' ? 'Tools (2🔩→3🔨)' : 'Weapons (2🔩→2⚔️)';
      html += `<span class="dim">making:</span> ${making} <button id="rp-recipe">switch</button><br/>`;
    }
    if (room.level < 2) {
      const up = roomCostParts(def, 1.5);
      const upTxt = [up.logs ? `${up.logs}🌲` : '', up.stone ? `${up.stone}🪨` : '', up.clay ? `${up.clay}🧱` : '', up.planks ? `${up.planks}🪵` : ''].filter(Boolean).join('+');
      html += `<button id="rp-upgrade">⬆ Upgrade to Lv2 (${upTxt}) — 2× speed</button><br/>`;
    }
  }
  if (room.built && def.maxWorkers > 0) {
    html += `<span class="dim">workers ${room.workers.length}/${def.maxWorkers}</span><br/>`;
    for (const wid of room.workers) {
      const s = sim.settlers.find((st) => st.id === wid);
      html += `<div>#${wid}${s ? ` (${s.state}, hunger ${Math.round(s.hunger)})` : ''} <button data-fire="${wid}">−</button></div>`;
    }
    const idle = sim.idleSettlers();
    if (room.workers.length < def.maxWorkers && idle.length > 0) {
      html += `<span class="dim">hire:</span> `;
      for (const s of idle.slice(0, 6)) {
        html += `<button data-hire="${s.id}">#${s.id}</button> `;
      }
      if (idle.length > 6) html += `<span class="dim">+${idle.length - 6} more idle</span>`;
    } else if (room.workers.length >= def.maxWorkers) {
      html += `<span class="dim">fully staffed</span>`;
    } else {
      html += `<span class="dim">no idle settlers — free someone up or grow</span>`;
    }
  } else if (room.built && room.type === 'house') {
    html += `<span class="dim">houses 4 settlers</span>`;
  }
  roomPanel.innerHTML = html;
  const close = document.getElementById('rp-close');
  if (close) close.onclick = () => (sim.inspectedRoom = null);
  roomPanel.querySelectorAll('[data-hire]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.assignWorker(room.id, Number((b as HTMLElement).dataset.hire));
    });
  });
  roomPanel.querySelectorAll('[data-fire]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.unassignWorker(room.id, Number((b as HTMLElement).dataset.fire));
    });
  });
  const upBtn = document.getElementById('rp-upgrade');
  if (upBtn) {
    upBtn.onclick = () => {
      sim.upgradeRoom(room.id);
      renderRoomPanel();
      renderBuildMenu();
    };
  }
  const recBtn = document.getElementById('rp-recipe');
  if (recBtn) {
    recBtn.onclick = () => {
      sim.setRoomRecipe(room.id, room.recipe === 'tools' ? 'weapons' : 'tools');
      renderRoomPanel();
    };
  }
}

// ---------- P4: settlement panel ----------
function statBar(v: number): string {
  const c = v >= 60 ? '#7ac74f' : v >= 30 ? '#e8c84a' : '#c74f4f';
  return `<div class="cbar"><div style="width:${Math.round(v)}%;background:${c}"></div></div>`;
}

function renderSettlementPanel(): void {
  const ki = renderer.inspectedKingdom;
  const k = ki != null ? sim.kingdoms[ki] : null;
  if (!k || ki == null) {
    settlePanel.style.display = 'none';
    return;
  }
  const isPlayer = ki === sim.playerTownIndex;
  settlePanel.style.display = 'block';
  let html = `<b style="color:${k.color}">${k.name}</b> <span class="dim">${k.kind}${isPlayer ? ' — yours' : ''}</span>`;
  html += ` <button id="sp-close" style="float:right">✕</button><br/>`;
  html += `<span class="dim">owner:</span> ${isPlayer ? 'You' : k.name} &nbsp; <span class="dim">pop:</span> ${k.population.toLocaleString()}<br/>`;
  html += `<span class="dim">prosperity</span> ${statBar(k.prosperity)}`;
  html += `<span class="dim">loyalty</span> ${statBar(k.loyalty)}`;
  html += `<span class="dim">security</span> ${statBar(k.security)}`;
  html += `<div><span class="dim">garrison:</span> ${k.garrison} &nbsp; <span class="dim">treasury:</span> 🪙${Math.round(k.gold)}</div>`;
  if (k.project) {
    const done = k.project.days - k.project.left;
    html += `<div><span class="dim">building:</span> ${k.project.name} ${done}/${k.project.days} days</div>`;
  } else {
    html += `<div><span class="dim">projects:</span></div>`;
    for (const id of Object.keys(BUILD_PROJECTS) as SettlementProject['id'][]) {
      const p = BUILD_PROJECTS[id];
      const can = sim.gold >= p.cost;
      html += `<button data-sbuild="${id}" ${can ? '' : 'disabled'} title="${p.desc}">${p.name} — ${p.cost}g</button> `;
    }
    html += `<br/>`;
  }
  if (isPlayer) {
    html += `<div><span class="dim">daily taxes auto-transfer to you</span></div>`;
    html += `<button data-collect="${ki}">Collect ${Math.round(k.gold)}g treasury</button>`;
  }
  // P7: player sieges of enemy towns.
  const pAtWar = sim.playerWars.includes(ki);
  const ps = sim.playerSiege;
  if (!isPlayer && pAtWar) {
    if (ps && ps.town === ki) {
      html += `<div style="margin-top:6px"><b>⚔ Besieging ${k.name}</b> <span class="dim">day ${ps.days} · garrison ${k.garrison}</span><br/>`;
      if (!ps.ram) html += `<button data-ram="1" ${sim.gold >= 50 ? '' : 'disabled'}>🐏 Ram — 50g</button> `;
      else html += `<span class="dim">🐏 ram ${ps.ramT > 0 ? 'building' : 'ready'}</span> `;
      if (!ps.ladders) html += `<button data-lad="1" ${sim.gold >= 30 ? '' : 'disabled'}>🪜 Ladders — 30g</button> `;
      else html += `<span class="dim">🪜 ladders ready</span> `;
      html += `<button data-assault="${ki}">⚔ ASSAULT</button></div>`;
    } else if (!ps) {
      html += `<div style="margin-top:6px"><button data-psiege="${ki}">⚔ Begin siege</button> <span class="dim">march your party to the walls first</span></div>`;
    }
  }
  // P5: trade.
  html += `<div style="margin-top:6px"><b>🏪 Market</b> <span class="dim">cargo ${sim.cargoUsed()}/${sim.cargoCap()} · you hold ${Math.round(sim.gold)}g</span></div>`;
  html += `<div style="font-size:13px">`;
  for (const g of TRADE_GOODS) {
    const p = sim.marketPrice(ki, g.id);
    const st = k.market.stock[g.id];
    const tag = k.market.produces.includes(g.id)
      ? '<span style="color:#7ac74f">cheap</span>'
      : k.market.demands.includes(g.id)
        ? '<span style="color:#e8c84a">wanted</span>'
        : '';
    html += `<div>${g.icon} ${g.name} <b>${p}g</b> <span class="dim">×${st}</span> ${tag} `;
    html += `<button data-buy="${g.id}" data-n="1">Buy 1</button><button data-buy="${g.id}" data-n="5">Buy 5</button> `;
    html += `<button data-sell="${g.id}" data-n="1">Sell 1</button><button data-sell="${g.id}" data-n="5">Sell 5</button> `;
    html += `<span class="dim">you: ${sim.goods[g.id]}</span></div>`;
  }
  html += `</div>`;
  const myCar = sim.playerCaravans.filter((c) => true);
  html += `<div style="margin-top:4px"><button data-pcar="${ki}" ${sim.gold >= 200 && sim.playerCaravans.length < 2 ? '' : 'disabled'}>🏇 Send caravan (200g)</button> `;
  html += `<button data-wshop="${ki}" ${sim.gold >= 400 && !sim.workshops.some((w) => w.town === ki) ? '' : 'disabled'}>🏭 Buy workshop (400g)</button></div>`;
  if (myCar.length > 0) {
    html += `<div class="dim">your caravans: ${myCar.map((c) => `${sim.kingdoms[c.from]?.name}→${sim.kingdoms[c.to]?.name}${c.cargo ? ` (${c.units} ${c.cargo})` : ''}`).join(' · ')}</div>`;
  }
  const myWs = sim.workshops.filter((w) => w.town === ki);
  if (myWs.length > 0) {
    html += `<div class="dim">your workshop: making ${myWs[0]!.good}, daily income</div>`;
  }
  settlePanel.innerHTML = html;
  const close = document.getElementById('sp-close');
  if (close) close.onclick = () => (renderer.inspectedKingdom = null);
  settlePanel.querySelectorAll('[data-sbuild]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.buyProject(ki, (b as HTMLElement).dataset.sbuild as SettlementProject['id']);
      renderSettlementPanel();
    });
  });
  settlePanel.querySelectorAll('[data-collect]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.collectTaxes(Number((b as HTMLElement).dataset.collect));
      renderSettlementPanel();
    });
  });
  settlePanel.querySelectorAll('[data-buy]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.buyGood(ki, (b as HTMLElement).dataset.buy as GoodId, Number((b as HTMLElement).dataset.n));
      renderSettlementPanel();
    });
  });
  settlePanel.querySelectorAll('[data-sell]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.sellGood(ki, (b as HTMLElement).dataset.sell as GoodId, Number((b as HTMLElement).dataset.n));
      renderSettlementPanel();
    });
  });
  settlePanel.querySelectorAll('[data-pcar]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.sendCaravan(Number((b as HTMLElement).dataset.pcar));
      renderSettlementPanel();
    });
  });
  settlePanel.querySelectorAll('[data-wshop]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.buyWorkshop(Number((b as HTMLElement).dataset.wshop));
      renderSettlementPanel();
    });
  });
  settlePanel.querySelectorAll('[data-psiege]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.startPlayerSiege(Number((b as HTMLElement).dataset.psiege));
      renderSettlementPanel();
    });
  });
  settlePanel.querySelectorAll('[data-ram]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.buildSiegeRam();
      renderSettlementPanel();
    });
  });
  settlePanel.querySelectorAll('[data-lad]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.buildSiegeLadders();
      renderSettlementPanel();
    });
  });
  settlePanel.querySelectorAll('[data-assault]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.assaultTown(Number((b as HTMLElement).dataset.assault));
      renderSettlementPanel();
    });
  });
}

// ---------- P13: village panel ----------
function renderVillagePanel(): void {
  const vid = renderer.inspectedVillage;
  const v = vid != null ? sim.villages.find((x) => x.id === vid) : null;
  if (!v) {
    villagePanel.style.display = 'none';
    return;
  }
  const k = sim.kingdoms[v.townId];
  const isFief = sim.playerTownIndex != null && v.townId === sim.playerTownIndex;
  const price = sim.villagePrice(v.id);
  const gi = TRADE_GOODS.find((g) => g.id === v.produces);
  villagePanel.style.display = 'block';
  let html = `<b>${v.name}</b> <span class="dim">${v.neutral ? '🛖 free village — beholden to no lord' : `village of ${k ? k.name : '?'}`}</span>`;
  html += ` <button id="vp-close" style="float:right">✕</button><br/>`;
  html += `<span class="dim">prosperity</span> ${statBar(v.prosperity)}`;
  if (v.raidedDays > 0) html += `<div>🔥 raided — recovering (${v.raidedDays}d)</div>`;
  html += `<div><span class="dim">produces:</span> ${gi ? gi.icon : ''} ${v.produces} <span class="dim">×${v.stock}</span> <b>${price}g</b> <span class="dim">10% off town price</span></div>`;
  html += `<div><span class="dim">recruits:</span> ${v.recruits}/6</div>`;
  html += `<button data-vrecruit="${v.id}" ${sim.gold >= 20 && v.recruits > 0 ? '' : 'disabled'}>🗡 Recruit militia — 20g</button> `;
  html += `<button data-vbuy="${v.id}" ${sim.gold >= price && v.stock > 0 ? '' : 'disabled'}>Buy ${v.produces} — ${price}g</button>`;
  if (isFief) {
    const inc = Math.floor((2 * v.prosperity) / 25);
    html += `<div class="dim">your fief: +${inc}g/day</div>`;
  }
  villagePanel.innerHTML = html;
  const close = document.getElementById('vp-close');
  if (close) close.onclick = () => (renderer.inspectedVillage = null);
  villagePanel.querySelectorAll('[data-vrecruit]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.recruitFromVillage(Number((b as HTMLElement).dataset.vrecruit));
      renderVillagePanel();
    });
  });
  villagePanel.querySelectorAll('[data-vbuy]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.buyVillageGood(Number((b as HTMLElement).dataset.vbuy), 1);
      renderVillagePanel();
    });
  });
}

// ---------- family panel ----------
const familyBtn = document.getElementById('familybtn') as HTMLDivElement;
const familyPanel = document.getElementById('familypanel') as HTMLDivElement;
const gameoverDiv = document.getElementById('gameover') as HTMLDivElement;
let familyOpen = false;
familyBtn.textContent = '👑 Family';
familyBtn.onclick = () => {
  familyOpen = !familyOpen;
  renderFamilyPanel();
};

function personLine(p: Person, extra: string): string {
  return `<div><img src="${portraitSrc(p)}" width="44" height="44" style="vertical-align:middle;image-rendering:pixelated;border:1px solid #3a4356;border-radius:4px"/> ${p.name} <span class="dim">${extra}</span></div>`;
}

// ---------- family tree view (task 63) ----------
let familyTreeView = false;
let treeDetailId: string | null = null;

/** Visual dynasty chart: generations as nested rows; click a person for details. */
function renderFamilyTreeHtml(): string {
  const d = sim.dynasty;
  const nowDay = sim.day;
  const kidsOf = (id: string): Person[] =>
    [...d.people.values()].filter((p) => p.fatherId === id || p.motherId === id);
  const box = (p: Person): string => {
    const alive = d.isAlive(p);
    const sel = treeDetailId === p.id ? ' style="border-color:#ffd97a"' : '';
    return `<span class="tbox" data-person="${p.id}" title="${esc(p.name)} — click for details"${sel}>${esc(p.name.split(' ')[0])}${alive ? '' : ' †'}</span>`;
  };
  const renderGen = (id: string, depth: number): string => {
    const p = d.people.get(id);
    if (!p || depth > 4) return '';
    const kids = kidsOf(id);
    const spouse = d.spouseOf(p);
    let h = `<div class="tgen">${box(p)}`;
    if (spouse && d.isAlive(spouse)) h += ` <span class="dim">💍</span> ${box(spouse)}`;
    for (const k of kids) h += renderGen(k.id, depth + 1);
    h += `</div>`;
    return h;
  };
  let html = `<span class="dim">House ${esc(d.houseName)} — generations</span><br/>`;
  html += renderGen(d.founderId, 0);
  if (treeDetailId) {
    const p = d.people.get(treeDetailId);
    if (p) {
      const spouse = d.spouseOf(p);
      const kids = kidsOf(p.id);
      html += `<hr/><div><img src="${portraitSrc(p)}" width="60" height="60" style="image-rendering:pixelated;border:1px solid #3a4356;border-radius:4px"/> `;
      html += `<b>${esc(p.name)}</b> <span class="dim">${d.ageYears(p, nowDay)} yrs${d.isAlive(p) ? '' : ' †'}</span><br/>`;
      html += `<span class="dim">spouse:</span> ${spouse ? esc(spouse.name) : '—'}<br/>`;
      html += `<span class="dim">children (${kids.length}):</span> ${kids.length > 0 ? kids.map((k) => esc(k.name)).join(', ') : '—'}</div>`;
    }
  }
  return html;
}

function renderFamilyPanel(): void {
  if (!familyOpen) {
    familyPanel.style.display = 'none';
    return;
  }
  familyPanel.style.display = 'block';
  const d = sim.dynasty;
  const nowDay = sim.day;
  const founder = d.founder;
  let html = `<b>House ${d.houseName}</b> <button id="fp-close">✕</button> `;
  html += `<button id="fp-tree" title="Toggle between list and family tree view">${familyTreeView ? '📋 List' : '🌳 Tree'}</button><br/>`;
  if (familyTreeView) {
    html += renderFamilyTreeHtml();
    familyPanel.innerHTML = html;
    (document.getElementById('fp-close') as HTMLButtonElement).onclick = () => ((familyOpen = false), renderFamilyPanel());
    (document.getElementById('fp-tree') as HTMLButtonElement).onclick = () => {
      familyTreeView = false;
      renderFamilyPanel();
    };
    familyPanel.querySelectorAll('[data-person]').forEach((el) => {
      (el as HTMLElement).onclick = () => {
        treeDetailId = (el as HTMLElement).dataset.person!;
        renderFamilyPanel();
      };
    });
    return;
  }
  html += personLine(founder, `${d.ageYears(founder, nowDay)} yrs${d.isAlive(founder) ? '' : ' †'} 👑`);
  const spouse = d.spouseOf(founder);
  if (spouse && d.isAlive(spouse)) {
    html += personLine(spouse, `${d.ageYears(spouse, nowDay)} yrs 💍`);
  } else if (d.isAlive(founder)) {
    const hasHouse = sim.rooms.some((r) => r.built && r.type === 'house');
    html += `<span class="dim">unwed</span> `;
    if (!hasHouse) {
      html += `<span class="dim">(build a house to marry)</span><br/>`;
    } else {
      const candidates = sim.settlers.filter((s) => {
        if (!s.personId || s.personId === founder.id) return false;
        const p = d.people.get(s.personId);
        return !!p && d.isAlive(p) && !p.spouseId && d.ageYears(p, nowDay) >= 18;
      });
      if (candidates.length === 0) {
        html += `<span class="dim">(no adult candidates)</span><br/>`;
      } else {
        html += `<br/><span class="dim">marry:</span> `;
        for (const c of candidates.slice(0, 6)) {
          const p = d.people.get(c.personId!)!;
          html += `<button data-marry="${c.id}">${p.name.split(' ')[0]}</button> `;
        }
        html += `<br/>`;
      }
    }
  }
  const heir = d.gameOver ? null : d.heirOf(d.founderId, nowDay);
  html += `<span class="dim">heir:</span> ${heir ? `<b>${heir.name}</b> (${d.ageYears(heir, nowDay)} yrs) 🥈` : '<b style="color:#ff7a7a">none — the line will end</b>'}<br/>`;
  const kids = d.livingChildrenOf(d.founderId);
  if (kids.length > 0) {
    html += `<span class="dim">children:</span><br/>`;
    for (const k of kids) {
      html += personLine(k, `${d.ageYears(k, nowDay)} yrs`);
    }
  }
  if (sim.announcements.length > 0) {
    html += `<hr/><span class="dim">chronicle:</span><br/>`;
    for (const a of sim.announcements.slice(-4)) {
      html += `<div class="dim">day ${a.day}: ${a.text}</div>`;
    }
  }
  familyPanel.innerHTML = html;
  const close = document.getElementById('fp-close');
  if (close) close.onclick = () => ((familyOpen = false), renderFamilyPanel());
  const treeBtn = document.getElementById('fp-tree');
  if (treeBtn) treeBtn.onclick = () => {
    familyTreeView = true;
    renderFamilyPanel();
  };
  familyPanel.querySelectorAll('[data-marry]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.marryFounder(Number((b as HTMLElement).dataset.marry));
    });
  });
}

// ---------- quest log ----------
const questBtn = document.getElementById('questbtn') as HTMLDivElement;
const questPanel = document.getElementById('questpanel') as HTMLDivElement;
const invBtn = document.getElementById('invbtn') as HTMLDivElement;
const invPanel = document.getElementById('invpanel') as HTMLDivElement;
let questOpen = false;
questBtn.onclick = () => {
  questOpen = !questOpen;
  renderQuestPanel();
};
// ---------- P11: needs overview ----------
const needsPanel = document.getElementById('needspanel') as HTMLDivElement;
let needsOpen = false;

const NEED_LABELS: [keyof Needs, string, string][] = [
  ['food', '🍖 Food', 'Food low — build Farms, chop less, hunt more'],
  ['water', '💧 Water', 'Water low — build Wells'],
  ['housing', '🏠 Housing', 'Housing low — build Houses for free beds'],
  ['clothing', '👕 Clothing', 'Clothing low — build a Tailor, stock clothes'],
  ['health', '❤️ Health', 'Health low — stock Medicine (Herbalist), avoid fights'],
  ['joy', '🍺 Joy', 'Joy low — build a Tavern (needs an innkeeper)'],
  ['faith', '🛕 Faith', 'Faith low — build a Shrine'],
];

function renderNeedsPanel(): void {
  if (!needsOpen) {
    needsPanel.style.display = 'none';
    return;
  }
  needsPanel.style.display = 'block';
  const n = sim.settlers.length;
  const avg: Record<string, number> = {};
  for (const [key] of NEED_LABELS) avg[key] = 0;
  for (const s of sim.settlers) {
    for (const [key] of NEED_LABELS) avg[key]! += s.needs[key];
  }
  if (n > 0) for (const [key] of NEED_LABELS) avg[key]! /= n;
  const happy = Math.round(sim.avgHappiness());
  let html = `<b>📊 Camp Needs</b> ${sim.happinessFace(sim.avgHappiness())} <b>${happy}%</b> <button id="np-close">✕</button><br/>`;
  html += `<span class="dim">${n} settlers • happy: work +15% • miserable: work −20%, may leave</span><br/>`;
  let worst: string | null = null;
  let worstV = 101;
  for (const [key, label] of NEED_LABELS) {
    const v = Math.round(avg[key]!);
    if (v < worstV) {
      worstV = v;
      worst = key;
    }
    const c = v >= 60 ? '#7ac74f' : v >= 35 ? '#e8c84a' : '#c74f4f';
    html += `<div>${label} <b>${v}</b><div class="nbar"><div style="width:${v}%;background:${c}"></div></div></div>`;
  }
  if (worst) {
    const advice = NEED_LABELS.find(([k]) => k === worst)![2];
    html += `<span style="color:#ffd97a">💡 ${advice}</span>`;
  }
  needsPanel.innerHTML = html;
  const close = document.getElementById('np-close');
  if (close) close.onclick = () => {
    needsOpen = false;
    renderNeedsPanel();
  };
}
let invOpen = false;
invBtn.onclick = () => {
  invOpen = !invOpen;
  renderInvPanel();
};

// Zoom buttons (mobile-friendly).
(document.getElementById('zin') as HTMLButtonElement).onclick = () => renderer.zoomBy(1.3);
(document.getElementById('zout') as HTMLButtonElement).onclick = () => renderer.zoomBy(1 / 1.3);

// ---------- save / load ----------
const saveBtn = document.getElementById('savebtn') as HTMLDivElement;
const savePanel = document.getElementById('savepanel') as HTMLDivElement;
let saveOpen = false;
saveBtn.onclick = () => {
  saveOpen = !saveOpen;
  void renderSavePanel();
};

// ---------- save timestamps (for the "Saved Xm ago" indicator) ----------
let saveTimes: Record<string, number> = {};
try {
  saveTimes = JSON.parse(localStorage.getItem('pl-savetimes') || '{}') as Record<string, number>;
} catch {
  saveTimes = {};
}
function recordSaveTime(slot: string): void {
  saveTimes[slot] = Date.now();
  try {
    localStorage.setItem('pl-savetimes', JSON.stringify(saveTimes));
  } catch {
    /* ignore */
  }
}
/** Human age of a timestamp: "just now", "2m ago", "3h ago", "4d ago". */
function ageStr(t: number): string {
  const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
  if (s < 60) return 'just now';
  const m = Math.floor(s / 60);
  if (m < 60) return `${m}m ago`;
  const h = Math.floor(m / 60);
  if (h < 24) return `${h}h ago`;
  return `${Math.floor(h / 24)}d ago`;
}

async function renderSavePanel(): Promise<void> {
  if (!saveOpen) {
    savePanel.style.display = 'none';
    return;
  }
  savePanel.style.display = 'block';
  let slots: string[] = [];
  let idbOk = true;
  try {
    slots = await listSlots();
  } catch {
    idbOk = false;
  }
  let html = `<b>💾 Save / Load</b> <button id="sp-close">✕</button><br/><hr/>`;
  if (!idbOk) {
    html += `<span class="dim">storage unavailable in this browser</span><br/>`;
  }
  html += `<button id="sp-export" title="Download the save as a JSON file">Export</button> `;
  html += `<button id="sp-importbtn" title="Load a save from a JSON file">Import</button><input id="sp-import" type="file" accept=".json,application/json" style="display:none"/><br/><hr/>`;
  html += `<span class="dim">autosaves every 60s</span><br/>`;
  const slotName = (s: string): string => (s === 'auto' ? '💾 autosave' : `💾 slot ${s.replace('slot', '')}`);
  const slotTime = (s: string): string => {
    const t = saveTimes[s];
    if (!t) return '';
    return `<span class="dim" title="${new Date(t).toLocaleString()}"> — ${ageStr(t)}</span>`;
  };
  for (const s of ['slot1', 'slot2', 'slot3']) {
    const has = slots.includes(s);
    html += `<div>${slotName(s)}${slotTime(s)} <button data-save="${s}" title="Save the game into this slot">Save</button> `;
    if (has) html += `<button data-load="${s}" title="Load this save">Load</button> <button data-del="${s}" title="Delete this save">Delete</button>`;
    else html += `<span class="dim">empty</span>`;
    html += `</div>`;
  }
  if (slots.includes('auto')) {
    html += `<div>${slotName('auto')}${slotTime('auto')} <button data-load="auto" title="Load the autosave">Load</button></div>`;
  }
  savePanel.innerHTML = html;
  (document.getElementById('sp-close') as HTMLButtonElement).onclick = () => {
    saveOpen = false;
    void renderSavePanel();
  };
  savePanel.querySelectorAll('[data-save]').forEach((b) => {
    b.addEventListener('click', async () => {
      const slot = (b as HTMLElement).dataset.save!;
      try {
        await saveGame(sim, slot);
        recordSaveTime(slot);
        beep(660, 0.08);
        flashToast(`<b>Saved to slot ${slot.replace('slot', '')}.</b>`);
      } catch {
        flashToast('<b>Save failed</b> — storage unavailable.');
      }
      void renderSavePanel();
    });
  });
  (document.getElementById('sp-export') as HTMLButtonElement).onclick = () => {
    const blob = new Blob([exportJSON(sim)], { type: 'application/json' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = 'pixel-lords-save.json';
    a.click();
    setTimeout(() => URL.revokeObjectURL(a.href), 5000);
  };
  (document.getElementById('sp-importbtn') as HTMLButtonElement).onclick = () => {
    (document.getElementById('sp-import') as HTMLInputElement).click();
  };
  (document.getElementById('sp-import') as HTMLInputElement).onchange = (e) => {
    const file = (e.target as HTMLInputElement).files?.[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => {
      try {
        const data = importJSON(String(reader.result));
        applySave(sim, data);
        flashToast('<b>Save imported.</b>');
      } catch {
        flashToast('<b>Import failed</b> — not a valid save file.');
      }
    };
    reader.readAsText(file);
  };
  savePanel.querySelectorAll('[data-load]').forEach((b) => {
    b.addEventListener('click', async () => {
      const slot = (b as HTMLElement).dataset.load!;
      const res = await loadGame(slot);
      if (res.status === 'ok') {
        applySave(sim, res.data);
        renderer.centerOn(sim.stock.x + 1, sim.stock.y + 1);
        renderer.followPlayer = true; // P12: may have been in battle view
        flashToast('<b>Loaded.</b>');
      } else if (res.status === 'corrupt') {
        flashToast('<b>Save corrupted, starting fresh.</b>');
      } else {
        flashToast('<b>No save in that slot.</b>');
      }
      void renderSavePanel();
    });
  });
  savePanel.querySelectorAll('[data-del]').forEach((b) => {
    b.addEventListener('click', async () => {
      const slot = (b as HTMLElement).dataset.del!;
      try {
        await deleteSlot(slot);
      } catch {
        /* noop */
      }
      delete saveTimes[slot];
      try {
        localStorage.setItem('pl-savetimes', JSON.stringify(saveTimes));
      } catch {
        /* ignore */
      }
      void renderSavePanel();
    });
  });
}

// Autosave every 60s and on page hide.
setInterval(() => {
  saveGame(sim, 'auto')
    .then(() => recordSaveTime('auto'))
    .catch(() => {});
}, 60000);
window.addEventListener('pagehide', () => {
  saveGame(sim, 'auto').catch(() => {});
});

// ---------- settings (P9; extended: volume, particle quality) ----------
interface Settings {
  zoomSens: number;
  reduceParticles: boolean; // legacy flag, migrated to `particles`
  showTutorial: boolean;
  volume: number; // 0..100
  particles: 'low' | 'high';
}
const DEFAULT_SETTINGS: Settings = { zoomSens: 1, reduceParticles: false, showTutorial: true, volume: 40, particles: 'high' };
function loadSettings(): Settings {
  try {
    const raw = localStorage.getItem('pl-settings');
    if (raw) {
      const s = { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
      if (!s.particles) s.particles = s.reduceParticles ? 'low' : 'high';
      return s;
    }
  } catch {
    /* ignore */
  }
  return { ...DEFAULT_SETTINGS };
}
const settings = loadSettings();
function persistSettings(): void {
  try {
    localStorage.setItem('pl-settings', JSON.stringify(settings));
  } catch {
    /* ignore */
  }
}
function applySettings(): void {
  renderer.zoomSens = settings.zoomSens;
  renderer.reduceParticles = settings.particles === 'low';
}
applySettings();

// ---------- tiny WebAudio SFX (drives the volume slider) ----------
let audioCtx: AudioContext | null = null;
function ensureAudio(): void {
  try {
    if (!audioCtx) audioCtx = new AudioContext();
    if (audioCtx.state === 'suspended') void audioCtx.resume();
  } catch {
    /* no audio */
  }
}
window.addEventListener('pointerdown', ensureAudio, { once: true });
/** Play a short blip. Volume comes from settings.volume. */
function beep(freq: number, dur = 0.08, type: OscillatorType = 'square'): void {
  if (settings.volume <= 0) return;
  try {
    ensureAudio();
    if (!audioCtx || audioCtx.state !== 'running') return;
    const o = audioCtx.createOscillator();
    const g = audioCtx.createGain();
    o.type = type;
    o.frequency.value = freq;
    g.gain.value = (settings.volume / 100) * 0.06;
    o.connect(g);
    g.connect(audioCtx.destination);
    o.start();
    g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + dur);
    o.stop(audioCtx.currentTime + dur);
  } catch {
    /* no audio */
  }
}

const setBtn = document.getElementById('setbtn') as HTMLDivElement;
const setPanel = document.getElementById('setpanel') as HTMLDivElement;
let setOpen = false;
function renderSettingsPanel(): void {
  if (!setOpen) {
    setPanel.style.display = 'none';
    return;
  }
  setPanel.style.display = 'block';
  setPanel.innerHTML =
    `<b>⚙ Settings</b> <button id="set-close" style="float:right">✕</button><br/>` +
    `<span class="dim">Volume</span><br/>` +
    `<input type="range" id="set-vol" min="0" max="100" step="5" value="${settings.volume}" title="Sound effects volume"/> ` +
    `<span id="set-volv">${settings.volume}%</span><br/>` +
    `<span class="dim">Zoom sensitivity</span><br/>` +
    `<input type="range" id="set-zoom" min="0.5" max="2" step="0.1" value="${settings.zoomSens}"/> ` +
    `<span id="set-zoomv">${settings.zoomSens.toFixed(1)}×</span><br/>` +
    `<span class="dim">Particle quality</span> ` +
    `<select id="set-pq" title="Particle effects density">` +
    `<option value="high" ${settings.particles === 'high' ? 'selected' : ''}>High</option>` +
    `<option value="low" ${settings.particles === 'low' ? 'selected' : ''}>Low</option>` +
    `</select><br/>` +
    `<label><input type="checkbox" id="set-tut" ${settings.showTutorial ? 'checked' : ''}/> Show tutorial</label><br/>` +
    `<hr/><b>⌨ Keybinds</b><br/>` +
    `<span class="dim">WASD / arrows</span> — move your character<br/>` +
    `<span class="dim">drag</span> — pan camera &nbsp; <span class="dim">wheel</span> — zoom<br/>` +
    `<span class="dim">double-tap</span> — toggle zoom<br/>` +
    `<span class="dim">1 / 2 / 3</span> — game speed 1× / 2× / 4×<br/>` +
    `<span class="dim">space</span> — pause / resume<br/>` +
    `<span class="dim">esc</span> — pause menu`;
  (document.getElementById('set-close') as HTMLButtonElement).onclick = () => {
    setOpen = false;
    renderSettingsPanel();
  };
  const vol = document.getElementById('set-vol') as HTMLInputElement;
  vol.oninput = () => {
    settings.volume = Number(vol.value);
    (document.getElementById('set-volv') as HTMLSpanElement).textContent = settings.volume + '%';
    persistSettings();
    beep(660, 0.06);
  };
  const zoom = document.getElementById('set-zoom') as HTMLInputElement;
  zoom.oninput = () => {
    settings.zoomSens = Number(zoom.value);
    (document.getElementById('set-zoomv') as HTMLSpanElement).textContent =
      settings.zoomSens.toFixed(1) + '×';
    persistSettings();
    applySettings();
  };
  (document.getElementById('set-pq') as HTMLSelectElement).onchange = (e) => {
    settings.particles = (e.target as HTMLSelectElement).value as 'low' | 'high';
    settings.reduceParticles = settings.particles === 'low';
    persistSettings();
    applySettings();
  };
  (document.getElementById('set-tut') as HTMLInputElement).onchange = (e) => {
    settings.showTutorial = (e.target as HTMLInputElement).checked;
    persistSettings();
    if (!settings.showTutorial) finishTutorial();
    else if (!sim.tutorialDone && Math.floor(sim.time / DAY_SECONDS) + 1 <= 2) startTutorial();
  };
}
setBtn.onclick = () => {
  setOpen = !setOpen;
  renderSettingsPanel();
};

// ---------- hamburger menu (P9) ----------
// The 8 right-side buttons are hidden (CSS); this menu reuses their wiring.
const menuBtn = document.getElementById('menubtn') as HTMLDivElement;
const menuPanel = document.getElementById('menupanel') as HTMLDivElement;
let menuOpen = false;
const MENU_ROWS: { label: string; btn: string }[] = [
  { label: '👑 Family', btn: 'familybtn' },
  { label: '⚔ Quests', btn: 'questbtn' },
  { label: '🎒 Inventory', btn: 'invbtn' },
  { label: '🏪 Trade', btn: 'tradebtn' },
  { label: '🔬 Research', btn: 'techbtn' },
  { label: '🏆 Achievements', btn: 'achbtn' },
  { label: '💾 Save', btn: 'savebtn' },
  { label: '👤 Character', btn: 'charbtn' },
  { label: '⚔ Party', btn: 'partybtn' },
  { label: '👑 Kingdoms', btn: 'kingbtn' },
  { label: '⚙ Settings', btn: 'setbtn' },
];
// The 3 new menu rows reuse hidden buttons (same pattern as the other 8).
for (const id of ['tradebtn', 'techbtn', 'achbtn']) {
  const b = document.createElement('div');
  b.id = id;
  b.style.display = 'none';
  document.body.appendChild(b);
}
function renderMenuPanel(): void {
  if (!menuOpen) {
    menuPanel.style.display = 'none';
    return;
  }
  menuPanel.style.display = 'block';
  menuPanel.innerHTML = MENU_ROWS.map(
    (r, i) => `<div class="mrow" data-mi="${i}">${r.label}</div>`,
  ).join('');
  menuPanel.querySelectorAll('[data-mi]').forEach((el) => {
    (el as HTMLElement).onclick = () => {
      const row = MENU_ROWS[Number((el as HTMLElement).dataset.mi)];
      (document.getElementById(row.btn) as HTMLDivElement).click();
      menuOpen = false;
      renderMenuPanel();
    };
  });
}
menuBtn.onclick = () => {
  menuOpen = !menuOpen;
  renderMenuPanel();
};

// ---------- tutorial (P9) ----------
const tutToast = document.getElementById('tuttoast') as HTMLDivElement;
let tutStep = 0;
let tutActive = false;

function tutShouldShow(): boolean {
  if (sim.tutorialDone) return false;
  if (!settings.showTutorial) return false;
  return Math.floor(sim.time / DAY_SECONDS) + 1 <= 2;
}

function startTutorial(): void {
  if (!tutShouldShow()) return;
  tutActive = true;
  tutStep = 0;
  renderTutorialStep();
}

function renderTutorialStep(): void {
  if (!tutActive) return;
  const step = TUTORIAL_STEPS[tutStep];
  if (!step) {
    finishTutorial();
    return;
  }
  tutToast.innerHTML =
    `<b>📜 ${tutStep + 1}/${TUTORIAL_STEPS.length}</b> — ${step.text}` +
    `<button id="tut-skip">Skip</button>`;
  tutToast.style.display = 'block';
  (document.getElementById('tut-skip') as HTMLButtonElement).onclick = (e) => {
    e.stopPropagation();
    finishTutorial();
  };
}

function finishTutorial(): void {
  tutActive = false;
  sim.tutorialDone = true;
  tutToast.style.display = 'none';
}

/** UI-side tutorial latches (fed to TUTORIAL_STEPS). */
const tutCtx: TutorialCtx = { cameraMoved: false, settlerSelected: false, speedTouched: false };
const tutCam0 = { x: renderer.cam.x, y: renderer.cam.y, zoom: renderer.cam.zoom };

/** Advance the tutorial when a step completes; checkmark animation included. */
function tickTutorial(): void {
  // Latch camera movement / settler selection even before the tutorial starts.
  if (
    Math.abs(renderer.cam.x - tutCam0.x) > 3 ||
    Math.abs(renderer.cam.y - tutCam0.y) > 3 ||
    Math.abs(renderer.cam.zoom - tutCam0.zoom) > 0.2
  ) {
    tutCtx.cameraMoved = true;
  }
  if (renderer.inspected) tutCtx.settlerSelected = true;
  if (!tutActive) return;
  const step = TUTORIAL_STEPS[tutStep];
  if (!step) {
    finishTutorial();
    return;
  }
  if (step.done(sim, questOpen, tutCtx)) {
    tutToast.innerHTML = `<span class="done"><b style="color:#7ac74f">✓</b> ${step.text}</span>`;
    beep(760, 0.08);
    tutStep++;
    setTimeout(() => {
      if (tutActive) renderTutorialStep();
    }, 900);
  }
}
setInterval(tickTutorial, 500);

// ---------- toast ----------
// Intro toast: what to actually do. Tap to dismiss.
const toast = document.getElementById('toast') as HTMLDivElement;
let toastTimer: ReturnType<typeof setTimeout> | null = null;

function showIntroToast(): void {
  toast.dataset.mode = 'intro';
  toast.style.pointerEvents = 'auto'; // clickable: tap to dismiss
  toast.innerHTML =
    `<b>Welcome to Pixel Lords.</b><br/>` +
    `Tap the ground to walk &nbsp;•&nbsp; pinch or double-tap to zoom<br/>` +
    `Tap trees to chop, rocks to mine &nbsp;•&nbsp; build from the menu below<br/>` +
    `Visit the walled towns, talk to villagers, take quests.<br/>` +
    `<span style="color:#9aa3b2">(tap to dismiss)</span>`;
  toast.style.display = 'block';
  toast.onclick = () => {
    toast.style.display = 'none';
  };
  if (toastTimer) clearTimeout(toastTimer);
  toastTimer = setTimeout(() => {
    if (toast.dataset.mode === 'intro') toast.style.display = 'none';
  }, 12000);
}

function flashToast(html: string, ms = 4000): void {
  toast.dataset.mode = 'msg';
  toast.style.pointerEvents = 'none'; // P12: transient notes must not block battle taps
  toast.innerHTML = html;
  toast.style.display = 'block';
  toast.onclick = () => {
    toast.style.display = 'none';
  };
  if (toastTimer) clearTimeout(toastTimer);
  toastTimer = setTimeout(() => {
    if (toast.dataset.mode === 'msg') toast.style.display = 'none';
  }, ms);
}

// On boot: offer Continue when an autosave exists.
(async () => {
  let res: LoadResult;
  try {
    res = await loadGame('auto');
  } catch {
    res = { status: 'corrupt' };
  }
  if (res.status === 'ok') {
    const d = res.data;
    const day = Math.floor(d.time / DAY_SECONDS) + 1;
    toast.dataset.mode = 'continue';
    toast.style.pointerEvents = 'auto'; // has real buttons
    toast.innerHTML =
      `<b>Save found</b> <span class="dim">— day ${day}, ${SEASONS[d.season]}</span><br/>` +
      `<button id="toast-continue">Continue</button> <button id="toast-new">New game</button>`;
    toast.style.display = 'block';
    toast.onclick = null;
    (document.getElementById('toast-continue') as HTMLButtonElement).onclick = (e) => {
      e.stopPropagation();
      applySave(sim, d);
      renderer.centerOn(sim.stock.x + 1, sim.stock.y + 1);
      toast.style.display = 'none';
      startTutorial(); // no-op unless the save is fresh (day ≤ 2, tutorial unfinished)
    };
    (document.getElementById('toast-new') as HTMLButtonElement).onclick = (e) => {
      e.stopPropagation();
      toast.style.display = 'none';
      showIntroToast();
      startTutorial();
    };
  } else {
    if (res.status === 'corrupt') {
      // P9: show the corruption notice first; the intro toast would
      // otherwise overwrite it in the same tick.
      flashToast('<b>Save corrupted, starting fresh.</b>', 5000);
      setTimeout(showIntroToast, 5200);
    } else {
      showIntroToast();
    }
    startTutorial();
  }
})();

function renderInvPanel(): void {
  if (!invOpen) {
    invPanel.style.display = 'none';
    return;
  }
  invPanel.style.display = 'block';
  const row = (icon: string, name: string, n: number, src: string) =>
    `<div class="row"><span>${icon} ${name}</span><span><b>${n}</b> <span class="dim">${src}</span></span></div>`;
  invPanel.innerHTML =
    `<b>🎒 Inventory</b><br/><hr/>` +
    row('🌲', 'Logs', sim.logs, 'chop trees') +
    row('🌾', 'Food', sim.food, 'farms') +
    row('💧', 'Water', sim.water, 'wells') +
    row('🪨', 'Stone', sim.stone, 'mine gray rocks') +
    row('⛓️', 'Iron', sim.iron, 'mine rusty rocks') +
    row('🔩', 'Metal', sim.metal, 'smelter: 2 iron') +
    row('💎', 'Diamonds', sim.diamond, 'mine peaks') +
    row('🪙', 'Gold', sim.gold, 'raider loot') +
    `<hr/><b>Refined goods</b><br/>` +
    row('🧱', 'Clay', sim.clay, 'clay pit: dig') +
    row('🪵', 'Planks', sim.planks, 'sawmill: 2🌲→3') +
    row('🏺', 'Pottery', sim.pottery, 'potter: 2 clay→2') +
    row('👕', 'Clothes', sim.clothes, 'tailor: 3 wool→2') +
    row('⚔️', 'Weapons', sim.weapons, 'blacksmith: 2 metal→2') +
    row('💊', 'Medicine', sim.medicine, 'herbalist: 2 food→1') +
    row('🪑', 'Furniture', sim.furniture, 'workshop: 3 planks→2');
}

// ---------- character sheet ----------
const charBtn = document.getElementById('charbtn') as HTMLDivElement;
const charPanel = document.getElementById('charpanel') as HTMLDivElement;
let charOpen = false;
charBtn.onclick = () => {
  charOpen = !charOpen;
  renderCharPanel();
};

const SKILL_KEYS = Object.keys(SKILL_NAMES) as SkillKey[];
const ATTR_KEYS = Object.keys(ATTR_NAMES) as AttrKey[];

function renderCharPanel(): void {
  if (!charOpen) {
    charPanel.style.display = 'none';
    return;
  }
  charPanel.style.display = 'block';
  const p = sim.settlers.find((s) => s.id === sim.dynasty.playerSettlerId);
  const hp = p ? Math.max(0, Math.round(p.hp)) : 0;
  const xpNeed = sim.xpForLevel(sim.playerLevel);
  const stamPct = Math.round((sim.stamina / sim.staminaMax) * 100);
  let html = `<b>👤 Character</b> <span class="dim">level ${sim.playerLevel}</span><br/>`;
  html += `<div class="cbar xp"><div style="width:${Math.round((sim.playerXp / xpNeed) * 100)}%"></div></div>`;
  html += `<span class="dim">XP ${sim.playerXp}/${xpNeed}</span><br/>`;
  html += `<div class="cbar hp"><div style="width:${hp}%"></div></div><span class="dim">HP ${hp}/100</span><br/>`;
  html += `<div class="cbar stam"><div style="width:${stamPct}%"></div></div><span class="dim">Stamina ${Math.round(sim.stamina)}/${sim.staminaMax}</span><br/>`;
  // P14: renown + vassal rank.
  html += `<span class="dim">Renown <b style="color:#ffd97a">${sim.renown}</b></span>`;
  if (sim.vassalOf != null) {
    const liege = sim.kingdoms[sim.vassalOf];
    html += ` <span class="dim">• ${VASSAL_RANKS[sim.vassalRank - 1]} of ${liege ? liege.name : '?'}</span>`;
  } else if (sim.playerKingdomIndex != null) {
    html += ` <span class="dim">• <b style="color:#ffd94a">KING</b></span>`;
  }
  html += `<hr/>`;
  // Attributes.
  html += `<b>Attributes</b> `;
  if (sim.attrPoints > 0) {
    html += `<span style="color:#ffd97a">${sim.attrPoints} point${sim.attrPoints > 1 ? 's' : ''} to spend</span>`;
  }
  html += `<br/>`;
  for (const k of ATTR_KEYS) {
    html += `<div class="row"><span>${ATTR_NAMES[k]}</span><span><b>${sim.attrs[k]}</b> `;
    if (sim.attrPoints > 0) html += `<button data-attr="${k}">+</button>`;
    html += `</span></div>`;
  }
  html += `<hr/><b>Skills</b><br/>`;
  for (const k of SKILL_KEYS) {
    const lvl = sim.skillLevel(k);
    const xp = sim.skills[k];
    const into = xp - lvl * 100;
    const pend = sim.pendingPerks.filter((pp) => pp.skill === k);
    html += `<div><b>${SKILL_NAMES[k]}</b> <span class="dim">lvl ${lvl}</span>`;
    html += `<div class="cbar xp"><div style="width:${lvl >= 100 ? 100 : into}%"></div></div>`;
    html += `<span class="dim">${into}/100 to next</span>`;
    for (const pp of pend) {
      const defs = PERKS[k].filter((d) => d.at === pp.at);
      if (defs.length === 0) {
        html += `<br/><span class="dim">perk ${pp.at} — coming in a later phase</span>`;
      } else {
        html += `<br/>`;
        for (const d of defs) {
          html += `<button data-perk-skill="${k}" data-perk-at="${pp.at}" data-perk-id="${d.id}" title="${d.desc}">${d.name} (${pp.at})</button> `;
        }
      }
    }
    html += `</div><hr/>`;
  }
  charPanel.innerHTML = html;
  charPanel.querySelectorAll('[data-attr]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.spendAttrPoint((b as HTMLElement).dataset.attr as AttrKey);
      renderCharPanel();
    });
  });
  charPanel.querySelectorAll('[data-perk-id]').forEach((b) => {
    b.addEventListener('click', () => {
      const el = b as HTMLElement;
      sim.choosePerk(el.dataset.perkSkill as SkillKey, Number(el.dataset.perkAt), el.dataset.perkId!);
      renderCharPanel();
    });
  });
}

function renderQuestPanel(): void {
  if (!questOpen) {
    questPanel.style.display = 'none';
    return;
  }
  questPanel.style.display = 'block';
  let html = `<b>⚔ Quest Log</b> <span class="dim">— level ${sim.playerLevel} (${sim.playerXp}/${sim.xpForLevel(sim.playerLevel)} XP)</span><br/><hr/>`;
  const questHtml = (q: (typeof sim.quests)[number]): string => {
    const have = sim.questHave(q);
    const pct = Math.min(100, Math.round((have / q.need) * 100));
    let h = `<div><b>${q.title}</b> <span class="dim">+${q.xp} XP${q.gold > 0 ? ` +${q.gold}g` : ''}</span><br/>`;
    h += `<span class="dim">${q.desc}</span><br/>`;
    h += `<div class="qbar"><div style="width:${pct}%"></div></div>`;
    h += `<span class="dim">${have}/${q.need}</span> `;
    if (q.timeLimit != null && q.expiresDay != null && !q.done) {
      const left = q.expiresDay - sim.day;
      h += `<span class="dim">⏳ ${left}d left</span> `;
    }
    if (q.deliver && !q.done && !q.claimed) {
      h += have >= q.need
        ? `<button data-handin="${q.id}">Hand in</button>`
        : `<span class="dim">(gather the goods)</span>`;
    } else if (q.done && !q.claimed) {
      h += `<button data-claim="${q.id}">Claim</button>`;
    } else if (q.claimed) {
      h += `<span class="dim">✓ claimed</span>`;
    }
    h += `</div><hr/>`;
    return h;
  };
  const story = sim.quests.filter((q) => q.chain === 'unite');
  const active = sim.quests.filter((q) => q.chain !== 'unite' && !q.claimed);
  const done = sim.quests.filter((q) => q.chain !== 'unite' && q.claimed);
  if (story.length > 0) {
    html += `<b>📜 Unite the Kingdoms</b><br/>`;
    for (const q of story) html += questHtml(q);
  }
  if (active.length > 0) {
    html += `<b>Active</b><br/>`;
    for (const q of active) html += questHtml(q);
  }
  if (done.length > 0) {
    html += `<b>Completed</b><br/>`;
    for (const q of done) html += questHtml(q);
  }
  if (sim.quests.length === 0) html += `<span class="dim">No quests. Talk to town elders (marked ?).</span>`;
  questPanel.innerHTML = html;
  questPanel.querySelectorAll('[data-claim]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.claimQuest((b as HTMLElement).dataset.claim!);
      renderQuestPanel();
    });
  });
  questPanel.querySelectorAll('[data-handin]').forEach((b) => {
    b.addEventListener('click', () => {
      if (!sim.handInQuest((b as HTMLElement).dataset.handin!)) {
        sim.announce('Not enough goods to hand in.');
      }
      renderQuestPanel();
    });
  });
}

// ---------- party panel (P3) ----------
const partyBtn = document.getElementById('partybtn') as HTMLDivElement;
const partyPanel = document.getElementById('partypanel') as HTMLDivElement;
let partyOpen = false;
partyBtn.textContent = '⚔ Party';
partyBtn.onclick = () => {
  partyOpen = !partyOpen;
  renderPartyPanel();
};

function renderPartyPanel(): void {
  if (!partyOpen) {
    partyPanel.style.display = 'none';
    return;
  }
  partyPanel.style.display = 'block';
  const cap = sim.partyCap();
  const alive = sim.party.filter((t) => !t.dead);
  const wages = alive.reduce((s, t) => s + TROOP_TIERS[t.tier].wages, 0);
  const foodNeed = alive.reduce((s, t) => s + TROOP_TIERS[t.tier].foodPerDay, 0);
  let html = `<b>⚔ Party</b> <button id="pp-close">✕</button><br/>`;
  html += `<span class="dim">morale ${Math.round(sim.partyMorale)}/100</span> `;
  html += `<div class="cbar xp"><div style="width:${Math.round(sim.partyMorale)}%"></div></div>`;
  html += `<span class="dim">${wages}g/day • ${foodNeed} food/day • ${sim.party.length}/${cap} troops</span><hr/>`;
  if (sim.party.length === 0) {
    html += `<span class="dim">No troops. Visit a town elder to recruit militia (30g) or hire a companion (100g).</span>`;
  }
  for (const t of sim.party) {
    const tierName = TROOP_TIER_NAMES[t.tier];
    const nextXp = TROOP_TIERS[t.tier].upgradeXp;
    const xpPct = nextXp ? Math.min(100, Math.round((t.xp / nextXp) * 100)) : 100;
    const hpPct = Math.round((t.hp / t.maxHp) * 100);
    html += `<div><b>${t.name}</b>${t.companion ? ' ⭐' : ''}${t.leader ? ' 🛡' : ''} `;
    html += `<span class="dim">${tierName}${t.dead ? ' †' : t.wounded ? ' (wounded)' : ''}</span><br/>`;
    html += `<span class="dim">xp</span><div class="cbar xp"><div style="width:${xpPct}%"></div></div>`;
    html += `<span class="dim">${nextXp ? `${Math.floor(t.xp)}/${nextXp}` : 'max tier'}</span> `;
    html += `<span class="dim">hp</span><div class="cbar hp"><div style="width:${hpPct}%"></div></div>`;
    html += `<span class="dim">${Math.round(t.hp)}/${t.maxHp}</span> `;
    if (!t.companion && !t.dead) html += `<button data-dismiss="${t.id}">Dismiss</button>`;
    if (t.companion && !t.dead && !t.leader) html += `<button data-lead="${t.id}">Lead</button>`;
    html += `</div><hr/>`;
  }
  html += renderPrisonersHtml();
  partyPanel.innerHTML = html;
  const close = document.getElementById('pp-close');
  if (close) close.onclick = () => ((partyOpen = false), renderPartyPanel());
  partyPanel.querySelectorAll('[data-dismiss]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.dismissTroop(Number((b as HTMLElement).dataset.dismiss));
      renderPartyPanel();
    });
  });
  partyPanel.querySelectorAll('[data-lead]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.setLeader(Number((b as HTMLElement).dataset.lead));
      renderPartyPanel();
    });
  });
  // P7: prisoners.
  partyPanel.querySelectorAll('[data-ransom]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.ransomPrisoner(Number((b as HTMLElement).dataset.ransom));
      renderPartyPanel();
    });
  });
  partyPanel.querySelectorAll('[data-free]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.recruitPrisoner(Number((b as HTMLElement).dataset.free));
      renderPartyPanel();
    });
  });
}

function renderPrisonersHtml(): string {
  if (sim.prisoners.length === 0) return '';
  let html = `<hr/><b>⛓ Prisoners</b> <span class="dim">(${sim.prisoners.length}/10 · 1 food/day each)</span><br/>`;
  sim.prisoners.forEach((p, i) => {
    html += `<div><b>${p.name}</b> <span class="dim">${TROOP_TIER_NAMES[p.tier]} · ${p.days}d</span> `;
    html += `<button data-ransom="${i}">Ransom 25g</button> <button data-free="${i}">Recruit 10g</button></div>`;
  });
  return html;
}

// ---------- diplomacy: alliance tracking (persisted) ----------
function loadAllies(): number[] {
  try {
    return JSON.parse(localStorage.getItem('pl-allies') || '[]') as number[];
  } catch {
    return [];
  }
}
let allies: number[] = loadAllies();
function isAlly(ki: number): boolean {
  return allies.includes(ki);
}
function addAlly(ki: number): void {
  if (!allies.includes(ki)) {
    allies.push(ki);
    try {
      localStorage.setItem('pl-allies', JSON.stringify(allies));
    } catch {
      /* ignore */
    }
  }
}

// ---------- kingdoms panel (P6) ----------
const kingBtn = document.getElementById('kingbtn') as HTMLDivElement;
const kingPanel = document.getElementById('kingpanel') as HTMLDivElement;
let kingOpen = false;
kingBtn.onclick = () => {
  kingOpen = !kingOpen;
  renderKingPanel();
};

function renderKingPanel(): void {
  if (!kingOpen) {
    kingPanel.style.display = 'none';
    return;
  }
  kingPanel.style.display = 'block';
  let html = `<b>👑 Kingdoms</b> <button id="kp-close">✕</button><br/>`;
  html += `<span class="dim">strength = prosperity + garrison×5 + security</span><br/>`;
  html += `<span class="dim">your renown: <b style="color:#ffd97a">${sim.renown}</b></span><hr/>`;
  // P14: declare yourself king.
  if (sim.canDeclareKing()) {
    html += `<button data-declare-king style="font-size:1.1em">👑 Declare yourself KING (3 towns)</button><hr/>`;
  }
  if (sim.playerKingdomIndex != null) {
    const pk = sim.kingdoms[sim.playerKingdomIndex]!;
    const owned = sim.playerOwnedTowns();
    html += `<div><b style="color:#ffd94a">👑 ${pk.name}</b> <span class="dim">(your realm)</span><br/>`;
    html += `<span class="dim">towns: ${owned.map((i) => sim.kingdoms[i]!.name).join(', ')} • strength ${Math.round(sim.kingdomStrength(sim.playerKingdomIndex))}</span></div><hr/>`;
  }
  // P14: pending policy vote.
  if (sim.pendingPolicyVote) {
    const v = sim.pendingPolicyVote;
    const k = sim.kingdoms[v.kingdom];
    if (k) {
      html += `<div><b>📜 Policy vote — ${k.name}</b><br/>`;
      html += `<span class="dim">Your liege considers <b>${POLICY_DEFS[v.newPolicy].name}</b> (${POLICY_DEFS[v.newPolicy].desc}), replacing <b>${POLICY_DEFS[v.oldPolicy].name}</b>.</span><br/>`;
      html += `<button data-vote="support">Support (+5 relations)</button> <button data-vote="oppose">Oppose (−5 relations)</button></div><hr/>`;
    }
  }
  for (let ki = 0; ki < sim.kingdoms.length; ki++) {
    const k = sim.kingdoms[ki]!;
    if (k.hidden) continue; // player-realm shell shown above
    const str = Math.round(sim.kingdomStrength(ki));
    const rel = sim.playerRelations[ki] ?? 0;
    const atWar = sim.playerWars.includes(ki);
    const kw = sim.warOf(ki);
    const relColor = rel >= 20 ? '#4ae87a' : rel <= -20 ? '#e84a4a' : '#9aa3b2';
    const ruler = sim.rulerOf(ki);
    const vassals = sim.vassals.filter((v) => v.kingdom === ki);
    html += `<div><b style="color:${k.color}">${k.name}</b> `;
    if (atWar) html += `<span style="color:#ff5a5a">⚔ AT WAR</span> `;
    else if (kw) html += `<span style="color:#e8c84a">⚔ warring</span> `;
    html += `<br/><span class="dim">ruled by ${ruler ? ruler.name : '—'}</span><br/>`;
    if (vassals.length > 0) {
      html += `<span class="dim">vassals: ${vassals.map((v) => v.name).join(', ')}</span><br/>`;
    }
    html += `<span class="dim">📜 ${k.policies.map((p) => POLICY_DEFS[p].name).join(' • ')}</span><br/>`;
    html += `<span class="dim">strength ${str} • garrison ${k.garrison} • </span>`;
    html += `<span style="color:${relColor}" title="Relations range from −100 (hatred) to +100 (devotion)">relations ${rel}</span>`;
    if (isAlly(ki)) html += ` <span style="color:#7ac74f" title="Allied — they will not turn on you">🤝 allied</span>`;
    html += `<br/>`;
    // Relation bar: −100 (red) … 0 … +100 (green).
    const relPct = ((rel + 100) / 200) * 100;
    const fillL = Math.min(50, relPct);
    const fillW = Math.abs(relPct - 50);
    const fillC = rel >= 0 ? '#7ac74f' : '#c74f4f';
    html += `<div class="relbar" title="Relations ${rel} (−100 to +100)"><div class="relfill" style="left:${fillL}%;width:${fillW}%;background:${fillC}"></div><div class="relmid"></div></div>`;
    // P14: vassalage.
    if (sim.vassalOf === ki) {
      html += `<span class="dim">You serve ${k.name} — <b>${VASSAL_RANKS[sim.vassalRank - 1]}</b> (renown ${sim.renown})</span><br/>`;
      const fiefs = sim.playerFiefs;
      if (fiefs.length > 0) {
        html += `<span class="dim">fiefs: ${fiefs
          .map((f) =>
            f.kind === 'village'
              ? sim.villages.find((v) => v.id === f.id)?.name ?? '?'
              : sim.kingdoms[f.id]?.name ?? '?',
          )
          .join(', ')}</span><br/>`;
      }
      html += `<button data-renounce="${ki}">Renounce oath</button><br/>`;
    } else if (sim.vassalOf == null && sim.playerKingdomIndex == null && !atWar) {
      const canFealty = rel >= 30 && sim.renown >= 50;
      html += `<button data-fealty="${ki}" ${canFealty ? '' : 'disabled'}>Swear fealty${canFealty ? '' : ` (need 30 rel, 50 renown)`}</button><br/>`;
    }
    if (atWar) {
      html += `<button data-peace="${ki}" title="End the war for 200g">Make peace — 200g</button>`;
    } else {
      html += `<button data-tribute="${ki}" title="Pay 100g: +20 relations">Tribute — 100g</button> `;
      html += `<button data-gift="${ki}" title="Send a gift of 50g: +10 relations" ${sim.gold >= 50 ? '' : 'disabled'}>🎁 Gift — 50g</button> `;
      const canAlly = !isAlly(ki) && rel >= 50 && sim.gold >= 300;
      html += `<button data-ally="${ki}" title="${isAlly(ki) ? 'Already allied' : rel < 50 ? 'Needs 50+ relations' : 'Form an alliance: 300g, relations to 100, they send 200g in return'}" ${canAlly ? '' : 'disabled'}>🤝 Alliance — 300g</button> `;
      html += `<button data-war="${ki}" title="Declare war on this kingdom">Declare war</button>`;
    }
    html += `</div><hr/>`;
  }
  kingPanel.innerHTML = html;
  const close = document.getElementById('kp-close');
  if (close) close.onclick = () => ((kingOpen = false), renderKingPanel());
  kingPanel.querySelectorAll('[data-tribute]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.tributeToKingdom(Number((b as HTMLElement).dataset.tribute));
      renderKingPanel();
    });
  });
  kingPanel.querySelectorAll('[data-war]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.declareWarOnKingdom(Number((b as HTMLElement).dataset.war));
      renderKingPanel();
    });
  });
  kingPanel.querySelectorAll('[data-peace]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.makePeaceWithKingdom(Number((b as HTMLElement).dataset.peace));
      renderKingPanel();
    });
  });
  kingPanel.querySelectorAll('[data-gift]').forEach((b) => {
    b.addEventListener('click', () => {
      const ki = Number((b as HTMLElement).dataset.gift);
      if (sim.gold >= 50 && sim.kingdoms[ki]) {
        sim.gold -= 50;
        sim.playerRelations[ki] = Math.min(100, (sim.playerRelations[ki] ?? 0) + 10);
        sim.announce(`You send gifts to ${sim.kingdoms[ki]!.name}. Relations improve (+10).`);
        beep(660, 0.08);
      }
      renderKingPanel();
    });
  });
  kingPanel.querySelectorAll('[data-ally]').forEach((b) => {
    b.addEventListener('click', () => {
      const ki = Number((b as HTMLElement).dataset.ally);
      if (sim.gold >= 300 && (sim.playerRelations[ki] ?? 0) >= 50 && sim.kingdoms[ki]) {
        sim.gold -= 300;
        sim.playerRelations[ki] = 100;
        addAlly(ki);
        sim.gold += 200;
        sim.announce(`🤝 ${sim.kingdoms[ki]!.name} signs an alliance! They send 200g in return.`);
        beep(880, 0.15);
      }
      renderKingPanel();
    });
  });
  // P14: vassalage + kingship + policy votes.
  kingPanel.querySelectorAll('[data-fealty]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.swearFealty(Number((b as HTMLElement).dataset.fealty));
      renderKingPanel();
    });
  });
  kingPanel.querySelectorAll('[data-renounce]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.renounceOath();
      renderKingPanel();
    });
  });
  kingPanel.querySelectorAll('[data-declare-king]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.declareKing();
      renderKingPanel();
    });
  });
  kingPanel.querySelectorAll('[data-vote]').forEach((b) => {
    b.addEventListener('click', () => {
      sim.votePolicy((b as HTMLElement).dataset.vote === 'support');
      renderKingPanel();
    });
  });
}

// ---------- dialogue ----------
const dialogueDiv = document.getElementById('dialogue') as HTMLDivElement;
function renderDialogue(): void {
  const d = sim.dialogue;
  if (!d) {
    dialogueDiv.style.display = 'none';
    return;
  }
  dialogueDiv.style.display = 'block';
  dialogueDiv.innerHTML =
    `<img src="${portraitURL(d.kingdom + ':' + d.speaker, d.gender)}" width="80" height="80" style="image-rendering:pixelated"/>` +
    `<b style="color:${d.color}">${d.speaker}</b> <span class="dim">— ${d.kingdom}</span><br/>` +
    `${d.text}<br/>` +
    (d.questId ? `<button id="dlg-accept">Accept quest</button>` : '') +
    (d.recruit ? `<button id="dlg-recruit">Recruit militia — 30g</button>` : '') +
    (d.companion ? `<button id="dlg-hire">Hire ${d.companion.name} — 100g</button>` : '') +
    (d.tribute ? `<button id="dlg-tribute">Pay tribute — 100g</button>` : '') +
    (d.declareWar ? `<button id="dlg-war">Declare war</button>` : '') +
    (d.makePeace ? `<button id="dlg-peace">Make peace — 200g</button>` : '') +
    (d.swearFealty ? `<button id="dlg-fealty">Swear fealty (30 rel, 50 renown)</button>` : '') +
    (d.build
      ? d.build.projects
          .map(
            (p) =>
              `<button data-proj="${p.id}" title="${p.desc}">Build ${p.name} — ${p.cost}g (${p.days}d)</button>`,
          )
          .join('') +
        (d.build.active ? `<br/><span class="dim">Under construction: ${d.build.active}</span>` : '')
      : '') +
    `<button id="dlg-close">Leave</button>`;
  const close = document.getElementById('dlg-close');
  if (close) close.onclick = () => (sim.dialogue = null);
  const accept = document.getElementById('dlg-accept');
  if (accept) accept.onclick = () => sim.acceptDialogueQuest();
  const recruit = document.getElementById('dlg-recruit');
  if (recruit)
    recruit.onclick = () => {
      sim.recruitTroop();
      renderDialogue();
    };
  const hire = document.getElementById('dlg-hire');
  if (hire)
    hire.onclick = () => {
      sim.hireCompanion();
      renderDialogue();
    };
  // P6: lord diplomacy buttons.
  const dlgKingdom = () => sim.kingdoms.findIndex((kk) => kk.name === sim.dialogue?.kingdom);
  const tribute = document.getElementById('dlg-tribute');
  if (tribute)
    tribute.onclick = () => {
      const li = sim.lords.findIndex((l) => l.name === sim.dialogue?.speaker);
      sim.tributeToKingdom(dlgKingdom());
      if (li >= 0) sim.openLordDialogue(li);
      renderDialogue();
    };
  const dwar = document.getElementById('dlg-war');
  if (dwar)
    dwar.onclick = () => {
      const li = sim.lords.findIndex((l) => l.name === sim.dialogue?.speaker);
      sim.declareWarOnKingdom(dlgKingdom());
      if (li >= 0) sim.openLordDialogue(li);
      renderDialogue();
    };
  const dpeace = document.getElementById('dlg-peace');
  if (dpeace)
    dpeace.onclick = () => {
      const li = sim.lords.findIndex((l) => l.name === sim.dialogue?.speaker);
      sim.makePeaceWithKingdom(dlgKingdom());
      if (li >= 0) sim.openLordDialogue(li);
      renderDialogue();
    };
  // P14: swear fealty via lord dialogue.
  const dfealty = document.getElementById('dlg-fealty');
  if (dfealty)
    dfealty.onclick = () => {
      const li = sim.lords.findIndex((l) => l.name === sim.dialogue?.speaker);
      sim.swearFealty(dlgKingdom());
      if (li >= 0) sim.openLordDialogue(li);
      renderDialogue();
    };
  dialogueDiv.querySelectorAll('[data-proj]').forEach((b) => {
    b.addEventListener('click', () => {
      const ki = sim.kingdoms.findIndex((kk) => kk.name === sim.dialogue?.kingdom);
      if (ki >= 0) {
        sim.buyProject(ki, (b as HTMLElement).dataset.proj as SettlementProject['id']);
        sim.openDialogue(ki, 0); // refresh elder text + project state
      }
      renderDialogue();
    });
  });
}

// ---------- game over ----------
// P8: High King victory overlay reuses the game-over div.
let victoryShown = false;
let victoryRendered = false;
function renderGameOver(): void {
  if (sim.highKing && !victoryShown) {
    if (!victoryRendered) {
      victoryRendered = true;
      gameoverDiv.style.display = 'flex';
      gameoverDiv.innerHTML =
        `<div><h2>👑 High King of the Realm!</h2>` +
        `<p>House ${sim.dynasty.houseName} has united the kingdoms. The bards will sing of this day.</p>` +
        `<button id="hk-continue">Continue ruling</button></div>`;
      const btn = document.getElementById('hk-continue');
      if (btn) btn.onclick = () => { victoryShown = true; };
    }
  } else if (sim.dynasty.gameOver) {
    gameoverDiv.style.display = 'flex';
    gameoverDiv.innerHTML =
      `<div><h2>House ${sim.dynasty.houseName} has fallen</h2>` +
      `<p>The line has ended with no heir.</p>` +
      `<button onclick="location.reload()">Begin a new line</button></div>`;
  } else {
    gameoverDiv.style.display = 'none';
  }
}

// ---------- raid banner ----------
const raidBanner = document.getElementById('raidbanner') as HTMLDivElement;
function renderRaidBanner(): void {
  if (sim.time < sim.raidAlarmUntil) {
    raidBanner.style.display = 'block';
    raidBanner.textContent = `⚔ RAID — ${sim.raiders.length} raiders attack!`;
  } else {
    raidBanner.style.display = 'none';
  }
}

// ---------- P12: tactical battle UI ----------

/** Fight-it / auto-resolve prompt when a field battle starts. */
function renderBattlePrompt(): void {
  const bp = sim.battlePrompt;
  if (!bp || sim.tactical || sim.dynasty.gameOver) {
    battlePromptEl.style.display = 'none';
    battlePromptEl.innerHTML = '';
    return;
  }
  battlePromptEl.style.display = 'block';
  battlePromptEl.innerHTML = '';
  const title = document.createElement('div');
  title.innerHTML = `<b>⚔ ${bp.battle.enemyName} engages your party!</b><br/><span class="dim">Auto-resolves in ${Math.ceil(bp.t)}s</span>`;
  battlePromptEl.appendChild(title);
  const row = document.createElement('div');
  row.style.marginTop = '8px';
  const fight = document.createElement('button');
  fight.textContent = '⚔ Fight it!';
  fight.style.marginRight = '8px';
  fight.onclick = () => {
    const cur = sim.battlePrompt;
    sim.battlePrompt = null;
    if (cur) startTacticalBattle(sim, renderer, cur.battle);
  };
  const auto = document.createElement('button');
  auto.textContent = 'Auto-resolve';
  auto.onclick = () => {
    const cur = sim.battlePrompt;
    sim.battlePrompt = null;
    if (cur) sim.battles.push(cur.battle);
  };
  row.appendChild(fight);
  row.appendChild(auto);
  battlePromptEl.appendChild(row);
}

/** In-battle command bar: orders, group toggle, retreat. */
function renderBattleBar(): void {
  const tb = sim.tactical;
  const inBattle = !!tb;
  battleBarEl.style.display = inBattle ? 'block' : 'none';
  buildMenu.style.display = inBattle ? 'none' : '';
  if (!tb) {
    battleBarEl.innerHTML = '';
    return;
  }
  battleBarEl.innerHTML = '';
  const mk = (label: string, active: boolean, fn: () => void): HTMLButtonElement => {
    const b = document.createElement('button');
    b.textContent = label;
    if (active) b.style.borderColor = '#ffd97a';
    b.onclick = fn;
    return b;
  };
  battleBarEl.appendChild(mk('⚔ Charge', tb.orders === 'charge', () => { tb.orders = 'charge'; }));
  battleBarEl.appendChild(mk('🛡 Hold', tb.orders === 'hold', () => { tb.orders = 'hold'; }));
  battleBarEl.appendChild(mk('🚶 Follow me', tb.orders === 'follow', () => { tb.orders = 'follow'; }));
  battleBarEl.appendChild(
    mk(tb.selected === 'all' ? '👥 All' : '🗡 Infantry', false, () => {
      tb.selected = tb.selected === 'all' ? 'infantry' : 'all';
    }),
  );
  const ret = mk('🏳 Retreat', false, () => {
    resolveTactical(sim, renderer, 'retreat');
  });
  ret.style.marginLeft = '8px';
  battleBarEl.appendChild(ret);
  const hint = document.createElement('span');
  hint.className = 'dim';
  hint.style.marginLeft = '8px';
  hint.textContent = 'tap ground: move • tap enemy: attack';
  battleBarEl.appendChild(hint);
}

// Game-speed / pause state (tasks 55, 56). Declared here so updateHud can use them.
let gameSpeed: 1 | 2 | 4 = 1;
let paused = false;
let raidAlarmSounded = false;

function updateHud(): void {
  const starving = sim.settlers.filter((s) => s.starving).length;
  const busy = sim.settlers.filter((s) => s.state !== 'idle').length;
  const militia = sim.guards().length;
  let insp = '';
  if (renderer.inspected) {
    const s = renderer.inspected;
    const h = sim.settlerHappiness(s);
    const mini = (lbl: string, v: number) => {
      const c = v >= 60 ? '#7ac74f' : v >= 35 ? '#e8c84a' : '#c74f4f';
      return `<span class="dim">${lbl}</span><span style="display:inline-block;width:30px;height:7px;background:#1a1d24;border-radius:3px;vertical-align:middle"><span style="display:block;width:${Math.round(v)}%;height:7px;background:${c};border-radius:3px"></span></span> `;
    };
    const nd = s.needs;
    insp =
      ` &nbsp;•&nbsp; settler #${s.id}: <b>${s.state}</b> ${sim.happinessFace(h)}${Math.round(h)}` +
      `${s.carrying ? ' carrying log' : ''}<br/>&nbsp;&nbsp;` +
      mini('🍖', nd.food) + mini('💧', nd.water) + mini('🏠', nd.housing) + mini('👕', nd.clothing) +
      mini('❤️', nd.health) + mini('🍺', nd.joy) + mini('🛕', nd.faith);
  }
  const avgH = Math.round(sim.avgHappiness());
  // Cleaner resource bar: icon + count chips with tooltips.
  const res = (icon: string, label: string, n: number | string): string =>
    `<span class="res" title="${label}: ${n}">${icon}<b>${n}</b></span>`;
  const spdBtn = (label: string, sp: number, title: string): string =>
    `<button data-sp="${sp}" class="${(!paused && gameSpeed === sp) || (sp === 0 && paused) ? 'on' : ''}" title="${title}">${label}</button>`;
  const autoT = saveTimes['auto'];
  hud.innerHTML =
    `<span title="Season and day">${SEASON_ICONS[sim.season]} <b>${SEASONS[sim.season]}</b> day <b>${sim.day}</b></span><span class="hsec">|</span>` +
    `<span title="Settlers and housing cap">${res('🧍', 'Settlers', `${sim.settlers.length}/${sim.housingCap()}`)}<span class="dim">(${busy} busy)</span></span><span class="hsec">|</span>` +
    res('🌲', 'Logs — chop trees, woodcutter', sim.logs) +
    res('🌾', 'Food — farms, hunting', Math.floor(sim.food)) +
    res('💧', 'Water — wells', Math.floor(sim.water)) +
    res('🪨', 'Stone — mine gray rocks', sim.stone) +
    res('⛓️', 'Iron — mine rusty rocks', sim.iron) +
    res('🔩', 'Metal — smelter: 2 iron into 1', sim.metal) +
    res('💎', 'Diamonds — mine peaks', sim.diamond) +
    res('🪙', 'Gold — raider loot, trade, taxes', Math.floor(sim.gold)) +
    `<span class="hsec">|</span>` +
    `<span title="Built rooms">${res('🏠', 'Built rooms', sim.rooms.filter((r) => r.built).length)}</span>` +
    `<span title="Militia guards">${res('⚔', 'Militia', militia)}</span>` +
    `<span title="Player level and XP">${res('⭐', `Level ${sim.playerLevel} — ${sim.playerXp}/${sim.xpForLevel(sim.playerLevel)} XP`, sim.playerLevel)}</span>` +
    `<span id="hud-happy" style="cursor:pointer;pointer-events:auto" title="Average happiness — click for camp needs">${sim.happinessFace(sim.avgHappiness())} <b>${avgH}%</b></span>` +
    (sim.attrPoints > 0 ? `<span title="Attribute points to spend (character sheet)" style="color:#ffd97a">+${sim.attrPoints}</span>` : '') +
    `<span class="hsec">|</span>` +
    `<span id="speedctl">${spdBtn('1×', 1, 'Normal speed (key: 1)')}${spdBtn('2×', 2, 'Double speed (key: 2)')}${spdBtn('4×', 4, 'Quadruple speed (key: 3)')}${spdBtn('⏸', 0, 'Pause (key: space)')}</span>` +
    (autoT ? `<span id="autosaveind" title="Last autosave">💾 ${ageStr(autoT)}</span>` : '') +
    (sim.raiders.length > 0 ? `<span title="Raiders attacking!"> <b style="color:#ff7a7a">⚔ ${sim.raiders.length} raiders!</b></span>` : '') +
    (sim.wolves.length > 0 ? `<span title="Wolves nearby"> 🐺 <b>${sim.wolves.length}</b></span>` : '') +
    (sim.bandits.length > 0 ? `<span title="Bandit parties"> 🦹 <b>${sim.bandits.length}</b></span>` : '') +
    (sim.battles.length > 0 ? `<span title="Battle waiting"> <b style="color:#ff7a7a">⚔ BATTLE!</b></span>` : '') +
    (sim.prisoners.length > 0 ? `<span title="Prisoners"> ⛓ <b>${sim.prisoners.length}</b></span>` : '') +
    (sim.playerSiege ? `<span title="Siege in progress"> 🐏 <b>sieging ${sim.kingdoms[sim.playerSiege.town]?.name}</b></span>` : '') +
    (sim.party.length > 0 ? `<span title="Party troops"> ⚔ party <b>${sim.party.length}</b></span>` : '') +
    (starving > 0 ? `<span title="Starving settlers!"> <b style="color:#ff7a7a">${starving} starving</b></span>` : '') +
    insp;
  const happyEl = document.getElementById('hud-happy');
  if (happyEl) happyEl.onclick = () => {
    needsOpen = !needsOpen;
    renderNeedsPanel();
  };
  hud.querySelectorAll('[data-sp]').forEach((b) => {
    b.addEventListener('click', () => {
      const sp = Number((b as HTMLElement).dataset.sp);
      if (sp === 0) setPaused(true);
      else setSpeed(sp as 1 | 2 | 4);
      beep(600, 0.05);
    });
  });
  // Raid alarm sound on the raid-start transition.
  if (sim.time < sim.raidAlarmUntil && !raidAlarmSounded) {
    raidAlarmSounded = true;
    beep(180, 0.4, 'sawtooth');
  } else if (sim.time >= sim.raidAlarmUntil) {
    raidAlarmSounded = false;
  }
  renderBuildMenu();
  renderRoomPanel();
  renderSettlementPanel();
  renderVillagePanel(); // P13
  renderFamilyPanel();
  renderQuestPanel();
  renderCharPanel();
  renderInvPanel();
  renderPartyPanel();
  renderDialogue();
  renderGameOver();
  renderRaidBanner();
  renderNeedsPanel();
  renderBattlePrompt();
  renderBattleBar();
  // UI tasks 46-65: new panels / widgets.
  drawMinimap();
  renderNotifFeed();
  renderQuestTracker();
  renderSettlerPanel();
  renderTechPanel();
  renderTradePanel();
  renderAchPanel();
  checkAchievements();
  tickTech();
}
// NOTE: no immediate updateHud() here — the end-of-file UI state (minimap
// ctx, techs, achievements) is still in TDZ at this point. First paint
// happens on the first 500ms tick, after the whole module has evaluated.
setInterval(updateHud, 500);

// ---------- game speed + pause (tasks 55, 56) ----------
// (gameSpeed / paused / raidAlarmSounded are declared just above updateHud.)
function setSpeed(s: 1 | 2 | 4): void {
  gameSpeed = s;
  paused = false;
  tutCtx.speedTouched = true;
  renderPauseMenu();
}
function setPaused(p: boolean): void {
  paused = p;
  renderPauseMenu();
}
function togglePauseMenu(force?: boolean): void {
  const open = force ?? pauseMenuEl.style.display !== 'flex';
  pauseMenuEl.style.display = open ? 'flex' : 'none';
  if (open) setPaused(true);
}
function renderPauseMenu(): void {
  // Pause menu visibility is driven by togglePauseMenu; nothing to re-render.
}
(document.getElementById('pm-resume') as HTMLButtonElement).onclick = () => {
  togglePauseMenu(false);
  setPaused(false);
};
(document.getElementById('pm-settings') as HTMLButtonElement).onclick = () => {
  togglePauseMenu(false);
  setPaused(false);
  setOpen = true;
  renderSettingsPanel();
};
(document.getElementById('pm-savequit') as HTMLButtonElement).onclick = async () => {
  try {
    await saveGame(sim, 'auto');
    recordSaveTime('auto');
  } catch {
    /* best effort */
  }
  location.reload();
};
// Keybinds: 1/2/3 speed, space pause toggle, Esc pause menu.
window.addEventListener('keydown', (e) => {
  const t = e.target as HTMLElement | null;
  if (t && (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'TEXTAREA')) return;
  const k = e.key.toLowerCase();
  if (k === '1') setSpeed(1);
  else if (k === '2') setSpeed(2);
  else if (k === '3') setSpeed(4);
  else if (k === ' ') {
    e.preventDefault();
    if (pauseMenuEl.style.display === 'flex') {
      togglePauseMenu(false);
      setPaused(false);
    } else setPaused(!paused);
  } else if (k === 'escape') {
    if (pauseMenuEl.style.display === 'flex') {
      togglePauseMenu(false);
      setPaused(false);
    } else togglePauseMenu(true);
  }
});

// Fixed-timestep sim, render every frame.
let last = performance.now();
let acc = 0;
const STEP = 1 / 20;
let lastCrashAnnounce = -Infinity;
function frame(now: number): void {
  const dt = Math.min(0.25, (now - last) / 1000);
  last = now;
  acc += dt;
  // WORLD/POLISH: smooth camera pan toward a focus target (task 93).
  if (smoothTarget) {
    const k = Math.min(1, dt * 4);
    renderer.cam.x += (smoothTarget.x - renderer.cam.x) * k;
    renderer.cam.y += (smoothTarget.y - renderer.cam.y) * k;
    if (Math.hypot(smoothTarget.x - renderer.cam.x, smoothTarget.y - renderer.cam.y) < 0.1) {
      smoothTarget = null;
    }
  }
  // Perf: tell the sim where the camera is so far entities skip AI (task 95).
  sim.view.x = renderer.cam.x;
  sim.view.y = renderer.cam.y;
  sim.view.r = Math.max(canvas.width, canvas.height) / (TILE * renderer.cam.zoom) / 2 + 20;
  let n = 0;
  while (acc >= STEP && n < 10) {
    if (!paused) {
      // Game speed multiplies how many sim ticks run per step.
      for (let s = 0; s < gameSpeed; s++) {
        try {
          sim.tick(STEP);
          // P12: tactical battle ticks on the same fixed step.
          if (sim.tactical) tickTactical(sim, STEP);
        } catch (err) {
          // P9: never white-screen — skip the tick, recover next frame.
          console.error('sim.tick failed, skipped:', err);
          if (now - lastCrashAnnounce > 10000) {
            lastCrashAnnounce = now;
            sim.announce('Something went wrong — recovered.');
          }
        }
      }
    }
    acc -= STEP;
    n++;
  }
  // P12: resolve finished tactical battles (needs the renderer for the camera).
  if (sim.tactical?.pendingEnd) {
    try {
      resolveTactical(sim, renderer, sim.tactical.pendingEnd);
    } catch (err) {
      console.error('resolveTactical failed:', err);
    }
  }
  renderer.draw(map, sim, now / 1000);
  drawBattleOverlay();
  if (!sim.tactical && battleSel.size > 0) battleSel.clear();
  // WORLD/POLISH: camera shake (task 92) — offset around draw, then restore.
  // (World overlay only on the world map, not during tactical battles.)
  if (!sim.tactical) {
    if (shakeT > 0) {
      const ox = (Math.random() - 0.5) * 0.9;
      const oy = (Math.random() - 0.5) * 0.9;
      renderer.cam.x += ox;
      renderer.cam.y += oy;
      shakeT -= dt;
      drawWorldOverlay(map, sim, octx);
      renderer.cam.x -= ox;
      renderer.cam.y -= oy;
    } else {
      drawWorldOverlay(map, sim, octx);
    }
  } else {
    shakeT = 0;
  }
  fx.update(dt);
  pollSfx();
  requestAnimationFrame(frame);
}

// WORLD/POLISH: main menu gates the game start (tasks 86-88).
// (The loop above only runs after a menu choice calls startGameLoop.)
loading.setProgress(100, 'Ready.');
loading.hide();
{
  let autoDay: string | null = null;
  try {
    const res = await loadGame('auto');
    if (res.status === 'ok') autoDay = `day ${Math.floor(res.data.time / DAY_SECONDS) + 1}`;
  } catch {
    /* no save */
  }
  showMainMenu({
    renderer,
    continueLabel: () => autoDay,
    onNewGame: () => {
      showIntro(() => {
        smoothFocus(sim.stock.x + 1, sim.stock.y + 1);
        startGameLoop();
      });
    },
    onContinue: () => {
      (async () => {
        try {
          const res = await loadGame('auto');
          if (res.status === 'ok') {
            applySave(sim, res.data);
            renderer.centerOn(sim.stock.x + 1, sim.stock.y + 1);
          }
        } catch (e) {
          console.error('continue failed:', e);
        }
        startGameLoop();
      })();
    },
  });
}

// Keep the inspected settler reference fresh after sim changes.
setInterval(() => {
  if (renderer.inspected && !sim.settlers.includes(renderer.inspected)) {
    renderer.inspected = null;
  }
  if (sim.inspectedRoom && !sim.rooms.includes(sim.inspectedRoom)) {
    sim.inspectedRoom = null;
  }
  if (
    renderer.inspectedVillage != null &&
    !sim.villages.some((v) => v.id === renderer.inspectedVillage)
  ) {
    renderer.inspectedVillage = null;
  }
}, 1000);

// Debug handle for automated playtesting.
(window as unknown as { __sim: Sim }).__sim = sim;
(window as unknown as { __renderer: Renderer }).__renderer = renderer;
(window as unknown as { __battle: unknown }).__battle = { startTacticalBattle, tickTactical, resolveTactical, battleTap };
(window as unknown as { __save: unknown }).__save = { saveGame, loadGame, applySave };
(window as unknown as { __save: object }).__save = {
  saveGame,
  loadGame,
  listSlots,
  deleteSlot,
  applySave,
  exportJSON,
  importJSON,
};

console.log(`Pixel Lords booted in ${(DAY_SECONDS / 60).toFixed(0)}-minute days.`);

// =====================================================================
// UI tasks 46-65.
// =====================================================================

// ---------- minimap (task 47) ----------
const mmCtx = minimapEl.getContext('2d')!;
const MM_TERRAIN_COLORS = ['#3d7a35', '#2c5c28', '#2f5f9e', '#7a7a7a', '#b8a05a', '#e8f0fa', '#4a5e3a', '#966e46', '#605020']; // grass, forest, water, mountain, sand, snow, swamp, road, farmland
function drawMinimap(): void {
  const W = minimapEl.width;
  const H = minimapEl.height;
  const img = mmCtx.createImageData(W, H);
  const d = img.data;
  for (let y = 0; y < H; y++) {
    const ty = Math.min(map.h - 1, Math.floor((y / H) * map.h));
    for (let x = 0; x < W; x++) {
      const tx = Math.min(map.w - 1, Math.floor((x / W) * map.w));
      const c = MM_TERRAIN_COLORS[map.tiles[ty * map.w + tx] ?? Terrain.Grass] ?? '#000';
      const i = (y * W + x) * 4;
      d[i] = parseInt(c.slice(1, 3), 16);
      d[i + 1] = parseInt(c.slice(3, 5), 16);
      d[i + 2] = parseInt(c.slice(5, 7), 16);
      d[i + 3] = 255;
    }
  }
  mmCtx.putImageData(img, 0, 0);
  const px = (tx: number): number => (tx / map.w) * W;
  const py = (ty: number): number => (ty / map.h) * H;
  // Kingdoms as colored dots.
  for (const k of sim.kingdoms) {
    if (k.hidden) continue;
    mmCtx.fillStyle = k.color;
    mmCtx.fillRect(px(k.keepX) - 2, py(k.keepY) - 2, 5, 5);
  }
  // Player units: settlers white, player gold.
  mmCtx.fillStyle = '#ffffff';
  for (const s of sim.settlers) mmCtx.fillRect(px(s.x), py(s.y), 1, 1);
  mmCtx.fillStyle = '#ffd97a';
  mmCtx.fillRect(px(sim.stock.x) - 1, py(sim.stock.y) - 1, 3, 3);
  // Hostiles red.
  mmCtx.fillStyle = '#ff5a5a';
  for (const r of sim.raiders) mmCtx.fillRect(px(r.x), py(r.y), 2, 2);
  for (const b of sim.bandits) mmCtx.fillRect(px(b.x) - 1, py(b.y) - 1, 3, 3);
  for (const w of sim.wolves) mmCtx.fillRect(px(w.x), py(w.y), 2, 2);
  // Viewport rectangle.
  const scale = TILE * renderer.cam.zoom;
  const vw = canvas.clientWidth / scale / map.w * W;
  const vh = canvas.clientHeight / scale / map.h * H;
  mmCtx.strokeStyle = 'rgba(255,255,255,.85)';
  mmCtx.lineWidth = 1;
  mmCtx.strokeRect(px(renderer.cam.x) - vw / 2, py(renderer.cam.y) - vh / 2, vw, vh);
}
minimapEl.addEventListener('click', (e) => {
  const r = minimapEl.getBoundingClientRect();
  const tx = ((e.clientX - r.left) / r.width) * map.w;
  const ty = ((e.clientY - r.top) / r.height) * map.h;
  renderer.centerOn(tx, ty);
  renderer.followPlayer = false;
  beep(520, 0.05);
});

// ---------- notification feed (task 51) ----------
function renderNotifFeed(): void {
  // Last 8 of the most recent 50 announcements; old ones fade out.
  const items = sim.announcements.slice(-50).slice(-8).reverse();
  notifeedEl.innerHTML = items
    .map((a) => {
      const ageDays = sim.day - a.day;
      const op = Math.max(0.25, 1 - ageDays / 4).toFixed(2);
      return `<div class="nitem" style="opacity:${op}"><span class="dim">d${a.day}</span> ${esc(a.text)}</div>`;
    })
    .join('');
}

// ---------- quest tracker (task 58) ----------
function renderQuestTracker(): void {
  const active = sim.quests.filter((q) => !q.claimed).slice(0, 4);
  if (active.length === 0) {
    questTrackerEl.innerHTML = '';
    return;
  }
  questTrackerEl.innerHTML = active
    .map((q) => {
      const have = sim.questHave(q);
      return `<div class="qt" data-qt="${esc(q.id)}" title="${esc(q.desc)} — click to travel there">📜 <b>${esc(q.title)}</b><br/><span class="dim">${have}/${q.need}</span></div>`;
    })
    .join('');
  questTrackerEl.querySelectorAll('[data-qt]').forEach((el) => {
    (el as HTMLElement).onclick = () => {
      const q = sim.quests.find((qq) => qq.id === (el as HTMLElement).dataset.qt);
      const ki = q?.giver?.kingdom;
      const k = ki != null ? sim.kingdoms[ki] : null;
      if (k) {
        renderer.centerOn(k.keepX + 1, k.keepY + 1);
        renderer.followPlayer = false;
        beep(520, 0.05);
      } else {
        flashToast('<b>Quest location unknown</b> — no giver town.');
      }
    };
  });
}

// ---------- settler panel (task 49) ----------
function renderSettlerPanel(): void {
  const s = renderer.inspected;
  if (!s || !sim.settlers.includes(s)) {
    settlerPanel.style.display = 'none';
    return;
  }
  settlerPanel.style.display = 'block';
  const d = sim.dynasty;
  const p = s.personId ? d.people.get(s.personId) : null;
  const name = p ? p.name : `Settler #${s.id}`;
  const h = Math.round(sim.settlerHappiness(s));
  const hpPct = Math.max(0, Math.round((s.hp / 100) * 100));
  const needRow = (icon: string, label: string, v: number): string => {
    const c = v >= 60 ? '#7ac74f' : v >= 35 ? '#e8c84a' : '#c74f4f';
    return `<div>${icon} ${label} <b>${Math.round(v)}</b><div class="nbar" style="background:#1a1d24;border-radius:4px;height:8px;margin:2px 0 6px"><div style="width:${Math.round(v)}%;background:${c};border-radius:4px;height:8px"></div></div></div>`;
  };
  const room = s.employedRoom != null ? sim.rooms.find((r) => r.id === s.employedRoom) : null;
  const jobName = room ? ROOM_DEFS[room.type].name : s.isGuard ? 'Guard' : '—';
  let html = `<b>${esc(name)}</b> ${sim.happinessFace(h)} <b>${h}%</b> <button id="stp-close" style="float:right" title="Close">✕</button><br/>`;
  html += `<span class="dim">doing:</span> <b>${s.state}</b>${s.carrying ? ' (carrying)' : ''}${s.starving ? ' <b style="color:#ff7a7a">starving!</b>' : ''}<br/>`;
  html += `<span class="dim">HP</span><div class="cbar hp"><div style="width:${hpPct}%"></div></div>`;
  html += `<span class="dim">hunger</span><div class="cbar xp"><div style="width:${Math.round(s.hunger)}%"></div></div>`;
  html += `<span class="dim">thirst</span><div class="cbar stam"><div style="width:${Math.round(s.thirst)}%"></div></div>`;
  html += `<hr/><b>Needs</b><br/>`;
  html += needRow('🍖', 'Food', s.needs.food);
  html += needRow('💧', 'Water', s.needs.water);
  html += needRow('🏠', 'Housing', s.needs.housing);
  html += needRow('👕', 'Clothing', s.needs.clothing);
  html += needRow('❤️', 'Health', s.needs.health);
  html += needRow('🍺', 'Joy', s.needs.joy);
  html += needRow('🛕', 'Faith', s.needs.faith);
  html += `<hr/><span class="dim">current job:</span> <b>${jobName}</b><br/>`;
  html += `<span class="dim">assign job:</span> <select id="stp-job" title="Assign this settler to a building">`;
  html += `<option value="">Unassigned</option>`;
  for (const r of sim.rooms) {
    if (!r.built) continue;
    const def = ROOM_DEFS[r.type];
    if (def.maxWorkers === 0) continue;
    const sel = s.employedRoom === r.id ? ' selected' : '';
    const dis = r.workers.length >= def.maxWorkers && s.employedRoom !== r.id ? ' disabled' : '';
    html += `<option value="${r.id}"${sel}${dis}>${def.icon} ${def.name} (${r.workers.length}/${def.maxWorkers})</option>`;
  }
  html += `</select>`;
  settlerPanel.innerHTML = html;
  (document.getElementById('stp-close') as HTMLButtonElement).onclick = () => {
    renderer.inspected = null;
  };
  (document.getElementById('stp-job') as HTMLSelectElement).onchange = (e) => {
    const v = (e.target as HTMLSelectElement).value;
    if (s.employedRoom != null) sim.unassignWorker(s.employedRoom, s.id);
    if (v !== '') {
      if (sim.assignWorker(Number(v), s.id)) beep(660, 0.07);
    }
    renderSettlerPanel();
    renderRoomPanel();
  };
}

// ---------- tech tree (task 61) ----------
interface TechDef {
  id: string;
  name: string;
  icon: string;
  desc: string;
  cost: number; // gold
  secs: number; // research time (real seconds)
  effect: () => void;
}
const TECHS: TechDef[] = [
  {
    id: 'herbalism', name: 'Herbal Remedies', icon: '🌿',
    desc: '+15 medicine, brewed by the camp herbalists.', cost: 100, secs: 30,
    effect: () => { sim.medicine += 15; },
  },
  {
    id: 'storehouse', name: 'Grand Storehouse', icon: '🏚️',
    desc: '+200 food and +100 water from deep stores.', cost: 150, secs: 45,
    effect: () => { sim.food += 200; sim.water += 100; },
  },
  {
    id: 'drill', name: 'Militia Drill', icon: '🗡️',
    desc: 'Every party troop gains +25 XP.', cost: 200, secs: 60,
    effect: () => { for (const t of sim.party) t.xp += 25; },
  },
  {
    id: 'contacts', name: 'Guild Contacts', icon: '🤝',
    desc: '+15 relations with every kingdom you are not at war with.', cost: 250, secs: 60,
    effect: () => {
      for (let ki = 0; ki < sim.kingdoms.length; ki++) {
        if (!sim.playerWars.includes(ki)) {
          sim.playerRelations[ki] = Math.min(100, (sim.playerRelations[ki] ?? 0) + 15);
        }
      }
    },
  },
  {
    id: 'surgery', name: 'Field Surgery', icon: '⛑️',
    desc: 'Heal your whole party and yourself to full HP.', cost: 300, secs: 90,
    effect: () => {
      for (const t of sim.party) t.hp = t.maxHp;
      const me = sim.settlers.find((s) => s.id === sim.dynasty.playerSettlerId);
      if (me) me.hp = 100;
    },
  },
  {
    id: 'irrigation', name: 'Irrigation', icon: '💦',
    desc: '+300 food from watered fields.', cost: 350, secs: 90,
    effect: () => { sim.food += 300; },
  },
  {
    id: 'engineering', name: 'Siege Engineering', icon: '🏗️',
    desc: 'Instantly finish all buildings under construction.', cost: 400, secs: 120,
    effect: () => { for (const r of sim.rooms) if (!r.built) r.progress = 1; },
  },
  {
    id: 'crownlaw', name: 'Crown Law', icon: '📜',
    desc: '+50 renown and +30 relations with every kingdom you are not at war with.', cost: 500, secs: 120,
    effect: () => {
      sim.renown += 50;
      for (let ki = 0; ki < sim.kingdoms.length; ki++) {
        if (!sim.playerWars.includes(ki)) {
          sim.playerRelations[ki] = Math.min(100, (sim.playerRelations[ki] ?? 0) + 30);
        }
      }
    },
  },
];
let techResearched: string[] = [];
let techActive: { id: string; left: number } | null = null;
try {
  const saved = JSON.parse(localStorage.getItem('pl-techs') || '{}') as { done?: string[]; active?: { id: string; left: number } | null };
  techResearched = saved.done ?? [];
  techActive = saved.active ?? null;
} catch {
  /* ignore */
}
function persistTechs(): void {
  try {
    localStorage.setItem('pl-techs', JSON.stringify({ done: techResearched, active: techActive }));
  } catch {
    /* ignore */
  }
}
/** Advance research (called every 500ms from updateHud). */
function tickTech(): void {
  if (!techActive) return;
  techActive.left -= 0.5;
  if (techActive.left <= 0) {
    const def = TECHS.find((t) => t.id === techActive!.id);
    techActive = null;
    if (def && !techResearched.includes(def.id)) {
      def.effect();
      techResearched.push(def.id);
      sim.announce(`🔬 Research complete: ${def.name}!`);
      flashToast(`<b>🔬 Research complete:</b> ${def.icon} ${def.name}<br/><span class="dim">${def.desc}</span>`);
      beep(880, 0.15);
    }
    persistTechs();
  } else {
    persistTechs();
  }
}
let techOpen = false;
(document.getElementById('techbtn') as HTMLDivElement).onclick = () => {
  techOpen = !techOpen;
  renderTechPanel();
};
function renderTechPanel(): void {
  if (!techOpen) {
    techPanel.style.display = 'none';
    return;
  }
  techPanel.style.display = 'block';
  let html = `<b>🔬 Research</b> <button id="tp-close" style="float:right" title="Close">✕</button><br/>`;
  html += `<span class="dim">one project at a time — research runs in real time</span><hr/>`;
  html += `<div class="techgrid">`;
  for (const t of TECHS) {
    const done = techResearched.includes(t.id);
    const active = techActive?.id === t.id;
    const cls = done ? 'techcard done' : active ? 'techcard active' : 'techcard';
    html += `<div class="${cls}" title="${esc(t.desc)}">${t.icon} <b>${t.name}</b><br/>`;
    html += `<span class="dim">${t.cost}🪙 · ${t.secs}s</span><br/>`;
    if (done) html += `<span style="color:#7ac74f">✓ researched</span>`;
    else if (active) {
      const pct = Math.max(0, Math.min(100, ((t.secs - techActive!.left) / t.secs) * 100));
      html += `<div class="cbar xp"><div style="width:${pct}%"></div></div>`;
      html += `<button data-tcancel="${t.id}" title="Cancel research (gold is not refunded)">cancel</button>`;
    } else {
      const busy = techActive != null;
      const afford = sim.gold >= t.cost;
      html += `<button data-tech="${t.id}" ${busy || !afford ? 'disabled' : ''} title="${esc(t.desc)}${busy ? ' — another project is running' : afford ? '' : ' — not enough gold'}">Research</button>`;
    }
    html += `</div>`;
  }
  html += `</div>`;
  techPanel.innerHTML = html;
  (document.getElementById('tp-close') as HTMLButtonElement).onclick = () => {
    techOpen = false;
    renderTechPanel();
  };
  techPanel.querySelectorAll('[data-tech]').forEach((b) => {
    b.addEventListener('click', () => {
      const id = (b as HTMLElement).dataset.tech!;
      const def = TECHS.find((t) => t.id === id);
      if (!def || techActive || techResearched.includes(id) || sim.gold < def.cost) return;
      sim.gold -= def.cost;
      techActive = { id, left: def.secs };
      persistTechs();
      sim.announce(`🔬 Research started: ${def.name}.`);
      beep(520, 0.08);
      renderTechPanel();
    });
  });
  techPanel.querySelectorAll('[data-tcancel]').forEach((b) => {
    b.addEventListener('click', () => {
      techActive = null;
      persistTechs();
      renderTechPanel();
    });
  });
}

// ---------- trade UI (task 62): merchant panel for every town market ----------
let tradeOpen = false;
let tradeQty: Record<number, number> = {}; // kingdom idx -> selected quantity
(document.getElementById('tradebtn') as HTMLDivElement).onclick = () => {
  tradeOpen = !tradeOpen;
  renderTradePanel();
};
function renderTradePanel(): void {
  if (!tradeOpen) {
    tradePanel.style.display = 'none';
    return;
  }
  tradePanel.style.display = 'block';
  let html = `<b>🏪 Merchant</b> <button id="trp-close" style="float:right" title="Close">✕</button><br/>`;
  html += `<span class="dim">cargo ${sim.cargoUsed()}/${sim.cargoCap()} · you hold <b>${Math.round(sim.gold)}g</b></span><hr/>`;
  for (let ki = 0; ki < sim.kingdoms.length; ki++) {
    const k = sim.kingdoms[ki]!;
    if (k.hidden) continue;
    const atWar = sim.playerWars.includes(ki);
    const qty = tradeQty[ki] ?? 1;
    html += `<div><b style="color:${k.color}">${esc(k.name)}</b> <span class="dim">${k.kind}</span>`;
    if (atWar) html += ` <span style="color:#ff5a5a">⚔ at war — no trade</span>`;
    html += `<br/>`;
    if (!atWar) {
      html += `<span class="dim">qty:</span> <select data-tqty="${ki}" title="Trade quantity">`;
      for (const n of [1, 5, 10]) html += `<option value="${n}"${qty === n ? ' selected' : ''}>${n}</option>`;
      html += `</select><br/>`;
      for (const g of TRADE_GOODS) {
        const price = sim.marketPrice(ki, g.id);
        const stock = k.market.stock[g.id];
        const tag = k.market.produces.includes(g.id)
          ? '<span style="color:#7ac74f">cheap</span>'
          : k.market.demands.includes(g.id)
            ? '<span style="color:#e8c84a">wanted</span>'
            : '';
        html += `<div style="font-size:12px">${g.icon} ${g.name} <b>${price}g</b> <span class="dim">×${stock}</span> ${tag} `;
        html += `<button data-tbuy="${ki}:${g.id}" title="Buy ${qty} ${g.name} for ${price * qty}g">Buy</button>`;
        html += `<button data-tsell="${ki}:${g.id}" title="Sell ${qty} ${g.name} for about ${price * qty}g">Sell</button>`;
        html += ` <span class="dim">you: ${sim.goods[g.id]}</span></div>`;
      }
    }
    html += `</div><hr/>`;
  }
  tradePanel.innerHTML = html;
  (document.getElementById('trp-close') as HTMLButtonElement).onclick = () => {
    tradeOpen = false;
    renderTradePanel();
  };
  tradePanel.querySelectorAll('[data-tqty]').forEach((el) => {
    (el as HTMLSelectElement).onchange = (e) => {
      const ki = Number((e.target as HTMLSelectElement).dataset.tqty);
      tradeQty[ki] = Number((e.target as HTMLSelectElement).value);
      renderTradePanel();
    };
  });
  tradePanel.querySelectorAll('[data-tbuy]').forEach((b) => {
    b.addEventListener('click', () => {
      const [ki, gid] = (b as HTMLElement).dataset.tbuy!.split(':');
      if (sim.buyGood(Number(ki), gid as GoodId, tradeQty[Number(ki)] ?? 1)) beep(660, 0.06);
      renderTradePanel();
    });
  });
  tradePanel.querySelectorAll('[data-tsell]').forEach((b) => {
    b.addEventListener('click', () => {
      const [ki, gid] = (b as HTMLElement).dataset.tsell!.split(':');
      if (sim.sellGood(Number(ki), gid as GoodId, tradeQty[Number(ki)] ?? 1)) beep(520, 0.06);
      renderTradePanel();
    });
  });
}

// ---------- achievements (task 64) ----------
interface AchDef {
  id: string;
  name: string;
  icon: string;
  desc: string;
  test: () => boolean;
}
const ACHIEVEMENTS: AchDef[] = [
  { id: 'first-home', name: 'Raised Roof', icon: '🏠', desc: 'Build your first house.', test: () => sim.rooms.some((r) => r.type === 'house' && r.built) },
  { id: 'settlers-10', name: 'Growing Village', icon: '🧍', desc: 'Reach 10 settlers.', test: () => sim.settlers.length >= 10 },
  { id: 'settlers-25', name: 'Bustling Town', icon: '🏘️', desc: 'Reach 25 settlers.', test: () => sim.settlers.length >= 25 },
  { id: 'gold-1000', name: 'Hoarder', icon: '🪙', desc: 'Hold 1,000 gold at once.', test: () => sim.gold >= 1000 },
  { id: 'raider-1', name: 'First Blood', icon: '🩸', desc: 'Slay your first raider.', test: () => sim.raidersSlain >= 1 },
  { id: 'raiders-50', name: 'Raiderbane', icon: '⚔️', desc: 'Slay 50 raiders.', test: () => sim.raidersSlain >= 50 },
  { id: 'level-5', name: 'Veteran', icon: '⭐', desc: 'Reach player level 5.', test: () => sim.playerLevel >= 5 },
  { id: 'married', name: 'Wedding Bells', icon: '💍', desc: 'Marry and secure the line.', test: () => { const d = sim.dynasty; return !!d.spouseOf(d.founder); } },
  { id: 'child', name: 'New Blood', icon: '👶', desc: 'Have a child.', test: () => [...sim.dynasty.people.values()].some((p) => p.fatherId === sim.dynasty.founderId || p.motherId === sim.dynasty.founderId) },
  { id: 'town', name: 'Founder', icon: '🏰', desc: 'Found your own town.', test: () => sim.playerTownIndex != null },
  { id: 'battle-1', name: 'Field Commander', icon: '🛡️', desc: 'Win your first field battle.', test: () => sim.battlesWon >= 1 },
  { id: 'highking', name: 'High King', icon: '👑', desc: 'Unite the kingdoms as High King.', test: () => sim.highKing },
];
let achUnlocked: string[] = [];
try {
  achUnlocked = JSON.parse(localStorage.getItem('pl-ach') || '[]') as string[];
} catch {
  achUnlocked = [];
}
/** Check and unlock achievements (called every 500ms from updateHud). */
function checkAchievements(): void {
  let changed = false;
  for (const a of ACHIEVEMENTS) {
    if (!achUnlocked.includes(a.id) && a.test()) {
      achUnlocked.push(a.id);
      changed = true;
      flashToast(`<b>🏆 Achievement unlocked:</b> ${a.icon} ${a.name}<br/><span class="dim">${a.desc}</span>`, 6000);
      beep(880, 0.18);
      sim.announce(`🏆 Achievement: ${a.name} — ${a.desc}`);
    }
  }
  if (changed) {
    try {
      localStorage.setItem('pl-ach', JSON.stringify(achUnlocked));
    } catch {
      /* ignore */
    }
  }
}
let achOpen = false;
(document.getElementById('achbtn') as HTMLDivElement).onclick = () => {
  achOpen = !achOpen;
  renderAchPanel();
};
function renderAchPanel(): void {
  if (!achOpen) {
    achPanel.style.display = 'none';
    return;
  }
  achPanel.style.display = 'block';
  let html = `<b>🏆 Achievements</b> <span class="dim">${achUnlocked.length}/${ACHIEVEMENTS.length}</span> <button id="ap-close" style="float:right" title="Close">✕</button><br/><hr/>`;
  for (const a of ACHIEVEMENTS) {
    const got = achUnlocked.includes(a.id);
    html += `<div style="${got ? '' : 'opacity:.45'}" title="${esc(a.desc)}">${got ? a.icon : '🔒'} <b>${a.name}</b><br/><span class="dim">${esc(a.desc)}</span></div><hr/>`;
  }
  achPanel.innerHTML = html;
  (document.getElementById('ap-close') as HTMLButtonElement).onclick = () => {
    achOpen = false;
    renderAchPanel();
  };
}

// ---------- tactical combat UI (task 59) ----------
const boCtx = battleOverlay.getContext('2d')!;
battleOverlay.style.touchAction = 'none';
function sizeBattleOverlay(): void {
  battleOverlay.width = window.innerWidth;
  battleOverlay.height = Math.max(1, window.innerHeight - 40);
}
sizeBattleOverlay();
window.addEventListener('resize', sizeBattleOverlay);

/** Custom drag-selection of player fighters (ids into battleSel). */
let battleSel = new Set<number>();
let battleDrag: { x0: number; y0: number; x1: number; y1: number } | null = null;
let battleDown: { x: number; y: number; btn: number } | null = null;

/** Screen (client) -> arena coords using the renderer's camera math. */
function battleToArena(cx: number, cy: number): { x: number; y: number } {
  const scale = TILE * renderer.cam.zoom;
  return {
    x: (cx - window.innerWidth / 2) / scale + renderer.cam.x,
    y: (cy - window.innerHeight / 2) / scale + renderer.cam.y,
  };
}
function battleToScreen(wx: number, wy: number): { x: number; y: number } {
  const scale = TILE * renderer.cam.zoom;
  return {
    x: (wx - renderer.cam.x) * scale + window.innerWidth / 2,
    y: (wy - renderer.cam.y) * scale + window.innerHeight / 2 - 40, // overlay starts 40px down
  };
}

battleOverlay.addEventListener('pointerdown', (e) => {
  battleDown = { x: e.clientX, y: e.clientY, btn: e.button };
  battleDrag = null;
  battleOverlay.setPointerCapture(e.pointerId);
});
battleOverlay.addEventListener('pointermove', (e) => {
  if (!battleDown || battleDown.btn !== 0 || !sim.tactical) return;
  const dx = e.clientX - battleDown.x;
  const dy = e.clientY - battleDown.y;
  if (!battleDrag && Math.hypot(dx, dy) > 10) {
    battleDrag = { x0: battleDown.x, y0: battleDown.y, x1: e.clientX, y1: e.clientY };
  } else if (battleDrag) {
    battleDrag.x1 = e.clientX;
    battleDrag.y1 = e.clientY;
  }
});
battleOverlay.addEventListener('pointerup', (e) => {
  const tb = sim.tactical;
  if (!tb || !battleDown) {
    battleDown = null;
    battleDrag = null;
    return;
  }
  if (battleDrag && battleDown.btn === 0) {
    // Click-drag: select player fighters inside the box.
    const r = battleOverlay.getBoundingClientRect();
    const x0 = Math.min(battleDrag.x0, battleDrag.x1) - r.left;
    const x1 = Math.max(battleDrag.x0, battleDrag.x1) - r.left;
    const y0 = Math.min(battleDrag.y0, battleDrag.y1) - r.top;
    const y1 = Math.max(battleDrag.y0, battleDrag.y1) - r.top;
    battleSel.clear();
    for (const f of tb.fighters) {
      if (f.side !== 0 || f.dead || f.fled) continue;
      const s = battleToScreen(f.x, f.y);
      if (s.x >= x0 && s.x <= x1 && s.y >= y0 && s.y <= y1) battleSel.add(f.id);
    }
    if (battleSel.size > 0) sim.announce(`${battleSel.size} fighters selected. Right-click an enemy to attack.`);
  } else if (!battleDrag && battleDown.btn === 0) {
    // Plain left-click: same as tapping the battlefield (move / attack via battleTap).
    const a = battleToArena(e.clientX, e.clientY);
    battleTap(sim, tb, a.x, a.y);
  }
  battleDown = null;
  battleDrag = null;
});
battleOverlay.addEventListener('contextmenu', (e) => {
  e.preventDefault();
  const tb = sim.tactical;
  if (!tb) return;
  const a = battleToArena(e.clientX, e.clientY);
  const enemy = tb.fighters.find(
    (f) => !f.dead && !f.fled && f.side === 1 && Math.hypot(f.x - a.x, f.y - a.y) < 1.2,
  );
  const group: BattleFighter[] = battleSel.size > 0
    ? tb.fighters.filter((f) => battleSel.has(f.id) && !f.dead && !f.fled && f.side === 0)
    : tb.fighters.filter(
        (f) =>
          !f.dead && !f.fled && f.side === 0 &&
          (tb.selected === 'all' || f.isPlayer || f.tier === 'militia' || f.tier === 'footman'),
      );
  if (enemy) {
    // Right-click attack order.
    for (const f of group) {
      f.targetId = enemy.id;
      f.moveX = f.moveY = null;
    }
    sim.announce(`Attack ${enemy.name}!`);
    beep(300, 0.1, 'sawtooth');
  } else if (battleSel.size > 0) {
    // Right-click ground: move the selected fighters there.
    for (const f of group) {
      f.moveX = a.x;
      f.moveY = a.y;
      f.targetId = null;
    }
    tb.moveMark = { x: a.x, y: a.y, t: 0.8 };
  }
});

/** Draw health bars, selection rings, and the drag box over the battle. */
function drawBattleOverlay(): void {
  const tb = sim.tactical;
  if (!tb) {
    battleOverlay.style.display = 'none';
    battleOverlay.style.pointerEvents = 'none';
    return;
  }
  battleOverlay.style.display = 'block';
  battleOverlay.style.pointerEvents = 'auto';
  const scale = TILE * renderer.cam.zoom;
  boCtx.clearRect(0, 0, battleOverlay.width, battleOverlay.height);
  for (const f of tb.fighters) {
    if (f.dead || f.fled) continue;
    const s = battleToScreen(f.x, f.y);
    const w = Math.max(18, scale * 0.9);
    const h = 4;
    const pct = Math.max(0, f.hp / f.maxHp);
    const bx = s.x - w / 2;
    const by = s.y - scale * 1.15;
    boCtx.fillStyle = 'rgba(0,0,0,.7)';
    boCtx.fillRect(bx - 1, by - 1, w + 2, h + 2);
    boCtx.fillStyle = pct > 0.6 ? '#7ac74f' : pct > 0.3 ? '#e8c84a' : '#c74f4f';
    boCtx.fillRect(bx, by, w * pct, h);
    if (f.side === 0 && battleSel.has(f.id)) {
      boCtx.strokeStyle = '#ffd97a';
      boCtx.lineWidth = 2;
      boCtx.beginPath();
      boCtx.arc(s.x, s.y, scale * 0.55, 0, Math.PI * 2);
      boCtx.stroke();
    }
  }
  if (battleDrag) {
    const r = battleOverlay.getBoundingClientRect();
    const x = Math.min(battleDrag.x0, battleDrag.x1) - r.left;
    const y = Math.min(battleDrag.y0, battleDrag.y1) - r.top;
    const w = Math.abs(battleDrag.x1 - battleDrag.x0);
    const h = Math.abs(battleDrag.y1 - battleDrag.y0);
    boCtx.strokeStyle = '#ffd97a';
    boCtx.lineWidth = 1;
    boCtx.setLineDash([4, 4]);
    boCtx.strokeRect(x, y, w, h);
    boCtx.setLineDash([]);
    boCtx.fillStyle = 'rgba(255,217,122,.08)';
    boCtx.fillRect(x, y, w, h);
  }
}

// ---------- draggable panels (task 65) ----------
/** Make a fixed-position panel draggable by any non-interactive area. */
function makeDraggable(el: HTMLElement): void {
  let drag: { sx: number; sy: number; lx: number; ty: number } | null = null;
  el.classList.add('draggable-hint');
  el.addEventListener('mousedown', (e) => {
    const t = e.target as HTMLElement;
    if (t.closest('button, input, select, textarea, a, .tbox, .qt')) return;
    const r = el.getBoundingClientRect();
    drag = { sx: e.clientX, sy: e.clientY, lx: r.left, ty: r.top };
    e.preventDefault();
  });
  window.addEventListener('mousemove', (e) => {
    if (!drag) return;
    el.style.left = `${drag.lx + e.clientX - drag.sx}px`;
    el.style.top = `${drag.ty + e.clientY - drag.sy}px`;
    el.style.right = 'auto';
    el.style.bottom = 'auto';
  });
  window.addEventListener('mouseup', () => {
    drag = null;
  });
}
for (const id of [
  'roompanel', 'settlementpanel', 'villagepanel', 'familypanel', 'questpanel',
  'needspanel', 'invpanel', 'charpanel', 'partypanel', 'kingpanel', 'savepanel',
  'setpanel', 'settlerpanel', 'techpanel', 'tradepanel', 'achpanel',
]) {
  const el = document.getElementById(id);
  if (el) makeDraggable(el);
}

} // end boot()
void boot();
