<script lang="ts">
  import { attentionLabel, net, spellName } from "./net.svelte";
  import Board from "./Board.svelte";
  import { PLAYER_COLORS, wizardColor } from "./colors";
  import Faq from "./Faq.svelte";
  import Card from "./Card.svelte";
  import Help from "./Help.svelte";
  import Replay from "./Replay.svelte";
  import FxGallery from "./FxGallery.svelte";
  import { scheduleFx, type BoardFx } from "./fx";
  import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
  import { local } from "./local.svelte";
  import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
  import type { CardInstance, Side } from "@wizwar/engine";

  net.connect();
  net.startGamePolling();

  let name = $state("");
  let joinCode = $state("");
  let claimPhrase = $state("");
  let showHelp = $state(false);
  /** The finished game whose victory fanfare has been dismissed. */
  let victorySeen = $state(false);
  /** The fireworks get the stage before the modal takes it. */
  let victoryCurtain = $state(false);
  $effect(() => {
    if (view?.phase === "finished" && view.winner && !victoryCurtain) {
      const t = setTimeout(() => (victoryCurtain = true), 2400);
      return () => clearTimeout(t);
    }
  });
  /** Quiet mode: skip the attack announcement modal (device preference). */
  let noFanfare = $state(localStorage.getItem("wizwar-no-fanfare") === "1");
  function setFanfare(announce: boolean) {
    noFanfare = !announce;
    localStorage.setItem("wizwar-no-fanfare", announce ? "0" : "1");
  }
  /** Which pending attack the player has already acknowledged (modal dismissed). */
  const fxWorkshop = new URLSearchParams(location.search).has("fx");
  let attackNoticeSeen = $state<string | null>(null);
  /** The interruption fanfare, dismissed once per window. */
  let momentSeen = $state(false);
  $effect(() => {
    if (!view?.outOfTurnWindow) momentSeen = false;
  });
  /** Live spell flourishes on the board (cosmetic, self-expiring). */
  let boardFx = $state<BoardFx[]>([]);
  let fxCancels: (() => void)[] = [];
  /** A trap drawn by YOU earns a modal; buried log lines get missed. */
  let trapNotice = $state<string | null>(null);
  function playFx(events: Parameters<typeof scheduleFx>[0]) {
    if (!view) return;
    for (const e of events) {
      if (e.type === "trapSprung" && e.player === view.you && e.cardId) trapNotice = e.cardId;
    }
    fxCancels.push(scheduleFx(
      events, view,
      (fx) => (boardFx = [...boardFx, fx]),
      (id) => (boardFx = boardFx.filter((f) => f.id !== id)),
    ));
  }
  $effect(() => {
    net.onFx = playFx;
    local.onFx = playFx;
    return () => {
      net.onFx = null;
      local.onFx = null;
      fxCancels.forEach((c) => c());
      fxCancels = [];
    };
  });
  const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
  function dismissRolls() {
    net.openingRolls = null;
    local.openingRolls = null;
  }
  /** A face-up card being peeked at from the scoresheet (view-only). */
  let peekCard = $state<CardInstance | null>(null);
  /** When the peeked card is a creature on the board, its live stats ride along. */
  let peekCreatureId = $state<string | null>(null);
  /** The clicked token itself, enlarged beside its card. */
  let peekToken = $state<string | null>(null);
  /** Bare-knuckle demolition: click a wall to punch it. */
  let punchWallMode = $state(false);
  /** Leafing through the face-up discard pile. */
  let showDiscards = $state(false);
  let chatDraft = $state("");
  let botTier = $state("adept");
  /** Card whose official FAQ rulings are open. */
  let faqCardId = $state<string | null>(null);
  /** A discard-pile card enlarged above the pile. */
  let discardPeek = $state<CardInstance | null>(null);
  let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play");
  let hotseatCount = $state(2);
  let setupName = $state("");
  let setupColor = $state(0);
  /** Ambush arming flow: the Interrupt/OF card, trigger, and committed attack. */
  let ambushVia = $state<CardInstance | null>(null);
  let ambushTrigger = $state<"los" | "near" | "treasure" | null>(null);
  let ambushSpell = $state<CardInstance | null>(null);
  // Default each new wizard to the first unclaimed standee.
  $effect(() => {
    if (local.setup && local.setup.colors.includes(setupColor)) {
      setupColor = [0, 1, 2, 3, 4, 5].find((c) => !local.setup!.colors.includes(c)) ?? 0;
    }
  });
  /** Tap-to-inspect: hide the enlarged card for the current selection. */
  let inspectorHidden = $state(false);
  $effect(() => {
    void selectedCard?.instanceId;
    inspectorHidden = false;
  });
  let drawCount = $state(2);
  let withExpansion = $state(true);
  /** Your creature selected for movement/attacks. */
  let selectedCreature = $state<string | null>(null);

  /** Card selected in hand, pending a target. */
  let selectedCard = $state<CardInstance | null>(null);
  /** Number card attached to the pending cast. */
  let attachedNumber = $state<CardInstance | null>(null);
  /** Waterbolt split. */
  let wbDamage = $state(0);
  /** teleport-opponent: player chosen, waiting for the destination cell. */
  let pendingCellFor = $state<string | null>(null);
  /** card-erasure / drop-object: the named card. */
  let nameInput = $state("");
  /** power-run points. */
  let runPoints = $state(1);
  /** Modifier cards attached to the pending cast. */
  let attachedMods = $state<CardInstance[]>([]);
  /** relocate-sector: the sector picked up, awaiting its destination. */
  let pendingSectorFrom = $state<{ x: number; y: number } | null>(null);
  /** boobytrap: cells picked so far (first is the real one). */
  let trapCells = $state<{ x: number; y: number }[]>([]);
  /** trader: first item square picked. */
  let tradeFrom = $state<{ x: number; y: number } | null>(null);
  /** rotate-sector direction. */
  let rotateCW = $state(true);
  /** pick-lock / master-key: prop the door for others once it opens. */
  let holdDoor = $state(false);
  /** mega-monster: which stat the chosen monster doubles. */
  let megaBoost = $state<"life" | "movement">("life");
  /** teleport: the marked destination awaiting its confirming second tap. */
  let pendingTeleport = $state<{ x: number; y: number } | null>(null);
  /** Cards marked for discard. */
  let discardSelection = $state<Set<string>>(new Set());
  /** Voluntary discard mode: hand clicks mark cards instead of playing them. */
  let discardMode = $state(false);

  const view = $derived(local.active ? local.view : net.view);
  const isYourTurn = $derived(
    view !== null && view.phase === "playing" && view.activePlayerId === view.you && !view.stack,
  );
  const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you);
  /** An interruption window (INTERRUPT / OPPORTUNITY FIRE) is your moment:
   * casting and targeting open up as if the turn were yours. */
  const yourMoment = $derived(
    isYourTurn || (view != null && view.outOfTurnWindow?.playerId === view.you),
  );
  const attackNoticeKey = $derived(
    youMustRespond && view?.stack
      ? `${view.stack.attackerId}:${view.stack.attackCard?.instanceId ?? view.stack.creatureId ?? "punch"}:${view.stack.counters.length}`
      : null,
  );
  const attackingCreature = $derived(
    view?.stack?.creatureId ? view.creatures.find((c) => c.id === view!.stack!.creatureId) ?? null : null,
  );
  const myShieldstoneDisplayed = $derived(
    view?.players.find((p) => p.id === view.you)?.displayed.some((c) => c.cardId === "shieldstone") ?? false,
  );
  const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
  const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null);
  /** Picking up an object ends the turn's actions: the hand goes quiet until
   * the draw — but never while a counter, shield, or discard is owed. */
  const actionsSpent = $derived(
    view != null && isYourTurn && view.turn.actionsEnded &&
    !youMustRespond && !youMustShield && !youMustDiscard,
  );
  const commandedCreature = $derived(
    selectedCreature && view
      ? (view.creatures.find((c) => c.id === selectedCreature) ?? null)
      : null,
  );

  /** The 5x5 sector a picked-up square belongs to (its origin), or null. */
  const pendingSectorOrigin = $derived.by(() => {
    if (!view || !pendingSectorFrom) return null;
    const p = view.board.placements.find(
      (p) =>
        pendingSectorFrom!.x >= p.origin.x && pendingSectorFrom!.x < p.origin.x + 5 &&
        pendingSectorFrom!.y >= p.origin.y && pendingSectorFrom!.y < p.origin.y + 5,
    );
    return p ? p.origin : null;
  });

  /** Legal empty slots the lifted sector may land on — mirroring the
   * engine's relocateSector checks: on the 5-grid, non-negative, vacant,
   * and leaving every sector adjacent to at least one other. */
  const relocateGhosts = $derived.by(() => {
    if (!view || selectedCard?.cardId !== "relocate-sector" || !pendingSectorOrigin) return null;
    const origins = view.board.placements.map((p) => p.origin);
    const idx = origins.findIndex(
      (o) => o.x === pendingSectorOrigin!.x && o.y === pendingSectorOrigin!.y,
    );
    const adjacent = (a: { x: number; y: number }, b: { x: number; y: number }) =>
      (Math.abs(a.x - b.x) === 5 && a.y === b.y) || (Math.abs(a.y - b.y) === 5 && a.x === b.x);
    const seen = new Set<string>();
    const out: { x: number; y: number }[] = [];
    for (let i = 0; i < origins.length; i++) {
      if (i === idx) continue;
      for (const [dx, dy] of [[5, 0], [-5, 0], [0, 5], [0, -5]] as const) {
        const c = { x: origins[i]!.x + dx, y: origins[i]!.y + dy };
        const k = `${c.x},${c.y}`;
        if (seen.has(k)) continue;
        seen.add(k);
        // Before rev 11 the grid stopped at zero; newer games renormalize.
        if (view!.deckRev < 11 && (c.x < 0 || c.y < 0)) continue;
        if (origins.some((o) => o.x === c.x && o.y === c.y)) continue;
        const next = origins.map((o, j) => (j === idx ? c : o));
        if (next.every((o, j) => next.some((p, m) => m !== j && adjacent(o, p)))) out.push(c);
      }
    }
    return out;
  });

  function confirmTeleport() {
    if (!selectedCard || !pendingTeleport) return;
    dispatch({
      type: "cast", instanceId: selectedCard.instanceId,
      target: { kind: "cell", cell: pendingTeleport },
      ...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
    });
    clearSelection();
  }

  function clickGhostSlot(origin: { x: number; y: number }) {
    if (!selectedCard || !pendingSectorFrom) return;
    dispatch({
      type: "cast", instanceId: selectedCard.instanceId,
      target: { kind: "cell", cell: origin }, params: { cell: pendingSectorFrom },
    });
    clearSelection();
  }
  const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false);
  /** A live moment to interrupt: another wizard acts, no attack is pending. */
  const canInterruptNow = $derived(
    view != null && !isYourTurn && !view.stack && !view.chaosPending &&
    !view.outOfTurnWindow && view.phase === "playing" && view.turn.round > 1 && !local.active,
  );
  const interruptCards = $derived(
    canInterruptNow
      ? view!.yourHand.filter((c) => c.cardId === "interrupt" || c.cardId === "opportunity-fire")
      : [],
  );

  const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null);
  const WAND_IDS = new Set(["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"]);
  /** What the edge click actually is, per card — "wall line" fits few. */
  const EDGE_HINTS: Record<string, string> = {
    "create-wall": "click an open corridor line to wall it",
    "destroy-wall": "click a wall or door to destroy it",
    "illusion-wall": "click an open corridor line for the false wall",
    "wall-of-fire": "click a corridor line for the fire",
    "waterwall": "click a corridor line — the wave collapses at once",
    "pick-lock": "click a locked door",
    "master-key": "click a locked door",
    "jam-lock": "click a door to jam its lock solid",
    "remove-lock": "click a door to strip its lock for good",
    "create-door": "click a wall for the new door",
    "warp-wand": "click a wall to warp it open",
    "stone-to-water": "click a stone wall, or a stone-filled square",
    "thumb-of-god": "aim the die at a square in sight — it may drift on impact",
    "dispel-creation": "click the creation — a square, a creature, or a conjured wall",
  };
  const EDGE_CARDS = new Set([
    "create-wall", "destroy-wall", "illusion-wall", "wall-of-fire", "waterwall",
    "pick-lock", "jam-lock", "remove-lock", "master-key", "dispel-creation",
    "warp-wand", "create-door", "stone-to-water",
  ]);
  const CELL_CARDS = new Set([
    "teleport", "fill-square-with-stone", "thornbush", "dispel-creation", "drag",
    "rotate-sector", "relocate-sector",
    "troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
    "killer-ooze", "rosebush", "dust-cloud", "fill-square-with-slime", "create-pit",
    "handful-of-tacks", "glue", "safe", "trader", "stone-to-water", "boobytrap",
    "dimensional-warp", "redirection", "thumb-of-god",
  ]);
  const TWO_CELL_CARDS = new Set(["trader", "dimensional-warp", "redirection"]);
  const CREATURE_TARGET_CARDS = new Set(["mega-monster"]);
  const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]);

  const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]);
  const attackVsWall = $derived(
    selectedCard != null && cardDef(selectedCard.cardId).cardType === "attack" && !EDGE_CARDS.has(selectedCard.cardId),
  );
  const edgeSelectMode = $derived(
    (selectedCard != null && EDGE_CARDS.has(selectedCard.cardId)) || attackVsWall || punchWallMode,
  );
  const cellSelectMode = $derived(
    (selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null,
  );
  const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);

  function dispatch(command: Parameters<typeof net.command>[0]) {
    if (local.active) local.command(command);
    else net.command(command);
  }

  function clearSelection() {
    punchWallMode = false;
    swapMeetTarget = null;
    swapMine = null;
    ambushVia = null;
    ambushTrigger = null;
    ambushSpell = null;
    trapCells = [];
    tradeFrom = null;
    selectedCreature = null;
    selectedCard = null;
    megaBoost = "life";
    holdDoor = false;
    pickChoice = false;
    pendingTeleport = null;
    attachedNumber = null;
    attachedMods = [];
    wbDamage = 0;
    pendingCellFor = null;
    pendingSectorFrom = null;
    nameInput = "";
  }

  /** Fill the modifier fields of a cast command from the attachments. */
  function applyMods(cmd: Parameters<typeof net.command>[0] & { type: "cast" }) {
    for (const m of attachedMods) {
      if (m.cardId === "amplify") (cmd.amplifyInstanceIds ??= []).push(m.instanceId);
      else if (m.cardId === "add") cmd.addInstanceId = m.instanceId;
      else if (m.cardId === "extend") cmd.extendInstanceId = m.instanceId;
      else if (m.cardId === "around-the-corner") cmd.aroundCornerInstanceId = m.instanceId;
    }
  }

  /** Suggest-as-you-type pool for the card-naming field, tuned per card. */
  const nameSuggestions = $derived.by(() => {
    if (!selectedCard) return [];
    const pool = allCardDefs().filter(
      (d) => (d.set === "basic" || d.set === "expansion1") && (d.quantity ?? 0) > 0,
    );
    let picks = pool;
    if (selectedCard.cardId === "illusionary-attack") {
      picks = pool.filter((d) => d.cardType === "attack");
    } else if (selectedCard.cardId === "drop-object") {
      picks = pool.filter((d) => isMovableObject(d.id));
    }
    const names = picks.map((d) => d.name).sort();
    return selectedCard.cardId === "drop-object" ? ["Treasure", ...names] : names;
  });

  /** Map a typed card name to its id (case-insensitive). */
  function nameToCardId(name: string): string | null {
    const wanted = name.trim().toLowerCase();
    if (!wanted) return null;
    if (wanted === "treasure") return "treasure";
    for (const def of allCardDefs()) {
      if (def.name.toLowerCase() === wanted || def.id === wanted) return def.id;
    }
    return null;
  }

  function selectCard(card: CardInstance) {
    if (!view) return;
    peekCard = null;
    peekCreatureId = null;
    peekToken = null;
    if (youMustRespond || youMustShield) {
      if (youMustRespond && card.cardId === "teleport") {
        selectedCard = card; // then click the escape square
        return;
      }
      // A number can ride a counter (INVISIBLE's duration) — unless a
      // displayed Shieldstone makes the number itself the counteraction.
      if (youMustRespond && isNumberCard(card.cardId) && !myShieldstoneDisplayed) {
        attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
        return;
      }
      // Fully reflecting a SWAP MEET: "lets the other player decide which
      // objects, if any, will be swapped" — the choice rides the counter.
      if (youMustRespond && card.cardId === "full-reflection" &&
          view.stack?.attackCard?.cardId === "swap-meet" && view.deckRev >= 27) {
        reflectSwapCard = card;
        reflectSwapMine = null;
        return;
      }
      dispatch({
        type: "counteract", instanceId: card.instanceId,
        ...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
      });
      attachedNumber = null;
      return;
    }
    if (youMustDiscard || discardMode || discardSelection.size > 0) {
      const next = new Set(discardSelection);
      next.has(card.instanceId) ? next.delete(card.instanceId) : next.add(card.instanceId);
      discardSelection = next;
      return;
    }
    if (actionsSpent) return; // picking up an object ended the turn's actions
    if (!isYourTurn && !yourMoment) {
      // Live interruption: Interrupt / Opportunity Fire may be played during
      // another player's turn (when no attack is pending).
      if (!view.stack && (card.cardId === "interrupt" || card.cardId === "opportunity-fire")) {
        dispatch({ type: "cast", instanceId: card.instanceId });
      }
      return;
    }
    // On your own turn, Interrupt / Opportunity Fire arm an ambush instead.
    if (card.cardId === "interrupt" || card.cardId === "opportunity-fire") {
      clearSelection();
      ambushVia = card;
      return;
    }
    if (ambushVia) {
      if (isNumberCard(card.cardId)) {
        attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
        return;
      }
      if (cardDef(card.cardId).cardType === "attack") {
        ambushSpell = ambushSpell?.instanceId === card.instanceId ? null : card;
        return;
      }
      return;
    }
    if (selectedCard && isNumberCard(card.cardId) && !isNumberCard(selectedCard.cardId)) {
      attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
      wbDamage = numberTotal;
      return;
    }
    if (selectedCard && MODIFIER_CARDS.has(card.cardId) && !MODIFIER_CARDS.has(selectedCard.cardId)) {
      attachedMods = attachedMods.some((m) => m.instanceId === card.instanceId)
        ? attachedMods.filter((m) => m.instanceId !== card.instanceId)
        : [...attachedMods, card];
      return;
    }
    if (selectedCard?.instanceId === card.instanceId) {
      clearSelection();
      return;
    }
    selectedCard = card;
    attachedNumber = null;
    // Nothing casts on first click: every play is confirmed from the hint
    // bar or by choosing a target. Misclicks cost nothing.
  }

  /** Untargeted spells confirmed from the hint bar (with optional number). */
  const CONFIRM_CAST = new Set([
    "speed", "pass-through-wall", "reuse-spell", "ugly", "alter-ego", "lifesaver", "mad-dash",
    "gift-from-above", "chaos", "interrupt", "opportunity-fire",
    "bloodstone", "brainstone", "powerstone", "shadowstone",
    "shieldstone", "soulstone", "speedstone", "visionstone",
    "invisible", "shrink", "mist-body", "strength", "empathy", "big-man", "fear", "adrenaline",
  ]);

  function playNumberForMovement() {
    if (!selectedCard || !view) return;
    const cmd: Parameters<typeof net.command>[0] = {
      type: "playNumberForMovement",
      instanceId: selectedCard.instanceId,
    };
    // A second movement number needs an ADD — spend one from hand automatically.
    if (view.turn.numberPlayedForMovement) {
      const add = view.yourHand.find((c) => c.cardId === "add");
      if (add) cmd.addInstanceId = add.instanceId;
    }
    dispatch(cmd);
    clearSelection();
  }

  function startDiscardMode() {
    clearSelection();
    discardMode = true;
  }
  function cancelDiscardMode() {
    discardMode = false;
    discardSelection = new Set();
  }

  function castSelfWithNumber() {
    if (!selectedCard) return;
    const cmd: Parameters<typeof net.command>[0] = { type: "cast", instanceId: selectedCard.instanceId };
    if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
    dispatch(cmd);
    clearSelection();
  }

  function armAmbush() {
    if (!ambushVia || !ambushTrigger || !ambushSpell) return;
    dispatch({
      type: "setAmbush",
      instanceId: ambushVia.instanceId,
      trigger: { kind: ambushTrigger },
      spellInstanceId: ambushSpell.instanceId,
      ...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
    });
    clearSelection();
  }

  function castPowerRun() {
    if (!selectedCard) return;
    dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { points: runPoints } });
    clearSelection();
  }

  function clickCell(cell: { x: number; y: number }) {
    if (!view) return;
    if (youMustRespond && selectedCard?.cardId === "teleport") {
      // Mark first, jump on the second tap: an escape spent on a misclick
      // is an escape wasted.
      if (!pendingTeleport || cellKey(pendingTeleport) !== cellKey(cell)) {
        pendingTeleport = cell;
        return;
      }
      dispatch({ type: "counteract", instanceId: selectedCard.instanceId, params: { cell } });
      clearSelection();
      return;
    }
    if (!yourMoment) { peekAt(cell); return; }
    if (pendingCellFor && selectedCard) {
      // Stage 2 of teleport-opponent: destination chosen.
      dispatch({
        type: "cast", instanceId: selectedCard.instanceId,
        target: { kind: "player", playerId: pendingCellFor },
        params: { cell },
      });
      clearSelection();
      return;
    }
    if (selectedCard?.cardId === "teleport") {
      // Teleport is one jump, not a walk: the first tap only marks the
      // destination, the second (or the hint-bar button) commits it.
      if (!pendingTeleport || cellKey(pendingTeleport) !== cellKey(cell)) {
        pendingTeleport = cell;
        return;
      }
      confirmTeleport();
      return;
    }
    if (selectedCard?.cardId === "relocate-sector") {
      // Any board click (re-)picks the sector; the landing is chosen from the
      // dashed ghost slots beyond the maze, since every on-board slot is taken.
      pendingSectorFrom = cell;
      return;
    }
    if (selectedCard?.cardId === "boobytrap") {
      // Tapping a placed token lifts it again; the four must be distinct.
      const already = trapCells.findIndex((c) => cellKey(c) === cellKey(cell));
      trapCells = already !== -1
        ? trapCells.filter((_, i) => i !== already)
        : [...trapCells, cell];
      if (trapCells.length === 4) {
        dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } });
        clearSelection();
      }
      return;
    }
    if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) {
      if (!tradeFrom) { tradeFrom = cell; return; }
      dispatch({
        type: "cast", instanceId: selectedCard.instanceId,
        target: { kind: "cell", cell }, params: { cell: tradeFrom },
      });
      clearSelection();
      return;
    }
    if (selectedCard?.cardId === "rotate-sector") {
      dispatch({
        type: "cast", instanceId: selectedCard.instanceId,
        target: { kind: "cell", cell }, params: { clockwise: rotateCW },
      });
      clearSelection();
      return;
    }
    if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
      dispatch({
        type: "cast", instanceId: selectedCard.instanceId,
        target: { kind: "cell", cell },
        ...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
      });
      clearSelection();
      return;
    }
    const meNow = view.players.find((p) => p.id === view.you);
    if (!selectedCard && meNow) {
      const pos = meNow.position;
      const pair = view.dimWarps.find((w) =>
        (w.a.x === pos.x && w.a.y === pos.y) || (w.b.x === pos.x && w.b.y === pos.y));
      if (pair) {
        const dest = pair.a.x === pos.x && pair.a.y === pos.y ? pair.b : pair.a;
        if (dest.x === cell.x && dest.y === cell.y) {
          dispatch({ type: "warpStep" });
          return;
        }
      }
    }
    if (selectedCreature) {
      const creature = view.creatures.find((c) => c.id === selectedCreature);
      if (creature) {
        for (const side of SIDES) {
          const n = { x: creature.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
                      y: creature.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
          if (cellKey(n) === cellKey(cell)) {
            dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: side });
            return;
          }
        }
        // Standing on a board-edge opening: clicking the far mouth warps it.
        const w = view.board.warps.find((w) =>
          cellKey(w.from.cell) === cellKey(creature.position) && cellKey(w.to.cell) === cellKey(cell));
        if (w) {
          dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: w.from.side });
          return;
        }
      }
      selectedCreature = null;
      return;
    }
    const me = view.players.find((p) => p.id === view.you)!;
    // A cell click is a move if the cell is one legal step away (the server
    // also lets doors/walls pass when unlocked/misted — try the direction).
    for (const side of SIDES) {
      const t = stepTarget(view.board, me.position, side);
      if (t.kind !== "blocked" && cellKey(t.to) === cellKey(cell)) {
        dispatch({ type: "move", direction: side });
        return;
      }
    }
    // Adjacent but blocked? Send the move anyway — unlocked doors, mist-body
    // and pass-through-wall are resolved server-side.
    for (const side of SIDES) {
      const n = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
                  y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
      if (cellKey(n) === cellKey(cell)) {
        dispatch({ type: "move", direction: side });
        return;
      }
    }
    // BIG MAN: clicking two squares away over a pit/tacks/ooze leaps it.
    if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === view!.you)) {
      for (const side of SIDES) {
        const mid = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
                      y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
        const far = { x: me.position.x + (side === "E" ? 2 : side === "W" ? -2 : 0),
                      y: me.position.y + (side === "S" ? 2 : side === "N" ? -2 : 0) };
        const hazard = view.squareContents[cellKey(mid)]?.kind;
        if (cellKey(far) === cellKey(cell) &&
            (hazard === "pit" || hazard === "tacks" || hazard === "ooze")) {
          dispatch({ type: "move", direction: side, over: true });
          return;
        }
      }
    }
    // The click claimed no action — offer the square's card instead.
    peekAt(cell);
  }

  /** Card ids behind the terrain tokens, for tap-to-read. */
  const CONTENT_CARD: Record<string, string> = {
    stone: "fill-square-with-stone", thornbush: "thornbush", rosebush: "rosebush",
    ooze: "killer-ooze", dust: "dust-cloud", slime: "fill-square-with-slime",
    tacks: "handful-of-tacks", pit: "create-pit", safe: "safe",
  };

  /** Sustained ids include engine constructs the card library cannot show. */
  function realCard(cardId: string): boolean {
    try { cardDef(cardId); return true; } catch { return false; }
  }

  function creatureStats(c: NonNullable<GameView["creatures"]>[number]): string {
    const hp = Number.isFinite(c.maxDamage)
      ? `${c.maxDamage - c.damage} of ${c.maxDamage} hits left`
      : "unharmed by ordinary damage";
    const moves = `${c.movesPerTurn - c.movementUsed} of ${c.movesPerTurn} moves`;
    const atk = c.justCreated ? "no attack the turn it appears" : c.attackUsed ? "attack spent" : "attack ready";
    return `${hp} · ${moves} · ${atk}`;
  }
  const peekNote = $derived.by(() => {
    if (!view || !peekCreatureId) return null;
    const c = view.creatures.find((c) => c.id === peekCreatureId);
    return c ? creatureStats(c) : null;
  });

  /** Show the card behind whatever occupies this square (view-only). */
  function peekAt(cell: { x: number; y: number }): boolean {
    if (!view) return false;
    const key = `${cell.x},${cell.y}`;
    const creature = view.creatures.find((c) => c.position.x === cell.x && c.position.y === cell.y);
    const content = view.squareContents[key];
    const objects = view.groundObjects[key] ?? [];
    const cardId =
      creature ? creature.kind
      : content ? CONTENT_CARD[content.kind]
      : objects.length > 0 ? objects[objects.length - 1]!.cardId
      : view.dimWarps.some((w) => (w.a.x === cell.x && w.a.y === cell.y) || (w.b.x === cell.x && w.b.y === cell.y)) ? "dimensional-warp"
      : view.boobytraps.some((t) => t.cells.some((c) => c.x === cell.x && c.y === cell.y)) ? "boobytrap"
      : null;
    if (!cardId) return false;
    peekCard = { instanceId: `peek-${cardId}`, cardId };
    peekCreatureId = creature?.id ?? null;
    peekToken =
      creature && CREATURE_ART[creature.kind] ? tokenArt(CREATURE_ART[creature.kind]!, "creatures")
      : content && TERRAIN_ART[content.kind] ? tokenArt(TERRAIN_ART[content.kind]!, "terrain")
      : objects.length > 0 && objectArt(cardId) ? tokenArt(objectArt(cardId)!, "objects")
      : cardId === "dimensional-warp" ? tokenArt("dimensional-warp", "terrain")
      : cardId === "boobytrap" ? tokenArt("boobytrap", "terrain")
      : null;
    return true;
  }

  function clickWarp(cell: { x: number; y: number }, side: Side) {
    if (!view || !isYourTurn || !me) return;
    // Standing on the opening: step through — the selected creature first,
    // your wizard otherwise. Elsewhere it is just a cell click.
    const creature = selectedCreature ? view.creatures.find((c) => c.id === selectedCreature) : null;
    if (creature && creature.position.x === cell.x && creature.position.y === cell.y) {
      dispatch({ type: "moveCreature", creatureId: creature.id, direction: side });
    } else if (me.position.x === cell.x && me.position.y === cell.y) {
      dispatch({ type: "move", direction: side });
    } else {
      clickCell(cell);
    }
  }

  function clickEdge(cell: { x: number; y: number }, side: Side) {
    if (punchWallMode) {
      dispatch({ type: "punchWall", cell, side });
      punchWallMode = false;
      return;
    }
    if (!selectedCard || !edgeSelectMode) return;
    // The attached number rides along for every edge cast: wand charges,
    // wall-of-fire durations, wall attacks alike.
    dispatch({
      type: "cast",
      instanceId: selectedCard.instanceId,
      target: { kind: "edge", cell, side },
      ...(holdDoor && (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key")
        ? { params: { hold: true } } : {}),
      ...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
    });
    clearSelection();
  }

  function clickCreature(creatureId: string) {
    if (!view) return;
    const creature = view.creatures.find((c) => c.id === creatureId);
    if (!creature) return;
    if (!yourMoment) { peekAt(creature.position); return; }
    if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
      // Cell-target spells aimed at a creature (DISPEL CREATION on a wraith)
      // land on its square — the engine finds the creature there.
      clickCell(creature.position);
      return;
    }
    if (selectedCard && (CREATURE_TARGET_CARDS.has(selectedCard.cardId) || cardDef(selectedCard.cardId).cardType === "attack")) {
      dispatch({
        type: "cast", instanceId: selectedCard.instanceId,
        target: { kind: "creature", creatureId },
        ...(selectedCard.cardId === "mega-monster" ? { params: { boost: megaBoost } } : {}),
        ...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
      });
      clearSelection();
      return;
    }
    if (selectedCreature && selectedCreature !== creatureId) {
      // Your selected creature attacks another creature in its square.
      dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: creatureId });
      selectedCreature = null;
      return;
    }
    const mine = creature.controllerId === view.you || creature.kind === "democratic-monster";
    if (mine) {
      // Commanding your own creature docks its card in the rail instead of
      // opening the centered peek — a modal would block the very board
      // squares you are about to march it across.
      selectedCreature = selectedCreature !== creatureId ? creatureId : null;
    } else {
      peekAt(creature.position);
    }
  }

  function clickPlayer(playerId: string) {
    if (!view || !yourMoment) return;
    if (selectedCreature) {
      dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId });
      selectedCreature = null;
      return;
    }
    if (!selectedCard) {
      // No card selected: same-square click = punch.
      const me = view.players.find((p) => p.id === view.you)!;
      const them = view.players.find((p) => p.id === playerId)!;
      if (playerId !== view.you && cellKey(me.position) === cellKey(them.position)) {
        dispatch({ type: "punch", targetId: playerId });
      }
      return;
    }
    if (selectedCard.cardId === "teleport-opponent" || selectedCard.cardId === "shift-wand") {
      pendingCellFor = playerId;
      return; // next: click the destination cell
    }
    if (selectedCard.cardId === "swap-meet") {
      swapMeetTarget = playerId;
      return; // next: pick your item, then theirs
    }
    const cmd: Parameters<typeof net.command>[0] = {
      type: "cast",
      instanceId: selectedCard.instanceId,
      target: { kind: "player", playerId },
    };
    if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
    applyMods(cmd);
    if (selectedCard.cardId === "waterbolt") {
      cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
    }
    if (NAMED_CARDS.has(selectedCard.cardId)) {
      const id = nameToCardId(nameInput);
      if (!id) return; // needs a card name typed first
      cmd.params = { cardId: id };
    }
    dispatch(cmd);
    clearSelection();
  }

  function doDiscard() {
    dispatch({ type: "discard", instanceIds: [...discardSelection] });
    discardSelection = new Set();
    discardMode = false;
  }

  function endTurn() {
    clearSelection();
    dispatch({ type: "endTurn", draw: drawCount });
  }

  // SWAP MEET: pick one of your carried items, then one of theirs you can
  // see (their displayed items and any treasure in their arms — hidden
  // hand cards are not on the table).
  let swapMeetTarget = $state<string | null>(null);
  let swapMine = $state<string | null>(null);
  // Reflecting a SWAP MEET: the reflector picks the trade — or none at all.
  let reflectSwapCard = $state<CardInstance | null>(null);
  let reflectSwapMine = $state<string | null>(null);
  const reflectMyTradables = $derived.by(() => {
    if (!view || !reflectSwapCard) return [];
    const opts = new Map<string, string>();
    for (const c of view.yourHand) {
      if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
    }
    if (me?.carriedTreasureId) {
      const t = view.treasures.find((t) => t.id === me.carriedTreasureId);
      opts.set("treasure", `${t?.owner ?? "the"}'s treasure (carried)`);
    }
    return [...opts].map(([key, label]) => ({ key, label }));
  });
  const reflectTheirTradables = $derived.by(() => {
    if (!view || !reflectSwapCard || !view.stack) return [];
    const them = view.players.find((p) => p.id === view.stack!.attackerId);
    if (!them) return [];
    const opts = new Map<string, string>();
    for (const c of them.displayed) {
      if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
    }
    if (them.carriedTreasureId && (reflectSwapMine === "treasure" || !me?.carriedTreasureId)) {
      const t = view.treasures.find((t) => t.id === them.carriedTreasureId);
      opts.set("treasure", `${t?.owner ?? "the"}'s treasure (carried)`);
    }
    return [...opts].map(([key, label]) => ({ key, label }));
  });
  function castReflectSwap(pair: string) {
    if (!reflectSwapCard) return;
    dispatch({
      type: "counteract", instanceId: reflectSwapCard.instanceId,
      params: { cardId: pair },
    });
    reflectSwapCard = null;
    reflectSwapMine = null;
  }
  // Older rooms' rules trade only object-typed cards (rev < 26): the picker
  // must offer exactly what that room's engine will honor.
  function tradableHere(cardId: string): boolean {
    if (!view) return false;
    return view.deckRev >= 26 ? isMovableObject(cardId) : cardDef(cardId).cardType === "object";
  }
  const myTradables = $derived.by(() => {
    if (!view) return [];
    const opts = new Map<string, string>();
    for (const c of view.yourHand) {
      if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
    }
    if (me?.carriedTreasureId) {
      const t = view.treasures.find((t) => t.id === me.carriedTreasureId);
      opts.set("treasure", `${t?.owner ?? "the"}'s treasure (carried)`);
    }
    return [...opts].map(([key, label]) => ({ key, label }));
  });
  const theirTradables = $derived.by(() => {
    if (!view || !swapMeetTarget) return [];
    const them = view.players.find((p) => p.id === swapMeetTarget);
    if (!them) return [];
    const opts = new Map<string, string>();
    for (const c of them.displayed) {
      if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
    }
    // Their treasure is claimable unless your arms are already full.
    if (them.carriedTreasureId && (swapMine === "treasure" || !me?.carriedTreasureId)) {
      const t = view.treasures.find((t) => t.id === them.carriedTreasureId);
      opts.set("treasure", `${t?.owner ?? "the"}'s treasure (carried)`);
    }
    return [...opts].map(([key, label]) => ({ key, label }));
  });
  function castSwapMeet(theirs: string) {
    if (!selectedCard || !swapMeetTarget || !swapMine) return;
    dispatch({
      type: "cast", instanceId: selectedCard.instanceId,
      target: { kind: "player", playerId: swapMeetTarget },
      params: { cardId: `${swapMine};${theirs}` },
    });
    clearSelection();
  }

  let pickChoice = $state(false);
  function pickUp() {
    if (treasuresHere.length > 1) { pickChoice = true; return; }
    dispatch({ type: "pickUpTreasure" });
  }
  function pickUpNamed(treasureId: string) {
    pickChoice = false;
    dispatch({ type: "pickUpTreasure", treasureId });
  }
  function drop() { dispatch({ type: "dropTreasure" }); }
  function pass() { dispatch({ type: "pass" }); }

  const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null);
  /** Two treasures can share a square: one dispatches, more ask whose. */
  const treasuresHere = $derived(
    view && me
      ? view.treasures.filter((t) => t.position && t.position.x === me.position.x &&
          t.position.y === me.position.y && !t.carriedBy)
      : [],
  );
  const carryingTreasure = $derived(me?.carriedTreasureId != null);
  const treasureHere = $derived(
    view != null && me != null &&
    view.treasures.some((t) => t.position && t.position.x === me.position.x &&
                               t.position.y === me.position.y && !t.carriedBy),
  );
  /** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */
  // While an LOS attack sits on the stack, draw the line it traveled — the
  // answer to "how can he even see me?" when sight ran through a warp mouth.
  const sightTrace = $derived(view ? stackSightTrace(view) : null);

  const litCells = $derived.by(() => {
    if (!view || !selectedDef || !yourMoment) return null;
    if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future
    if (!me) return null;
    if (CELL_CARDS.has(selectedCard!.cardId)) {
      return eligibleCellsFor(view, selectedCard!.cardId);
    }
    if (selectedDef.adjacent === true) {
      const { x, y } = me.position;
      return new Set(
        [[x, y], [x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]
          .map(([cx, cy]) => `${cx},${cy}`)
          .filter((k) => view.board.cells[k]),
      );
    }
    if (selectedDef.los !== true) return null;
    const sighted = sightedCellsFor(view);
    if (attachedMods.some((m) => m.cardId === "around-the-corner")) {
      const widened = new Set(sighted);
      for (const key of Object.keys(view.board.cells)) {
        if (widened.has(key)) continue;
        const [cx, cy] = key.split(",").map(Number);
        if ([[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => sighted.has(`${cx! + dx!},${cy! + dy!}`))) {
          widened.add(key);
        }
      }
      return widened;
    }
    return sighted;
  });

  /** Creatures awaiting your orders this turn (yours + any democratic monster). */
  const idleCreatures = $derived(
    view != null && isYourTurn
      ? view.creatures.filter((c) =>
          (c.controllerId === view!.you || c.kind === "democratic-monster") &&
          (c.movementUsed < c.movesPerTurn || (!c.attackUsed && !c.justCreated)))
      : [],
  );

  const objectsHere = $derived(
    view != null && me != null
      ? (view.groundObjects[`${me.position.x},${me.position.y}`] ?? [])
      : [],
  );
  const onWarpToken = $derived(
    view != null && me != null &&
    view.dimWarps.some((w) =>
      (w.a.x === me.position.x && w.a.y === me.position.y) ||
      (w.b.x === me.position.x && w.b.y === me.position.y)),
  );

  let chronicleEl = $state<HTMLElement | null>(null);
  $effect(() => {
    void net.log.length;
    void local.log.length;
    if (chronicleEl) chronicleEl.scrollTop = chronicleEl.scrollHeight;
  });

  // Tab title + favicon carry the turn signal even from another tab.
  const anyTurnWaiting = $derived(
    (view != null && view.phase === "playing" &&
      (isYourTurn || youMustRespond || youMustDiscard ||
       view.outOfTurnWindow?.playerId === view.you)) ||
    net.games.some((g) => g.yourTurn && g.roomId !== net.roomId),
  );
  // The purple wizard from the physical counter sheet; a red badge marks your turn.
  const FAVICON_IDLE = "/favicon.png";
  const FAVICON_TURN = "/favicon-turn.png";
  $effect(() => {
    document.title = anyTurnWaiting ? "● Your turn — Wiz-War" : "Wiz-War";
    let link = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null;
    if (!link) {
      link = document.createElement("link");
      link.rel = "icon";
      document.head.appendChild(link);
    }
    link.href = anyTurnWaiting ? FAVICON_TURN : FAVICON_IDLE;
  });

  function timeAgo(iso: string | null): string {
    if (!iso) return "no moves yet";
    const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
    if (s < 90) return "moments ago";
    if (s < 3600) return `${Math.round(s / 60)} min ago`;
    if (s < 86400) return `${Math.round(s / 3600)} h ago`;
    return `${Math.round(s / 86400)} d ago`;
  }

  function playerColor(id: string): string {
    return view ? wizardColor(view, id) : PLAYER_COLORS[0]!;
  }
</script>

{#if fxWorkshop}
  <FxGallery />
{:else}
<div class="table">
  <header class="masthead">
    <span class="mast-title">Wiz-War</span>
    <span class="mast-sub">sixth edition</span>
    {#if local.active}
      <span class="mast-room">hotseat</span>
      <button class="mast-leave" onclick={() => local.leave()}>set the game aside</button>
      <button class="mast-leave" onclick={() => local.abandon()}>abandon game</button>
    {:else if net.roomId}
      <span class="mast-room">room <b>{net.roomId}</b></span>
      <button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
      <button class="mast-leave" onclick={() => net.leave()}>leave table</button>
    {/if}
    <button class="mast-leave" class:mast-help-solo={!net.roomId} onclick={() => { helpTab = "play"; showHelp = true; }}>
      help &amp; rules
    </button>
    <span class="mast-status" class:offline={net.status !== "connected"}>
      {net.status === "connected" ? "" : "reconnecting…"}
    </span>
  </header>

  {#if showDiscards && view}
    <div class="scrim attack-scrim" role="button" tabindex="-1" onclick={() => (showDiscards = false)} onkeydown={() => {}}>
      <div class="discard-book" role="dialog" aria-label="the discard pile" tabindex="-1"
        onclick={(e) => e.stopPropagation()} onkeydown={() => {}}>
        <header class="discard-head">
          <span>The discard pile — {view.discardCount} card{view.discardCount === 1 ? "" : "s"}, newest first</span>
          <button class="inspector-close discard-close" onclick={() => (showDiscards = false)} aria-label="close">×</button>
        </header>
        <div class="discard-grid">
          {#each [...view.discardPile].reverse() as card (card.instanceId)}
            <Card {card} onclick={() => (discardPeek = card)} onfaq={(id) => (faqCardId = id)} />
          {/each}
          {#if view.discardPile.length === 0}
            <span class="discard-empty">Nothing has been cast away yet.</span>
          {/if}
        </div>
      </div>
    </div>
  {/if}

  {#if peekCard || discardPeek}
    <div class="big-peek-scrim" role="button" tabindex="-1"
      onclick={() => { peekCard = null; peekCreatureId = null; peekToken = null; discardPeek = null; }} onkeydown={() => {}}>
      <div class="big-peek">
        {#if peekCard && peekToken}
          <img class="big-peek-token" src={peekToken} alt="" />
        {/if}
        <div class="card-stage">
          <Card card={(peekCard ?? discardPeek)!} onfaq={(id) => (faqCardId = id)} />
        </div>
        {#if peekNote}<div class="peek-note big-peek-note">{peekNote}</div>{/if}
      </div>
    </div>
  {/if}

  {#if faqCardId}
    <Faq cardId={faqCardId} onclose={() => (faqCardId = null)} />
  {/if}

  {#if showHelp}
    <Help initialTab={helpTab} stats={net.stats} onstats={() => net.requestStats()} onclose={() => (showHelp = false)} />
  {/if}

  {#if view?.phase === "finished" && view.winner && victoryCurtain && !victorySeen}
    <div class="scrim attack-scrim" role="alertdialog" aria-label="the game is won">
      <div class="attack-notice victory-notice">
        <div class="victory-trophy">🏆</div>
        <div class="victory-name">{view.winner}</div>
        <div class="victory-how">
          {#if view.winReason === "treasures"}
            carried two stolen treasures home — the maze is theirs
          {:else if view.winReason === "lastStanding"}
            the last wizard standing
          {:else}
            wins the game
          {/if}
        </div>
        <button class="stamp big" onclick={() => (victorySeen = true)}>Behold the final board</button>
      </div>
    </div>
  {/if}

  {#if trapNotice}
    <div class="scrim attack-scrim" role="alertdialog" aria-label="a trap card">
      <div class="attack-notice">
        <div class="attack-headline">A cruel draw!</div>
        <div class="attack-card"><Card card={{ instanceId: "trap-notice", cardId: trapNotice }} /></div>
        <div class="attack-power">
          {trapNotice === "gift-from-below"
            ? "It bites for 3 points, then the deck deals you another."
            : "The trap snaps shut — you lose a turn."}
        </div>
        <button class="stamp big" onclick={() => (trapNotice = null)}>Curse the deck</button>
      </div>
    </div>
  {/if}

  {#if view && view.outOfTurnWindow?.playerId === view.you && !momentSeen}
    <div class="scrim attack-scrim" role="alertdialog" aria-label="your interruption">
      <div class="attack-notice">
        <div class="attack-headline">The maze holds its breath</div>
        <div class="attack-card"><Card card={{ instanceId: "moment", cardId: view.outOfTurnWindow.kind }} /></div>
        <div class="attack-power">
          Time parts for your {cardDef(view.outOfTurnWindow.kind).name} —
          pick an attack card and a target, or let the moment pass.
        </div>
        <button class="stamp big" onclick={() => (momentSeen = true)}>Seize it</button>
      </div>
    </div>
  {/if}

  {#if openingRolls}
    <div class="scrim attack-scrim" role="alertdialog" aria-label="the roll for first wizard">
      <div class="attack-notice">
        <div class="attack-headline">The dice decide who casts first</div>
        <div class="rolloff">
          {#each openingRolls.players as p (p)}
            <div class="rolloff-row" class:rolloff-winner={p === openingRolls.first}>
              <span class="rolloff-name">{p === openingRolls.first ? "🏆 " : ""}{p}</span>
              <span class="rolloff-dice">
                {#each openingRolls.rolls[p] ?? [] as r, i (i)}<span class="die-face">🎲{r}</span>{/each}
              </span>
            </div>
          {/each}
        </div>
        {#if Object.values(openingRolls.rolls).some((r) => r.length > 1)}
          <div class="rolloff-note">Tied wizards rolled again.</div>
        {/if}
        <button class="stamp big" onclick={dismissRolls}>{openingRolls.first} leads — begin!</button>
      </div>
    </div>
  {/if}

  {#if view?.stack && attackNoticeKey && attackNoticeSeen !== attackNoticeKey && !noFanfare}
    <div class="scrim attack-scrim" role="alertdialog" aria-label="you are under attack">
      <div class="attack-notice">
        <div class="attack-headline">
          {#if view.stack.defenderId === view.you && attackingCreature}
            <strong>{view.stack.attackerId}</strong>'s {cardDef(attackingCreature.kind).name} attacks you!
          {:else if view.stack.defenderId === view.you && view.stack.reflectedBase}
            Your own spell comes screaming back!
          {:else if view.stack.defenderId === view.you}
            <strong>{view.stack.attackerId}</strong> attacks you!
          {:else}
            <strong>{view.stack.counters[view.stack.counters.length - 1]?.player}</strong> counters your spell!
          {/if}
        </div>
        {#if view.stack.defenderId === view.you && attackingCreature}
          <div class="attack-card"><Card card={{ instanceId: `atk-${attackingCreature.id}`, cardId: attackingCreature.kind }} /></div>
        {:else if view.stack.defenderId === view.you && view.stack.attackCard}
          {@const illusionOf = view.stack.attackCard.cardId === "illusionary-attack"
            ? (view.stack.params?.cardId ?? null) : null}
          {#if illusionOf}
            <div class="attack-card illusion-pair">
              <Card card={{ instanceId: "illusion-named", cardId: illusionOf }} onfaq={(id) => (faqCardId = id)} />
              <Card card={view.stack.attackCard} onfaq={(id) => (faqCardId = id)} />
            </div>
            <div class="attack-power">an ILLUSIONARY {cardDef(illusionOf).name} — it bites only if believed</div>
          {:else}
            <div class="attack-card"><Card card={view.stack.attackCard} /></div>
          {/if}
          {#if view.stack.reflectedBase}
            <div class="attack-power">
              REFLECTED — {view.stack.reflectedBase.damage} damage inbound unless you answer it
            </div>
          {:else if view.stack.numberValue != null || view.stack.amplifyFactor > 1}
            <div class="attack-power">
              {view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""}
              {view.stack.amplifyFactor > 1 ? ` — AMPLIFIED ×${view.stack.amplifyFactor}` : ""}
            </div>
          {/if}
        {:else if view.stack.defenderId === view.you}
          <div class="attack-fist">👊 a bare-knuckled punch</div>
        {:else if view.stack.counters.length > 0}
          <div class="attack-card"><Card card={view.stack.counters[view.stack.counters.length - 1]!.card} /></div>
        {/if}
        {#if view.stack.defenderId === view.you && view.stack.counters.length > 0}
          {@const standing = view.stack.counters.filter((c) => c.player === view.you && !c.nullified)}
          {@const broken = view.stack.counters.filter((c) => c.player === view.you && c.nullified)}
          {#if broken.length > 0 && standing.length === 0}
            <div class="attack-standing nullified">
              Your {broken.map((c) => cardDef(c.card.cardId).name).join(" and ")}
              {broken.length === 1 ? "was" : "were"} nullified — the attack comes on unchecked.
            </div>
          {:else if standing.length > 0}
            <div class="attack-standing">
              Your {standing.map((c) => cardDef(c.card.cardId).name).join(" and ")}
              stand{standing.length === 1 ? "s" : ""} — only what slips past
              {standing.length === 1 ? "it" : "them"} will land. Counter again, or let it resolve.
            </div>
          {/if}
        {/if}
        <button class="stamp big" onclick={() => (attackNoticeSeen = attackNoticeKey)}>To arms!</button>
      </div>
    </div>
  {/if}

  {#if net.catchUp && net.catchUp.length > 0}
    <Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} />
  {:else if local.replaySteps && local.replaySteps.length > 0}
    <Replay steps={local.replaySteps} onclose={() => (local.replaySteps = null)} />
  {/if}

  {#if net.error}
    <div class="toast" role="alert">{net.error}</div>
  {/if}

  {#if net.transferCode}
    <div class="slip transfer-slip">
      Speak this phrase into your other device (good for 10 minutes, one use):
      <strong class="transfer-phrase">{net.transferCode.code}</strong>
      <button class="hint-cancel" onclick={() => (net.transferCode = null)}>dismiss</button>
    </div>
  {/if}

  {#if local.setup}
    <div class="scrim-handoff">
      <div class="handoff-card">
        <div class="handoff-eyebrow">wizard {local.setup.names.length + 1} of {local.setup.count}</div>
        <div class="handoff-name setup-title">What is your name?</div>
        <div class="standee-row" role="group" aria-label="choose your wizard">
          {#each [0, 1, 2, 3, 4, 5] as c (c)}
            {@const taken = local.setup.colors.includes(c)}
            <button
              class="standee" class:current={setupColor === c} class:taken
              disabled={taken}
              style:--ring={PLAYER_COLORS[c]}
              onclick={() => (setupColor = c)}
              aria-label={`wizard color ${c + 1}${taken ? " (taken)" : ""}`}
            >
              <img src={tokenArt(`wizard-${c}`, "players")} alt="" />
            </button>
          {/each}
        </div>
        <form class="setup-form" onsubmit={(e) => {
          e.preventDefault();
          const err = local.submitName(setupName, setupColor);
          if (err) net.error = err;
          else setupName = "";
        }}>
          <!-- svelte-ignore a11y_autofocus -->
          <input class="setup-input" bind:value={setupName} maxlength="20"
            placeholder="e.g. Mordecai" autofocus />
          <button class="stamp" type="submit" disabled={!setupName.trim()}>
            {local.setup.names.length + 1 === local.setup.count ? "Flip the boards" : "Pass the device on"}
          </button>
        </form>
        {#if local.setup.names.length > 0}
          <div class="setup-roster">so far: {local.setup.names.join(", ")}</div>
        {/if}
        <button class="hint-cancel setup-cancel" onclick={() => local.cancelSetup()}>never mind</button>
      </div>
    </div>
  {:else if local.handoffTo}
    <div class="scrim-handoff" role="button" tabindex="0"
      onclick={() => local.takeSeat()} onkeydown={(e) => e.key === "Enter" && local.takeSeat()}>
      <div class="handoff-card">
        <div class="handoff-eyebrow">pass the device to</div>
        <div class="handoff-name">{local.handoffTo}</div>
        <div class="handoff-hint">tap when only they can see the screen</div>
      </div>
    </div>
  {/if}

  {#if !net.roomId && !local.active}
    <section class="boxlid">
      <div class="boxlid-inner">
        <div class="boxlid-title">Wiz-War</div>
        <div class="boxlid-tag">A game of magical combat in a stone labyrinth</div>
        <button class="about-link" onclick={() => { helpTab = "about"; showHelp = true; }}>
          about this game — a labor of love
        </button>
        <label class="field">
          <span>Your wizard's name</span>
          <input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" />
        </label>
        <div class="boxlid-actions">
          <button class="stamp" disabled={!name.trim()} onclick={() => net.create(name)}>
            Create a game
          </button>
          <span class="or">or join one</span>
          <input class="code" bind:value={joinCode} maxlength="4" placeholder="CODE" />
          <button class="stamp" disabled={!name.trim() || !joinCode.trim()} onclick={() => net.join(joinCode, name)}>
            Join
          </button>
        </div>

        <div class="hotseat-row">
          <span class="hotseat-label">or play here, passing the device:</span>
          <div class="count-picker" role="group" aria-label="number of wizards">
            {#each [2, 3, 4, 5, 6] as n (n)}
              <button class="count-btn" class:current={hotseatCount === n}
                onclick={() => (hotseatCount = n)}>{n}</button>
            {/each}
          </div>
          <button class="stamp tiny" onclick={() => { setupName = ""; local.beginSetup(hotseatCount, withExpansion); }}>
            Gather {hotseatCount} wizards
          </button>
          {#if local.hasSave()}
            <button class="stamp tiny" onclick={() => local.resume()}>Resume hotseat</button>
          {/if}
        </div>
        <label class="check hotseat-exp">
          <input type="checkbox" bind:checked={withExpansion} /> with Expansion Set #1
        </label>

        <div class="claim-row">
          <input class="claim-input" bind:value={claimPhrase}
            placeholder="ember-troll-dagger" aria-label="seat transfer phrase" />
          <button class="stamp tiny" disabled={!claimPhrase.trim()}
            onclick={() => { net.claimTransfer(claimPhrase); claimPhrase = ""; }}>
            Claim a transferred seat
          </button>
        </div>

        {#if net.seats.length > 0}
          <div class="ledger">
            <div class="ledger-head">
              <span>your games</span>
              {#if !net.notificationsEnabled}
                <button class="hint-cancel" onclick={() => net.enableNotifications()}>
                  notify me on my turn
                </button>
              {/if}
              {#if noFanfare}
                <button class="hint-cancel" onclick={() => setFanfare(true)}>announce attacks</button>
              {/if}
            </div>
            {#each net.seats as seat (seat.roomId + seat.name)}
              {@const g = net.games.find((x) => x.roomId === seat.roomId && x.name === seat.name)}
              <div class="ledger-row" class:your-turn={g?.yourTurn}>
                <button class="ledger-resume" onclick={() => net.resume(seat)}>
                  <span class="ledger-code">{seat.roomId}</span>
                  <span class="ledger-info">
                    {#if !g}
                      as {seat.name} — unreachable
                    {:else if g.finished}
                      {g.winner === seat.name ? "you won! 🏆" : `${g.winner} won`}
                    {:else if !g.started}
                      waiting to start · {g.players.join(", ")}
                    {:else if g.yourTurn}
                      {attentionLabel(g.attention)} · round {g.round} · {timeAgo(g.lastMoveAt)}{#if net.unreadChat(g.roomId, g.chatCount) > 0}&nbsp;· 💬{net.unreadChat(g.roomId, g.chatCount)}{/if}
                    {:else}
                      {g.activePlayerId}'s turn · round {g.round} · {timeAgo(g.lastMoveAt)}{#if net.unreadChat(g.roomId, g.chatCount) > 0}&nbsp;· 💬{net.unreadChat(g.roomId, g.chatCount)}{/if}
                    {/if}
                  </span>
                </button>
                <button class="ledger-forget" title="forget this game"
                  onclick={() => net.forgetSeat(seat.roomId)}>×</button>
              </div>
            {/each}
          </div>
        {/if}
      </div>
    </section>
  {:else if !local.active && !net.started}
    <section class="boxlid">
      <div class="boxlid-inner">
        <div class="boxlid-title small">Room {net.roomId}</div>
        <div class="boxlid-tag">Share the code. Two to six wizards enter the maze.</div>
        <ul class="roster">
          {#each net.players as p (p)}
            {@const chosen = net.roomColors[p]}
            <li>
              {#if chosen !== undefined}
                <img class="roster-standee" src={tokenArt(`wizard-${chosen}`, "players")} alt="" />
              {:else}
                <span class="dot" style:background="#b3a687"></span>
              {/if}
              {p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
            </li>
          {/each}
        </ul>
        <div class="standee-row" role="group" aria-label="choose your wizard">
          {#each [0, 1, 2, 3, 4, 5] as c (c)}
            {@const takenBy = Object.entries(net.roomColors).find(([, v]) => v === c)?.[0]}
            <button
              class="standee"
              class:current={net.you != null && net.roomColors[net.you] === c}
              class:taken={takenBy !== undefined && takenBy !== net.you}
              disabled={takenBy !== undefined && takenBy !== net.you}
              style:--ring={PLAYER_COLORS[c]}
              onclick={() => net.pickColor(c)}
              aria-label={`wizard color ${c + 1}${takenBy && takenBy !== net.you ? ` (taken by ${takenBy})` : ""}`}
            >
              <img src={tokenArt(`wizard-${c}`, "players")} alt="" />
            </button>
          {/each}
        </div>
        {#if net.you === net.hostId}
          {#if net.players.length < 6}
            <span class="bot-row">
              <span class="bot-label">⚙ seat a</span>
              <select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty">
                <option value="apprentice">apprentice</option>
                <option value="adept">adept</option>
                <option value="archmage">archmage</option>
              </select>
              <button class="stamp tiny" onclick={() => net.addBot("hunter", botTier)}>hunter</button>
              <button class="stamp tiny" onclick={() => net.addBot("berserker", botTier)}>berserker</button>
              <button class="stamp tiny" onclick={() => net.addBot("worrier", botTier)}>worrier</button>
              <button class="stamp tiny" onclick={() => net.addBot(undefined, botTier)}>mystery</button>
            </span>
          {/if}
          <label class="check">
            <input type="checkbox" bind:checked={withExpansion} />
            Include Expansion Set #1 — monsters &amp; wands
          </label>
          <button
            class="stamp big"
            disabled={net.players.length < 2 || net.players.length > 6}
            onclick={() => net.start(withExpansion)}
          >
            Flip the boards ({net.players.length} wizard{net.players.length === 1 ? "" : "s"})
          </button>
        {:else}
          <p class="waiting">Waiting for {net.hostId} to flip the boards…</p>
        {/if}
      </div>
    </section>
  {:else if view}
    <div class="game">
      <section class="board-zone">
        <Board
          {view}
          edgeSelectMode={edgeSelectMode && yourMoment}
          selectedCreatureId={selectedCreature}
          onCellClick={clickCell}
          onEdgeClick={clickEdge}
          onPlayerClick={clickPlayer}
          onCreatureClick={clickCreature}
          onWarpClick={clickWarp}
          markedCell={tradeFrom ?? pendingTeleport}
          markedCells={trapCells.length > 0 ? trapCells : null}
          effects={boardFx}
          markedSector={pendingSectorOrigin}
          ghostSlots={relocateGhosts}
          onGhostClick={clickGhostSlot}
          onCellPeek={(c) => peekAt(c)}
          {litCells}
          {sightTrace}
        />
      </section>

      <aside class="paper-rail">
        {#if !local.active && net.missedMoves > 0}
          <div class="slip catchup">
            You missed {net.missedMoves} move{net.missedMoves === 1 ? "" : "s"}.
            <button class="stamp tiny" onclick={() => net.requestCatchUp()}>▶ Watch what happened</button>
            <button class="hint-cancel" onclick={() => net.markSeen()}>skip</button>
          </div>
        {/if}
        {#if view.phase === "finished"}
          <div class="slip winner">🏆 {view.winner} wins!</div>
        {:else if youMustRespond}
          <div class="slip urgent">
            {#if reflectSwapCard}
              Your reflection turns the trade over to you —
              {#if !reflectSwapMine}
                give your:
                {#each reflectMyTradables as c (c.key)}
                  <button class="stamp tiny" onclick={() => (reflectSwapMine = c.key)}>{c.label}</button>
                {/each}
              {:else}
                and take their:
                {#each reflectTheirTradables as c (c.key)}
                  <button class="stamp tiny" onclick={() => castReflectSwap(`${reflectSwapMine};${c.key}`)}>{c.label}</button>
                {/each}
              {/if}
              <button class="hint-cancel" onclick={() => castReflectSwap("none")}>swap nothing</button>
            {:else if view.stack?.defenderId === view.you}
              Under attack — respond by your hand.
            {:else}
              Your spell is countered — respond by your hand.
            {/if}
          </div>
        {:else if view.stack}
          <div class="slip">Waiting on {view.stack.waitingOn}…</div>
        {:else if view.outOfTurnWindow?.playerId === view.you}
          <div class="slip urgent">Your interruption — pick an attack card and a target, or <button class="stamp tiny" onclick={pass}>waste it</button></div>
        {:else if isYourTurn}
          <div class="slip yours">
            Your turn — round {view.turn.round}.
            {view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""}
          </div>
        {:else}
          <div class="slip">{view.activePlayerId} is taking their turn…</div>
        {/if}
        {#if interruptCards.length > 0}
          <div class="slip urgent">
            ⚡ Your {cardDef(interruptCards[0]!.cardId).name} can seize this moment — tap the glowing card.
          </div>
        {/if}

        {#if view.yourWardArmed}
          <div class="slip ambush-note">🗡 Your ward is set — a thief who grabs your treasure bleeds for 3.</div>
        {/if}
        {#if youMustShield}
          <div class="slip urgent">
            Chaos comes for your hand — tap your Full Shield to sit out, or
            <button class="stamp tiny" onclick={pass}>let it take you</button>
          </div>
        {:else if view.chaosPending}
          <div class="slip">Chaos gathers — waiting on {view.chaosPending.queue[0]}…</div>
        {/if}
        {#each idleCreatures as c (c.id)}
          {@const moves = c.movesPerTurn - c.movementUsed}
          <div class="slip creature-note">
            🜲 {c.controllerId === view.you ? "Your" : "The"} {cardDef(c.kind).name} awaits orders —
            {#if moves > 0}{moves} move{moves === 1 ? "" : "s"}{/if}{#if moves > 0 && !c.attackUsed && !c.justCreated}&nbsp;·&nbsp;{/if}{#if !c.attackUsed && !c.justCreated}attack ready{/if}.
            Tap its token to command it.
          </div>
        {/each}
        {#if commandedCreature}
          <div class="creature-dock">
            <Card
              card={{ instanceId: `dock-${commandedCreature.kind}`, cardId: commandedCreature.kind }}
              onfaq={(id) => (faqCardId = id)}
            />
            <div class="peek-note">{creatureStats(commandedCreature)}</div>
          </div>
        {/if}
        {#if actionsSpent}
          <div class="slip">Your spellwork is spent for this turn — all that remains is to draw and end it.</div>
        {/if}

        {#if view.yourAmbushes.length > 0}
          {#each view.yourAmbushes as a (a.id)}
            <div class="slip ambush-note">
              🗡 {cardDef(a.spell.cardId).name} waits —
              {a.trigger.kind === "los" ? "when seen" : a.trigger.kind === "near" ? "when approached" : "when treasure is grabbed"}
              {#if isYourTurn}
                <button class="hint-cancel" onclick={() => dispatch({ type: "cancelAmbush", ambushId: a.id })}>disarm</button>
              {/if}
            </div>
          {/each}
        {/if}

        <div class="scoresheet">
          <div class="scoresheet-head">life-points</div>
          {#each view.players as p (p.id)}
            <div class="score-row" class:dead={!p.alive} class:active={p.id === view.activePlayerId}>
              <span class="dot" style:background={playerColor(p.id)}></span>
              <span class="score-name">
                {p.id}{p.id === view.you ? " (you)" : ""}{#if net.roomBots[p.id]}
                  <span class="score-temper">⚙ {net.roomBots[p.id]}</span>{/if}
              </span>
              <span class="score-life">{p.life}</span>
              <span class="score-marks">
                {p.handCount} cards{#if p.lostTurns > 0}&nbsp;· dazed {p.lostTurns}{/if}{#if p.carriedTreasureId}&nbsp;· 💰{/if}
              </span>
            </div>
            {@const spellsOn = view.sustained.filter((e) => e.targetId === p.id)}
            {#if p.displayed.length > 0 || spellsOn.length > 0}
              <div class="table-cards">
                {#each p.displayed as c (c.instanceId)}
                  <button class="table-card" onclick={() => (peekCard = peekCard?.instanceId === c.instanceId ? null : c)}>
                    {cardDef(c.cardId).name}{#if view.wandCharges[c.instanceId] != null}&nbsp;·&nbsp;{"●".repeat(view.wandCharges[c.instanceId]!)}{/if}
                  </button>
                {/each}
                {#each spellsOn as e (e.id)}
                  <button class="table-card spell-chip" onclick={() => {
                    if (!realCard(e.cardId)) return;
                    peekCard = { instanceId: `peek-${e.id}`, cardId: e.cardId };
                    peekCreatureId = null;
                  }}>
                    ✦ {spellName(e.cardId)}{isPermanentDuration(e.remainingTurns) ? "" : ` · ${e.remainingTurns}`}
                  </button>
                {/each}
              </div>
            {/if}
          {/each}
          <div class="deck-line">draw pile {view.deckCount} ·
            <button class="discard-link" onclick={() => (showDiscards = true)}>discards {view.discardCount}</button>
          </div>
        </div>

        <div class="chronicle" aria-label="game log" bind:this={chronicleEl}>
          {#each (local.active ? local.log : net.log).slice(-60) as line, i (i)}
            <div class:table-talk={line.startsWith("\u{1F4AC}")}>{line}</div>
          {/each}
        </div>
        {#if local.active && local.viewerId}
          <div class="say-box">
            <button class="stamp tiny" type="button" title="roll the die (house calls)"
              onclick={() => local.rollTableDie()}>🎲 roll the die</button>
          </div>
        {/if}
        {#if !local.active && net.roomId}
          <form class="say-box" onsubmit={(e) => {
            e.preventDefault();
            const t = chatDraft.trim();
            if (t) net.sendChat(t);
            chatDraft = "";
          }}>
            <input class="say-input" bind:value={chatDraft} maxlength="300"
              placeholder="say something to the table…" aria-label="table talk" />
            <button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
            <button class="stamp tiny" type="button" title="roll the die (house calls)"
              onclick={() => net.rollTableDie()}>🎲</button>
          </form>
        {/if}
      </aside>
    </div>

    <div class="table-edge">
      {#if ambushVia}
        <div class="hint-strip">
          <strong class="hint-name">{cardDef(ambushVia.cardId).name} — set an ambush</strong>
          {#if !ambushTrigger}
            <span>springs when an opponent…</span>
            <button class="stamp tiny" onclick={() => (ambushTrigger = "los")}>enters my sight</button>
            <button class="stamp tiny" onclick={() => (ambushTrigger = "near")}>comes beside me</button>
            <button class="stamp tiny" onclick={() => (ambushTrigger = "treasure")}>grabs a treasure</button>
          {:else if !ambushSpell}
            <span>— now tap the attack card to commit</span>
          {:else}
            <span>{cardDef(ambushSpell.cardId).name}{attachedNumber ? ` with a ${numberTotal}` : " (tap a number to power it)"}</span>
            <button class="stamp tiny" onclick={armAmbush}>Arm the ambush</button>
          {/if}
          <button class="hint-cancel" onclick={clearSelection}>cancel</button>
        </div>
      {/if}
      {#if youMustRespond || selectedDef || selectedCreature || youMustDiscard || discardMode || discardSelection.size > 0}
        <div class="hint-strip">
          {#if selectedCreature && !selectedDef}
            {@const sc = view.creatures.find((c) => c.id === selectedCreature)}
            {#if sc}
              <span>Commanding <strong>{cardDef(sc.kind).name}</strong> — {creatureStats(sc)}.
                Tap a square beside it to march, a target in its square to attack.</span>
              <button class="hint-cancel" onclick={() => (selectedCreature = null)}>done</button>
            {/if}
          {/if}
          {#if youMustRespond && view.stack}
            <span class="hint-alert">
              {#if view.stack.defenderId === view.you && selectedCard?.cardId === "teleport"}
                {pendingTeleport
                  ? "Escaping by teleport — tap the marked square again to jump."
                  : "Escaping by teleport — tap a square within four spaces to mark it."}
              {:else if view.stack.defenderId === view.you}
                {view.stack.attackerId} attacks with
                {attackingCreature ? `the ${cardDef(attackingCreature.kind).name}`
                  : view.stack.attackCard?.cardId === "illusionary-attack" && view.stack.params?.cardId
                    ? `an illusionary ${cardDef(view.stack.params.cardId).name}`
                    : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} —
                tap a counteraction card, or
              {:else}
                {view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""}
              {/if}
            </span>
            <button class="stamp tiny" onclick={pass}>let it resolve</button>
          {/if}
          {#if youMustDiscard}
            <span class="hint-alert">Hand over the limit — mark cards and discard.</span>
          {:else if discardMode && discardSelection.size === 0}
            <span>Mark the cards to throw away, then confirm.</span>
          {/if}
          {#if discardSelection.size > 0}
            <button class="stamp tiny" onclick={doDiscard}>Discard {discardSelection.size} marked</button>
          {/if}
          {#if discardMode || (discardSelection.size > 0 && !youMustDiscard)}
            <button class="hint-cancel" onclick={cancelDiscardMode}>never mind</button>
          {/if}
          {#if selectedDef}
            <strong class="hint-name">{selectedDef.name}</strong>
            {#if selectedCard && EDGE_CARDS.has(selectedCard.cardId)}
              <span>— {EDGE_HINTS[selectedCard.cardId] ?? "click a wall line"}</span>
            {/if}
            {#if selectedCard && WAND_IDS.has(selectedCard.cardId) && view.wandCharges[selectedCard.instanceId] == null && !attachedNumber}
              <span>— first use: tap a NUMBER card to set the wand's charges, then click a target</span>
            {:else if selectedDef.cardType === "attack" && !cellSelectMode && !EDGE_CARDS.has(selectedCard?.cardId ?? "")}
              <span>— click a target, or a wall line to batter it{attachedNumber ? ` (powered by a ${numberTotal})` : ""}</span>
            {/if}
            {#if attachedMods.length > 0}
              <span class="hint-mods">[+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]</span>
            {/if}
            {#if selectedCard?.cardId === "boobytrap"}
              <span>— place 4 tokens ({trapCells.length}/4; the first is real)</span>
            {/if}
            {#if selectedCard?.cardId === "redirection"}
              <span>{tradeFrom ? "— now the exit it should connect TO" : "— click the first exit"}</span>
            {:else if selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)}
              <span>{tradeFrom ? "— now the second square" : "— click the first square"}</span>
            {/if}
            {#if selectedCard?.cardId === "teleport" && !youMustRespond}
              <span>{pendingTeleport
                ? "— tap the marked square again to jump, or pick another"
                : "— tap a destination (up to four squares, walls ignored)"}</span>
              {#if pendingTeleport}
                <button class="stamp tiny" onclick={confirmTeleport}>teleport!</button>
              {/if}
            {/if}
            {#if selectedCard?.cardId === "mega-monster"}
              <span>— double its</span>
              <button class="stamp tiny" class:lit={megaBoost === "life"}
                onclick={() => (megaBoost = "life")}>hit points</button>
              <button class="stamp tiny" class:lit={megaBoost === "movement"}
                onclick={() => (megaBoost = "movement")}>movement</button>
              <span>— then tap the monster</span>
            {/if}
            {#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"}
              <label class="inline"><input type="checkbox" bind:checked={holdDoor} /> hold the door open</label>
            {/if}
            {#if selectedCard?.cardId === "rotate-sector"}
              <label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
              <span>— click the sector</span>
            {/if}
            {#if selectedCard?.cardId === "relocate-sector"}
              <span>{pendingSectorFrom
                ? (relocateGhosts?.length
                    ? "— now tap a dashed landing beside the maze"
                    : "— that sector has nowhere legal to go; pick another")
                : "— click the sector to move"}</span>
            {/if}
            {#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)}
              <label class="inline">name <input class="text-input" list="card-name-options" bind:value={nameInput} placeholder="e.g. Fireball" /></label>
              <datalist id="card-name-options">
                {#each nameSuggestions as n (n)}<option value={n}></option>{/each}
              </datalist>
              {#if selectedCard.cardId === "deja-vu"}
                <button class="stamp tiny" onclick={() => {
                  const id = nameToCardId(nameInput);
                  if (id && selectedCard) {
                    dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cardId: id } });
                    clearSelection();
                  }
                }}>Retrieve</button>
              {:else}
                <span>— then click the target</span>
              {/if}
            {/if}
            {#if selectedCard?.cardId === "swap-meet"}
              {#if !swapMeetTarget}
                <span>— click the other trader</span>
              {:else if myTradables.length === 0}
                <span>— you carry no items to trade</span>
              {:else if !swapMine}
                <span>— trade away your:</span>
                {#each myTradables as c (c.key)}
                  <button class="stamp tiny" onclick={() => (swapMine = c.key)}>{c.label}</button>
                {/each}
              {:else if theirTradables.length === 0}
                <span>— they show no item you could claim</span>
              {:else}
                <span>— and take their:</span>
                {#each theirTradables as c (c.key)}
                  <button class="stamp tiny" onclick={() => castSwapMeet(c.key)}>{c.label}</button>
                {/each}
              {/if}
            {/if}
            {#if selectedCard?.cardId === "waterbolt"}
              <label class="inline">damage <input class="num-input" type="number" min="0" max={numberTotal} bind:value={wbDamage} /></label>
              <span>(knockback {numberTotal - wbDamage})</span>
            {/if}
            {#if selectedCard?.cardId === "power-run"}
              <label class="inline">life to trade <input class="num-input" type="number" min="1" max="10" bind:value={runPoints} /></label>
              <button class="stamp tiny" onclick={castPowerRun}>Run!</button>
            {/if}
            {#if selectedCard && CONFIRM_CAST.has(selectedCard.cardId)}
              <button class="stamp tiny" onclick={castSelfWithNumber}>
                Cast{attachedNumber ? ` with the ${numberTotal}` : ""}
              </button>
            {/if}
            {#if selectedCard && isMovableObject(selectedCard.cardId) && isYourTurn}
              <button class="stamp tiny" onclick={() => {
                if (selectedCard) dispatch({ type: "dropObject", instanceId: selectedCard.instanceId });
                clearSelection();
              }}>Drop it here</button>
            {/if}
            {#if selectedCard?.cardId === "add"}
              <span>— ADD rides a second NUMBER: play one number for movement,
                then play another, and the Add spends itself</span>
            {/if}
            {#if selectedCard && isNumberCard(selectedCard.cardId)}
              {@const needsAdd = view.turn.numberPlayedForMovement}
              {@const haveAdd = view.yourHand.some((c) => c.cardId === "add")}
              {#if needsAdd && !haveAdd}
                <span>— a second movement number needs an ADD card</span>
              {:else}
                <button class="stamp tiny" onclick={playNumberForMovement}>
                  Play for +{cardDef(selectedCard.cardId).value} movement{needsAdd ? " (spends your Add)" : ""}
                </button>
              {/if}
            {/if}
            <button class="hint-cancel" onclick={clearSelection}>cancel</button>
          {/if}
        </div>
      {/if}

      <div class="actions">
        {#if isYourTurn && onWarpToken}
          <button class="stamp" onclick={() => dispatch({ type: "warpStep" })}>Step through the warp</button>
        {/if}
        {#if isYourTurn}
          <button class="stamp" disabled={!treasureHere || carryingTreasure || view.turn.actionsEnded}
            onclick={pickUp}>Pick up treasure</button>
          {#if pickChoice}
            <span class="pick-which">
              — whose?
              {#each treasuresHere as t (t.id)}
                <button class="stamp tiny" onclick={() => pickUpNamed(t.id)}>{t.owner}'s</button>
              {/each}
              <button class="hint-cancel" onclick={() => (pickChoice = false)}>never mind</button>
            </span>
          {/if}
          <button class="stamp" disabled={!carryingTreasure} onclick={drop}>Drop treasure</button>
          {#each objectsHere as obj (obj.instanceId)}
            <button class="stamp" disabled={view.turn.actionsEnded}
              onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
              Pick up {cardDef(obj.cardId).name}</button>
          {/each}
          {#if holdingWard}
            <button class="stamp" onclick={() => dispatch({ type: "armWard", armed: !view.yourWardArmed })}>
              {view.yourWardArmed ? "Stand down the ward" : "Set the ward"}</button>
          {/if}
          <button class="stamp" class:primary={punchWallMode} disabled={view.turn.attackUsed && !punchWallMode}
            onclick={() => (punchWallMode = !punchWallMode)}>
            {punchWallMode ? "Click the wall to punch — or cancel" : "Punch a wall…"}</button>
          <button class="stamp" onclick={startDiscardMode}>Discard cards…</button>
          <label class="inline draw-pick">
            draw
            <select bind:value={drawCount}>
              <option value={0}>0</option>
              <option value={1}>1</option>
              <option value={2}>2</option>
            </select>
          </label>
          <button class="stamp primary" onclick={endTurn}>End turn</button>
        {/if}
      </div>

      {#if selectedCard && !inspectorHidden}
        <div class="inspector" role="img" aria-label="selected card, enlarged">
          <button class="inspector-close" onclick={() => (inspectorHidden = true)} aria-label="hide enlarged card">×</button>
          <div class="inspector-card">
            <Card card={selectedCard} onfaq={(id) => (faqCardId = id)} />
          </div>
        </div>
      {/if}

      {#if view.phase === "finished" && view.revealedHands}
        <div class="final-reveal" aria-label="all hands revealed">
          <div class="reveal-head">
            The hands, face-up
            <button class="stamp tiny"
              onclick={() => (local.active ? local.buildReplay() : net.requestFullReplay())}>
              ⟲ Watch the whole game</button>
          </div>
          {#each view.players as p (p.id)}
            <div class="reveal-row">
              <span class="reveal-name" class:reveal-winner={p.id === view.winner}>
                {p.id === view.winner ? "🏆 " : ""}{p.id}
                {#if !p.alive}<span class="reveal-note">as they fell</span>{/if}
              </span>
              <div class="reveal-cards">
                {#each view.revealedHands[p.id] ?? [] as card (card.instanceId)}
                  <Card {card} onclick={() => (peekCard = card)} onfaq={(id) => (faqCardId = id)} />
                {/each}
                {#if (view.revealedHands[p.id] ?? []).length === 0}
                  <span class="reveal-empty">empty-handed</span>
                {/if}
              </div>
            </div>
          {/each}
        </div>
      {/if}

      <div class="hand" class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand">
        {#each view.phase === "finished" ? [] : view.yourHand as card (card.instanceId)}
          <Card
            {card}
            selected={selectedCard?.instanceId === card.instanceId ||
              ambushVia?.instanceId === card.instanceId}
            attached={attachedNumber?.instanceId === card.instanceId ||
              ambushSpell?.instanceId === card.instanceId ||
              attachedMods.some((m) => m.instanceId === card.instanceId)}
            marked={discardSelection.has(card.instanceId)}
            displayed={view.players.find((p) => p.id === view.you)?.displayed.some((c) => c.instanceId === card.instanceId) ?? false}
            glowing={interruptCards.some((c) => c.instanceId === card.instanceId)}
            charges={view.wandCharges[card.instanceId] ?? null}
            onclick={() => selectCard(card)}
            onfaq={(id) => (faqCardId = id)}
          />
        {/each}
      </div>
    </div>
  {/if}
</div>
{/if}

<style>
  :global(html, body) {
    margin: 0;
    background: #171a20;
  }
  :global(*, *::before, *::after) {
    box-sizing: border-box;
  }
  .table {
    min-height: 100vh;
    background:
      radial-gradient(ellipse at 50% 0%, rgba(64, 74, 92, 0.25), transparent 55%),
      repeating-linear-gradient(115deg, rgba(255,255,255,0.012) 0 3px, transparent 3px 7px),
      #171a20;
    color: #d8d2c0;
    font-family: "Archivo Narrow", system-ui, sans-serif;
    display: flex;
    flex-direction: column;
    padding: 0 1.1rem 1.1rem;
    box-sizing: border-box;
  }

  .masthead {
    display: flex;
    align-items: baseline;
    gap: 0.7rem;
    padding: 0.7rem 0.2rem 0.6rem;
    border-bottom: 1px solid rgba(216, 210, 192, 0.15);
    margin-bottom: 0.9rem;
  }
  .mast-title {
    font-family: "Oswald", sans-serif;
    font-weight: 700;
    font-size: 1.35rem;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: #e9e1cb;
  }
  .mast-sub {
    font-family: "Oswald", sans-serif;
    font-weight: 400;
    font-size: 0.7rem;
    letter-spacing: 0.24em;
    text-transform: uppercase;
    color: #8d8672;
  }
  .mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; }
  .mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
  .mast-status { font-size: 0.8rem; color: #c98a2a; }
  .mast-leave {
    background: none;
    border: none;
    color: #8d8672;
    font-family: "Archivo Narrow", sans-serif;
    font-size: 0.8rem;
    text-decoration: underline;
    cursor: pointer;
  }
  .mast-leave:hover { color: #d8d2c0; }
  .mast-help-solo { margin-left: auto; }
  .hotseat-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; justify-content: center; margin-top: 1.3rem; }
  .hotseat-exp { margin-top: 0.4rem; margin-bottom: 0; font-size: 0.85rem; }

  .scrim-handoff {
    position: fixed;
    inset: 0;
    background: #14161b;
    display: grid;
    place-items: center;
    z-index: 60;
    cursor: pointer;
  }
  .handoff-card {
    background: #e9e1cb;
    color: #43331f;
    border-radius: 8px;
    padding: 2.4rem 3.2rem;
    text-align: center;
    box-shadow: 0 16px 44px rgba(0, 0, 0, 0.6);
  }
  .handoff-eyebrow {
    font-family: "Oswald", sans-serif;
    font-size: 0.7rem;
    letter-spacing: 0.22em;
    text-transform: uppercase;
    color: #6b5a41;
  }
  .handoff-name {
    font-family: "Caveat", cursive;
    font-size: 3rem;
    line-height: 1.15;
  }
  .handoff-hint { font-size: 0.85rem; color: #6b5a41; margin-top: 0.4rem; }
  .setup-title { font-size: 2.2rem; }
  .standee-row {
    display: flex;
    gap: 0.45rem;
    justify-content: center;
    margin-top: 0.9rem;
    flex-wrap: wrap;
  }
  .standee {
    width: 3.1rem;
    height: 3.1rem;
    padding: 0;
    border: 2.5px solid transparent;
    border-radius: 6px;
    background: #f4eede;
    cursor: pointer;
    overflow: hidden;
  }
  .standee img { width: 100%; height: 100%; object-fit: cover; display: block; }
  .standee.current { border-color: var(--ring); box-shadow: 0 0 0 2px rgba(0,0,0,0.15); }
  .standee.taken { opacity: 0.28; cursor: default; filter: grayscale(0.8); }
  .setup-form { display: flex; flex-direction: column; gap: 0.7rem; margin-top: 0.8rem; }
  .setup-input {
    background: #f4eede;
    border: 1px solid #b3a687;
    border-radius: 4px;
    padding: 0.6rem 0.8rem;
    font-family: "Archivo Narrow", sans-serif;
    font-size: 1.1rem;
    color: #43331f;
    text-align: center;
  }
  .setup-roster { margin-top: 0.8rem; font-size: 0.85rem; color: #6b5a41; }
  .setup-cancel { margin: 0.6rem auto 0; display: block; }
  .hotseat-label { flex-basis: 100%; font-size: 0.85rem; color: #6b5a41; }
  .count-picker { display: flex; gap: 0.3rem; }
  .count-btn {
    width: 2.1rem;
    height: 2.1rem;
    border-radius: 50%;
    border: 1.5px solid #6b5a41;
    background: none;
    color: #43331f;
    font-family: "Oswald", sans-serif;
    font-size: 0.95rem;
    cursor: pointer;
  }
  .count-btn.current { background: #43331f; color: #e9e1cb; }

  .toast {
    background: #6d2119;
    color: #f2e6d8;
    border: 1px solid #96382c;
    padding: 0.5rem 0.85rem;
    border-radius: 4px;
    margin-bottom: 0.7rem;
    font-size: 0.95rem;
  }

  .boxlid {
    flex: 1;
    display: grid;
    place-items: center;
    padding: 2rem 0;
  }
  .boxlid-inner {
    background: #e9e1cb;
    color: #43331f;
    border-radius: 8px;
    border: 1px solid #b3a687;
    box-shadow: 0 14px 40px rgba(0, 0, 0, 0.55);
    padding: 2.2rem 2.6rem 2rem;
    max-width: min(26rem, 100%);
    width: 100%;
    text-align: center;
  }
  .boxlid-title {
    font-family: "Oswald", sans-serif;
    font-weight: 700;
    font-size: 3rem;
    line-height: 1;
    text-transform: uppercase;
    letter-spacing: 0.02em;
  }
  .boxlid-title.small { font-size: 1.7rem; }
  .boxlid-tag {
    font-size: 0.95rem;
    color: #6b5a41;
    margin: 0.5rem 0 0.4rem;
  }
  .about-link {
    background: none;
    border: none;
    font-family: "Caveat", cursive;
    font-size: 1.05rem;
    color: #8a6d3f;
    text-decoration: underline;
    text-decoration-style: dotted;
    cursor: pointer;
    margin-bottom: 1.2rem;
  }
  .about-link:hover { color: #43331f; }
  .field { display: block; text-align: left; margin-bottom: 1rem; }
  .field span {
    display: block;
    font-family: "Oswald", sans-serif;
    font-size: 0.62rem;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    color: #6b5a41;
    margin-bottom: 0.3rem;
  }
  .field input, .code {
    width: 100%;
    box-sizing: border-box;
    background: #f4eede;
    border: 1px solid #b3a687;
    border-radius: 4px;
    padding: 0.55rem 0.7rem;
    font-family: inherit;
    font-size: 1rem;
    color: #43331f;
  }
  .boxlid-actions { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; justify-content: center; }
  .or { font-size: 0.85rem; color: #6b5a41; }
  .code { width: 6.5rem; padding: 0.55rem 0.4rem; text-transform: uppercase; text-align: center; letter-spacing: 0.15em; }
  .roster { list-style: none; padding: 0; margin: 0 0 1rem; text-align: left; }
  .roster li { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; font-size: 1.05rem; }
  .roster-standee { width: 1.9rem; height: 1.9rem; border-radius: 4px; object-fit: cover; }
  .check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; }
  .waiting { color: #6b5a41; font-style: italic; }

  .transfer-slip {
    max-width: 30rem;
    margin: 0 auto 0.7rem;
    display: flex;
    align-items: center;
    gap: 0.6rem;
    flex-wrap: wrap;
  }
  .transfer-phrase {
    font-family: "Courier Prime", monospace;
    font-size: 1.05rem;
    letter-spacing: 0.04em;
    background: rgba(67, 51, 31, 0.12);
    padding: 0.1rem 0.45rem;
    border-radius: 3px;
    user-select: all;
  }
  .claim-row {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    align-items: center;
    justify-content: center;
    margin-top: 1.1rem;
  }
  .claim-input {
    background: #f4eede;
    border: 1px solid #b3a687;
    border-radius: 4px;
    padding: 0.4rem 0.6rem;
    font-family: "Courier Prime", monospace;
    font-size: 0.9rem;
    color: #43331f;
    width: 13rem;
  }

  /* the games ledger */
  .ledger {
    margin-top: 1.6rem;
    border-top: 1.5px solid #6b5a41;
    padding-top: 0.5rem;
    text-align: left;
  }
  .ledger-head {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    font-family: "Caveat", cursive;
    font-size: 1.15rem;
    color: #6b5a41;
    margin-bottom: 0.3rem;
  }
  .ledger-row {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    border-radius: 4px;
    padding: 0.15rem 0.3rem;
  }
  .ledger-row.your-turn { background: rgba(46, 125, 50, 0.14); }
  .ledger-resume {
    flex: 1;
    display: flex;
    align-items: baseline;
    gap: 0.7rem;
    background: none;
    border: none;
    padding: 0.3rem 0.2rem;
    cursor: pointer;
    text-align: left;
    color: #43331f;
    font-family: "Archivo Narrow", sans-serif;
    font-size: 0.95rem;
  }
  .ledger-resume:hover .ledger-code { text-decoration: underline; }
  .ledger-code {
    font-family: "Oswald", sans-serif;
    font-weight: 600;
    letter-spacing: 0.12em;
  }
  .ledger-row.your-turn .ledger-info { color: #1d5720; font-weight: 600; }
  .ledger-info { color: #6b5a41; }
  .ledger-forget {
    background: none;
    border: none;
    color: #a4906c;
    font-size: 1.05rem;
    cursor: pointer;
    padding: 0 0.3rem;
  }
  .ledger-forget:hover { color: #b3372b; }

  .stamp {
    font-family: "Oswald", sans-serif;
    font-weight: 500;
    font-size: 0.78rem;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    background: #e9e1cb;
    color: #43331f;
    border: 1.5px solid #43331f;
    border-radius: 3px;
    padding: 0.42rem 0.9rem;
    cursor: pointer;
    box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.35);
  }
  .stamp.lit {
    background: #43331f;
    color: #e9e1cb;
  }
  .stamp:hover:not(:disabled) { background: #f4eede; }
  .stamp:active:not(:disabled) { transform: translate(1px, 1px); box-shadow: 1px 1px 0 rgba(0,0,0,0.35); }
  .stamp:disabled { opacity: 0.45; cursor: default; }
  .stamp.primary { background: #43331f; color: #e9e1cb; }
  .stamp.primary:hover:not(:disabled) { background: #5a4429; }
  .stamp.big { font-size: 0.95rem; padding: 0.6rem 1.3rem; }
  .stamp.tiny { font-size: 0.62rem; padding: 0.22rem 0.5rem; box-shadow: 1px 1px 0 rgba(0,0,0,0.3); }
  .stamp:focus-visible { outline: 2px solid #d8b23a; outline-offset: 2px; }

  .game {
    display: grid;
    grid-template-columns: minmax(340px, 1fr) minmax(250px, 330px);
    gap: 1.1rem;
    flex: 1;
    min-height: 0;
  }
  .board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; }
  .board-zone :global(svg.board) {
    max-height: calc(100dvh - 21.5rem);
    width: auto;
    max-width: 100%;
  }
  .paper-rail { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }

  .slip {
    background: #e9e1cb;
    color: #43331f;
    border-radius: 3px;
    padding: 0.55rem 0.8rem;
    font-size: 0.95rem;
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45);
    transform: rotate(-0.4deg);
  }
  .slip.yours { border-left: 4px solid #2e7d32; }
  .slip.urgent { border-left: 4px solid #b3372b; transform: rotate(0.4deg); }
  .slip.ambush-note { border-left: 4px solid #43331f; font-size: 0.85rem; }
  .slip.catchup { border-left: 4px solid #5b3f9e; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
  .slip.winner {
    font-family: "Oswald", sans-serif;
    font-size: 1.25rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    border-left: 4px solid #c9a72a;
  }

  .scoresheet {
    background: #f2ecdb;
    color: #43331f;
    border-radius: 2px;
    padding: 0.6rem 0.8rem 0.5rem;
    box-shadow: 0 2px 7px rgba(0, 0, 0, 0.45);
    background-image: repeating-linear-gradient(
      transparent 0 1.55rem,
      rgba(95, 74, 51, 0.18) 1.55rem calc(1.55rem + 1px)
    );
  }
  .scoresheet-head {
    font-family: "Caveat", cursive;
    font-size: 1.05rem;
    color: #6b5a41;
    border-bottom: 1.5px solid #6b5a41;
    margin-bottom: 0.2rem;
  }
  .score-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    height: 1.55rem;
  }
  .slip.creature-note { background: #e7edda; border-color: #7d8a5a; }
  .score-row.active .score-name { font-weight: 700; }
  .score-temper {
    font-family: "Caveat", cursive;
    font-size: 0.85rem;
    color: #8a7a5e;
    margin-left: 0.25rem;
  }
  .table-cards {
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
    margin: -0.15rem 0 0.25rem 1.1rem;
  }
  .table-card {
    font-family: "Courier Prime", monospace;
    font-size: 0.68rem;
    color: #43331f;
    background: #f6f0df;
    border: 1px solid #b3a687;
    border-radius: 3px;
    padding: 0.05rem 0.35rem;
    cursor: pointer;
  }
  .table-card:hover { background: #efe6cc; }
  .table-card.spell-chip { background: #ece2f2; border-color: #8a6fa0; color: #4a3260; }
  .table-card.spell-chip:hover { background: #e3d5ee; }
  .score-row.dead { opacity: 0.5; text-decoration: line-through; }
  .score-name { font-family: "Caveat", cursive; font-size: 1.25rem; line-height: 1; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .score-life { font-family: "Caveat", cursive; font-size: 1.4rem; font-weight: 700; min-width: 1.6rem; text-align: right; }
  .score-marks { font-size: 0.75rem; color: #6b5a41; }
  .deck-line {
    font-family: "Courier Prime", monospace;
    font-size: 0.72rem;
    color: #8a7a5e;
    padding-top: 0.3rem;
  }
  .dot { width: 11px; height: 11px; border-radius: 50%; flex: 0 0 auto; border: 1px solid rgba(0,0,0,0.4); }

  .chronicle {
    flex: 1;
    min-height: 8rem;
    max-height: 40vh;
    overflow-y: auto;
    background: #efe8d4;
    color: #3a2f1f;
    font-family: "Courier Prime", monospace;
    font-size: 0.74rem;
    line-height: 1.5;
    padding: 0.6rem 0.75rem;
    border-radius: 2px;
    box-shadow: 0 2px 7px rgba(0, 0, 0, 0.45);
  }
  .chronicle div + div { margin-top: 0.05rem; }

  .table-edge {
    margin-top: 0.9rem;
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
  }
  .hint-strip {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    flex-wrap: wrap;
    background: rgba(233, 225, 203, 0.12);
    border: 1px dashed rgba(233, 225, 203, 0.35);
    border-radius: 4px;
    padding: 0.45rem 0.7rem;
    font-size: 0.9rem;
  }
  .hint-name { font-family: "Oswald", sans-serif; text-transform: uppercase; letter-spacing: 0.06em; color: #e9e1cb; }
  .hint-mods { color: #9fca6a; }
  .hint-alert { color: #e8a03c; }
  .hint-cancel {
    margin-left: auto;
    background: none;
    border: none;
    color: #a49c86;
    text-decoration: underline;
    cursor: pointer;
    font-size: 0.85rem;
  }
  .inline { display: inline-flex; align-items: center; gap: 0.35rem; }
  .num-input { width: 3.2rem; }
  .text-input { width: 9rem; }
  .num-input, .text-input, .draw-pick select {
    background: #e9e1cb;
    border: 1px solid #43331f;
    border-radius: 3px;
    padding: 0.2rem 0.35rem;
    font-family: inherit;
    color: #43331f;
  }

  .actions { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; min-height: 2rem; }
  .draw-pick { color: #a49c86; font-size: 0.9rem; }

  .attack-scrim {
    position: fixed;
    inset: 0;
    background: rgba(10, 12, 16, 0.72);
    display: grid;
    place-items: center;
    z-index: 45;
    padding: 1rem;
  }
  .attack-notice {
    background: #efe8d4;
    border: 2px solid #43331f;
    border-radius: 8px;
    box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
    padding: 1.2rem 1.6rem;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.8rem;
    max-width: min(22rem, 92vw);
    text-align: center;
  }
  .attack-headline {
    font-family: "Oswald", sans-serif;
    font-size: 1.15rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: #b3372b;
  }
  .attack-card :global(.card) { transform: scale(1.35); margin: 1.2rem 0; }
  .attack-card :global(.card:hover) { transform: scale(1.35); }
  .attack-card.illusion-pair {
    display: flex;
    justify-content: center;
    gap: 2.6rem;
  }
  .attack-card.illusion-pair :global(.card) { transform: scale(1.1); }
  .attack-card.illusion-pair :global(.card:hover) { transform: scale(1.1); }
  .attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; }
  .attack-standing {
    font-family: "Courier Prime", monospace;
    font-size: 0.85rem;
    color: #3f6b41;
    max-width: 17rem;
    margin: 0.3rem auto 0;
  }
  .attack-standing.nullified { color: #b3372b; }
  .rolloff {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    margin: 0.4rem 0 0.6rem;
  }
  .rolloff-row {
    display: flex;
    justify-content: space-between;
    gap: 1.2rem;
    font-size: 1rem;
    color: #43331f;
  }
  .rolloff-winner { font-weight: 700; }
  .rolloff-name { font-family: "Oswald", sans-serif; letter-spacing: 0.04em; }
  .rolloff-dice { font-family: "Courier Prime", monospace; }
  .die-face { margin-left: 0.4rem; }
  .rolloff-note { font-size: 0.8rem; color: #6b5a41; font-style: italic; margin-bottom: 0.3rem; }
  .attack-fist { font-size: 1.1rem; color: #43331f; }

  .table-talk {
    background: #efe8d4;
    color: #43331f;
    border-radius: 3px;
    padding: 0.1rem 0.4rem;
    margin: 0.15rem 0;
    font-style: italic;
  }
  .say-box { display: flex; gap: 0.35rem; margin-top: 0.4rem; }
  .say-input {
    flex: 1;
    min-width: 0;
    background: #f6f0df;
    border: 1px solid #b3a687;
    border-radius: 4px;
    padding: 0.35rem 0.55rem;
    font-family: "Courier Prime", monospace;
    font-size: 0.8rem;
    color: #43331f;
  }
  .big-peek-scrim {
    position: fixed;
    inset: 0;
    background: rgba(10, 12, 16, 0.55);
    display: grid;
    place-items: center;
    z-index: 55;
  }
  .big-peek :global(.card) {
    transform: scale(2.1);
    cursor: default;
    box-shadow: 0 18px 50px rgba(0, 0, 0, 0.7);
  }
  .big-peek :global(.card:hover) { transform: scale(2.1); }
  .big-peek { display: flex; flex-direction: column; align-items: center; }
  .big-peek-note { margin-top: 0.7rem; max-width: 15rem; font-size: 0.8rem; }
  /* The card scales 2.1x out of its layout box; the stage reserves its
     TRUE visual footprint so neighbors never sit underneath it. */
  .card-stage {
    width: calc(8.2rem * 2.1);
    height: calc(11.4rem * 2.1);
    display: grid;
    place-items: center;
  }
  .big-peek-token {
    width: 6.5rem;
    height: 6.5rem;
    object-fit: contain;
    margin-bottom: 0.8rem;
    filter: drop-shadow(2px 4px 8px rgba(0, 0, 0, 0.6));
  }
  .creature-dock {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.3rem;
  }
  .creature-dock :global(.card) { cursor: default; }
  .creature-dock :global(.card:hover) { transform: none; }
  .hand.spent :global(.card) {
    opacity: 0.45;
    filter: grayscale(0.5);
    cursor: default;
  }
  .hand.spent :global(.card:hover) { transform: none; }
  .tier-pick {
    background: #f6f0df;
    border: 1px solid #b3a687;
    border-radius: 3px;
    font: inherit;
    color: #43331f;
    padding: 0.1rem 0.25rem;
  }
  .bot-row {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: center;
    gap: 0.35rem;
    max-width: 100%;
    font-size: 0.85rem;
    color: #6b5a41;
  }
  .bot-label { white-space: nowrap; }
  .discard-link {
    background: none;
    border: none;
    font: inherit;
    color: inherit;
    text-decoration: underline;
    text-decoration-style: dotted;
    cursor: pointer;
    padding: 0;
  }
  .discard-link:hover { color: #43331f; }
  .discard-book {
    background: #efe8d4;
    border: 1px solid #b3a687;
    border-radius: 6px;
    box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
    width: min(52rem, 94vw);
    max-height: calc(100vh - 4rem);
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }
  .discard-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0.6rem 1rem;
    border-bottom: 2px solid #43331f;
    font-family: "Oswald", sans-serif;
    font-size: 0.85rem;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: #43331f;
  }
  .discard-close { position: static; }
  .discard-grid {
    overflow-y: auto;
    padding: 0.9rem;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(8.4rem, 1fr));
    gap: 0.7rem;
    justify-items: center;
  }
  .discard-empty { font-family: "Caveat", cursive; color: #8a7a5e; font-size: 1.1rem; }

  .peek-note {
    margin-top: 0.4rem;
    background: #2b2218;
    color: #e8dfc6;
    font-family: "Courier Prime", monospace;
    font-size: 0.72rem;
    padding: 0.25rem 0.5rem;
    border-radius: 3px;
    text-align: center;
    max-width: 11rem;
  }

  .final-reveal {
    background: #efe8d4;
    border: 1px solid #b3a687;
    border-radius: 6px;
    padding: 0.7rem 0.9rem;
    margin-bottom: 0.6rem;
  }
  .reveal-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    font-family: "Oswald", sans-serif;
    font-size: 0.8rem;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: #43331f;
    border-bottom: 1px solid #b3a687;
    padding-bottom: 0.2rem;
    margin-bottom: 0.5rem;
  }
  .reveal-row { display: flex; align-items: flex-start; gap: 0.6rem; margin-bottom: 0.5rem; }
  .reveal-name {
    font-family: "Courier Prime", monospace;
    font-size: 0.8rem;
    color: #43331f;
    min-width: 6.5rem;
    padding-top: 0.4rem;
  }
  .reveal-name.reveal-winner { font-weight: 700; }
  .reveal-note { display: block; font-family: "Caveat", cursive; font-size: 0.85rem; color: #8a7a5e; }
  .reveal-cards { display: flex; flex-wrap: wrap; gap: 0.3rem; }
  .reveal-cards :global(.card) { transform: scale(0.82); transform-origin: top left; margin: -0.35rem -0.8rem -1.1rem 0; }
  .reveal-empty { font-family: "Caveat", cursive; color: #8a7a5e; padding-top: 0.5rem; }

  .victory-notice { border-color: #a5842c; box-shadow: 0 0 0 4px rgba(201, 167, 42, 0.35), 0 18px 50px rgba(0, 0, 0, 0.6); }
  .victory-trophy { font-size: 3rem; line-height: 1; }
  .victory-name {
    font-family: "Oswald", sans-serif;
    font-size: 1.7rem;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: #43331f;
  }
  .victory-how { font-family: "Caveat", cursive; font-size: 1.25rem; color: #6b5a41; }

  .inspector {
    position: fixed;
    right: 0.9rem;
    bottom: 0.9rem;
    z-index: 35;
  }
  .inspector-card {
    transform: scale(1.55);
    transform-origin: bottom right;
    filter: drop-shadow(0 10px 26px rgba(0, 0, 0, 0.65));
  }
  .inspector-card :global(.card) {
    cursor: default;
  }
  .inspector-card :global(.card:hover) {
    transform: none;
  }
  .inspector-close {
    position: absolute;
    top: -2.05rem;
    right: -0.4rem;
    z-index: 2;
    background: #2b2218;
    color: #e9e1cb;
    border: 1px solid #6b5a41;
    border-radius: 50%;
    width: 1.7rem;
    height: 1.7rem;
    font-size: 1rem;
    line-height: 1;
    cursor: pointer;
  }

  .hand {
    display: flex;
    gap: 0.45rem;
    padding: 0.9rem 0.3rem 0.35rem;
    overflow-x: auto;
    overflow-y: visible;
    align-items: flex-end;
  }

  @media (max-width: 900px) {
    /* One-column phone flow: board, then your controls and hand, THEN the
       paperwork — cards must never hide below the chronicle. */
    .game { display: contents; }
    .inspector { right: 0.5rem; bottom: 0.5rem; }
    .inspector-card { transform: scale(1.3); }
    .board-zone { order: 1; }
    .table-edge { order: 2; margin-top: 0.6rem; }
    .paper-rail { order: 3; margin-top: 0.9rem; }
    .board-zone :global(svg.board) {
      max-height: 56dvh;
    }
    .chronicle { max-height: 30dvh; flex: initial; }
    .masthead { gap: 0.45rem; padding-top: 0.5rem; }
    .boxlid { padding: 1rem 0; }
    .boxlid-inner { padding: 1.5rem 1.2rem 1.4rem; }
    .boxlid-title { font-size: 2.3rem; }
    .claim-input { width: 100%; flex: 1 1 100%; }
    .hotseat-row .stamp, .claim-row .stamp { flex: 0 0 auto; }
    .mast-title { font-size: 1.05rem; white-space: nowrap; }
    .mast-sub { display: none; }
    .mast-leave { font-size: 0.72rem; }
  }
</style>
