Back to Squad Table
SOURCE / PINNED RELEASE

Made of little things.

Squad Table

Release
d36201f8ed4f…
Author-recorded commit
7b9bb681d6ba…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-c043b0c0f1d

Archive hash verified: 8c0604b31f00cc5a…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/main.ts
import './napplet-settings.js';
import { identity, IDENTITY_DOMAIN, storage, type Subscription } from '@napplet/sdk';
import { Board } from './board.js';
import { NATIONS, templatesFor, type NationId } from './catalog.js';
import { counterIcon, esc, NATION_STYLE } from './counters.js';
import { runtimeHasDomain } from './domain-availability.js';
import {
  counterName,
  locationName,
  mapOf,
  newId,
  phaseActors,
  sideName,
  sideNation,
  templateOf,
  undoCandidate,
  type Action,
  type Entry,
  type Owner,
  type SideId,
  type TableState,
} from './model.js';
import {
  createLocal,
  createOnline,
  forgetTable,
  listTables,
  onlineAvailable,
  parseShareCode,
  rememberTable,
  shareCode,
  TableSession,
  type SavedTable,
} from './net.js';
import { settings, onSettings } from './napplet-settings.js';
import { Effects } from './effects.js';
import { endButtonState, renderMenu } from './phase-menu.js';
import { setSoundEnabled, soundEnabled } from './sound.js';
import { RULE_NOTES } from './rules/data.js';
import { planFire, type FirePlan } from './rules/fire.js';
import { lineOfSight } from './rules/los.js';
import { checkPath, fmt, phaseKind, phaseName, reachable, type Reach } from './rules/movement.js';
import { SCENARIO_BY_ID, SCENARIOS, TERRAIN_NAMES } from './scenarios.js';
import './styles.css';

// Keep app-owned colors: the map, counters and HUD share one wartime-table
// palette. Host theme data is not applied. See docs/napplet-visual-design.md.
const FOLLOW_HOST_THEME = false;
void FOLLOW_HOST_THEME;

function $<T extends HTMLElement>(selector: string): T {
  const element = document.querySelector<T>(selector);
  if (!element) throw new Error(`Missing required element: ${selector}`);
  return element;
}

const el = {
  lobby: $<HTMLElement>('#lobby'),
  table: $<HTMLElement>('#table'),
  newScenario: $<HTMLSelectElement>('#newScenario'),
  newBrief: $<HTMLParagraphElement>('#newBrief'),
  newSide: $<HTMLDivElement>('#newSide'),
  createOnline: $<HTMLButtonElement>('#createOnline'),
  createLocal: $<HTMLButtonElement>('#createLocal'),
  onlineNote: $<HTMLParagraphElement>('#onlineNote'),
  joinCode: $<HTMLInputElement>('#joinCode'),
  joinButton: $<HTMLButtonElement>('#joinButton'),
  joinNote: $<HTMLParagraphElement>('#joinNote'),
  myTables: $<HTMLUListElement>('#myTables'),
  myTablesEmpty: $<HTMLParagraphElement>('#myTablesEmpty'),
  identityNote: $<HTMLParagraphElement>('#identityNote'),
  back: $<HTMLButtonElement>('#backButton'),
  endPhase: $<HTMLButtonElement>('#endPhase'),
  actionsPanel: $<HTMLDivElement>('#actionsPanel'),
  turnLabel: $<HTMLSpanElement>('#turnLabel'),
  activeChip: $<HTMLSpanElement>('#activeChip'),
  phaseLabel: $<HTMLSpanElement>('#phaseLabel'),
  rollDR: $<HTMLButtonElement>('#rollDR'),
  rolldr: $<HTMLButtonElement>('#rolldr'),
  los: $<HTMLButtonElement>('#losButton'),
  undo: $<HTMLButtonElement>('#undoButton'),
  seatChip: $<HTMLSpanElement>('#seatChip'),
  syncDot: $<HTMLSpanElement>('#syncDot'),
  panelButton: $<HTMLButtonElement>('#panelButton'),
  panelClose: $<HTMLButtonElement>('#panelClose'),
  panel: $<HTMLElement>('#panel'),
  boardHost: $<HTMLDivElement>('#boardHost'),
  zoomIn: $<HTMLButtonElement>('#zoomIn'),
  zoomOut: $<HTMLButtonElement>('#zoomOut'),
  zoomFit: $<HTMLButtonElement>('#zoomFit'),
  modeBar: $<HTMLDivElement>('#modeBar'),
  modeText: $<HTMLSpanElement>('#modeText'),
  modeActions: $<HTMLDivElement>('#modeActions'),
  toast: $<HTMLDivElement>('#toast'),
  diceTray: $<HTMLDivElement>('#diceTray'),
  strip: $<HTMLDivElement>('#strip'),
  stripHex: $<HTMLDivElement>('#stripHex'),
  stripCounters: $<HTMLDivElement>('#stripCounters'),
  stripMove: $<HTMLButtonElement>('#stripMove'),
  stripMore: $<HTMLButtonElement>('#stripMore'),
  stackPanel: $<HTMLDivElement>('#stackPanel'),
  trayPanel: $<HTMLDivElement>('#trayPanel'),
  infoPanel: $<HTMLDivElement>('#infoPanel'),
  logList: $<HTMLOListElement>('#logList'),
  logBadge: $<HTMLSpanElement>('#logBadge'),
  chatSend: $<HTMLButtonElement>('#chatSend'),
  chatInput: $<HTMLInputElement>('#chatInput'),
  rollWhy: $<HTMLInputElement>('#rollWhy'),
  tooSmallState: $<HTMLParagraphElement>('#tooSmallState'),
};

type Tab = 'actions' | 'stack' | 'tray' | 'log' | 'info';
type Mode =
  | { kind: 'none' }
  | { kind: 'fire'; from: string; ids: string[]; to: string | null }
  | { kind: 'move'; ids: string[]; from: string; dt: boolean; override: string | null }
  | { kind: 'los'; from: string | null; to: string | null };

const ui = {
  me: 'guest',
  pubkey: '',
  session: null as TableSession | null,
  selected: null as string | null, // hex id or off-board box
  picked: new Set<string>(), // counters chosen within the selected location
  mode: { kind: 'none' } as Mode,
  tab: 'actions' as Tab,
  overrideTools: false, // reveal manual edit tools after setup (logged as overrides)
  endArmed: false,
  panelOpen: true,
  traySide: 'a' as SideId | 'n',
  newSide: 'a' as SideId,
  logSeen: 0,
  shownLos: null as { from: string; to: string } | null,
};

const board = new Board(el.boardHost, {
  tap: onBoardTap,
  dragMove: (from, to) => {
    const ids = ui.selected === from && ui.picked.size ? orderedPicked(from) : [...(stateOrNull()?.stacks.get(from) ?? [])];
    moveCounters(ids, to);
  },
  hover: (hex) => {
    if (settings.sharePointer) ui.session?.cursor(hex);
    if (ui.mode.kind === 'los' && ui.mode.from && !ui.mode.to) renderBoard();
  },
});

const effects = new Effects(board);

/** Play shooting/rally/melee effects for newly resolved entries (the last few only). */
function playEffects(fresh: Entry[]): void {
  const state = stateOrNull();
  if (!state) return;
  for (const entry of fresh.filter((e) => e.d?.length && e.a.t !== 'roll').slice(-2)) {
    const line = state.lines.find((l) => l.i === entry.i);
    if (!line || line.kind !== 'fire') continue;
    const a = entry.a;
    if (a.t === 'fire') {
      const firers = a.ids.map((id) => state.counters.get(id)).filter(Boolean);
      const from = firers[0]?.at;
      if (from) effects.fire(entry, line, from, firers.some((c) => /-(lmg|mmg|hmg)$/.test(c!.k)));
    } else if (a.t === 'cc') {
      effects.closeCombat(entry, line);
    } else if (a.t === 'rally' || a.t === 'repair') {
      const c = state.counters.get(a.id) ?? state.counters.get(`${a.id}h`);
      if (c && /^[A-Z]/.test(c.at)) effects.rally(line, c.at);
    }
  }
}

function stateOrNull(): TableState | null {
  return ui.session?.state ?? null;
}

function narrow(): boolean {
  return window.innerWidth < 760;
}

// ---------- identity ----------

let identitySub: Subscription | null = null;

async function loadIdentity(): Promise<void> {
  if (!runtimeHasDomain(IDENTITY_DOMAIN)) {
    setIdentity('');
    return;
  }
  try {
    setIdentity(await identity.getPublicKey());
  } catch {
    setIdentity('');
  }
  try {
    identitySub = identity.onChanged((pubkey) => setIdentity(pubkey));
  } catch {
    // Identity changes are an enhancement.
  }
}

function setIdentity(pubkey: string): void {
  const changed = pubkey !== ui.pubkey;
  ui.pubkey = pubkey;
  ui.me = pubkey ? `nostr:${pubkey}` : 'guest';
  if (ui.session && changed && ui.session.online) {
    ui.session.me = ui.me;
    ui.session.onChange('status');
  }
  renderLobbyNotes();
}

function shortKey(principal: string): string {
  const key = principal.replace(/^(nostr|guest):/, '');
  return key ? `${key.slice(0, 6)}…${key.slice(-4)}` : '';
}

// ---------- lobby ----------

function renderScenarioPicker(): void {
  el.newScenario.innerHTML = SCENARIOS.map((s) => `<option value="${s.id}">${esc(s.name)}</option>`).join('');
  updateNewScenario();
}

function updateNewScenario(): void {
  const s = SCENARIO_BY_ID.get(el.newScenario.value) ?? SCENARIOS[0];
  el.newBrief.textContent = s.brief;
  el.newSide.innerHTML = (['a', 'b'] as const)
    .map((side) => {
      const nat = s.sides[side].nat;
      const st = NATION_STYLE[nat];
      return `<button type="button" role="radio" class="seg-btn" data-side="${side}" aria-checked="${ui.newSide === side}">
        <span class="swatch" style="background:${st.face};border-color:${st.edge}"></span>${esc(s.sides[side].name)}
      </button>`;
    })
    .join('');
}

function renderLobbyNotes(): void {
  const online = onlineAvailable();
  el.createOnline.disabled = !online;
  el.joinButton.disabled = !online;
  if (!online) {
    el.onlineNote.textContent = 'Online tables need a host with shared-backend support. Hotseat games still work here.';
  } else if (!ui.pubkey) {
    el.onlineNote.textContent = 'Sign in with your Nostr account to create or sit at an online table. Anyone can watch.';
  } else {
    el.onlineNote.textContent = 'Online tables keep the log and roll the dice on the shared server. Share the code with your opponent.';
  }
  el.identityNote.textContent = ui.pubkey ? `Signed in as ${shortKey(ui.pubkey)}` : '';
  if (!runtimeHasDomain('storage')) {
    el.myTablesEmpty.textContent = 'This host has no app storage, so tables are not remembered and hotseat games are not saved.';
  }
}

async function renderMyTables(): Promise<void> {
  const tables = await listTables();
  el.myTablesEmpty.hidden = tables.length > 0;
  el.myTables.innerHTML = tables
    .map((t, i) => {
      const s = SCENARIO_BY_ID.get(t.scenario);
      const nat = t.side === 'n' || !s ? null : s.sides[t.side].nat;
      const when = new Date(t.updated).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
      return `<li class="table-item">
        <button type="button" class="table-open" data-index="${i}">
          <span class="swatch" style="background:${nat ? NATION_STYLE[nat].face : '#555'}"></span>
          <span class="table-name">${esc(t.title || s?.name || 'Table')}</span>
          <span class="table-meta">${t.kind === 'online' ? 'Online' : 'Hotseat'} · ${nat ? esc(NATIONS[nat].name) : 'Watching'} · ${when}</span>
        </button>
        <button type="button" class="tool slim table-forget" data-index="${i}" aria-label="Remove from list" title="Remove from list">
          <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m6 6 8 8M14 6l-8 8" /></svg>
        </button>
      </li>`;
    })
    .join('');
  el.myTables.querySelectorAll<HTMLButtonElement>('.table-open').forEach((b) =>
    b.addEventListener('click', () => void openTable(tables[Number(b.dataset.index)])),
  );
  el.myTables.querySelectorAll<HTMLButtonElement>('.table-forget').forEach((b) =>
    b.addEventListener('click', async () => {
      const t = tables[Number(b.dataset.index)];
      // Hotseat games exist only here, so ask twice (dialogs are blocked in the sandbox).
      if (t.kind === 'local' && b.dataset.armed !== '1') {
        b.dataset.armed = '1';
        b.classList.add('armed');
        b.title = 'Tap again to delete this hotseat game';
        b.setAttribute('aria-label', b.title);
        toast('Tap × again to delete this hotseat game from this device.');
        return;
      }
      await forgetTable(t);
      await renderMyTables();
    }),
  );
}

el.newScenario.addEventListener('change', updateNewScenario);
el.newSide.addEventListener('click', (e) => {
  const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-side]');
  if (!b) return;
  ui.newSide = b.dataset.side as SideId;
  updateNewScenario();
});

// Sandboxed frames do not allow form submission, so buttons and Enter keys
// call the handlers directly.
function onEnter(input: HTMLInputElement, action: () => void): void {
  input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && !e.isComposing) {
      e.preventDefault();
      action();
    }
  });
}

el.createOnline.addEventListener('click', async () => {
  if (!ui.pubkey) {
    el.onlineNote.textContent = 'Sign in with your Nostr account first: your seat belongs to that account.';
    return;
  }
  el.createOnline.disabled = true;
  el.onlineNote.textContent = 'Setting up the table… your host may ask to confirm your account.';
  try {
    const s = SCENARIO_BY_ID.get(el.newScenario.value)!;
    const saved = await createOnline(s.id, s.name, ui.newSide);
    await openTable(saved);
    ui.tab = 'info';
    renderPanel();
  } catch (error) {
    el.onlineNote.textContent = error instanceof Error ? error.message : 'Could not create the table.';
  } finally {
    el.createOnline.disabled = !onlineAvailable();
  }
});

el.createLocal.addEventListener('click', async () => {
  const s = SCENARIO_BY_ID.get(el.newScenario.value)!;
  const saved = createLocal(s.id, s.name);
  saved.side = ui.newSide;
  await rememberTable(saved);
  await openTable(saved);
});

async function joinWithCode(): Promise<void> {
  const code = parseShareCode(el.joinCode.value);
  if (!code) {
    el.joinNote.textContent = 'That does not look like a table code. It starts with ASL1.';
    return;
  }
  el.joinNote.textContent = 'Opening…';
  const saved: SavedTable = { kind: 'online', id: code.instance, release: code.release, scenario: '', title: '', side: 'n', updated: Date.now() };
  try {
    await openTable(saved);
    el.joinNote.textContent = '';
    el.joinCode.value = '';
    ui.tab = 'info';
    renderPanel();
  } catch (error) {
    el.joinNote.textContent = error instanceof Error ? error.message : 'Could not open that table.';
  }
}
el.joinButton.addEventListener('click', () => void joinWithCode());
onEnter(el.joinCode, () => void joinWithCode());

// ---------- table lifecycle ----------

async function openTable(saved: SavedTable): Promise<void> {
  closeTable();
  const session = new TableSession({ ...saved }, saved.kind === 'local' ? 'local' : ui.me);
  if (saved.kind === 'online') {
    await session.open();
    // A joined table learns its scenario from the server.
    const scenario = session.meta?.scenario ?? saved.scenario;
    (session.saved as SavedTable).scenario = scenario;
    (session.saved as SavedTable).title = session.meta?.title || SCENARIO_BY_ID.get(scenario)?.name || 'Table';
    await session.refresh();
    await rememberTable({ ...session.saved, side: session.mySide(), updated: Date.now() });
  } else {
    await session.open();
  }
  ui.session = session;
  ui.selected = null;
  ui.picked.clear();
  ui.mode = { kind: 'none' };
  ui.shownLos = null;
  ui.logSeen = session.state.lines.length;
  ui.traySide = session.mySide() === 'b' ? 'b' : 'a';
  session.onChange = onSessionChange;
  el.lobby.hidden = true;
  el.table.hidden = false;
  ui.panelOpen = !narrow();
  board.setMap(mapOf(session.state.scenario));
  renderAll();
  requestAnimationFrame(() => (narrow() ? board.fitPlayable() : board.fit()));
  if (session.liveAvailable() && settings.autoLive) void session.startLive();
}

function closeTable(): void {
  ui.session?.close();
  ui.session = null;
}

el.back.addEventListener('click', async () => {
  closeTable();
  el.table.hidden = true;
  el.lobby.hidden = false;
  await renderMyTables();
});

function onSessionChange(reason: 'state' | 'status' | 'presence', fresh?: Entry[]): void {
  const session = ui.session;
  if (!session) return;
  if (reason === 'state') {
    // Drop picks for counters that no longer exist or moved away.
    const state = session.state;
    for (const id of [...ui.picked]) {
      if (state.counters.get(id)?.at !== ui.selected) ui.picked.delete(id);
    }
    for (const entry of fresh ?? []) {
      if (entry.d?.length) showDice(entry);
    }
    requestAnimationFrame(() => playEffects(fresh ?? []));
    if (ui.mode.kind === 'fire' && ui.mode.ids.some((id) => state.counters.get(id)?.at !== (ui.mode as { from: string }).from || state.counters.get(id)?.fired)) {
      ui.mode = { kind: 'none' };
    }
    renderAll();
  } else if (reason === 'presence') {
    renderBoard();
  } else {
    renderHud();
    if (ui.tab === 'info') renderInfo();
  }
}

// ---------- actions ----------

function canAct(silent = false): boolean {
  const s = ui.session;
  if (!s) return false;
  if (!s.online) return true;
  if (s.mySide() !== 'n') return true;
  if (!silent) toast(ui.pubkey ? 'Take a seat in the Table tab to move counters and roll.' : 'Sign in and take a seat to play. You are watching.');
  return false;
}

function submit(actions: Action[]): void {
  if (!ui.session || !actions.length) return;
  ui.session.submit(actions);
}

/** After setup, manual edits are rules overrides and are flagged in the log. */
function overrideTag(): { ovr?: string } {
  const s = stateOrNull();
  return s && s.turn > 0 ? { ovr: 'manual edit' } : {};
}

function actor(): Owner {
  return ui.session?.mySide() ?? 'n';
}

/** Legal destinations for the group being moved, or null outside move mode. */
function currentReach(): Reach | null {
  const s = stateOrNull();
  const m = ui.mode;
  if (!s || m.kind !== 'move' || m.override) return null;
  return reachable(s, m.ids, actor(), m.dt);
}

function explainUnreachable(state: TableState, ids: string[], to: string, dt: boolean, reach: Reach): string {
  const check = checkPath(state, ids, [to], dt, actor());
  if (!check.ok && !/not next to/.test(check.reasons[0])) return check.reasons[0];
  if (reach.kind === 'advance') return 'Advancing units move one hex only.';
  return `${to} is out of reach: ${fmt(reach.left)} MF left.`;
}

/** Submit a rules-checked move. Returns false (with a toast) when it is not legal. */
function ruleMove(ids: string[], to: string, dt: boolean): boolean {
  const s = stateOrNull();
  if (!s || !canAct() || !ids.length) return false;
  const reach = reachable(s, ids, actor(), dt);
  if (!reach.ok) {
    toast(`${reach.reasons[0]} Use Move → Override to do it anyway.`, 4500);
    return false;
  }
  const target = reach.hexes.get(to);
  if (!target) {
    toast(explainUnreachable(s, ids, to, dt, reach), 4000);
    return false;
  }
  submit([{ t: 'mv', ids, path: target.path, ...(dt && reach.kind === 'move' ? { dt: true } : {}) }]);
  ui.selected = to;
  ui.picked = new Set(ids);
  board.reveal(to);
  return true;
}

function moveCounters(ids: string[], to: string): void {
  ruleMove(ids, to, false);
  ui.mode = { kind: 'none' };
  renderAll();
}

function orderedPicked(at: string): string[] {
  const stack = stateOrNull()?.stacks.get(at) ?? [];
  return stack.filter((id) => ui.picked.has(id));
}

function selectLocation(at: string | null): void {
  ui.selected = at;
  ui.picked = new Set(at ? (stateOrNull()?.stacks.get(at) ?? []) : []);
  renderAll();
}

function onBoardTap(hex: string | null): void {
  const mode = ui.mode;
  if (mode.kind === 'fire') {
    const s = stateOrNull();
    if (!hex || !s) return;
    const plan = planFire(s, mode.ids, hex, actor());
    if (plan.ok) {
      ui.mode = { ...mode, to: hex };
      fireError = null;
      ui.tab = 'actions';
      if (!ui.panelOpen) setPanel(true);
    } else {
      fireError = `${hex}: ${plan.reasons[0]}`;
      toast(fireError, 4000);
    }
    renderAll();
    return;
  }
  if (mode.kind === 'move') {
    if (!hex) {
      ui.mode = { kind: 'none' };
    } else if (mode.override) {
      submit([{ t: 'move', ids: mode.ids, to: hex, ovr: mode.override }]);
      ui.selected = hex;
      ui.picked = new Set(mode.ids);
      ui.mode = { kind: 'none' };
    } else if (hex !== mode.from && ruleMove(mode.ids, hex, mode.dt)) {
      // Keep moving the same group while it has MF and somewhere to go.
      ui.mode = { ...mode, from: hex, dt: false };
      const next = currentReach();
      if (!next || !next.ok || !next.hexes.size || next.kind === 'setup' || next.kind === 'advance') ui.mode = { kind: 'none' };
    }
    renderAll();
    return;
  }
  if (mode.kind === 'los') {
    if (!hex) return;
    if (!mode.from || mode.to) ui.mode = { kind: 'los', from: hex, to: null };
    else if (hex !== mode.from) ui.mode = { kind: 'los', from: mode.from, to: hex };
    renderAll();
    if (ui.mode.kind === 'los' && ui.mode.to) {
      ui.tab = 'stack';
      renderPanel();
    }
    return;
  }
  ui.shownLos = null;
  selectLocation(hex === ui.selected ? null : hex);
  if (hex && ui.panelOpen && ui.tab !== 'tray' && ui.tab !== 'actions') {
    ui.tab = 'stack';
    renderPanel();
  }
}

function startMove(): void {
  if (!ui.selected || !canAct()) return;
  const ids = orderedPicked(ui.selected);
  if (!ids.length) {
    toast('Pick at least one counter to move.');
    return;
  }
  ui.mode = { kind: 'move', ids, from: ui.selected, dt: false, override: null };
  if (narrow()) setPanel(false);
  renderAll();
}

function roll(n: 1 | 2): void {
  if (!canAct()) return;
  const why = el.rollWhy.value.trim().slice(0, 60);
  el.rollWhy.value = '';
  submit([{ t: 'roll', n, why }]);
}


function addCounter(k: string, side: Owner): void {
  if (!canAct()) return;
  const s = stateOrNull();
  if (!s) return;
  const target = ui.selected ?? (side === 'n' ? null : `pool-${side}`);
  if (!target) {
    toast('Select a hex first, then add the marker.');
    return;
  }
  if (s.turn > 0 && side !== 'n' && !ui.overrideTools) {
    toast('After setup, adding units is an override. Turn on Override tools in the Stack tab first.', 4500);
    return;
  }
  const id = newId();
  submit([{ t: 'add', id, k, side, to: target, ...(side === 'n' ? {} : overrideTag()) }]);
  if (!ui.selected) ui.selected = target;
  ui.picked.add(id);
  toast(`Added to ${locationName(target)}${target.includes('-') ? '' : ` ${target}`}`);
}

// ---------- rendering ----------

function renderAll(): void {
  renderHud();
  renderBoard();
  renderPanel();
  renderStrip();
  renderModeBar();
}

function renderBoard(): void {
  const s = ui.session;
  if (!s) return;
  const presence = [...s.presence.values()].filter((p) => Date.now() - p.at < 60000);
  const los = ui.mode.kind === 'los' && ui.mode.from ? { from: ui.mode.from, to: ui.mode.to } : ui.shownLos;
  board.setDragSelection(ui.selected ? orderedPicked(ui.selected) : []);
  const reach = currentReach();
  const m = ui.mode;
  board.render(s.state, {
    targets: fireTargets(),
    fireLine: m.kind === 'fire' && m.to ? { from: m.from, to: m.to } : null,
    selected: ui.selected,
    targeting: ui.mode.kind === 'move',
    reach: reach?.ok && reach.kind !== 'setup' ? new Map([...reach.hexes].map(([id, v]) => [id, v.cost])) : null,
    los,
    presence,
    showCoords: settings.showCoords,
  });
}

function natChip(state: TableState, side: Owner): string {
  const nat = sideNation(state.scenario, side);
  const st = NATION_STYLE[nat];
  return `<span class="chip-swatch" style="background:${st.face};border-color:${st.edge}"></span>${esc(NATIONS[nat].short)}`;
}

function renderHud(): void {
  const s = ui.session;
  if (!s) return;
  const state = s.state;
  el.turnLabel.textContent = state.turn === 0 ? 'Setup' : `T${state.turn}/${state.scenario.turns}`;
  el.activeChip.innerHTML = natChip(state, state.active);
  el.activeChip.title = `${sideName(state.scenario, state.active)} player turn`;
  el.phaseLabel.textContent = state.turn === 0 ? '' : phaseName(state);
  const side = s.mySide();
  const acting = canAct(true);
  for (const b of [el.rollDR, el.rolldr]) b.disabled = !acting;
  const end = endButtonState(state, endAs());
  el.endPhase.disabled = !end.enabled;
  el.endPhase.textContent = ui.endArmed && end.enabled ? 'Confirm?' : end.enabled ? 'End' : state.over ? 'Over' : 'Wait';
  el.endPhase.title = end.label;
  el.endPhase.classList.toggle('armed', ui.endArmed && end.enabled);
  el.undo.disabled = !acting || !undoCandidate(state, s.me);
  el.los.setAttribute('aria-pressed', String(ui.mode.kind === 'los'));
  if (!s.online) {
    el.seatChip.innerHTML = `<button type="button" class="seat-toggle" title="Switch which side you are acting for">${natChip(state, side)}<span class="seat-label">acting</span></button>`;
    el.seatChip.querySelector('button')!.addEventListener('click', () => {
      s.setLocalSide(side === 'a' ? 'b' : 'a');
      ui.traySide = s.mySide() as SideId;
      renderAll();
    });
  } else {
    el.seatChip.innerHTML = side === 'n' ? '<span class="seat-label">watching</span>' : `${natChip(state, side)}<span class="seat-label">you</span>`;
  }
  const st = s.status;
  const live = st.live === 'connected' ? ` · live with ${st.peers}` : st.live === 'waiting' ? ' · live link open' : '';
  const text = !s.online
    ? 'Hotseat game on this device'
    : st.sync === 'saving'
      ? `Saving${live}`
      : st.sync === 'offline'
        ? 'Offline: retrying'
        : st.sync === 'error'
          ? 'Not saved'
          : `Synced${live}`;
  el.syncDot.dataset.state = !s.online ? 'local' : st.sync === 'idle' ? (st.live === 'connected' ? 'live' : 'ok') : st.sync;
  el.syncDot.title = st.message || text;
  el.syncDot.setAttribute('aria-label', st.message || text);
  if (st.message && (st.sync === 'error' || st.sync === 'offline')) toast(st.message, 5000);
  el.panelButton.setAttribute('aria-expanded', String(ui.panelOpen));
  el.table.classList.toggle('panel-open', ui.panelOpen);
  const roll = state.lastRoll;
  el.tooSmallState.textContent = `Turn ${state.turn} · ${NATIONS[sideNation(state.scenario, state.active)].short} · ${phaseName(state)}${
    roll ? ` · last roll ${roll.dice.reduce((a, b) => a + b, 0)} (${NATIONS[sideNation(state.scenario, roll.side)].short})` : ''
  }`;
}

function renderModeBar(): void {
  const m = ui.mode;
  el.modeBar.hidden = m.kind === 'none';
  const actions: string[] = [];
  if (m.kind === 'move') {
    const reach = currentReach();
    const n = `${m.ids.length} counter${m.ids.length === 1 ? '' : 's'}`;
    if (m.override) {
      el.modeText.textContent = `Override: tap where to put ${n}`;
    } else if (!reach || !reach.ok) {
      el.modeText.textContent = `Can't move: ${reach?.reasons[0] ?? 'nothing selected'}`;
      actions.push(`<button type="button" class="btn small danger" data-mode="override">Override</button>`);
    } else if (reach.kind === 'setup') {
      el.modeText.textContent = `Setup: tap a hex for ${n}`;
    } else if (reach.kind === 'advance') {
      el.modeText.textContent = `Advance ${n}: tap an adjacent hex`;
      actions.push(`<button type="button" class="btn small" data-mode="override">Override</button>`);
    } else {
      el.modeText.textContent = `${reach.kind === 'rout' ? 'Rout' : 'Move'} ${n} · ${fmt(reach.left)} MF left`;
      if (reach.canDoubleTime)
        actions.push(`<button type="button" class="btn small" data-mode="dt" aria-pressed="${m.dt}" title="+1 MF; the units become CX">Double Time</button>`);
      actions.push(`<button type="button" class="btn small" data-mode="override">Override</button>`);
    }
  } else if (m.kind === 'fire') {
    el.modeText.textContent = m.to ? `Fire ${m.from} → ${m.to}: roll in the Actions panel` : `Fire from ${m.from}: tap a target`;
  } else if (m.kind === 'los') {
    el.modeText.textContent = !m.from ? 'LOS: tap the firing hex' : !m.to ? 'LOS: tap the target hex' : `LOS ${m.from} → ${m.to}`;
  }
  const moving = m.kind === 'move' && !m.override && Boolean(currentReach()?.ok);
  actions.push(`<button type="button" class="btn small" data-mode="cancel">${moving ? 'Done' : 'Cancel'}</button>`);
  el.modeActions.innerHTML = actions.join('');
}

function setPanel(open: boolean): void {
  ui.panelOpen = open;
  el.table.classList.toggle('panel-open', open);
  el.panelButton.setAttribute('aria-expanded', String(open));
  renderStrip();
  requestAnimationFrame(() => renderBoard());
}

function renderPanel(): void {
  el.panel.querySelectorAll<HTMLButtonElement>('.tab').forEach((t) => t.setAttribute('aria-selected', String(t.dataset.tab === ui.tab)));
  el.panel.querySelectorAll<HTMLElement>('.tab-body').forEach((b) => (b.hidden = b.dataset.body !== ui.tab));
  if (ui.tab === 'actions') renderActions();
  else if (ui.tab === 'stack') renderStack();
  else if (ui.tab === 'tray') renderTray();
  else if (ui.tab === 'log') renderLog();
  else renderInfo();
  const unseen = (stateOrNull()?.lines.length ?? 0) > ui.logSeen && ui.tab !== 'log';
  el.logBadge.hidden = !unseen;
}

function terrainSummary(state: TableState, hex: string): string {
  const cell = mapOf(state.scenario).cells.get(hex);
  if (!cell) return '';
  return `${TERRAIN_NAMES[cell.terrain]}${cell.road ? ' with road' : ''}${cell.level ? ` · level ${cell.level}` : ''}`;
}

// ---------- Actions menu and fire ----------

function currentFirePlan(): FirePlan | null {
  const s = stateOrNull();
  const m = ui.mode;
  if (!s || m.kind !== 'fire' || !m.to) return null;
  const plan = planFire(s, m.ids, m.to, actor());
  return plan.ok ? plan : null;
}

let fireError: string | null = null;

/** Whose End button this is: your seat online; in hotseat, whichever side still has to end. */
function endAs(): Owner {
  const s = ui.session;
  if (!s) return 'n';
  if (s.online) return s.mySide();
  return phaseActors(s.state).find((side) => !s.state.ready.has(side)) ?? s.mySide();
}

function renderActions(): void {
  const s = ui.session;
  if (!s) return;
  const m = ui.mode;
  el.actionsPanel.innerHTML = renderMenu({
    state: s.state,
    me: s.mySide(),
    endAs: endAs(),
    online: s.online,
    selected: ui.selected,
    picked: ui.picked,
    fire: m.kind === 'fire' ? { from: m.from, ids: m.ids, to: m.to } : null,
    firePlan: currentFirePlan(),
    fireError,
  });
}

/** Enemy hexes this fire group could attack, with a short FP/DRM label. */
function fireTargets(): Map<string, string> | null {
  const s = stateOrNull();
  const m = ui.mode;
  if (!s || m.kind !== 'fire') return null;
  const out = new Map<string, string>();
  const enemy = actor() === 'a' ? 'b' : 'a';
  for (const [hex, ids] of s.stacks) {
    if (!/^[A-Z]/.test(hex)) continue;
    if (!ids.some((id) => s.counters.get(id)?.side === enemy)) continue;
    const plan = planFire(s, m.ids, hex, actor());
    if (plan.ok) out.set(hex, `${plan.column}${plan.drm ? (plan.drm > 0 ? `+${plan.drm}` : `${plan.drm}`) : ''}`);
  }
  return out;
}

function endPhase(): void {
  const s = ui.session;
  if (!s || !canAct()) return;
  const end = endButtonState(s.state, endAs());
  if (!end.enabled) return;
  if (!ui.endArmed) {
    ui.endArmed = true;
    renderHud();
    window.setTimeout(() => {
      ui.endArmed = false;
      renderHud();
    }, 3500);
    toast(`Tap again to end the ${end.label.replace(/^End /, '')}. You can't go back.`, 3500);
    return;
  }
  ui.endArmed = false;
  ui.mode = { kind: 'none' };
  el.toast.hidden = true;
  if (s.online) {
    submit([{ t: 'end' }]);
    return;
  }
  // Hotseat: one person plays both sides, so finish the phase for every side
  // that acts in it, then hand control to whoever acts next.
  for (const side of phaseActors(s.state)) {
    if (s.state.ready.has(side)) continue;
    s.setLocalSide(side);
    submit([{ t: 'end' }]);
  }
  const next = phaseActors(s.state);
  s.setLocalSide(next.length === 1 ? next[0] : s.state.active);
  ui.traySide = s.mySide() as SideId;
  renderAll();
}

el.actionsPanel.addEventListener('click', (e) => {
  const b = (e.target as HTMLElement).closest<HTMLButtonElement>('button');
  const s = ui.session;
  if (!b || !s) return;
  const d = b.dataset;
  if (d.act === 'end-phase') {
    ui.endArmed = true; // the panel button is already explicit
    endPhase();
  } else if (d.goto) {
    selectLocation(d.goto);
    if (/^[A-Z]/.test(d.goto)) board.reveal(d.goto);
    ui.tab = 'actions';
    renderPanel();
  } else if (d.pick) {
    if (ui.picked.has(d.pick)) ui.picked.delete(d.pick);
    else ui.picked.add(d.pick);
    renderActions();
    renderBoard();
  } else if (d.act === 'fire-start' && ui.selected) {
    const ids = orderedPicked(ui.selected).filter((id) => !s.state.counters.get(id)?.fired);
    ui.mode = { kind: 'fire', from: ui.selected, ids, to: null };
    fireError = null;
    if (!fireTargets()?.size) fireError = 'No enemy unit is in range and line of sight of this group.';
    if (narrow()) setPanel(false);
    renderAll();
  } else if (d.act === 'fire-roll') {
    const m = ui.mode;
    const plan = currentFirePlan();
    if (m.kind !== 'fire' || !m.to || !plan || !canAct()) return;
    submit([{ t: 'fire', ids: m.ids, to: m.to }]);
    ui.mode = { kind: 'none' };
    ui.picked.clear();
    renderAll();
  } else if (d.act === 'fire-cancel') {
    ui.mode = { kind: 'none' };
    renderAll();
  } else if (d.rally && canAct()) submit([{ t: 'rally', id: d.rally }]);
  else if (d.repair && canAct()) submit([{ t: 'repair', id: d.repair }]);
  else if (d.cc && canAct()) submit([{ t: 'cc', to: d.cc }]);
});

el.endPhase.addEventListener('click', endPhase);

function renderLosReport(state: TableState, from: string, to: string): string {
  const map = mapOf(state.scenario);
  if (!map.cells.has(from) || !map.cells.has(to)) return '';
  const los = lineOfSight(state, from, to);
  const rows = los.hexes
    .map((hex) => {
      const cell = map.cells.get(hex)!;
      const hindrance = los.hindrances.find((h) => h.hex === hex);
      const blocked = los.blockedBy === hex;
      return `<li class="${blocked ? 'los-block' : hindrance ? 'los-hinder' : ''}"><b>${hex}</b> ${esc(TERRAIN_NAMES[cell.terrain])}${cell.level ? ` · L${cell.level}` : ''}${
        blocked ? ' · <b>blocks</b>' : hindrance ? ` · hindrance +${hindrance.value}` : ''
      }</li>`;
    })
    .join('');
  const verdict = los.clear
    ? `<p class="los-verdict ok">Clear${los.hindrance ? `, hindrance +${los.hindrance}` : ''}</p>`
    : `<p class="los-verdict bad">${esc(los.reason ?? 'Blocked')}</p>`;
  return `<section class="los-report">
    <h3>Line of sight ${from} → ${to}</h3>
    ${verdict}
    <ol>${rows}</ol>
    <p class="hint">Elevation is approximated with a straight sight line; blind hexes aren't modelled exactly.</p>
    <div class="row">
      <button type="button" class="btn small" data-act="los-share">Post to log</button>
      <button type="button" class="btn small" data-act="los-clear">Clear</button>
    </div>
  </section>`;
}

function phaseBanner(state: TableState): string {
  const side = state.scenario.sides[state.active].name;
  const kind = phaseKind(state);
  const text =
    kind === 'setup'
      ? 'Setup: place units anywhere, then press End in the top bar (or the Actions tab) to start turn 1.'
      : kind === 'move'
        ? `Movement Phase: ${side} units move. Pick a stack, tap Move, then tap a highlighted hex.`
        : kind === 'rout'
          ? 'Rout Phase: broken units of both sides move.'
          : kind === 'advance'
            ? `Advance Phase: ${side} units may advance one hex.`
            : `${phaseName(state)} Phase: no movement.`;
  return `<p class="phase-banner">${esc(text)}</p>`;
}

function unitStatus(state: TableState, id: string): string {
  const c = state.counters.get(id);
  if (!c) return '';
  const bits: string[] = [];
  const spent = state.move.spent.get(id) ?? 0;
  if (state.move.done.has(id)) bits.push('finished moving');
  else if (spent > 0) bits.push(`${fmt(spent)} MF used`);
  if (c.cx) bits.push('CX');
  if (c.pin) bits.push('pinned');
  if (c.fired) bits.push(c.fired === 'prep' ? 'prep fired' : c.fired === 'final' ? 'final fired' : 'advancing fired');
  return bits.length ? ` · ${bits.join(' · ')}` : '';
}

function renderStack(): void {
  const s = ui.session;
  if (!s) return;
  const state = s.state;
  const los = ui.mode.kind === 'los' && ui.mode.from && ui.mode.to ? { from: ui.mode.from, to: ui.mode.to } : ui.shownLos;
  const parts: string[] = [];
  parts.push(phaseBanner(state));
  if (los) parts.push(renderLosReport(state, los.from, los.to));
  const at = ui.selected;
  if (!at) {
    parts.push(`<p class="empty">Tap a hex to see its stack. ${
      narrow() ? '' : 'Drag a stack with the mouse to move it; '
    }use Move to send picked counters elsewhere.</p>`);
  } else {
    const ids = [...(state.stacks.get(at) ?? [])].reverse(); // top first
    const isHex = !at.includes('-');
    parts.push(`<div class="stack-head">
      <div><b class="stack-hex">${esc(isHex ? at : `${sideName(state.scenario, at.endsWith('a') ? 'a' : 'b')} ${locationName(at)}`)}</b>
      <span class="hint">${isHex ? esc(terrainSummary(state, at)) : ''}</span></div>
      <span class="hint">${ids.length} counter${ids.length === 1 ? '' : 's'}</span>
    </div>`);
    const edits = state.turn === 0 || ui.overrideTools;
    if (ids.length) {
      parts.push(`<div class="row stack-tools">
        <button type="button" class="btn small primary" data-act="move" ${ui.picked.size ? '' : 'disabled'}>Move ${ui.picked.size || ''}</button>
        <button type="button" class="btn small" data-act="pick-all">${ui.picked.size === ids.length ? 'Pick none' : 'Pick all'}</button>
        ${isHex && edits ? `<button type="button" class="btn small${state.turn ? ' danger' : ''}" data-act="to-pool" ${ui.picked.size ? '' : 'disabled'}>Off-board</button>
        <button type="button" class="btn small danger" data-act="to-elim" ${ui.picked.size ? '' : 'disabled'}>Eliminate</button>` : ''}
      </div>`);
      parts.push('<ul class="stack-list">');
      for (const id of ids) {
        const c = state.counters.get(id);
        if (!c) continue;
        const t = templateOf(c);
        const picked = ui.picked.has(id);
        const rotates = t.type === 'vehicle' || t.type === 'gun';
        parts.push(`<li class="stack-item${picked ? ' picked' : ''}">
          <button type="button" class="pick" data-pick="${esc(id)}" aria-pressed="${picked}" title="${picked ? 'Unpick' : 'Pick'} ${esc(counterName(c))}">
            ${counterIcon(c, 44)}
            <span class="pick-text"><span class="pick-name">${esc(counterName(c))}</span>
            <span class="pick-sub">${esc(NATIONS[t.nat].name)} ${esc(t.type === 'fort' ? 'terrain' : t.type)}${esc(unitStatus(state, id))}</span></span>
          </button>
          <div class="item-tools">
            ${edits ? `<button type="button" class="tool slim" data-flip="${esc(id)}" title="Flip${state.turn ? ' (override)' : ''}" aria-label="Flip ${esc(counterName(c))}">
              <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 7h10l-3-3M16 13H6l3 3" /></svg></button>` : ''}
            ${rotates ? `<button type="button" class="tool slim" data-rot="${esc(id)}" title="Turn facing" aria-label="Turn ${esc(counterName(c))}">
              <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M15 10a5 5 0 1 1-2-4M13 2v4h4" /></svg></button>` : ''}
            <button type="button" class="tool slim" data-top="${esc(id)}" title="Put on top" aria-label="Put ${esc(counterName(c))} on top">
              <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 15V5M5 9l5-5 5 5" /></svg></button>
            <button type="button" class="tool slim" data-label="${esc(id)}" title="Edit label" aria-label="Edit label of ${esc(counterName(c))}">
              <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 16h3l8-8-3-3-8 8z" /></svg></button>
            ${edits ? `<button type="button" class="tool slim" data-del="${esc(id)}" title="Remove from table${state.turn ? ' (override)' : ''}" aria-label="Remove ${esc(counterName(c))}">
              <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 6h10M8 6V4h4v2M6 6l1 10h6l1-10" /></svg></button>` : ''}
          </div>
        </li>`);
      }
      parts.push('</ul>');
    } else {
      parts.push('<p class="empty">Empty. Add counters from the Counters tab.</p>');
    }
  }
  parts.push(offboardBoxes(state));
  if (state.turn > 0) {
    parts.push(`<div class="override-row"><button type="button" class="btn small${ui.overrideTools ? ' danger' : ''}" data-act="override-tools" aria-pressed="${ui.overrideTools}">${
      ui.overrideTools ? 'Hide override tools' : 'Override tools…'
    }</button><span class="hint">Flip, remove, eliminate or add units by hand. Every use is flagged in the log.</span></div>`);
  }
  el.stackPanel.innerHTML = parts.join('');
}

function offboardBoxes(state: TableState): string {
  const box = (at: string, side: SideId, label: string) => {
    const n = state.stacks.get(at)?.length ?? 0;
    return `<button type="button" class="box${ui.selected === at ? ' active' : ''}" data-box="${at}">${natChip(state, side)} ${label} <b>${n}</b></button>`;
  };
  return `<div class="boxes"><span class="hint">Off-board</span>
    ${box('pool-a', 'a', 'ready')}${box('pool-b', 'b', 'ready')}${box('elim-a', 'a', 'lost')}${box('elim-b', 'b', 'lost')}
  </div>`;
}

el.stackPanel.addEventListener('click', (e) => {
  const target = (e.target as HTMLElement).closest<HTMLElement>('button');
  const s = ui.session;
  if (!target || !s) return;
  const d = target.dataset;
  if (d.pick) {
    if (ui.picked.has(d.pick)) ui.picked.delete(d.pick);
    else ui.picked.add(d.pick);
    renderStack();
    renderStrip();
    renderBoard();
  } else if (d.flip && canAct()) submit([{ t: 'flip', id: d.flip, ...overrideTag() }]);
  else if (d.rot && canAct()) submit([{ t: 'rot', id: d.rot, r: ((s.state.counters.get(d.rot)?.rot ?? 0) + 1) % 6 }]);
  else if (d.top && canAct()) submit([{ t: 'top', id: d.top }]);
  else if (d.del && canAct()) {
    const c = s.state.counters.get(d.del);
    if (c) {
      submit([{ t: 'del', id: d.del, ...overrideTag() }]);
      toast(`Removed ${counterName(c)}. Undo brings it back.`);
    }
  } else if (d.label && canAct()) {
    editLabel(d.label);
  } else if (d.box) {
    selectLocation(ui.selected === d.box ? null : d.box);
  } else if (d.act === 'move') startMove();
  else if (d.act === 'pick-all') {
    const ids = s.state.stacks.get(ui.selected ?? '') ?? [];
    ui.picked = ui.picked.size === ids.length ? new Set() : new Set(ids);
    renderAll();
  } else if ((d.act === 'to-pool' || d.act === 'to-elim') && ui.selected && canAct()) {
    const ids = orderedPicked(ui.selected);
    const bySide = { a: [] as string[], b: [] as string[] };
    for (const id of ids) {
      const c = s.state.counters.get(id);
      if (c) bySide[c.side === 'b' ? 'b' : 'a'].push(id);
    }
    const kind = d.act === 'to-pool' ? 'pool' : 'elim';
    const actions: Action[] = [];
    if (bySide.a.length) actions.push({ t: 'move', ids: bySide.a, to: `${kind}-a`, ...overrideTag() });
    if (bySide.b.length) actions.push({ t: 'move', ids: bySide.b, to: `${kind}-b`, ...overrideTag() });
    submit(actions);
    ui.picked.clear();
  } else if (d.act === 'override-tools') {
    ui.overrideTools = !ui.overrideTools;
    renderStack();
  } else if (d.act === 'los-share' && canAct()) {
    const los = ui.mode.kind === 'los' && ui.mode.from && ui.mode.to ? { from: ui.mode.from, to: ui.mode.to } : ui.shownLos;
    if (los) submit([{ t: 'los', from: los.from, to: los.to }]);
    toast('LOS posted to the log');
  } else if (d.act === 'los-clear') {
    ui.mode = { kind: 'none' };
    ui.shownLos = null;
    renderAll();
  }
});

/** Inline label editor in the stack list. */
function editLabel(id: string): void {
  const c = stateOrNull()?.counters.get(id);
  const row = el.stackPanel.querySelector<HTMLElement>(`[data-pick="${CSS.escape(id)}"] .pick-name`);
  if (!c || !row) return;
  const input = document.createElement('input');
  input.type = 'text';
  input.maxLength = 24;
  input.className = 'label-input';
  input.value = c.label ?? templateOf(c).label;
  input.setAttribute('aria-label', 'Counter label. Leave empty to restore the printed one.');
  row.replaceWith(input);
  input.focus();
  input.select();
  let done = false;
  const commit = (save: boolean) => {
    if (done) return;
    done = true;
    const text = input.value.trim().slice(0, 24);
    const current = c.label ?? templateOf(c).label;
    if (save && text !== current) submit([{ t: 'label', id, text }]);
    else renderStack();
  };
  input.addEventListener('click', (e) => e.stopPropagation());
  input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') commit(true);
    else if (e.key === 'Escape') commit(false);
    e.stopPropagation();
  });
  input.addEventListener('blur', () => commit(true));
}

function renderTray(): void {
  const s = ui.session;
  if (!s) return;
  const state = s.state;
  const sides: (SideId | 'n')[] = ['a', 'b', 'n'];
  const tabs = sides
    .map((side) => {
      const nat: NationId = side === 'n' ? 'mk' : state.scenario.sides[side].nat;
      return `<button type="button" role="radio" class="seg-btn" data-tray="${side}" aria-checked="${ui.traySide === side}">
        <span class="swatch" style="background:${NATION_STYLE[nat].face};border-color:${NATION_STYLE[nat].edge}"></span>${esc(NATIONS[nat].name)}</button>`;
    })
    .join('');
  const nat: NationId = ui.traySide === 'n' ? 'mk' : state.scenario.sides[ui.traySide].nat;
  const where = ui.selected ? (ui.selected.includes('-') ? locationName(ui.selected) : `hex ${ui.selected}`) : ui.traySide === 'n' ? 'a selected hex' : 'the off-board box';
  const items = templatesFor(nat)
    .map((t) => {
      const sample = { id: 'tray', k: t.k, side: ui.traySide, face: 0 as const, rot: 0, at: '' };
      return `<button type="button" class="tray-item" data-add="${t.k}" title="Add ${esc(t.label)}">${counterIcon(sample, 46)}<span>${esc(t.tag ? `${t.label} ${t.tag}` : t.label)}</span></button>`;
    })
    .join('');
  el.trayPanel.innerHTML = `<div class="seg" role="radiogroup" aria-label="Counter set">${tabs}</div>
    <p class="hint">Tap a counter to add it to ${esc(where)}. Edit its label afterwards to match your unit tables.</p>
    <div class="tray">${items}</div>`;
}

el.trayPanel.addEventListener('click', (e) => {
  const b = (e.target as HTMLElement).closest<HTMLButtonElement>('button');
  if (!b) return;
  if (b.dataset.tray) {
    ui.traySide = b.dataset.tray as SideId | 'n';
    renderTray();
  } else if (b.dataset.add) {
    addCounter(b.dataset.add, ui.traySide);
  }
});

const DIE_PIPS: Record<number, [number, number][]> = {
  1: [[0, 0]],
  2: [[-1, -1], [1, 1]],
  3: [[-1, -1], [0, 0], [1, 1]],
  4: [[-1, -1], [1, -1], [-1, 1], [1, 1]],
  5: [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
  6: [[-1, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [1, 1]],
};

function dieSvg(value: number, colored: boolean, size = 34): string {
  const pips = (DIE_PIPS[value] ?? [])
    .map(([x, y]) => `<circle cx="${12 + x * 6}" cy="${12 + y * 6}" r="2.3" fill="${colored ? '#fff4dc' : '#1b1a14'}"/>`)
    .join('');
  return `<svg class="die" viewBox="0 0 24 24" width="${size}" height="${size}" role="img" aria-label="${value}"><rect x="1" y="1" width="22" height="22" rx="4.5" fill="${
    colored ? '#a3321f' : '#f3ecd8'
  }" stroke="#1b1a14" stroke-width="1.2"/>${pips}</svg>`;
}

function renderLog(): void {
  const s = ui.session;
  if (!s) return;
  const state = s.state;
  ui.logSeen = state.lines.length;
  el.logBadge.hidden = true;
  const pendingFrom = s.confirmed.length;
  el.logList.innerHTML = state.lines
    .slice(-300)
    .map((line) => {
      const nat = sideNation(state.scenario, line.side);
      const st = NATION_STYLE[nat];
      const time = new Date(line.at * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
      const dice = line.dice ? `<span class="log-dice">${line.dice.map((d, i) => dieSvg(d, i === 0, 18)).join('')}</span>` : '';
      const pending = line.i >= pendingFrom ? ' pending' : '';
      const text =
        line.kind === 'chat'
          ? `<span class="chat-text">${esc(line.text)}</span>`
          : line.kind === 'los'
            ? `<button type="button" class="link" data-los="${line.los!.from}:${line.los!.to}">${esc(line.text)}</button>`
            : esc(line.text);
      return `<li class="log-line kind-${line.kind}${line.undone ? ' undone' : ''}${pending}">
        <span class="log-side" style="background:${st.face};border-color:${st.edge}" title="${esc(sideName(state.scenario, line.side))}"></span>
        <span class="log-body">${dice}${text}${
          line.detail?.length ? `<ul class="log-detail">${line.detail.map((d) => `<li>${esc(d)}</li>`).join('')}</ul>` : ''
        }</span><time class="log-time">${time}</time></li>`;
    })
    .join('');
  el.logList.scrollTop = el.logList.scrollHeight;
}

el.logList.addEventListener('click', (e) => {
  const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-los]');
  if (!b) return;
  const [from, to] = b.dataset.los!.split(':');
  ui.shownLos = { from, to };
  ui.mode = { kind: 'none' };
  board.reveal(to);
  ui.tab = 'stack';
  renderAll();
});

function sendChat(): void {
  const text = el.chatInput.value.trim();
  if (!text || !ui.session) return;
  if (ui.session.online && !ui.pubkey) {
    toast('Sign in to chat at an online table.');
    return;
  }
  submit([{ t: 'chat', text: text.slice(0, 280) }]);
  el.chatInput.value = '';
}
el.chatSend.addEventListener('click', sendChat);
onEnter(el.chatInput, sendChat);
onEnter(el.rollWhy, () => roll(2));

function renderInfo(): void {
  const s = ui.session;
  if (!s) return;
  const state = s.state;
  const sc = state.scenario;
  const meta = s.meta;
  const mine = s.mySide();
  const seat = (side: SideId) => {
    const holder = side === 'a' ? meta?.seatA : meta?.seatB;
    const nat = sc.sides[side].nat;
    const who = !s.online ? 'This device' : holder ? (holder === s.me ? 'You' : shortKey(holder)) : 'Open seat';
    const join =
      s.online && !holder && mine === 'n'
        ? `<button type="button" class="btn small primary" data-join="${side}" ${ui.pubkey ? '' : 'disabled'}>Sit here</button>`
        : '';
    return `<li class="seat-row"><span class="swatch" style="background:${NATION_STYLE[nat].face};border-color:${NATION_STYLE[nat].edge}"></span>
      <span><b>${esc(sc.sides[side].name)}</b>${sc.first === side ? ' · moves first' : ''}<br><span class="hint">${esc(who)}</span></span>${join}</li>`;
  };
  const code = s.online ? shareCode(s.saved.release!, s.saved.id) : '';
  const live = s.status.live;
  const liveText =
    live === 'connected'
      ? `Live: connected to ${s.status.peers} other player${s.status.peers === 1 ? '' : 's'}. Moves appear instantly.`
      : live === 'waiting'
        ? 'Live link open. Waiting for your opponent to open this table.'
        : live === 'connecting'
          ? 'Opening live link…'
          : 'Live link off. Moves still sync every few seconds.';
  el.infoPanel.innerHTML = `
    <section class="info-block">
      <h3>${esc(sc.name)}</h3>
      <p>${esc(sc.brief)}</p>
      <p><b>Length:</b> ${sc.turns} game turns. <b>Victory:</b> ${esc(sc.victory)}</p>
      <ul class="setup">
        <li><b>${esc(sc.sides.a.name)}:</b> ${esc(sc.sides.a.setup)}</li>
        <li><b>${esc(sc.sides.b.name)}:</b> ${esc(sc.sides.b.setup)}</li>
      </ul>
    </section>
    <section class="info-block">
      <h3>Seats</h3>
      <ul class="seats">${seat('a')}${seat('b')}</ul>
      ${s.online && mine === 'n' && !ui.pubkey ? '<p class="hint">Sign in with your Nostr account to take a seat. You can watch without one.</p>' : ''}
      ${!s.online ? '<p class="hint">Hotseat: use the side chip in the top bar to switch who is acting. Dice are rolled on this device.</p>' : ''}
    </section>
    ${
      s.online
        ? `<section class="info-block">
      <h3>Invite</h3>
      <p class="hint">Send this code to your opponent. They paste it under “Join with a code”. Anyone with it can watch.</p>
      <div class="code-row"><input id="shareCode" type="text" readonly value="${esc(code)}" aria-label="Table code" data-napplet-select="text" /><button type="button" class="btn small" data-act="copy">Copy</button></div>
    </section>
    <section class="info-block">
      <h3>Live play</h3>
      <p class="hint">${esc(liveText)}</p>
      ${
        s.liveAvailable()
          ? `<button type="button" class="btn small" data-act="live">${live === 'off' ? 'Go live' : 'Stop live link'}</button>`
          : '<p class="hint">This host has no peer connections; the table still syncs through the server.</p>'
      }
    </section>`
        : ''
    }
    <section class="info-block">
      <h3>Rules</h3>
      <ul class="setup">${RULE_NOTES.map((n) => `<li>${esc(n)}</li>`).join('')}</ul>
      <p class="hint">Blocked moves show the reason. Override moves anyway; overrides are flagged in red in the log.</p>
    </section>
    <section class="info-block">
      <h3>Map key</h3>
      <ul class="key">
        <li><span class="key-sw" style="background:#46612f"></span>Woods</li>
        <li><span class="key-sw" style="background:#7f9152"></span>Brush</li>
        <li><span class="key-sw" style="background:#dcc376"></span>Grain</li>
        <li><span class="key-sw" style="background:#6f8c45;border-radius:50%"></span>Orchard</li>
        <li><span class="key-sw" style="background:#a5724a"></span>Wooden building</li>
        <li><span class="key-sw" style="background:#8f8b83"></span>Stone building</li>
        <li><span class="key-sw" style="background:#86a9b6"></span>Water</li>
        <li><span class="key-sw" style="background:#4f7a72"></span>Marsh</li>
        <li><span class="key-sw" style="background:#d5c296"></span>Hill level 1</li>
        <li><span class="key-sw" style="background:#c3a874"></span>Hill level 2</li>
        <li><span class="key-line" style="background:#3a5527"></span>Hedge</li>
        <li><span class="key-line" style="background:#5e5a54"></span>Wall</li>
        <li><span class="key-line" style="background:#ddd3b4;outline:1px solid #8f8466"></span>Road</li>
      </ul>
    </section>
    <div class="row"><button type="button" class="btn small" data-act="leave">Back to your tables</button></div>
    <p class="hint">${s.online ? `Table ${esc(shortKey(s.saved.id))} · dice are rolled by the table server and logged for both players.` : ''}</p>`;
}

el.infoPanel.addEventListener('click', async (e) => {
  const b = (e.target as HTMLElement).closest<HTMLButtonElement>('button');
  const s = ui.session;
  if (!b || !s) return;
  if (b.dataset.join) {
    b.disabled = true;
    try {
      await s.join(b.dataset.join as SideId);
      ui.traySide = s.mySide() === 'b' ? 'b' : 'a';
      toast(`You are playing ${sideName(s.state.scenario, s.mySide())}`);
      renderAll();
    } catch (error) {
      toast(error instanceof Error ? error.message : 'Could not take that seat.');
      b.disabled = false;
    }
  } else if (b.dataset.act === 'copy') {
    const input = el.infoPanel.querySelector<HTMLInputElement>('#shareCode');
    if (!input) return;
    input.select();
    try {
      await navigator.clipboard.writeText(input.value);
      toast('Code copied');
    } catch {
      toast('Copy the selected code with your keyboard or long-press.');
    }
  } else if (b.dataset.act === 'leave') {
    el.back.click();
  } else if (b.dataset.act === 'live') {
    if (s.status.live === 'off') await s.startLive();
    else await s.stopLive();
    renderInfo();
  }
});

function renderStrip(): void {
  const s = ui.session;
  const show = Boolean(s && ui.selected && !ui.panelOpen && ui.mode.kind === 'none');
  el.strip.hidden = !show;
  if (!show || !s) return;
  const at = ui.selected!;
  const ids = [...(s.state.stacks.get(at) ?? [])].reverse();
  el.stripHex.innerHTML = `<b>${esc(at.includes('-') ? locationName(at) : at)}</b><span>${esc(at.includes('-') ? '' : terrainSummary(s.state, at))}</span>`;
  el.stripCounters.innerHTML = ids.length
    ? ids
        .map((id) => {
          const c = s.state.counters.get(id)!;
          return `<button type="button" class="strip-ctr" data-pick="${esc(id)}" aria-pressed="${ui.picked.has(id)}" title="${esc(counterName(c))}">${counterIcon(c, 38)}</button>`;
        })
        .join('')
    : '<span class="hint">Empty hex</span>';
  el.stripMove.disabled = !ui.picked.size;
}

el.stripCounters.addEventListener('click', (e) => {
  const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-pick]');
  if (!b) return;
  const id = b.dataset.pick!;
  if (ui.picked.has(id)) ui.picked.delete(id);
  else ui.picked.add(id);
  renderStrip();
  renderBoard();
});
el.stripMove.addEventListener('click', startMove);
el.stripMore.addEventListener('click', () => {
  ui.tab = 'stack';
  setPanel(true);
  renderPanel();
});

// ---------- toasts and dice ----------

let toastTimer = 0;
let lastToast = '';
function toast(text: string, ms = 2600): void {
  if (text === lastToast && !el.toast.hidden) return;
  lastToast = text;
  el.toast.textContent = text;
  el.toast.hidden = false;
  window.clearTimeout(toastTimer);
  toastTimer = window.setTimeout(() => {
    el.toast.hidden = true;
    lastToast = '';
  }, ms);
}

let diceTimer = 0;
function showDice(entry: Entry): void {
  const state = stateOrNull();
  if (!state || !entry.d) return;
  // Rules rolls carry a batch of dice; the first one or two are the headline roll.
  const shown = entry.a.t === 'roll' ? entry.d : entry.d.slice(0, entry.a.t === 'repair' ? 1 : 2);
  const total = shown.reduce((a, b) => a + b, 0);
  const nat = sideNation(state.scenario, entry.side);
  const line = state.lines.find((l) => l.i === entry.i);
  const why =
    entry.a.t === 'roll'
      ? entry.a.why
      : line?.text.replace(/^[A-Z]{2} /, '').replace(/^.*?→ /, '') ?? entry.a.t;
  el.diceTray.innerHTML = `<div class="dice-faces">${shown.map((d, i) => dieSvg(d, i === 0 && shown.length === 2)).join('')}</div>
    <div class="dice-text"><b>${shown.length === 2 ? 'DR' : 'dr'} ${total}</b><span>${esc(NATIONS[nat].short)}${why ? ` · ${esc(why)}` : ''}</span></div>`;
  el.diceTray.hidden = false;
  el.diceTray.classList.remove('pop');
  void el.diceTray.offsetWidth;
  el.diceTray.classList.add('pop');
  window.clearTimeout(diceTimer);
  diceTimer = window.setTimeout(() => (el.diceTray.hidden = true), 8000);
}
el.diceTray.addEventListener('click', () => (el.diceTray.hidden = true));

// ---------- controls ----------

el.rollDR.addEventListener('click', () => roll(2));
el.rolldr.addEventListener('click', () => roll(1));
el.los.addEventListener('click', () => {
  ui.mode = ui.mode.kind === 'los' ? { kind: 'none' } : { kind: 'los', from: ui.selected && !ui.selected.includes('-') ? ui.selected : null, to: null };
  ui.shownLos = null;
  if (ui.mode.kind === 'los' && narrow()) setPanel(false);
  renderAll();
});
el.undo.addEventListener('click', () => {
  const s = ui.session;
  if (!s || !canAct()) return;
  const line = undoCandidate(s.state, s.me);
  if (line) submit([{ t: 'undo', ref: line.i }]);
});
el.modeActions.addEventListener('click', (e) => {
  const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-mode]');
  const m = ui.mode;
  if (!b) return;
  if (b.dataset.mode === 'cancel') ui.mode = { kind: 'none' };
  else if (b.dataset.mode === 'dt' && m.kind === 'move') ui.mode = { ...m, dt: !m.dt };
  else if (b.dataset.mode === 'override' && m.kind === 'move') {
    const reach = currentReach();
    const reason = reach && !reach.ok ? reach.reasons[0] : 'moved outside the movement rules';
    ui.mode = { ...m, override: reason.slice(0, 120) };
    toast('Override: this move skips the rules and is flagged in the log.', 4000);
  }
  renderAll();
});
el.panelButton.addEventListener('click', () => {
  setPanel(!ui.panelOpen);
  if (ui.panelOpen) renderPanel();
});
el.panelClose.addEventListener('click', () => setPanel(false));
el.panel.querySelectorAll<HTMLButtonElement>('.tab').forEach((tab) =>
  tab.addEventListener('click', () => {
    ui.tab = tab.dataset.tab as Tab;
    renderPanel();
  }),
);
const soundButton = $<HTMLButtonElement>('#soundButton');
function renderSound(): void {
  soundButton.setAttribute('aria-pressed', String(!soundEnabled()));
  soundButton.title = soundEnabled() ? 'Mute battle sounds' : 'Turn battle sounds on';
  soundButton.querySelector('.tool-label')!.textContent = soundEnabled() ? 'Sound' : 'Muted';
}
soundButton.addEventListener('click', () => {
  setSoundEnabled(!soundEnabled());
  renderSound();
  void savePrefs();
});
async function savePrefs(): Promise<void> {
  if (!runtimeHasDomain('storage')) return;
  try {
    await storage.setItem('prefs', JSON.stringify({ sound: soundEnabled() }));
  } catch {
    // Preference is a nicety; keep the in-memory value.
  }
}
async function loadPrefs(): Promise<void> {
  if (!runtimeHasDomain('storage')) return;
  try {
    const prefs = JSON.parse((await storage.getItem('prefs')) ?? '{}') as { sound?: boolean };
    if (typeof prefs.sound === 'boolean') setSoundEnabled(prefs.sound);
  } catch {
    // Defaults apply.
  }
  renderSound();
}
void loadPrefs();
renderSound();

el.zoomIn.addEventListener('click', () => board.zoomBy(1.25));
el.zoomOut.addEventListener('click', () => board.zoomBy(0.8));
el.zoomFit.addEventListener('click', () => board.fit());

document.addEventListener('keydown', (e) => {
  if (!ui.session || el.table.hidden) return;
  const typing = (e.target as HTMLElement).closest('input, textarea, select');
  if (typing) return;
  if (e.key === 'Escape') {
    if (ui.mode.kind !== 'none') ui.mode = { kind: 'none' };
    else if (ui.shownLos) ui.shownLos = null;
    else selectLocation(null);
    renderAll();
  } else if (e.key === 'm' && ui.selected) startMove();
  else if (e.key === 'l') el.los.click();
  else if (e.key === 'r') roll(2);
  else if (e.key === 'd') roll(1);
  else if ((e.key === 'z' && (e.ctrlKey || e.metaKey)) || e.key === 'u') el.undo.click();
  else if (e.key === 'f' && ui.selected && canAct()) {
    const ids = orderedPicked(ui.selected);
    if (ids.length) submit(ids.map((id) => ({ t: 'flip', id }) as Action));
  } else if (e.key === '+' || e.key === '=') board.zoomBy(1.25);
  else if (e.key === '-') board.zoomBy(0.8);
  else return;
  e.preventDefault();
});

let wasNarrow = narrow();
window.addEventListener('resize', () => {
  const now = narrow();
  if (now !== wasNarrow && ui.session) setPanel(!now);
  wasNarrow = now;
});

onSettings(() => {
  if (ui.session) renderBoard();
});

window.addEventListener('pagehide', () => {
  closeTable();
  identitySub?.close();
});

renderScenarioPicker();
renderLobbyNotes();
void loadIdentity().then(renderMyTables);
void renderMyTables();