Skip to content
import { animate, utils } from "animejs";
import { useEffect, useRef, useState } from "preact/hooks";
import archiveArt from "../assets/icons/ffffff/transparent/1x1/lorc/white-tower.svg";
import enemyAttackArt from "../assets/icons/ffffff/transparent/1x1/lorc/sword-clash.svg";
import enemyBlockArt from "../assets/icons/ffffff/transparent/1x1/lorc/magic-shield.svg";
import enemyBuffArt from "../assets/icons/ffffff/transparent/1x1/lorc/aura.svg";
import enemyHexArt from "../assets/icons/ffffff/transparent/1x1/lorc/poison-gas.svg";
import keepArt from "../assets/icons/ffffff/transparent/1x1/delapouite/castle.svg";
import gateArt from "../assets/icons/ffffff/transparent/1x1/delapouite/medieval-gate.svg";
import sanctumArt from "../assets/icons/ffffff/transparent/1x1/delapouite/viking-church.svg";
import {
  BOSS_TEMPLATES,
  type CardInstance,
  CLASSES,
  type ClassId,
  criticalChance,
  ELITE_TEMPLATES,
  ENEMY_TEMPLATES,
  type EnemyMoveDef,
  type EnemyTemplate,
  MAP_FLOORS,
  type MapNode,
  maxEnergy,
  maxVigor,
  scaleDamage,
  scaleHp,
} from "../lib/game.ts";
import type { Hero } from "../lib/heroes.ts";
import {
  type HeroProgression,
  levelUpCost,
  progressionPoints,
  type SkillId,
  skillRankCost,
  SKILLS,
} from "../lib/progression.ts";
import {
  ArcanaHallScreen,
  cardDef,
  CardFace,
  DeckBuilderScreen,
  DeckScreen,
  NODE_GLYPH,
  NODE_LABEL,
  PackScreen,
  ProgressionScreen,
  SanctumScreen,
} from "./game/ui.tsx";

type Screen =
  | "city"
  | "keep"
  | "arcana"
  | "deck"
  | "deckBuilder"
  | "pack"
  | "sanctum"
  | "map"
  | "combat"
  | "summary";
type Outcome = "victory" | "defeat" | "retreat";

interface CombatEnemy {
  key: number;
  name: string;
  art: string;
  hp: number;
  maxHp: number;
  block: number;
  strength: number;
  poison: number;
  weak: number;
  moves: EnemyMoveDef[];
  moveIdx: number;
}

interface CombatState {
  heroHp: number;
  heroMaxHp: number;
  block: number;
  strength: number;
  poison: number;
  evade: number;
  retaliate: number;
  energy: number;
  maxEnergy: number;
  handSize: number;
  hand: CardInstance[];
  draw: CardInstance[];
  discard: CardInstance[];
  enemies: CombatEnemy[];
  turn: number;
  log: string[];
  over: null | "win" | "lose";
}

interface RunState {
  floors: MapNode[][];
  floorIdx: number;
  heroHp: number;
  animaGained: number;
}

interface CombatEffect {
  id: number;
  art: string;
  type: "physical" | "defense" | "poison" | "arcane";
  sourceX: number;
  sourceY: number;
  targetX: number;
  targetY: number;
  targetSelector: string;
  aoe: boolean;
}

const ENEMY_MOVE_VISUALS: Record<
  EnemyMoveDef["kind"],
  { art: string; type: CombatEffect["type"] }
> = {
  attack: { art: enemyAttackArt, type: "physical" },
  block: { art: enemyBlockArt, type: "defense" },
  buff: { art: enemyBuffArt, type: "arcane" },
  hex: { art: enemyHexArt, type: "poison" },
};

let uid = 0;
let enemyKey = 0;
let combatEffectKey = 0;

function prefersReducedMotion(): boolean {
  return globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

function shuffle<T>(arr: T[]): T[] {
  const out = [...arr];
  for (let i = out.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [out[i], out[j]] = [out[j], out[i]];
  }
  return out;
}

function pick<T>(arr: T[]): T {
  return arr[Math.floor(Math.random() * arr.length)];
}

function buildDrawPile(deckIds: string[]): CardInstance[] {
  const cards: CardInstance[] = [];
  for (const id of deckIds) {
    cards.push({ uid: ++uid, def: cardDef(id), upgraded: false });
  }
  return cards;
}

function enemyFromTemplate(t: EnemyTemplate, depth: number): CombatEnemy {
  return {
    key: ++enemyKey,
    name: t.name,
    art: t.art,
    hp: scaleHp(t.hp, depth),
    maxHp: scaleHp(t.hp, depth),
    block: 0,
    strength: 0,
    poison: 0,
    weak: 0,
    moves: t.moves.map((m) => ({
      ...m,
      damage: m.damage === undefined ? undefined : scaleDamage(m.damage, depth),
    })),
    moveIdx: 0,
  };
}

function rollEnemyGroup(depth: number): EnemyTemplate[] {
  const pool = Object.values(ENEMY_TEMPLATES);
  const count = depth >= 3 && Math.random() < 0.4 ? 2 : 1;
  return Array.from({ length: count }, () => pick(pool));
}

function generateFloors(): MapNode[][] {
  const floors: MapNode[][] = [];
  for (let i = 0; i < MAP_FLOORS; i++) {
    if (i === MAP_FLOORS - 1) {
      floors.push([{ kind: "boss", done: false }]);
      continue;
    }
    const options: MapNode[] = [];
    for (let b = 0; b < 2; b++) {
      const roll = Math.random();
      let kind: MapNode["kind"];
      if (roll < 0.55) kind = "fight";
      else if (roll < 0.72) kind = "treasure";
      else if (roll < 0.87) kind = "rest";
      else kind = i >= 3 ? "elite" : "fight";
      options.push({ kind, done: false });
    }
    floors.push(options);
  }
  if (!floors.flat().some((n) => n.kind === "rest")) {
    floors[MAP_FLOORS - 2][0] = { kind: "rest", done: false };
  }
  return floors;
}

export default function Game(
  { hero: initialHero, isAdmin }: { hero: Hero; isAdmin: boolean },
) {
  const klass = CLASSES[initialHero.class as ClassId];
  const [deck, setDeck] = useState<string[]>(initialHero.deck);
  const [collection, setCollection] = useState(initialHero.collection);
  const [anima, setAnima] = useState(initialHero.anima);
  const [level, setLevel] = useState(initialHero.level);
  const [progression, setProgression] = useState(initialHero.progression);
  const [screen, setScreen] = useState<Screen>("city");
  const [displayScreen, setDisplayScreen] = useState<Screen>("city");
  const [leaving, setLeaving] = useState(false);
  const [run, setRun] = useState<RunState | null>(null);
  const [combat, setCombat] = useState<CombatState | null>(null);
  const [rewardAnima, setRewardAnima] = useState(0);
  const [rewardOpen, setRewardOpen] = useState(false);
  const [outcome, setOutcome] = useState<Outcome>("retreat");
  const [target, setTarget] = useState(0);
  const [openedPack, setOpenedPack] = useState<string[] | null>(null);
  const [buyingPack, setBuyingPack] = useState(false);
  const [savingDeck, setSavingDeck] = useState(false);
  const [featureError, setFeatureError] = useState<string | null>(null);
  const [message, setMessage] = useState<string | null>(null);
  const [combatEffects, setCombatEffects] = useState<CombatEffect[]>([]);
  const [enemyTurn, setEnemyTurn] = useState(false);
  const combatEffectAnimations = useRef<
    Map<number, ReturnType<typeof animate>[]>
  >(new Map());
  const combatFinishTimer = useRef<
    ReturnType<typeof globalThis.setTimeout> | null
  >(null);
  const savedProgress = useRef({
    anima: initialHero.anima,
    level: initialHero.level,
    progression: initialHero.progression,
    deck: initialHero.deck,
  });
  const saveQueue = useRef<Promise<void>>(Promise.resolve());
  const stageRef = useRef<HTMLDivElement | null>(null);
  const screenAnimation = useRef<ReturnType<typeof animate> | null>(null);
  const rewardClosing = useRef(false);
  const handPositions = useRef(new Map<number, { x: number; y: number }>());
  const handAnimations = useRef<ReturnType<typeof animate>[]>([]);

  const maxHp = maxVigor(klass.maxHp, progression);
  const heroMaxEnergy = maxEnergy(klass.energy, progression);
  const critChance = criticalChance(progression);
  const points = progressionPoints(level, progression);
  const levelCost = levelUpCost(level);
  const upgradeCost = 20 + 10 * level;

  useEffect(() => {
    if (prefersReducedMotion()) {
      return;
    }

    if (displayScreen !== "keep") return;

    const animation = animate(".passive-icon", {
      translateY: [0, -4],
      rotate: [-2, 2],
      duration: 2400,
      ease: "inOutSine",
      loop: true,
      alternate: true,
    });

    return () => {
      animation.revert();
    };
  }, [displayScreen]);

  // Screens never swap instantly: the outgoing one dims and sinks away, then
  // the incoming one rises into place.
  useEffect(() => {
    if (screen === displayScreen) return;
    const stage = stageRef.current;
    if (!stage || prefersReducedMotion()) {
      setDisplayScreen(screen);
      return;
    }
    setLeaving(true);
    screenAnimation.current?.revert();
    screenAnimation.current = animate(stage, {
      opacity: [1, 0],
      scale: [1, 0.985],
      translateY: [0, -14],
      duration: 200,
      ease: "in(2)",
      onComplete: () => {
        screenAnimation.current = null;
        setDisplayScreen(screen);
      },
    });
  }, [screen, displayScreen]);

  useEffect(() => {
    setLeaving(false);
    const stage = stageRef.current;
    if (!stage || prefersReducedMotion()) return;
    const entrance = animate(stage, {
      opacity: [0, 1],
      scale: [0.99, 1],
      translateY: [16, 0],
      duration: 340,
      ease: "out(3)",
      onComplete: (animation) => utils.cleanInlineStyles(animation),
    });
    screenAnimation.current = entrance;
    return () => {
      if (screenAnimation.current === entrance) screenAnimation.current = null;
      entrance.revert();
    };
  }, [displayScreen]);

  // The map keeps the same screen between floors, so the fresh branching paths
  // get their own arrival beat.
  useEffect(() => {
    if (displayScreen !== "map" || prefersReducedMotion()) return;
    const animation = animate(".path-choice", {
      opacity: [0, 1],
      translateY: [18, 0],
      duration: 420,
      delay: (_target, index) => (index ?? 0) * 90,
      ease: "out(3)",
      onComplete: (item) => utils.cleanInlineStyles(item),
    });
    return () => {
      animation.revert();
    };
  }, [displayScreen, run?.floorIdx]);

  // Playing or drawing a card re-fans the whole hand. Without this the cards
  // teleport to their new slots; here they glide there from where they were.
  useEffect(() => {
    for (const animation of handAnimations.current) animation.revert();
    handAnimations.current = [];

    const slots = Array.from(
      document.querySelectorAll<HTMLElement>("[data-card-uid]"),
    );
    for (const slot of slots) slot.style.transform = "";

    const previous = handPositions.current;
    const next = new Map<number, { x: number; y: number }>();
    const moves: { slot: HTMLElement; dx: number; dy: number }[] = [];

    for (const slot of slots) {
      const uid = Number(slot.dataset.cardUid);
      const rect = slot.getBoundingClientRect();
      next.set(uid, { x: rect.left, y: rect.top });
      const old = previous.get(uid);
      if (!old) continue;
      const dx = old.x - rect.left;
      const dy = old.y - rect.top;
      if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue;
      moves.push({ slot, dx, dy });
    }
    handPositions.current = next;

    if (prefersReducedMotion()) return;
    for (const move of moves) {
      handAnimations.current.push(
        animate(move.slot, {
          translateX: [move.dx, 0],
          translateY: [move.dy, 0],
          duration: 300,
          ease: "out(3)",
          onComplete: (animation) => utils.cleanInlineStyles(animation),
        }),
      );
    }
  }, [displayScreen, combat?.hand]);

  useEffect(() => {
    if (!rewardOpen || prefersReducedMotion()) return;
    const box = document.querySelector("[data-reward-box]");
    if (!box) return;
    const animation = animate(box, {
      opacity: [0, 1],
      scale: [0.88, 1],
      translateY: [24, 0],
      duration: 460,
      ease: "out(4)",
      onComplete: (item) => utils.cleanInlineStyles(item),
    });
    return () => {
      animation.revert();
    };
  }, [rewardOpen]);

  useEffect(() => {
    if (prefersReducedMotion()) {
      if (combatEffects.length > 0) setCombatEffects([]);
      return;
    }

    for (const effect of combatEffects) {
      if (combatEffectAnimations.current.has(effect.id)) continue;
      const visual = document.querySelector(
        `[data-combat-effect="${effect.id}"]`,
      );
      if (!visual) continue;
      const target = document.querySelector(effect.targetSelector);
      const travel = animate(visual, {
        translateX: [0, effect.targetX - effect.sourceX],
        translateY: [0, effect.targetY - effect.sourceY],
        rotate: effect.type === "physical" ? [0, 520] : [-18, 18],
        scale: effect.aoe ? [0.65, 1.65] : [0.65, 1.15],
        opacity: [1, 1, 0],
        duration: 620,
        ease: effect.type === "physical" ? "inOut(3)" : "out(4)",
        onComplete: () => {
          combatEffectAnimations.current.delete(effect.id);
          setCombatEffects((current) =>
            current.filter((item) => item.id !== effect.id)
          );
        },
      });
      const impact = target
        ? animate(target, {
          scale: [1, effect.aoe ? 1.08 : 1.045, 1],
          filter: ["brightness(1)", "brightness(1.45)", "brightness(1)"],
          delay: 410,
          duration: 260,
          ease: "outBack",
          onComplete: (animation) => utils.cleanInlineStyles(animation),
        })
        : null;
      combatEffectAnimations.current.set(
        effect.id,
        impact ? [travel, impact] : [travel],
      );
    }
  }, [combatEffects]);

  useEffect(() => () => {
    for (const animations of combatEffectAnimations.current.values()) {
      for (const animation of animations) animation.revert();
    }
    combatEffectAnimations.current.clear();
    for (const animation of handAnimations.current) animation.revert();
    handAnimations.current = [];
    if (combatFinishTimer.current !== null) {
      globalThis.clearTimeout(combatFinishTimer.current);
    }
  }, []);

  function persist(next: {
    anima: number;
    level: number;
    progression: HeroProgression;
    deck: string[];
  }) {
    saveQueue.current = saveQueue.current.then(async () => {
      const expected = savedProgress.current;
      const response = await fetch("/api/game/save", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          ...next,
          expectedAnima: expected.anima,
          expectedLevel: expected.level,
          expectedProgression: expected.progression,
          expectedDeck: expected.deck,
        }),
      });
      if (!response.ok) {
        if (response.status === 409) globalThis.location.reload();
        throw new Error("Could not save game progress.");
      }
      savedProgress.current = next;
    }).catch(() => {});
  }

  function levelUp() {
    if (anima < levelCost || level >= 999) return;
    const nextAnima = anima - levelCost;
    const nextLevel = level + 1;
    setAnima(nextAnima);
    setLevel(nextLevel);
    setMessage(
      `${klass.name} ascends to level ${nextLevel}: +1 attribute point.`,
    );
    persist({ anima: nextAnima, level: nextLevel, progression, deck });
  }

  function improveVigor() {
    if (points.attribute === 0) return;
    const next = { ...progression, vigor: progression.vigor + 1 };
    setProgression(next);
    setMessage(`Vigor rises by 8.`);
    persist({ anima, level, progression: next, deck });
  }

  function learnSkill(skillId: SkillId) {
    const skill = SKILLS[skillId];
    const cost = skillRankCost(progression.skills[skillId]);
    if (
      anima < cost || progression.skills[skillId] >= skill.maxRank
    ) return;
    const next = {
      ...progression,
      skills: {
        ...progression.skills,
        [skillId]: progression.skills[skillId] + 1,
      },
    };
    const nextAnima = anima - cost;
    setAnima(nextAnima);
    setProgression(next);
    setMessage(`${skill.name} grows stronger for ${cost} Anima.`);
    persist({ anima: nextAnima, level, progression: next, deck });
  }

  function upgradeCard(index: number) {
    if (anima < upgradeCost) return;
    const cardId = deck[index];
    if (!cardId || cardId.startsWith("+")) return;
    const def = cardDef(cardId);
    const nextDeck = [...deck];
    nextDeck[index] = `+${cardId}`;
    const nextAnima = anima - upgradeCost;
    setAnima(nextAnima);
    setDeck(nextDeck);
    setCollection((current) => ({
      ...current,
      [cardId]: Math.max(0, (current[cardId] ?? 0) - 1),
      [`+${cardId}`]: (current[`+${cardId}`] ?? 0) + 1,
    }));
    setMessage(`${def.name} gleams brighter.`);
    persist({ anima: nextAnima, level, progression, deck: nextDeck });
  }

  function startRun() {
    setRun({
      floors: generateFloors(),
      floorIdx: 0,
      heroHp: maxHp,
      animaGained: 0,
    });
    setScreen("map");
  }

  async function buyPack() {
    setBuyingPack(true);
    setFeatureError(null);
    try {
      await saveQueue.current;
      const response = await fetch("/api/cards/pack", { method: "POST" });
      const data = await response.json() as {
        cards?: string[];
        anima?: number;
        error?: string;
      };
      if (!response.ok || !data.cards || typeof data.anima !== "number") {
        throw new Error(data.error ?? "Could not open the pack.");
      }
      setAnima(data.anima);
      savedProgress.current = { ...savedProgress.current, anima: data.anima };
      setOpenedPack(data.cards);
      setCollection((current) => {
        const next = { ...current };
        for (const id of data.cards!) next[id] = (next[id] ?? 0) + 1;
        return next;
      });
    } catch (error) {
      setFeatureError(
        error instanceof Error ? error.message : "Could not open the pack.",
      );
    } finally {
      setBuyingPack(false);
    }
  }

  async function saveDeck(nextDeck: string[]) {
    setSavingDeck(true);
    setFeatureError(null);
    try {
      await saveQueue.current;
      const response = await fetch("/api/cards/deck", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ deck: nextDeck }),
      });
      const data = await response.json() as { error?: string };
      if (!response.ok) {
        throw new Error(data.error ?? "Could not save the deck.");
      }
      setDeck(nextDeck);
      savedProgress.current = { ...savedProgress.current, deck: nextDeck };
      setScreen("arcana");
      setMessage("Your active deck has been set.");
    } catch (error) {
      setFeatureError(
        error instanceof Error ? error.message : "Could not save the deck.",
      );
    } finally {
      setSavingDeck(false);
    }
  }

  function endRun(final: Outcome) {
    const gained = run ? run.animaGained : 0;
    const nextAnima = anima + gained;
    setAnima(nextAnima);
    persist({ anima: nextAnima, level, progression, deck });
    setOutcome(final);
    setRewardOpen(false);
    // Run and combat state stay put so the screen we are leaving can finish
    // its exit animation with its contents intact; the next run replaces them.
    setScreen("summary");
  }

  function advanceFloor() {
    if (!run) return;
    const nextIdx = run.floorIdx + 1;
    if (nextIdx >= run.floors.length) {
      endRun("victory");
      return;
    }
    setRun((r) =>
      r
        ? {
          ...r,
          floors: r.floors.map((f, i) =>
            i === r.floorIdx ? f.map((n) => ({ ...n, done: true })) : f
          ),
          floorIdx: nextIdx,
        }
        : r
    );
    setScreen("map");
  }

  function enterNode(node: MapNode) {
    if (!run) return;
    const depth = run.floorIdx;
    if (
      node.kind === "fight" || node.kind === "elite" || node.kind === "boss"
    ) {
      const templates = node.kind === "boss"
        ? [pick(BOSS_TEMPLATES)]
        : node.kind === "elite"
        ? [pick(ELITE_TEMPLATES)]
        : rollEnemyGroup(depth);
      startCombat(templates, depth, node.kind);
    } else if (node.kind === "treasure") {
      const amount = 15 + 5 * depth;
      setRun({ ...run, animaGained: run.animaGained + amount });
      setMessage(`The vault opens: +${amount} Anima.`);
      advanceFloor();
    } else if (node.kind === "rest") {
      const heal = Math.round(maxHp * 0.35);
      setRun({ ...run, heroHp: Math.min(maxHp, run.heroHp + heal) });
      setMessage(`The sanctum restores ${heal} vitality.`);
      advanceFloor();
    }
  }

  function startCombat(
    templates: EnemyTemplate[],
    depth: number,
    kind: MapNode["kind"],
  ) {
    if (!run) return;
    const enemies = templates.map((t) => enemyFromTemplate(t, depth));
    const draw = shuffle(buildDrawPile(deck));
    const hand = draw.splice(0, klass.handSize);
    setCombat({
      heroHp: run.heroHp,
      heroMaxHp: maxHp,
      block: 0,
      strength: progression.skills.strength,
      poison: 0,
      evade: 0,
      retaliate: 0,
      energy: heroMaxEnergy,
      maxEnergy: heroMaxEnergy,
      handSize: klass.handSize,
      hand,
      draw,
      discard: [],
      enemies,
      turn: 1,
      log: [
        kind === "boss"
          ? "The Sovereign descends from her gilded throne."
          : "Steel meets shadow.",
      ],
      over: null,
    });
    setTarget(0);
    setCombatEffects([]);
    setScreen("combat");
  }

  function drawCards(state: CombatState, n: number): CombatState {
    const draw = [...state.draw];
    const discard = [...state.discard];
    const hand = [...state.hand];
    for (let i = 0; i < n; i++) {
      if (draw.length === 0) {
        if (discard.length === 0) break;
        draw.push(...shuffle(discard));
        discard.length = 0;
      }
      const card = draw.pop();
      if (card) hand.push(card);
    }
    return { ...state, draw, discard, hand };
  }

  function animateCardPlay(
    def: ReturnType<typeof cardDef>,
    enemyIndex: number,
  ) {
    if (prefersReducedMotion()) {
      return;
    }
    const hostile = Boolean(
      def.effects.damage || def.effects.poison || def.effects.weak,
    );
    const aoe = hostile && Boolean(def.effects.aoe);
    const source = document.querySelector("[data-combat-hero]")
      ?.getBoundingClientRect();
    const targetSelector = hostile
      ? aoe ? ".combat-enemies" : `[data-enemy-index="${enemyIndex}"]`
      : "[data-combat-hero]";
    const targetRect = document.querySelector(targetSelector)
      ?.getBoundingClientRect();
    const sourceX = (source?.left ?? globalThis.innerWidth / 2) +
      (source?.width ?? 0) / 2 - 32;
    const sourceY = (source?.top ?? globalThis.innerHeight - 120) +
      (source?.height ?? 0) / 2 - 32;
    const targetX = (targetRect?.left ?? globalThis.innerWidth / 2) +
      (targetRect?.width ?? 0) / 2 - 32;
    const targetY = (targetRect?.top ?? globalThis.innerHeight / 3) +
      (targetRect?.height ?? 0) / 2 - 32;
    setCombatEffects((current) =>
      [
        ...current,
        {
          id: ++combatEffectKey,
          art: def.art,
          type: def.type,
          sourceX,
          sourceY,
          targetX,
          targetY,
          targetSelector,
          aoe,
        },
      ].slice(-8)
    );
  }

  function playCard(cardIndex: number) {
    if (!combat || combat.over) return;
    const card = combat.hand[cardIndex];
    if (!card) return;
    const def = cardDef(card.def.id);
    if (def.cost > combat.energy) return;
    const eff = def.effects;

    const log: string[] = [];
    const enemies = combat.enemies.map((e) => ({ ...e }));
    let heroHp = combat.heroHp;
    let block = combat.block;
    let strength = combat.strength;
    let evade = combat.evade;
    let retaliate = combat.retaliate;
    let energy = combat.energy - def.cost;
    let idx = Math.min(Math.max(target, 0), enemies.length - 1);
    if (!enemies[idx] || enemies[idx].hp <= 0) {
      const live = enemies.findIndex((e) => e.hp > 0);
      if (live !== -1) idx = live;
    }
    animateCardPlay(def, idx);

    const hit = (e: CombatEnemy, raw: number) => {
      const critical = critChance > 0 && Math.random() < critChance;
      const dmg = Math.max(1, critical ? raw * 2 : raw);
      const absorbed = Math.min(e.block, dmg);
      e.block -= absorbed;
      e.hp -= dmg - absorbed;
      log.push(
        `${def.name}${critical ? " critically" : ""} hits ${e.name} for ${
          dmg - absorbed
        }.`,
      );
    };

    if (eff.damage) {
      const hits = eff.hits ?? 1;
      for (let h = 0; h < hits; h++) {
        if (eff.aoe) {
          enemies
            .filter((e) => e.hp > 0)
            .forEach((e) => hit(e, eff.damage! + strength));
        } else {
          const e = enemies[idx];
          if (e && e.hp > 0) hit(e, eff.damage + strength);
        }
      }
    }
    if (eff.block) {
      block += eff.block;
      log.push(`+${eff.block} block.`);
    }
    if (eff.heal) {
      heroHp = Math.min(combat.heroMaxHp, heroHp + eff.heal);
      log.push(`Restored ${eff.heal} vitality.`);
    }
    if (eff.poison) {
      const e = enemies[idx];
      const poisonApplied = eff.poison + progression.skills.poison;
      if (e && e.hp > 0) e.poison += poisonApplied;
      log.push(`${poisonApplied} poison seeps in.`);
    }
    if (eff.weak) {
      const e = enemies[idx];
      if (e && e.hp > 0) {
        e.weak += eff.weak;
        log.push(`${e.name} is weakened.`);
      }
    }
    if (eff.strength) {
      strength += eff.strength;
      log.push(`+${eff.strength} strength.`);
    }
    if (eff.evade) {
      evade += eff.evade;
      log.push("She melts from sight.");
    }
    if (eff.retaliate) {
      retaliate += eff.retaliate;
      log.push("Blades stand ready to answer.");
    }
    if (eff.anima && run) {
      setRun({ ...run, animaGained: run.animaGained + eff.anima });
      log.push(`+${eff.anima} Anima drawn from the ether.`);
    }
    let state: CombatState = { ...combat };
    if (eff.draw) state = drawCards(state, eff.draw);
    if (eff.energyGain) energy += eff.energyGain;

    enemies.forEach((e) => {
      if (e.hp <= 0) log.push(`${e.name} falls!`);
    });

    const hand = state.hand.filter((_, i) => i !== cardIndex);
    const over = enemies.every((e) => e.hp <= 0) ? "win" : null;

    setCombat({
      ...state,
      hand,
      discard: [...state.discard, card],
      enemies,
      heroHp,
      block,
      strength,
      evade,
      retaliate,
      energy,
      log: [...state.log, ...log].slice(-6),
      over,
    });

    if (over) {
      if (prefersReducedMotion()) {
        finishCombat(true, heroHp);
      } else {
        combatFinishTimer.current = globalThis.setTimeout(() => {
          combatFinishTimer.current = null;
          finishCombat(true, heroHp);
        }, 650);
      }
    } else if (enemies[idx]?.hp <= 0) {
      const live = enemies.findIndex((e) => e.hp > 0);
      if (live !== -1) setTarget(live);
    }
  }

  function resolveEnemyMove(
    state: CombatState,
    enemyIndex: number,
  ): CombatState {
    const log: string[] = [];
    const enemies = state.enemies.map((e) => ({ ...e }));
    let heroHp = state.heroHp;
    let block = state.block;
    let evade = state.evade;
    const retaliate = state.retaliate;
    let poison = state.poison;

    const e = enemies[enemyIndex];
    if (e?.hp > 0) {
      const move = e.moves[e.moveIdx % e.moves.length];
      if (move.kind === "attack" && move.damage) {
        const hits = move.hits ?? 1;
        for (let h = 0; h < hits; h++) {
          if (evade > 0) {
            evade--;
            log.push(`${e.name}'s ${move.label} finds only air.`);
            continue;
          }
          const raw = Math.max(
            1,
            Math.round((move.damage + e.strength) * (e.weak > 0 ? 0.75 : 1)),
          );
          const absorbed = Math.min(block, raw);
          block -= absorbed;
          heroHp -= raw - absorbed;
          log.push(`${e.name} uses ${move.label} for ${raw}.`);
          if (retaliate > 0 && e.hp > 0) {
            e.hp -= retaliate;
            log.push(`Riposte wounds ${e.name} for ${retaliate}.`);
          }
          if (heroHp <= 0) break;
        }
      } else if (move.kind === "block" && move.block) {
        e.block += move.block;
        log.push(`${e.name} hardens (+${move.block} block).`);
      } else if (move.kind === "buff" && move.strength) {
        e.strength += move.strength;
        log.push(`${e.name} swells with power.`);
      } else if (move.kind === "hex" && move.poison) {
        poison += move.poison;
        log.push(`${e.name} hexes you: ${move.poison} poison.`);
      }
      e.moveIdx = (e.moveIdx + 1) % e.moves.length;
    }

    const over: null | "win" | "lose" = heroHp <= 0
      ? "lose"
      : enemies.every((enemy) => enemy.hp <= 0)
      ? "win"
      : null;

    return {
      ...state,
      enemies,
      heroHp: Math.max(0, heroHp),
      block,
      evade,
      poison,
      log: [...state.log, ...log].slice(-6),
      over,
    };
  }

  function resolveEndOfEnemyTurn(state: CombatState): CombatState {
    const log: string[] = [];
    const enemies = state.enemies.map((e) => ({ ...e }));
    let heroHp = state.heroHp;
    let poison = state.poison;

    for (const e of enemies) {
      if (e.poison > 0 && e.hp > 0) {
        e.hp -= e.poison;
        log.push(`${e.name} suffers ${e.poison} poison.`);
        e.poison -= 1;
        if (e.hp <= 0) log.push(`${e.name} succumbs.`);
      }
    }
    if (poison > 0 && heroHp > 0) {
      heroHp -= poison;
      log.push(`Poison burns for ${poison}.`);
      poison -= 1;
    }

    const over: null | "win" | "lose" = heroHp <= 0
      ? "lose"
      : enemies.every((e) => e.hp <= 0)
      ? "win"
      : null;

    return {
      ...state,
      enemies,
      heroHp: Math.max(0, heroHp),
      poison,
      retaliate: 0,
      log: [...state.log, ...log].slice(-6),
      over,
    };
  }

  function animateEnemyMove(enemyIndex: number, move: EnemyMoveDef) {
    if (prefersReducedMotion()) {
      return Promise.resolve();
    }

    const enemySelector = `[data-enemy-index="${enemyIndex}"]`;
    const targetSelector = move.kind === "attack" || move.kind === "hex"
      ? "[data-combat-hero]"
      : enemySelector;
    const source = document.querySelector(enemySelector)
      ?.getBoundingClientRect();
    const targetRect = document.querySelector(targetSelector)
      ?.getBoundingClientRect();
    const visual = ENEMY_MOVE_VISUALS[move.kind];

    if (source && targetRect) {
      setCombatEffects((current) =>
        [
          ...current,
          {
            id: ++combatEffectKey,
            art: visual.art,
            type: visual.type,
            sourceX: source.left + source.width / 2 - 32,
            sourceY: source.top + source.height / 2 - 32,
            targetX: targetRect.left + targetRect.width / 2 - 32,
            targetY: targetRect.top + targetRect.height / 2 - 32,
            targetSelector,
            aoe: false,
          },
        ].slice(-8)
      );
    }

    const enemy = document.querySelector(enemySelector);
    if (!enemy) return Promise.resolve();
    const horizontalDirection = targetRect && source
      ? Math.sign(targetRect.left - source.left) || -1
      : -1;

    return new Promise<void>((resolve) => {
      animate(enemy, {
        translateX: move.kind === "attack"
          ? [0, horizontalDirection * 30, 0]
          : [0, 0],
        translateY: move.kind === "hex" ? [0, -10, 0] : [0, 0],
        rotate: move.kind === "block"
          ? [0, -3, 3, 0]
          : move.kind === "buff"
          ? [0, -2, 2, 0]
          : [0, 0],
        scale: move.kind === "block" || move.kind === "buff"
          ? [1, 1.08, 1]
          : [1, 1.03, 1],
        filter: ["brightness(1)", "brightness(1.45)", "brightness(1)"],
        duration: 620,
        ease: "inOut(3)",
        onComplete: (animation) => {
          utils.cleanInlineStyles(animation);
          resolve();
        },
      });
    });
  }

  async function endTurn() {
    if (!combat || combat.over || enemyTurn) return;
    setEnemyTurn(true);
    let state: CombatState = {
      ...combat,
      hand: [],
      discard: [...combat.discard, ...combat.hand],
      enemies: combat.enemies.map((enemy) => ({ ...enemy, block: 0 })),
    };
    setCombat(state);

    for (let i = 0; i < state.enemies.length; i++) {
      const enemy = state.enemies[i];
      if (enemy.hp <= 0) continue;
      const move = enemy.moves[enemy.moveIdx % enemy.moves.length];
      await animateEnemyMove(i, move);
      state = resolveEnemyMove(state, i);
      setCombat(state);
      if (state.over) break;
    }

    if (!state.over) {
      state = resolveEndOfEnemyTurn(state);
      setCombat(state);
    }
    if (state.over) {
      setCombat({ ...state, energy: 0 });
      setEnemyTurn(false);
      finishCombat(state.over === "win", state.heroHp);
      return;
    }
    state = {
      ...state,
      turn: state.turn + 1,
      energy: state.maxEnergy,
      block: 0,
    };
    state = drawCards(state, state.handSize);
    setCombat(state);
    setTarget(0);
    setEnemyTurn(false);
  }

  function finishCombat(won: boolean, heroHp: number) {
    if (!run) return;
    if (!won) {
      endRun("defeat");
      return;
    }
    const depth = run.floorIdx;
    const kind = run.floors[run.floorIdx][0].kind;
    const base = kind === "boss"
      ? 40 + 10 * depth
      : kind === "elite"
      ? 25 + 6 * depth
      : 10 + 4 * depth;
    // Wounds carry over to the next battle; only rests restore vitality.
    setRun((r) =>
      r
        ? {
          ...r,
          animaGained: r.animaGained + base,
          heroHp: Math.max(0, Math.min(maxHp, heroHp)),
        }
        : r
    );
    setRewardAnima(base);
    setRewardOpen(true);
  }

  function takeReward() {
    if (rewardClosing.current) return;
    const box = document.querySelector("[data-reward-box]");
    if (!box || prefersReducedMotion()) {
      setRewardOpen(false);
      advanceFloor();
      return;
    }
    rewardClosing.current = true;
    animate(box, {
      opacity: [1, 0],
      scale: [1, 0.94],
      translateY: [0, -20],
      duration: 220,
      ease: "in(2)",
      onComplete: () => {
        rewardClosing.current = false;
        setRewardOpen(false);
        advanceFloor();
      },
    });
  }

  const hpBar = (hp: number, max: number) => (
    <progress
      class={`progress w-full ${
        hp / max < 0.3 ? "progress-error" : "progress-success"
      }`}
      value={hp}
      max={max}
    />
  );

  const inTown = [
    "city",
    "keep",
    "arcana",
    "deck",
    "deckBuilder",
    "pack",
    "sanctum",
  ].includes(displayScreen);
  const inDungeon = displayScreen === "map" || displayScreen === "combat";

  const nav = (
    <div class="navbar rivets min-h-16 border-b-0 px-3 sm:px-4">
      <div class="navbar-start">
        <span class="font-gothic tracking-[0.18em] text-accent text-lg sm:text-xl">
          Profectus
        </span>
      </div>
      <div class="navbar-center">
        {inTown
          ? (
            <div class="hero-bar flex items-center gap-2 sm:gap-3">
              <div class="avatar">
                <div class="shield-mount size-10 sm:size-12">
                  <img
                    src={klass.art}
                    alt=""
                    class={`shield mystic-class-icon mystic-class-${klass.id}`}
                  />
                </div>
              </div>
              <div class="min-w-0 pr-1">
                <p class="font-display truncate text-sm tracking-[0.08em] text-accent sm:text-base">
                  {initialHero.name}
                </p>
                <p class="text-[0.5rem] uppercase tracking-[0.18em] text-base-content/60">
                  Level {level}
                </p>
              </div>
              <span class="pillar-rule h-8" aria-hidden="true" />
              <div class="hidden items-center gap-3 sm:flex">
                <div class="text-center">
                  <p class="font-display text-sm text-primary">{maxHp}</p>
                  <p class="text-[0.45rem] uppercase tracking-wider text-base-content/55">
                    Vigor
                  </p>
                </div>
                <div class="text-center">
                  <p class="font-display text-sm text-secondary">
                    {heroMaxEnergy}
                  </p>
                  <p class="text-[0.45rem] uppercase tracking-wider text-base-content/55">
                    Energy
                  </p>
                </div>
                <div class="text-center">
                  <p class="font-display text-sm text-accent">{deck.length}</p>
                  <p class="text-[0.45rem] uppercase tracking-wider text-base-content/55">
                    Cards
                  </p>
                </div>
              </div>
            </div>
          )
          : run && inDungeon && (
            <span class="text-xs uppercase tracking-[0.3em] text-base-content/65">
              {klass.name} · Floor {run.floorIdx + 1}/{MAP_FLOORS}
            </span>
          )}
      </div>
      <div class="navbar-end gap-2">
        <span class="badge badge-sm anima-coin font-bold whitespace-nowrap">
          {anima} ✦
        </span>
        {isAdmin && (
          <a
            href="/admin"
            class="btn btn-ghost btn-sm hidden sm:inline-flex"
            title="Administration"
          >
            Admin
          </a>
        )}
        <a href="/logout" class="btn btn-ghost btn-sm hidden sm:inline-flex">
          Log out
        </a>
      </div>
    </div>
  );

  function renderScreen(current: Screen) {
    if (current === "city") {
      return (
        <div class="h-dvh overflow-hidden flex flex-col">
          {nav}
          <main class="city-screen tapestry flex min-h-0 flex-1 flex-col px-3 py-2 sm:px-5 sm:py-3">
            {message && (
              <div
                role="status"
                class="alert alert-success alert-soft mx-auto max-w-md py-2 text-sm"
              >
                <span>{message}</span>
              </div>
            )}

            <div class="cityscape stone-wall mx-auto min-h-0 w-full max-w-6xl flex-1">
              <div class="city-title" aria-hidden="true">
                <strong>Aetherkeep</strong>
                <span class="city-title-rule" />
              </div>
              <button
                type="button"
                class="city-building city-keep"
                onClick={() => setScreen("keep")}
                aria-label="Enter Ascendant Keep"
              >
                <img src={keepArt} alt="" />
                <span>Ascendant Keep</span>
                {points.attribute > 0 && (
                  <i class="badge badge-secondary badge-sm pennant">
                    {points.attribute}
                  </i>
                )}
              </button>

              <button
                type="button"
                class="city-building city-arcana"
                onClick={() => setScreen("arcana")}
                aria-label="Enter Arcana Hall"
              >
                <img src={archiveArt} alt="" />
                <span>Arcana Hall</span>
              </button>

              <button
                type="button"
                class="city-building city-sanctum"
                onClick={() => setScreen("sanctum")}
                aria-label="Enter Moon Sanctum"
              >
                <img src={sanctumArt} alt="" />
                <span>Moon Sanctum</span>
              </button>

              <button
                type="button"
                class="city-building city-gate"
                onClick={startRun}
                aria-label="Leave through the Dungeon Gate"
              >
                <img src={gateArt} alt="" />
                <span>Dungeon Gate</span>
              </button>
            </div>
          </main>
        </div>
      );
    }

    if (current === "keep") {
      return (
        <div class="h-dvh flex flex-col overflow-hidden">
          {nav}
          <ProgressionScreen
            anima={anima}
            level={level}
            levelCost={levelCost}
            progression={progression}
            onLevelUp={levelUp}
            onImproveVigor={improveVigor}
            onLearnSkill={learnSkill}
            onClose={() => setScreen("city")}
          />
        </div>
      );
    }

    if (current === "arcana") {
      return (
        <div class="h-dvh flex flex-col overflow-hidden">
          {nav}
          <ArcanaHallScreen
            deckSize={deck.length}
            collectionSize={Object.values(collection).reduce(
              (total, quantity) => total + quantity,
              0,
            )}
            onViewDeck={() => setScreen("deck")}
            onBuildDeck={() => {
              setFeatureError(null);
              setScreen("deckBuilder");
            }}
            onBuyPack={() => {
              setFeatureError(null);
              setOpenedPack(null);
              setScreen("pack");
            }}
            onClose={() => setScreen("city")}
          />
        </div>
      );
    }

    if (current === "deck") {
      return (
        <div class="h-dvh flex flex-col overflow-hidden">
          {nav}
          <DeckScreen deck={deck} onClose={() => setScreen("arcana")} />
        </div>
      );
    }

    if (current === "deckBuilder") {
      return (
        <div class="h-dvh flex flex-col overflow-hidden">
          {nav}
          <DeckBuilderScreen
            deck={deck}
            collection={collection}
            saving={savingDeck}
            error={featureError}
            onSave={saveDeck}
            onClose={() => setScreen("arcana")}
          />
        </div>
      );
    }

    if (current === "pack") {
      return (
        <div class="h-dvh flex flex-col overflow-hidden">
          {nav}
          <PackScreen
            anima={anima}
            opened={openedPack}
            buying={buyingPack}
            error={featureError}
            onBuy={buyPack}
            onClose={() => setScreen("arcana")}
          />
        </div>
      );
    }

    if (current === "sanctum") {
      return (
        <div class="h-dvh flex flex-col overflow-hidden">
          {nav}
          <SanctumScreen
            deck={deck}
            cost={upgradeCost}
            affordable={anima >= upgradeCost}
            onUpgrade={upgradeCard}
            onClose={() => setScreen("city")}
          />
        </div>
      );
    }

    if (current === "map" && run) {
      const floor = run.floors[run.floorIdx];
      return (
        <div class="tapestry min-h-screen flex flex-col">
          {nav}
          <main class="relative z-1 flex-1 flex flex-col items-center justify-center px-4 py-10">
            <p class="text-xs uppercase tracking-[0.4em] text-base-content/60">
              The branching path
            </p>
            <div class="fleuron-rule my-5 w-64">
              <span aria-hidden="true">☾ ✦ ☽</span>
            </div>

            <div class="flex flex-wrap items-center justify-center gap-3 mb-8">
              <span class="font-heading text-sm uppercase tracking-[0.2em] text-base-content/70">
                Vitality
              </span>
              <div class="gauge-frame iron-band w-40">
                {hpBar(run.heroHp, maxHp)}
              </div>
              <span class="text-sm text-accent font-bold">{run.heroHp}</span>
              {run.animaGained > 0 && (
                <span class="badge badge-secondary badge-sm pennant">
                  +{run.animaGained} anima this run
                </span>
              )}
            </div>

            <div class="grid gap-6 sm:grid-cols-2 max-w-2xl w-full">
              {floor.map((node, i) => (
                <button
                  type="button"
                  key={i}
                  class="path-choice arch-panel stone-wall card transition-all hover:-translate-y-1"
                  onClick={() => enterNode(node)}
                >
                  <div class="corner-fleurons card-body items-center text-center py-8">
                    <span class="shield node-medallion" aria-hidden="true">
                      {NODE_GLYPH[node.kind]}
                    </span>
                    <h2 class="font-heading uppercase tracking-[0.25em] mt-2 text-accent">
                      {NODE_LABEL[node.kind]}
                    </h2>
                    <p class="text-xs text-base-content/70">
                      {node.kind === "fight" &&
                        "A card battle against dungeon denizens."}
                      {node.kind === "elite" &&
                        "A greater foe — greater spoils."}
                      {node.kind === "treasure" && "Anima waits in the dark."}
                      {node.kind === "rest" && "Restore your vitality."}
                      {node.kind === "boss" && "The capstone of this delve."}
                    </p>
                  </div>
                </button>
              ))}
            </div>

            <button
              type="button"
              class="btn btn-ghost btn-sm mt-10 text-base-content/60"
              onClick={() => endRun("retreat")}
            >
              Retreat to the city (keep all spoils)
            </button>
          </main>
        </div>
      );
    }

    if (current === "combat" && combat && run) {
      const intentLabel = (e: CombatEnemy) => {
        const move = e.moves[e.moveIdx % e.moves.length];
        if (move.kind === "attack" && move.damage) {
          const dmg = Math.max(
            1,
            Math.round((move.damage + e.strength) * (e.weak > 0 ? 0.75 : 1)),
          );
          return `${move.label} · ${dmg}${
            (move.hits ?? 1) > 1 ? `×${move.hits}` : ""
          }`;
        }
        return move.label;
      };

      return (
        <div class="tapestry h-dvh overflow-hidden flex flex-col">
          {nav}
          <main class="flex-1 min-h-0 flex flex-col">
            <div class="combat-effects" aria-hidden="true">
              {combatEffects.map((effect) => (
                <div
                  key={effect.id}
                  data-combat-effect={effect.id}
                  class={`combat-effect combat-effect-${effect.type} ${
                    effect.aoe ? "combat-effect-aoe" : ""
                  }`}
                  style={`left:${effect.sourceX}px;top:${effect.sourceY}px`}
                >
                  <span />
                  <img src={effect.art} alt="" />
                </div>
              ))}
            </div>
            <div class="flex-1 min-h-0 flex flex-col items-stretch gap-3 px-4 py-4">
              <div class="battlefield flex min-w-0 min-h-0 flex-1 flex-col items-center justify-center gap-6 md:flex-row md:gap-0">
                <span class="sconce left-8 top-1/3" aria-hidden="true" />
                <span class="sconce right-8 top-1/3" aria-hidden="true" />
                <div class="flex min-w-0 flex-1 justify-center md:justify-end md:pr-10">
                  <div
                    data-combat-hero
                    class="combat-hero flex flex-col items-center gap-2 w-52"
                  >
                    <span class="shield-mount w-24">
                      <img
                        src={klass.art}
                        alt=""
                        class={`shield mystic-class-icon mystic-class-${klass.id}`}
                      />
                    </span>
                    <h3 class="font-heading text-sm uppercase tracking-[0.2em] text-accent">
                      {initialHero.name}
                    </h3>
                    <div class="gauge-frame iron-band w-full">
                      {hpBar(combat.heroHp, combat.heroMaxHp)}
                    </div>
                    <div class="status-badges flex flex-wrap content-start justify-center gap-1">
                      <span class="badge badge-sm badge-success">
                        {combat.heroHp}/{combat.heroMaxHp}
                      </span>
                      {combat.block > 0 && (
                        <span class="badge badge-sm badge-info">
                          🛡 {combat.block}
                        </span>
                      )}
                      {combat.strength > 0 && (
                        <span class="badge badge-sm badge-warning">
                          ↑ {combat.strength}
                        </span>
                      )}
                      {combat.poison > 0 && (
                        <span class="badge badge-sm badge-secondary">
                          ☠ {combat.poison}
                        </span>
                      )}
                      {combat.evade > 0 && (
                        <span class="badge badge-sm badge-ghost">
                          ~ {combat.evade}
                        </span>
                      )}
                      {combat.retaliate > 0 && (
                        <span class="badge badge-sm badge-error">
                          ⇄ {combat.retaliate}
                        </span>
                      )}
                    </div>
                  </div>
                </div>

                <div class="flex min-w-0 flex-1 justify-center md:justify-start md:pl-10">
                  <div class="combat-enemies flex flex-row flex-wrap justify-center gap-6">
                    {combat.enemies.map((e, i) => (
                      <button
                        type="button"
                        key={e.key}
                        data-enemy-index={i}
                        disabled={e.hp <= 0}
                        onClick={() => setTarget(i)}
                        class={`enemy-plate flex flex-col items-center gap-2 w-44 p-3 border transition-all ${
                          e.hp <= 0
                            ? "opacity-30 border-base-content/10"
                            : i === target
                            ? "enemy-plate-target"
                            : "border-base-content/25 hover:border-accent/60"
                        }`}
                      >
                        <img
                          src={e.art}
                          alt=""
                          class={`enemy-art h-16 w-16 ${
                            e.hp <= 0 ? "opacity-40" : ""
                          }`}
                        />
                        <h4 class="font-heading text-xs uppercase tracking-[0.2em]">
                          {e.name}
                        </h4>
                        <div class="gauge-frame iron-band w-full">
                          {hpBar(e.hp, e.maxHp)}
                        </div>
                        <div class="status-badges flex flex-wrap content-start justify-center gap-1">
                          <span class="badge badge-sm badge-success">
                            {Math.max(0, e.hp)}/{e.maxHp}
                          </span>
                          {e.block > 0 && (
                            <span class="badge badge-sm badge-info">
                              🛡 {e.block}
                            </span>
                          )}
                          {e.poison > 0 && (
                            <span class="badge badge-sm badge-secondary">
                              ☠ {e.poison}
                            </span>
                          )}
                          {e.weak > 0 && (
                            <span class="badge badge-sm badge-ghost">
                              ↓ {e.weak}
                            </span>
                          )}
                        </div>
                        <span
                          class={`badge badge-sm badge-outline h-auto gap-1 border-accent/50 px-2 py-1 text-[0.6rem] text-accent/90 ${
                            e.hp <= 0 ? "invisible" : ""
                          }`}
                        >
                          <img
                            src={ENEMY_MOVE_VISUALS[
                              e.moves[e.moveIdx % e.moves.length].kind
                            ].art}
                            alt=""
                            class="h-3.5 w-3.5"
                          />
                          <span>{intentLabel(e)}</span>
                        </span>
                      </button>
                    ))}
                  </div>
                </div>
              </div>

              <aside class="chronicle shrink-0 mx-auto w-full max-w-3xl px-3 py-1.5">
                <div class="flex items-center justify-between gap-3">
                  <h3 class="font-heading text-[0.6rem] uppercase tracking-[0.18em] text-accent">
                    Fight history
                  </h3>
                  <span class="text-[0.55rem] uppercase tracking-[0.16em] text-base-content/50">
                    Earlier → Latest
                  </span>
                </div>
                <details class="collapse collapse-arrow min-h-0">
                  <summary class="collapse-title min-h-0 px-0 py-1 text-[0.6rem] text-base-content/50">
                    {combat.log.length > 2
                      ? `${combat.log.length - 2} earlier events`
                      : "No earlier events"}
                  </summary>
                  <div class="collapse-content overflow-x-auto px-0 pb-1">
                    <div class="flex min-w-max items-center gap-2">
                      {combat.log.slice(0, -2).map((entry, index) => (
                        <div
                          key={`${index}-${entry}`}
                          class="flex items-center gap-2"
                        >
                          <span class="badge badge-ghost badge-xs">
                            {index + 1}
                          </span>
                          <span class="max-w-52 text-[0.65rem] text-base-content/60">
                            {entry}
                          </span>
                          <span class="text-primary/45" aria-hidden="true">
                            →
                          </span>
                        </div>
                      ))}
                    </div>
                  </div>
                </details>
                <div
                  class="chronicle-latest flex items-center justify-end gap-2 overflow-hidden py-1"
                  aria-live="polite"
                >
                  {combat.log.slice(-2).map((entry, index) => {
                    const eventNumber = combat.log.length -
                      Math.min(2, combat.log.length) + index + 1;
                    return (
                      <div
                        key={`${eventNumber}-${entry}`}
                        class="flex min-w-0 items-center gap-2"
                      >
                        {index > 0 && (
                          <span
                            class="shrink-0 text-primary/45"
                            aria-hidden="true"
                          >
                            →
                          </span>
                        )}
                        <span class="badge badge-primary badge-xs shrink-0">
                          {eventNumber}
                        </span>
                        <span class="max-w-64 text-[0.65rem] leading-tight text-base-content/65">
                          {entry}
                        </span>
                      </div>
                    );
                  })}
                </div>
              </aside>
            </div>

            <div class="hand-rail rivets shrink-0 px-4 pt-3 pb-3">
              <div class="flex items-center justify-between max-w-4xl mx-auto mb-1">
                <div class="flex items-center gap-2">
                  <span
                    class="radial-progress text-accent text-xs"
                    style={`--value:${
                      Math.round(
                        (combat.energy / combat.maxEnergy) * 100,
                      )
                    }; --size:2.6rem; --thickness:3px`}
                  >
                    {combat.energy}
                  </span>
                  <span class="text-[0.65rem] uppercase tracking-[0.25em] text-base-content/65">
                    Energy · Turn {combat.turn}
                  </span>
                </div>
                <button
                  type="button"
                  class="btn btn-primary iron-band btn-sm tracking-[0.2em] uppercase font-heading"
                  disabled={combat.over !== null || enemyTurn}
                  onClick={endTurn}
                >
                  {enemyTurn ? "Enemies act..." : "End turn"}
                </button>
              </div>

              <div class="card-fan max-w-4xl mx-auto">
                {combat.hand.map((card, i) => {
                  const mid = (combat.hand.length - 1) / 2;
                  const offset = i - mid;
                  return (
                    <div
                      key={card.uid}
                      data-card-uid={card.uid}
                      class="card-fan-flip"
                    >
                      <div
                        class="card-fan-slot combat-card-slot"
                        style={`--rot:${offset * 5}deg; --lift:${
                          Math.abs(offset) * 6
                        }px`}
                      >
                        <CardFace
                          id={card.def.id}
                          extraClass={cardDef(card.def.id).cost <= combat.energy
                            ? ""
                            : "opacity-50"}
                          onClick={enemyTurn ? undefined : () => playCard(i)}
                        />
                      </div>
                    </div>
                  );
                })}
              </div>
              <div class="flex justify-center gap-6 mt-1 text-[0.6rem] uppercase tracking-[0.25em] text-base-content/55">
                <span>Draw pile {combat.draw.length}</span>
                <span>Discard pile {combat.discard.length}</span>
              </div>
            </div>
          </main>

          {rewardOpen && (
            <dialog class="modal modal-open modal-middle reward-modal">
              <div
                data-reward-box
                class="modal-box reward-scroll vellum w-full max-w-md text-center"
              >
                <p class="text-xs uppercase tracking-[0.4em] text-base-content/60">
                  Spoils of battle
                </p>
                <h3 class="illuminated font-gothic text-4xl tracking-[0.1em] text-accent mt-3">
                  +{rewardAnima} ANIMA
                </h3>
                <div class="fleuron-rule my-5">
                  <span aria-hidden="true">☾ ✦ ☽</span>
                </div>
                <p class="text-sm text-base-content/75">
                  Cards are won only from packs in the city. Onward.
                </p>
                <div class="modal-action justify-center">
                  <button
                    type="button"
                    class="btn btn-primary iron-band tracking-[0.2em] uppercase font-heading"
                    onClick={takeReward}
                  >
                    Continue
                  </button>
                </div>
              </div>
              <div class="modal-backdrop" />
            </dialog>
          )}
        </div>
      );
    }

    if (current === "summary") {
      return (
        <div class="tapestry margin-vines min-h-screen flex flex-col">
          {nav}
          <main class="relative z-1 flex-1 flex flex-col items-center justify-center px-4 py-10">
            <div class="arch-panel vellum w-full max-w-xl px-6 pb-8 flex flex-col items-center text-center">
              <p class="text-xs uppercase tracking-[0.4em] text-base-content/60">
                Return to the city
              </p>
              <h1 class="illuminated font-gothic text-4xl tracking-[0.1em] text-accent mt-3">
                {outcome === "victory"
                  ? "TRIUMPH"
                  : outcome === "defeat"
                  ? "THE DARK CLAIMS YOU — FOR NOW"
                  : "A PRUDENT RETREAT"}
              </h1>
              <div class="fleuron-rule my-6 w-full">
                <span aria-hidden="true">☾ ✦ ☽</span>
              </div>
              <p class="text-sm text-base-content/75 max-w-md text-center">
                {outcome === "defeat"
                  ? "No penalty, no loss. Your spoils are safe — grow stronger and return."
                  : "Your spoils are banked. The city always welcomes a returning hero."}
              </p>
              <button
                type="button"
                class="btn btn-primary iron-band mt-8 tracking-[0.2em] uppercase font-heading"
                onClick={() => {
                  setScreen("city");
                  setOutcome("retreat");
                }}
              >
                Enter the city
              </button>
            </div>
          </main>
        </div>
      );
    }

    return null;
  }

  return (
    <div
      ref={stageRef}
      class={`screen-stage${leaving ? " is-leaving" : ""}`}
    >
      {renderScreen(displayScreen)}
    </div>
  );
}