// Frostcall Arena combat simulation: pure TypeScript, no three.js. All timers run on sim time (fixed 1/60 s steps).
import { ABILITIES, ABILITY_BY_ID, ARENA, DASH, ENEMIES, ENEMY_ABILITIES, ENEMY_GCD, PLAYER, QUEUE, ROUNDS, SCHOOL_NAME, SOLIDS, SPELLNUM, newStats, type Ability, type EnemyKind, type RunStats, type School, PILLARS, RESOURCE, type ResourceKind } from "../data";

export type CC = "root" | "stun" | "incapacitate" | "disorient" | "silence";
export type AuraKind = "slow" | "root" | "poly" | "stun" | "fear" | "silence" | "absorb" | "immune" | "dot" | "enrage" | "wall";
export type UnitKind = "player" | EnemyKind;

export interface Aura {
  id: string;
  kind: AuraKind;
  name: string;
  left: number;
  total: number;
  amount: number;
  src: string;
  harmful: boolean;
  tick: number;
}

export interface CastState {
  id: string;
  name: string;
  school: School;
  left: number;
  total: number;
  interruptible: boolean;
  kind: "cast" | "tell" | "channel";
  ox: number;
  oz: number;
}

export interface Unit {
  id: string;
  kind: UnitKind;
  name: string;
  x: number;
  z: number;
  yaw: number;
  hp: number;
  maxHp: number;
  mana: number;
  maxMana: number;
  auras: Aura[];
  cast: CastState | null;
  lock: Partial<Record<School, number>>;
  dr: Record<CC, { stage: number; left: number }>;
  dead: boolean;
  deadT: number;
  boss: boolean;
  speed: number;
  gcd: number;
  cds: Record<string, number>;
  cdMult: number;
  dmgMult: number;
  think: number;
  swingCd: number;
  charging: { dx: number; dz: number; left: number } | null;
  chargeLine: { x0: number; z0: number; x1: number; z1: number } | null;
  usedBarrier: boolean;
  usedWall: boolean;
  usedEnrage: boolean;
  whirl: number;
  moving: boolean;
  /** Seconds of invulnerability left (the player dodge). */
  iframe: number;
  /** The dodge burst in flight (player only). */
  dash: { dx: number; dz: number; left: number } | null;
  /** An enemy leap: locked landing spot, flight time left. */
  leap: { x0: number; z0: number; x1: number; z1: number; left: number; total: number } | null;
  leapTo: { x: number; z: number } | null;
}

export interface Proj {
  id: number;
  kind: "frostbolt" | "icelance" | "fireball" | "shadowbolt";
  src: string;
  dst: string;
  x: number;
  z: number;
  y: number;
  speed: number;
  dmg: number;
  school: School;
}

export interface Orb {
  x: number;
  z: number;
  dx: number;
  dz: number;
  dist: number;
  pulse: number;
  age: number;
}

/** The player's Blizzard: a ground area that lives as long as the channel does. */
export interface Storm {
  x: number;
  z: number;
  radius: number;
  left: number;
  total: number;
  tick: number;
}

export interface Coin {
  id: number;
  x: number;
  z: number;
  age: number;
}

/**
 * Events (all carry `t`). New in the combat-feel pass, for the VFX / SFX workers to hook:
 *  - dash       { from:{x,z}, to:{x,z}, dx, dz, dur }          player starts the Dodge burst (i-frames start now)
 *  - dodge      { unit, src, what }                            an attack was avoided by i-frames ("what" = damage | stun | cc)
 *  - shatter    { unit, amount, x, z }                         Ice Lance hit a frozen target for the big number (follows the damage event)
 *  - enemyLeapTell { unit, from:{x,z}, to:{x,z}, tell }        warning: enemy will land on `to` after `tell` s (a ring should show there)
 *  - enemyLeap  { unit, from:{x,z}, to:{x,z}, dur }            the enemy is in the air for `dur` s
 *  - enemyLand  { unit, x, z, hit }                            enemy landed; `hit` = the player was caught
 *  - blizzardStart { x, z, radius, dur }                       the player placed Blizzard (channel starts; a storm should show there)
 *  - blizzardTick  { x, z, radius }                            one damage tick landed
 *  - blizzardEnd   { x, z, ok }                                channel over (ok=false: stopped early by moving, a stun or a kick)
 *  - fail       { msg, id? }                                   a press could not happen (id = the ability); queued presses do not fail
 *  - queued     { id }                                         a press was held for up to QUEUE.hold s and fires when ready
 *  - crit       damage events already carry `crit:true`; combo callouts arrive as `notice` events too
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type SimEvent = { t: string; [k: string]: any };
export type Phase = "idle" | "intro" | "fight" | "won" | "lost";

const CC_STAT: Record<CC, keyof RunStats["cc"]> = { root: "root", stun: "stun", incapacitate: "poly", disorient: "fear", silence: "silence" };
const DR_STAGES = [1, 0.5, 0.25];
const DR_RESET = 18;
const TAU = Math.PI * 2;

const dist = (ax: number, az: number, bx: number, bz: number) => Math.hypot(ax - bx, az - bz);
const wrap = (a: number) => {
  while (a > Math.PI) a -= TAU;
  while (a < -Math.PI) a += TAU;
  return a;
};
const turnToward = (cur: number, want: number, maxStep: number) => {
  const d = wrap(want - cur);
  return cur + Math.max(-maxStep, Math.min(maxStep, d));
};

function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** Shortest 2D distance between segment A-B and segment C-D (0 when they cross). Allocation-free. */
export function segSegDist(ax: number, az: number, bx: number, bz: number, cx: number, cz: number, dx: number, dz: number): number {
  const rx = bx - ax, rz = bz - az, sx = dx - cx, sz = dz - cz;
  const den = rx * sz - rz * sx;
  if (Math.abs(den) > 1e-9) {
    const t = ((cx - ax) * sz - (cz - az) * sx) / den;
    const u = ((cx - ax) * rz - (cz - az) * rx) / den;
    if (t >= 0 && t <= 1 && u >= 0 && u <= 1) return 0;
  }
  return Math.min(ptSegDist(ax, az, cx, cz, dx, dz), ptSegDist(bx, bz, cx, cz, dx, dz), ptSegDist(cx, cz, ax, az, bx, bz), ptSegDist(dx, dz, ax, az, bx, bz));
}
export function ptSegDist(px: number, pz: number, ax: number, az: number, bx: number, bz: number): number {
  const vx = bx - ax, vz = bz - az;
  const l2 = vx * vx + vz * vz;
  const t = l2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * vx + (pz - az) * vz) / l2));
  return Math.hypot(ax + vx * t - px, az + vz * t - pz);
}

/** Index of the first solid (pillar, column, wall, block) the 2D segment passes through, -1 = clear. `extra` thickens every solid. */
export function segmentHitsPillar(ax: number, az: number, bx: number, bz: number, extra = 0): number {
  for (let i = 0; i < SOLIDS.length; i++) {
    const s = SOLIDS[i];
    if (segSegDist(ax, az, bx, bz, s.x1, s.z1, s.x2, s.z2) < s.r + extra) return i;
  }
  return -1;
}

/** Index of the solid a point is inside (thickened by `extra`), -1 = free. */
export function pointInSolid(x: number, z: number, extra = 0): number {
  for (let i = 0; i < SOLIDS.length; i++) {
    const s = SOLIDS[i];
    if (ptSegDist(x, z, s.x1, s.z1, s.x2, s.z2) < s.r + extra) return i;
  }
  return -1;
}

export const hasLOS = (a: { x: number; z: number }, b: { x: number; z: number }) => segmentHitsPillar(a.x, a.z, b.x, b.z) < 0;

export class Sim {
  time = 0;
  phase: Phase = "idle";
  phaseT = 0;
  round = 1;
  units: Unit[] = [];
  player!: Unit;
  targetId: string | null = null;
  projs: Proj[] = [];
  orb: Orb | null = null;
  storm: Storm | null = null;
  /** WoW-style spell queue: one held press that fires when the cast or cooldown it waits for is done. */
  /** Enemy resource bars by unit id (see resourceOf). */
  private res = new Map<string, { kind: ResourceKind; v: number; max: number }>();
  private queued: { code: string; gx?: number; gz?: number; left: number } | null = null;
  /** Reason of the last refused press (the game uses it to keep the reticle up after "Out of range"). */
  lastFail = "";
  coins: Coin[] = [];
  events: SimEvent[] = [];
  stats: RunStats = newStats();
  private pid = 1;
  private cid = 1;
  private rng = mulberry32(739232857);
  private wonT = 0;

  constructor() {
    this.player = this.makeUnit("player", ARENA.playerStart.x, ARENA.playerStart.z);
  }

  // ------------------------------------------------------------------ setup

  private makeUnit(kind: UnitKind, x: number, z: number): Unit {
    const def = kind === "player" ? null : ENEMIES[kind];
    const dr = { stage: 0, left: 0 };
    return {
      id: kind === "player" ? "player" : `${kind}-${this.pid++}`,
      kind,
      name: def ? def.name : "Frost Mage",
      x,
      z,
      yaw: kind === "player" ? Math.PI : 0,
      hp: def ? def.hp : PLAYER.hp,
      maxHp: def ? def.hp : PLAYER.hp,
      mana: PLAYER.mana,
      maxMana: PLAYER.mana,
      auras: [],
      cast: null,
      lock: {},
      dr: { root: { ...dr }, stun: { ...dr }, incapacitate: { ...dr }, disorient: { ...dr }, silence: { ...dr } },
      dead: false,
      deadT: 0,
      boss: def ? def.boss : false,
      speed: def ? def.speed : PLAYER.walk,
      gcd: 0,
      cds: {},
      cdMult: 1,
      dmgMult: 1,
      think: 0.25,
      swingCd: 1,
      charging: null,
      chargeLine: null,
      usedBarrier: false,
      usedWall: false,
      usedEnrage: false,
      whirl: 0,
      moving: false,
      iframe: 0,
      dash: null,
      leap: null,
      leapTo: null,
    };
  }

  /** Every system resets here: player, enemies, projectiles, orb, coins, stats, cooldowns. */
  startRound(n: number) {
    this.round = n;
    this.units = [];
    this.res.clear();
    this.projs = [];
    this.orb = null;
    this.storm = null;
    this.queued = null;
    this.coins = [];
    this.events = [];
    this.stats = newStats();
    this.targetId = null;
    this.rng = mulberry32(739232857 + n * 101);
    const p = this.makeUnit("player", ARENA.playerStart.x, ARENA.playerStart.z);
    this.player = p;
    this.units.push(p);
    const def = ROUNDS[n - 1];
    def.enemies.forEach((k, i) => {
      const spread = def.enemies.length > 1 ? (i === 0 ? -2 : 2) : 0;
      const u = this.makeUnit(k, spread, ARENA.gateNorth.z - 1.5);
      u.yaw = 0;
      if (n === 1) {
        u.cdMult = 1.5; // round 1: Charge, Hamstring and stun cooldowns x1.5, damage x0.8
        u.dmgMult = 0.8;
      }
      if (n === 4) u.dmgMult = 0.85; // two attackers: each hits a little softer
            if (k === "warrior") u.cds = { charge: 1.5, leap: 4 };
      if (k === "gladiator") u.cds = { leap: 3 };
      this.units.push(u);
    });
    this.phase = "intro";
    this.phaseT = 0;
    this.wonT = 0;
    this.emit({ t: "roundStart", round: n });
  }

  private emit(e: SimEvent) {
    this.events.push(e);
  }

  drain(): SimEvent[] {
    const e = this.events;
    this.events = [];
    return e;
  }

  get enemies(): Unit[] {
    return this.units.filter((u) => u.kind !== "player");
  }
  get alive(): Unit[] {
    return this.enemies.filter((u) => !u.dead);
  }
  unit(id: string | null): Unit | null {
    return id ? this.units.find((u) => u.id === id) ?? null : null;
  }
  get target(): Unit | null {
    const t = this.unit(this.targetId);
    return t && !t.dead ? t : null;
  }

  // ------------------------------------------------------------------ auras, CC, damage

  has(u: Unit, k: AuraKind) {
    return u.auras.some((a) => a.kind === k);
  }
  aura(u: Unit, k: AuraKind) {
    return u.auras.find((a) => a.kind === k) ?? null;
  }
  private removeKind(u: Unit, k: AuraKind) {
    for (const a of u.auras.filter((x) => x.kind === k)) this.removeAura(u, a);
  }
  private removeAura(u: Unit, a: Aura) {
    u.auras = u.auras.filter((x) => x !== a);
    this.emit({ t: "auraOff", unit: u.id, aura: a });
  }
  addAura(u: Unit, a: Omit<Aura, "tick"> & { tick?: number }) {
    const old = u.auras.find((x) => x.id === a.id);
    if (old) u.auras = u.auras.filter((x) => x !== old);
    const full: Aura = { tick: 2, ...a };
    u.auras.push(full);
    this.emit({ t: "aura", unit: u.id, aura: full });
    return full;
  }

  canAct(u: Unit) {
    return !u.dead && !this.has(u, "stun") && !this.has(u, "poly") && !this.has(u, "fear");
  }
  canMove(u: Unit) {
    if (u.dead || this.has(u, "root") || this.has(u, "stun") || this.has(u, "poly")) return false;
    if (u.kind === "player" && this.has(u, "immune")) return false;
    return true;
  }
  /** 0..1 multiplier on ground speed from slows. */
  speedMult(u: Unit) {
    if (!this.canMove(u)) return 0;
    let m = 1;
    for (const a of u.auras) if (a.kind === "slow") m = Math.min(m, a.amount);
    if (u.kind === "player" && u.cast && u.cast.kind === "cast") m *= PLAYER.castMoveMult;
    return m;
  }
  fearFrom(): Unit | null {
    const a = this.aura(this.player, "fear");
    return a ? this.unit(a.src) : null;
  }

  /** Crowd control with immunity, boss rules and diminishing returns. Returns the real duration (0 = nothing applied). */
  applyCC(src: Unit | null, dst: Unit, cat: CC, dur: number, aura: Omit<Aura, "left" | "total" | "tick" | "harmful" | "src">): number {
    if (dst.dead) return 0;
    if (dst.iframe > 0 && src && src !== dst) {
      this.emit({ t: "dodge", unit: dst.id, src: src.id, what: "cc" });
      return 0;
    }
    if (this.has(dst, "immune")) {
      this.emit({ t: "immune", unit: dst.id, why: "Immune" });
      return 0;
    }
    if (cat === "incapacitate" && dst.boss) {
      this.emit({ t: "immune", unit: dst.id, why: "Immune" });
      return 0;
    }
    const d = dst.dr[cat];
    if (d.left <= 0) d.stage = 0;
    if (d.stage >= DR_STAGES.length) {
      this.emit({ t: "immune", unit: dst.id, why: "Immune (diminished)" });
      d.left = DR_RESET;
      return 0;
    }
    const real = dur * DR_STAGES[d.stage];
    d.stage++;
    d.left = DR_RESET;
    if (dst.cast && (cat === "stun" || cat === "incapacitate" || cat === "disorient")) this.cancelCast(dst, "Interrupted");
    this.addAura(dst, { ...aura, left: real, total: real, harmful: true, src: src ? src.id : "" });
    if (src && src.kind === "player") this.stats.cc[CC_STAT[cat]]++;
    this.emit({ t: "cc", unit: dst.id, cat, dur: real, by: src ? src.id : "" });
    return real;
  }

  dealDamage(src: Unit | null, dst: Unit, base: number, school: School, o: { crit?: boolean; dot?: boolean } = {}) {
    if (dst.dead || base <= 0) return 0;
    if (dst.iframe > 0 && src && src !== dst && !o.dot) {
      this.emit({ t: "dodge", unit: dst.id, src: src.id, what: "damage" });
      if (dst.kind === "player") this.emit({ t: "notice", msg: "Dodged!", tone: "frost" });
      return 0;
    }
    if (this.has(dst, "immune")) {
      this.emit({ t: "immune", unit: dst.id, why: "Immune" });
      return 0;
    }
    let amount = base;
    let crit = false;
    if (src) {
      amount *= src.dmgMult;
      if (this.has(src, "enrage")) amount *= 1.25;
      if (src.kind === "player" && o.crit && this.rng() < PLAYER.crit) {
        crit = true;
        amount *= PLAYER.critMult;
      }
    }
    if (this.has(dst, "wall")) amount *= 0.4;
    amount = Math.round(amount);
    let absorbed = 0;
    for (const a of dst.auras.filter((x) => x.kind === "absorb")) {
      const take = Math.min(a.amount, amount - absorbed);
      a.amount -= take;
      absorbed += take;
      if (a.amount <= 0) this.removeAura(dst, a);
      if (absorbed >= amount) break;
    }
    const real = amount - absorbed;
    dst.hp = Math.max(0, dst.hp - real);
    if (src && src.kind === "player") {
      this.stats.dmgDealt += real;
      if (crit) this.stats.crits++;
    }
    if (dst.kind === "player") this.stats.dmgTaken += real;
    this.emit({ t: "damage", src: src ? src.id : "", dst: dst.id, amount: real, absorbed, crit, school, dot: !!o.dot });
    if (real > 0 || absorbed > 0) {
      const poly = this.aura(dst, "poly");
      if (poly && real > 0) {
        this.removeAura(dst, poly);
        this.emit({ t: "polyBreak", unit: dst.id });
      }
    }
    this.resourceOnDamage(src, dst, real);
    if (dst.hp <= 0) this.kill(dst);
    return real;
  }

  heal(u: Unit, amount: number) {
    if (u.dead) return;
    const real = Math.min(amount, u.maxHp - u.hp);
    u.hp += real;
    this.emit({ t: "heal", unit: u.id, amount: Math.round(amount) });
  }

  private kill(u: Unit) {
    u.dead = true;
    u.deadT = 0;
    u.cast = null;
    u.charging = null;
    u.chargeLine = null;
    u.leap = null;
    u.leapTo = null;
    u.dash = null;
    u.auras = [];
    this.emit({ t: "kill", unit: u.id });
    if (u.kind !== "player") {
      for (let i = 0; i < 5; i++) this.dropCoin(u.x, u.z, 1.5);
      if (this.targetId === u.id) this.targetId = null;
    }
  }

  private dropCoin(x: number, z: number, spread: number) {
    if (this.coins.length >= 40) this.coins.shift();
    const a = this.rng() * TAU;
    const r = this.rng() * spread;
    let cx = x + Math.cos(a) * r, cz = z + Math.sin(a) * r;
    const rr = Math.hypot(cx, cz);
    if (rr > ARENA.sandR - 1) {
      cx *= (ARENA.sandR - 1) / rr;
      cz *= (ARENA.sandR - 1) / rr;
    }
    this.coins.push({ id: this.cid++, x: cx, z: cz, age: 0 });
    this.emit({ t: "coinDrop", x: cx, z: cz });
  }

  cancelCast(u: Unit, why = "Interrupted") {
    if (!u.cast) return;
    const c = u.cast;
    u.cast = null;
    this.emit({ t: "castEnd", unit: u.id, id: c.id, ok: false, why });
    if (u.kind === "player" && why) this.emit({ t: "notice", msg: why, tone: "info" });
  }

  interrupt(by: Unit, u: Unit) {
    const c = u.cast;
    if (!c || !c.interruptible) return false;
    u.cast = null;
    u.lock[c.school] = 4;
    this.emit({ t: "castEnd", unit: u.id, id: c.id, ok: false, why: "Interrupted" });
    this.emit({ t: "interrupt", by: by.id, unit: u.id, id: c.id, school: c.school, lockSeconds: 4 });
    if (by.kind === "player") {
      this.stats.interrupts++;
      this.dropCoin(u.x, u.z, 1);
    }
    return true;
  }

  // ------------------------------------------------------------------ targeting

  tab() {
    const p = this.player;
    const c = this.alive
      .filter((u) => dist(u.x, u.z, p.x, p.z) <= 40)
      .map((u) => ({ u, los: hasLOS(p, u) ? 0 : 1, d: dist(u.x, u.z, p.x, p.z) }))
      .sort((a, b) => a.los - b.los || a.d - b.d)
      .map((e) => e.u);
    if (!c.length) return;
    const i = c.findIndex((u) => u.id === this.targetId);
    this.targetId = c[(i + 1) % c.length].id;
    this.emit({ t: "target", id: this.targetId });
  }
  select(id: string | null) {
    const u = this.unit(id);
    if (u && !u.dead && u.kind !== "player") {
      this.targetId = id;
      this.emit({ t: "target", id });
    }
  }
  clearTarget(): boolean {
    if (!this.targetId) return false;
    this.targetId = null;
    this.emit({ t: "target", id: null });
    return true;
  }

  // ------------------------------------------------------------------ player abilities

  private fail(msg: string, id?: string) {
    this.lastFail = msg;
    this.emit({ t: "fail", msg, id });
    return "fail" as const;
  }

  /** Nearest living enemy in range, preferring the ones in front of the hero (WoW: a harmful spell with no target picks one). */
  autoTarget(): Unit | null {
    const p = this.player;
    const fx = Math.sin(this.heading), fz = Math.cos(this.heading);
    let best: Unit | null = null, bs = 1e9;
    for (const u of this.units) {
      if (u.kind === "player" || u.dead) continue;
      const d = dist(p.x, p.z, u.x, u.z);
      if (d > PLAYER.range) continue;
      const front = d > 0.01 && (fx * (u.x - p.x) + fz * (u.z - p.z)) / d > 0.2;
      const score = d + (front ? 0 : 100) + (hasLOS(p, u) ? 0 : 200);
      if (score < bs) {
        bs = score;
        best = u;
      }
    }
    return best;
  }

  /** Seconds until the press could fire: the longest of its cooldown, the global cooldown and the cast in progress. */
  private waitFor(ab: Ability): number {
    const p = this.player;
    let w = p.cds[ab.id] ?? 0;
    if (ab.gcd) w = Math.max(w, p.gcd);
    if (p.cast && (ab.gcd || ab.cast > 0)) w = Math.max(w, p.cast.left);
    return w;
  }

  /** Why a press can not happen at all right now (stunned, silenced, no mana...), or "". Timing (cooldown, GCD, cast) is separate. */
  private blocker(ab: Ability): string {
    const p = this.player;
    if (this.has(p, "immune")) return ab.id === "iceblock" ? "" : "Can't do that while in Ice Block";
    if (this.has(p, "stun")) return "Can't do that while stunned";
    if (this.has(p, "poly")) return "Can't do that while incapacitated";
    if (this.has(p, "fear")) return "Can't do that while feared";
    if (this.has(p, "silence") && ab.id !== "iceblock" && ab.id !== "dash") return "Can't do that while silenced";
    if (ab.id === "dash" && this.has(p, "root")) return "Can't dodge while frozen in place";
    if ((p.lock[ab.school] ?? 0) > 0) return `${SCHOOL_NAME[ab.school]} locked for ${Math.ceil(p.lock[ab.school] ?? 0)} s`;
    if (p.mana < ab.mana) return "Not enough mana";
    return "";
  }

  /** For the game: can the player start aiming this ground spell? Returns the refusal message or "" (a short wait is fine, it queues). */
  groundBlocker(code: string): string {
    const ab = ABILITIES.find((a) => a.code === code);
    if (!ab) return "Unknown spell";
    if (this.phase === "intro") return "Wait for the whistle";
    if (this.phase !== "fight" || this.player.dead) return "Not now";
    const b = this.blocker(ab);
    if (b) return b;
    const w = this.waitFor(ab);
    if ((this.player.cds[ab.id] ?? 0) > QUEUE.window) return `${ab.name} is on cooldown`;
    if (w > QUEUE.span) return "Not ready yet";
    return "";
  }

  /** Is a ground spot a legal place for this spell? "" = yes, else the reason. */
  groundSpotProblem(ab: Ability, gx: number, gz: number): string {
    const p = this.player;
    if (dist(p.x, p.z, gx, gz) > ab.range + 0.01) return "Out of range";
    if (Math.hypot(gx, gz) > ARENA.wallR - 1) return "Out of range";
    if (pointInSolid(gx, gz) >= 0) return "Target not in line of sight";
    if (segmentHitsPillar(p.x, p.z, gx, gz) >= 0) return "Target not in line of sight";
    return "";
  }

  cdLeft(id: string) {
    return this.player.cds[id] ?? 0;
  }

  /** Why a button is not usable right now against the current target: "range" and "sight" tint it red like WoW. */
  usableState(a: Ability): "ok" | "range" | "sight" | "mana" | "cd" {
    const p = this.player;
    const t = this.target;
    if (a.needsTarget && t && !t.dead) {
      if (dist(p.x, p.z, t.x, t.z) > PLAYER.range) return "range";
      if (!hasLOS(p, t)) return "sight";
    }
    if ((p.cds[a.id] ?? 0) > 0) return "cd";
    if (p.mana < a.mana) return "mana";
    return "ok";
  }

  /** The Not-enough-mana / Not-ready tint for the action bar. */
  usable(a: Ability): boolean {
    const p = this.player;
    return p.mana >= a.mana;
  }

  /** A key press. `gx, gz` = the ground spot for a ground spell. Returns "ok" (started), "queued" (held until ready) or "fail". */
  tryCast(code: string, gx?: number, gz?: number, fromQueue = false): "ok" | "queued" | "fail" | "ignored" {
    const ab = ABILITIES.find((a) => a.code === code);
    if (!ab) return "ignored";
    const p = this.player;
    if (this.phase === "intro") return this.fail("Wait for the whistle", ab.id);
    if (this.phase !== "fight" || p.dead) return "ignored";
    if (this.has(p, "immune") && ab.id === "iceblock") {
      for (const a of p.auras.filter((x) => x.kind === "immune")) this.removeAura(p, a);
      this.emit({ t: "notice", msg: "Ice Block ended", tone: "info" });
      return "ok";
    }
    const why = this.blocker(ab);
    if (why) return this.fail(why, ab.id);
    // timing: a press during a cast or the global cooldown, or in the last 0.4 s of a cooldown, is held and fires when ready
    const wait = this.waitFor(ab);
    if (wait > 0) {
      if (fromQueue) return "queued";
      const cdWait = p.cds[ab.id] ?? 0;
      if (cdWait <= QUEUE.window && wait <= QUEUE.span) {
        this.queued = { code, gx, gz, left: QUEUE.hold };
        this.emit({ t: "queued", id: ab.id });
        return "queued";
      }
      if (cdWait > 0) return this.fail(`${ab.name} is on cooldown`, ab.id);
      if (p.cast && (ab.gcd || ab.cast > 0)) return this.fail("Already casting", ab.id);
      return this.fail("Not ready yet", ab.id);
    }
    let t = this.target;
    if (ab.needsTarget) {
      if (!t) {
        t = this.autoTarget();
        if (!t) return this.fail("No enemy in range", ab.id);
        this.select(t.id);
      }
      if (dist(p.x, p.z, t.x, t.z) > PLAYER.range) return this.fail("Out of range", ab.id);
      if (!hasLOS(p, t)) return this.fail("Target not in line of sight", ab.id);
      if (ab.id === "counterspell" && !(t.cast && t.cast.interruptible)) return this.fail(t.cast ? "Can't be interrupted" : "Nothing to interrupt", ab.id);
    }
    if (ab.ground) {
      if (gx === undefined || gz === undefined) return this.fail("Pick a spot on the ground", ab.id);
      const bad = this.groundSpotProblem(ab, gx, gz);
      if (bad) return this.fail(bad, ab.id);
      this.ground = { x: gx, z: gz };
    }
    if (ab.cast > 0) {
      p.cast = { id: ab.id, name: ab.name, school: ab.school, left: ab.cast, total: ab.cast, interruptible: false, kind: "cast", ox: p.x, oz: p.z };
      p.gcd = PLAYER.gcd;
      this.emit({ t: "castStart", unit: p.id, id: ab.id, name: ab.name, total: ab.cast, school: ab.school });
      return "ok";
    }
    this.perform(ab, true);
    return "ok";
  }

  /** Ground spot of the spell being started (read by perform). */
  private ground: { x: number; z: number } | null = null;

  private startCooldowns(ab: Ability) {
    const p = this.player;
    if (ab.cd > 0) p.cds[ab.id] = ab.cd;
    if (ab.gcd && ab.cast === 0) p.gcd = PLAYER.gcd;
  }

  /** Effects of a player ability. Cast-time spells call this on release. */
  private perform(ab: Ability, instant: boolean) {
    const p = this.player;
    const t = this.target;
    p.mana -= ab.mana;
    this.startCooldowns(ab);
    this.emit({ t: "release", unit: p.id, id: ab.id, target: t ? t.id : null });
    switch (ab.id) {
      case "dash": {
        const len = Math.hypot(this.dashAim.x, this.dashAim.z) || 1;
        const dx = this.dashAim.x / len, dz = this.dashAim.z / len;
        p.iframe = DASH.iframes;
        p.dash = { dx, dz, left: DASH.time };
        this.emit({ t: "dash", from: { x: p.x, z: p.z }, to: { x: p.x + dx * DASH.dist, z: p.z + dz * DASH.dist }, dx, dz, dur: DASH.time });
        break;
      }
      case "frostbolt":
        if (t) this.spawnProj("frostbolt", p, t, ab.dmg!, "frost", 32);
        break;
      case "icelance":
        if (t) this.spawnProj("icelance", p, t, ab.dmg!, "frost", 50);
        break;
      case "nova": {
        this.emit({ t: "nova", x: p.x, z: p.z });
        for (const e of this.alive) {
          if (dist(p.x, p.z, e.x, e.z) <= ab.radius! && hasLOS(p, e)) {
            this.dealDamage(p, e, ab.dmg!, "frost");
            if (!e.dead) this.applyCC(p, e, "root", ab.dur!, { id: "root", kind: "root", name: "Frost Nova", amount: 0 });
          }
        }
        break;
      }
      case "polymorph":
        if (t) {
          const d = this.applyCC(p, t, "incapacitate", ab.dur!, { id: "poly", kind: "poly", name: "Polymorph", amount: 0 });
          if (d > 0) this.emit({ t: "polyOn", unit: t.id });
        }
        break;
      case "counterspell":
        if (t) this.interrupt(p, t);
        break;
      case "blink": {
        const from = { x: p.x, z: p.z };
        const dx = Math.sin(this.heading), dz = Math.cos(this.heading);
        let len = ab.dist!;
        // ray-clamp 0.6 m short of a pillar or the wall
        for (let s = 0.25; s <= ab.dist!; s += 0.25) {
          const x = p.x + dx * s, z = p.z + dz * s;
          const hitPillar = pointInSolid(x, z, 0.6) >= 0;
          if (hitPillar || Math.hypot(x, z) > ARENA.wallR - 0.6) {
            len = Math.max(0, s - 0.25);
            break;
          }
        }
        p.x += dx * len;
        p.z += dz * len;
        this.emit({ t: "blink", from, to: { x: p.x, z: p.z }, len });
        break;
      }
      case "barrier":
        this.addAura(p, { id: "barrier", kind: "absorb", name: "Ice Barrier", left: ab.dur!, total: ab.dur!, amount: ab.absorb!, src: p.id, harmful: false });
        break;
      case "iceblock":
        for (const a of p.auras.filter((x) => x.harmful)) this.removeAura(p, a);
        if (p.cast) this.cancelCast(p, "");
        this.addAura(p, { id: "iceblock", kind: "immune", name: "Ice Block", left: ab.dur!, total: ab.dur!, amount: 0, src: p.id, harmful: false });
        break;
      case "orb":
        this.orb = { x: p.x, z: p.z, dx: Math.sin(this.heading), dz: Math.cos(this.heading), dist: 0, pulse: 0.2, age: 0 };
        this.emit({ t: "orbStart" });
        break;
      case "blizzard": {
        const g = this.ground ?? { x: p.x, z: p.z };
        const n = ab.ticks!;
        this.storm = { x: g.x, z: g.z, radius: ab.radius!, left: ab.channel!, total: ab.channel!, tick: ab.channel! / n };
        p.cast = { id: ab.id, name: ab.name, school: ab.school, left: ab.channel!, total: ab.channel!, interruptible: false, kind: "channel", ox: p.x, oz: p.z };
        this.emit({ t: "castStart", unit: p.id, id: ab.id, name: ab.name, total: ab.channel!, school: ab.school });
        this.emit({ t: "blizzardStart", x: g.x, z: g.z, radius: ab.radius!, dur: ab.channel! });
        break;
      }
      case "coldsnap":
        for (const id of ["nova", "blink", "barrier", "blizzard", "dash"]) delete p.cds[id];
        this.emit({ t: "notice", msg: "Cold Snap: cooldowns reset", tone: "frost" });
        break;
    }
    void instant;
  }

  /** The player's heading in radians (set by the game from the hero drive every frame). */
  heading = Math.PI;
  /** World direction of the next Dodge (set by the game from the move keys every step). */
  dashAim = { x: 0, z: 1 };

  private spawnProj(kind: Proj["kind"], src: Unit, dst: Unit, dmg: number, school: School, speed: number) {
    const p: Proj = { id: this.pid++, kind, src: src.id, dst: dst.id, x: src.x, z: src.z, y: 1.5, speed, dmg, school };
    this.projs.push(p);
    this.emit({ t: "proj", proj: p });
  }

  private onProjHit(p: Proj, src: Unit | null, dst: Unit) {
    switch (p.kind) {
      case "frostbolt":
        if (src) {
          this.dealDamage(src, dst, p.dmg, "frost", { crit: true });
          if (!dst.dead) this.addAura(dst, { id: "frostbolt-slow", kind: "slow", name: "Frostbolt", left: SPELLNUM.frostbolt.dur, total: SPELLNUM.frostbolt.dur, amount: 1 - SPELLNUM.frostbolt.slow, src: src.id, harmful: true });
        }
        break;
      case "icelance": {
        const frozen = this.has(dst, "root");
        if (src) {
          const real = this.dealDamage(src, dst, frozen ? SPELLNUM.icelance.dmgFrozen : SPELLNUM.icelance.dmg, "frost", { crit: true });
          if (frozen && real > 0) {
            // SHATTER: the ice breaks. The root is spent, a short slow is left behind.
            this.emit({ t: "shatter", unit: dst.id, amount: real, x: dst.x, z: dst.z });
            this.emit({ t: "notice", msg: `SHATTER! ${real}`, tone: "gold" });
            this.removeKind(dst, "root");
            if (!dst.dead) this.addAura(dst, { id: "shatter-slow", kind: "slow", name: "Shatter", left: 2, total: 2, amount: 0.5, src: src.id, harmful: true });
          }
        }
        break;
      }
      case "fireball":
      case "shadowbolt":
        this.dealDamage(src, dst, p.dmg, p.school);
        break;
    }
    this.emit({ t: "projHit", proj: p, dst: dst.id });
  }

  // ------------------------------------------------------------------ step

  step(dt: number) {
    this.time += dt;
    this.phaseT += dt;
    const p = this.player;
    this.stats.timeMs += this.phase === "fight" ? dt * 1000 : 0;

    for (const u of this.units) this.tickUnit(u, dt);

    if (this.phase === "intro") {
      const k = Math.min(1, this.phaseT / 2.2);
      this.alive.forEach((e, i, arr) => {
        const gx = arr.length > 1 ? (i === 0 ? -2.5 : 2.5) : 0;
        e.x = gx;
        e.z = ARENA.gateNorth.z - 1.5 + (-9 - (ARENA.gateNorth.z - 1.5)) * k;
        e.yaw = 0;
        e.moving = k < 1;
      });
      if (this.phaseT >= 2.4) {
        this.phase = "fight";
        this.phaseT = 0;
        this.emit({ t: "whistle" });
      }
    }

    if (this.phase === "fight") {
      // player cast: moving more than 0.05 m cancels a cast-time spell, unless it is a moving cast (Frostbolt)
      if (p.cast && p.cast.kind === "cast") {
        if (!ABILITY_BY_ID[p.cast.id]?.moveCast && dist(p.x, p.z, p.cast.ox, p.cast.oz) > 0.05) this.cancelCast(p, "Interrupted");
        else {
          p.cast.left -= dt;
          if (p.cast.left <= 0) this.releasePlayerCast();
        }
      }
      if (p.cast && p.cast.kind === "channel") {
        if (dist(p.x, p.z, p.cast.ox, p.cast.oz) > 0.25) this.cancelCast(p, "Stopped: you moved"); // a nudge from a crowding enemy does not stop it
        else {
          p.cast.left -= dt;
          if (p.cast.left <= 0) {
            const c = p.cast;
            p.cast = null;
            this.emit({ t: "castEnd", unit: p.id, id: c.id, ok: true });
          }
        }
      }
      this.stepStorm(dt);
      this.stepQueue(dt);
      for (const e of this.alive) this.tickEnemy(e, dt);
      this.stepProjs(dt);
      this.stepOrb(dt);
      this.stepCoins(dt);
      this.separate();
      if (p.hp <= 0 && !p.dead) this.kill(p);
      if (p.dead) {
        this.phase = "lost";
        this.phaseT = 0;
        this.emit({ t: "lost" });
      } else if (this.alive.length === 0) {
        this.wonT += dt;
        if (this.wonT >= 1.4) {
          this.phase = "won";
          this.phaseT = 0;
          this.emit({ t: "won", round: this.round });
        }
      }
    } else if (this.phase === "won") {
      this.stepCoins(dt);
    }
  }

  private tickUnit(u: Unit, dt: number) {
    for (const k of Object.keys(u.cds)) u.cds[k] = Math.max(0, u.cds[k] - dt);
    u.gcd = Math.max(0, u.gcd - dt);
    this.resourceTick(u, dt);
    u.iframe = Math.max(0, u.iframe - dt);
    if (u.dash) {
      const s = Math.min(dt, u.dash.left) * (DASH.dist / DASH.time);
      u.x += u.dash.dx * s;
      u.z += u.dash.dz * s;
      u.dash.left -= dt;
      if (u.dash.left <= 0) u.dash = null;
      this.clampUnit(u);
    }
    for (const k of Object.keys(u.lock) as School[]) u.lock[k] = Math.max(0, (u.lock[k] ?? 0) - dt);
    for (const c of Object.keys(u.dr) as CC[]) u.dr[c].left = Math.max(0, u.dr[c].left - dt);
    if (u.dead) {
      u.deadT += dt;
      return;
    }
    if (u.kind === "player" && this.phase === "fight") u.mana = Math.min(u.maxMana, u.mana + PLAYER.regen * dt);
    for (const a of [...u.auras]) {
      a.left -= dt;
      if (a.kind === "dot") {
        a.tick -= dt;
        if (a.tick <= 0) {
          a.tick += 2;
          const src = this.unit(a.src);
          if (this.phase === "fight") this.dealDamage(src, u, a.amount, "shadow", { dot: true });
        }
      }
      if (a.left <= 0 && u.auras.includes(a)) this.removeAura(u, a);
    }
  }

  private releasePlayerCast() {
    const p = this.player;
    const c = p.cast;
    if (!c) return;
    const ab = ABILITIES.find((a) => a.id === c.id)!;
    const t = this.target;
    p.cast = null;
    if (ab.needsTarget) {
      if (!t) {
        this.emit({ t: "castEnd", unit: p.id, id: c.id, ok: false, why: "No target" });
        return this.fail("No target");
      }
      if (dist(p.x, p.z, t.x, t.z) > PLAYER.range) {
        this.emit({ t: "castEnd", unit: p.id, id: c.id, ok: false, why: "Out of range" });
        return this.fail("Out of range");
      }
      if (!hasLOS(p, t)) {
        this.emit({ t: "castEnd", unit: p.id, id: c.id, ok: false, why: "Target not in line of sight" });
        return this.fail("Target not in line of sight");
      }
    }
    this.emit({ t: "castEnd", unit: p.id, id: c.id, ok: true });
    this.perform(ab, false);
  }

  private stepProjs(dt: number) {
    for (const pr of [...this.projs]) {
      const dst = this.unit(pr.dst);
      const src = this.unit(pr.src);
      if (!dst || dst.dead) {
        this.projs = this.projs.filter((x) => x !== pr);
        this.emit({ t: "projGone", proj: pr });
        continue;
      }
      const dx = dst.x - pr.x, dz = dst.z - pr.z;
      const d = Math.hypot(dx, dz);
      const step = pr.speed * dt;
      if (d <= step + 0.5) {
        this.projs = this.projs.filter((x) => x !== pr);
        this.onProjHit(pr, src, dst);
        continue;
      }
      pr.x += (dx / d) * step;
      pr.z += (dz / d) * step;
      if (pointInSolid(pr.x, pr.z) >= 0) {
        this.projs = this.projs.filter((x) => x !== pr);
        this.emit({ t: "projBlock", proj: pr });
      }
    }
  }

  /** Blizzard: lives while the channel does. A tick every channel/ticks s; enemies inside are slowed the whole time. */
  private stepStorm(dt: number) {
    const st = this.storm;
    if (!st) return;
    const p = this.player;
    const ab = ABILITY_BY_ID.blizzard;
    if (!p.cast || p.cast.id !== "blizzard" || p.dead) {
      this.storm = null;
      this.emit({ t: "blizzardEnd", x: st.x, z: st.z, ok: false });
      return;
    }
    st.left -= dt;
    st.tick -= dt;
    const tickNow = st.tick <= 0;
    if (tickNow) st.tick += st.total / ab.ticks!;
    for (let i = 0; i < this.units.length; i++) {
      const e = this.units[i];
      if (e.kind === "player" || e.dead || dist(e.x, e.z, st.x, st.z) > st.radius || segmentHitsPillar(st.x, st.z, e.x, e.z) >= 0) continue;
      const sl = e.auras.find((a) => a.id === "blizzard-slow");
      if (!sl || sl.left < 0.25) this.addAura(e, { id: "blizzard-slow", kind: "slow", name: "Blizzard", left: 0.6, total: 0.6, amount: 1 - ab.slow!, src: "player", harmful: true });
      if (tickNow) this.dealDamage(p, e, ab.dmg!, "frost", { crit: true });
    }
    if (tickNow) this.emit({ t: "blizzardTick", x: st.x, z: st.z, radius: st.radius });
    if (st.left <= 0) {
      this.storm = null;
      this.emit({ t: "blizzardEnd", x: st.x, z: st.z, ok: true });
    }
  }

  /** Fires the held press the moment its cast / cooldown / GCD is over; drops it after QUEUE.hold s or when it can no longer work. */
  private stepQueue(dt: number) {
    const q = this.queued;
    if (!q) return;
    q.left -= dt;
    const ab = ABILITIES.find((a) => a.code === q.code);
    if (q.left <= 0 || !ab || this.player.dead) {
      this.queued = null;
      return;
    }
    if (this.waitFor(ab) > 0) return;
    this.queued = null;
    this.tryCast(q.code, q.gx, q.gz, true);
  }

  private stepOrb(dt: number) {
    const o = this.orb;
    if (!o) return;
    o.age += dt;
    const step = SPELLNUM.orb.speed * dt;
    o.x += o.dx * step;
    o.z += o.dz * step;
    o.dist += step;
    o.pulse -= dt;
    if (o.pulse <= 0) {
      o.pulse += SPELLNUM.orb.every;
      for (const e of this.alive) {
        if (dist(o.x, o.z, e.x, e.z) <= SPELLNUM.orb.radius && segmentHitsPillar(o.x, o.z, e.x, e.z) < 0) {
          this.dealDamage(this.player, e, SPELLNUM.orb.dmg, "frost");
          if (!e.dead) this.addAura(e, { id: "orb-slow", kind: "slow", name: "Frozen Orb", left: SPELLNUM.orb.dur, total: SPELLNUM.orb.dur, amount: 1 - SPELLNUM.orb.slow, src: "player", harmful: true });
        }
      }
      this.emit({ t: "orbPulse", x: o.x, z: o.z });
    }
    if (o.dist >= SPELLNUM.orb.dist || pointInSolid(o.x, o.z) >= 0) {
      this.orb = null;
      this.emit({ t: "orbEnd" });
    }
  }

  private stepCoins(dt: number) {
    const p = this.player;
    for (const c of [...this.coins]) {
      c.age += dt;
      if (!p.dead && dist(c.x, c.z, p.x, p.z) < 1.15) {
        this.coins = this.coins.filter((x) => x !== c);
        p.mana = Math.min(p.maxMana, p.mana + p.maxMana * 0.03);
        this.stats.coins++;
        this.emit({ t: "coinTake", x: c.x, z: c.z });
      }
    }
  }

  private separate() {
    const list = this.units.filter((u) => !u.dead);
    for (let i = 0; i < list.length; i++) {
      for (let j = i + 1; j < list.length; j++) {
        const a = list[i], b = list[j];
        const dx = a.x - b.x, dz = a.z - b.z;
        const d = Math.hypot(dx, dz);
        const min = 0.9;
        if (d > 0.0001 && d < min) {
          const k = (min - d) / (2 * d);
          a.x += dx * k;
          a.z += dz * k;
          b.x -= dx * k;
          b.z -= dz * k;
        }
      }
    }
    for (const u of list) this.clampUnit(u);
  }

  clampUnit(u: { x: number; z: number }) {
    // two passes so a corner between two solids settles
    for (let pass = 0; pass < 2; pass++) {
      for (let i = 0; i < SOLIDS.length; i++) {
        const sd = SOLIDS[i];
        const vx = sd.x2 - sd.x1, vz = sd.z2 - sd.z1;
        const l2 = vx * vx + vz * vz;
        const t = l2 === 0 ? 0 : Math.max(0, Math.min(1, ((u.x - sd.x1) * vx + (u.z - sd.z1) * vz) / l2));
        const cx = sd.x1 + vx * t, cz = sd.z1 + vz * t;
        const dx = u.x - cx, dz = u.z - cz;
        const d = Math.hypot(dx, dz);
        const min = sd.r + ARENA.unitRad;
        if (d < min) {
          if (d > 0.0001) {
            u.x = cx + (dx / d) * min;
            u.z = cz + (dz / d) * min;
          } else u.x = cx + min;
        }
      }
    }
    const r = Math.hypot(u.x, u.z);
    const max = ARENA.wallR - 0.9;
    if (r > max) {
      u.x *= max / r;
      u.z *= max / r;
    }
  }

  // ------------------------------------------------------------------ enemy AI

  private faceTo(u: Unit, x: number, z: number, dt: number) {
    u.yaw = turnToward(u.yaw, Math.atan2(x - u.x, z - u.z), 9 * dt);
  }

  /** Detour waypoint when the straight walk to (gx,gz) would grind into a solid: round the nearer end of a wall, or round a column. */
  private avoid(u: Unit, gx: number, gz: number): [number, number] {
    const vx = gx - u.x, vz = gz - u.z;
    const len = Math.hypot(vx, vz);
    if (len < 0.5) return [gx, gz];
    let best = -1, bestD = 1e9;
    for (let i = 0; i < SOLIDS.length; i++) {
      const sd = SOLIDS[i];
      if (segSegDist(u.x, u.z, gx, gz, sd.x1, sd.z1, sd.x2, sd.z2) >= sd.r + 0.9) continue;
      const near = ptSegDist(u.x, u.z, sd.x1, sd.z1, sd.x2, sd.z2);
      // a solid the goal itself sits inside or behind: only count it when it is between us and the goal
      if (ptSegDist(gx, gz, sd.x1, sd.z1, sd.x2, sd.z2) < sd.r + 0.3 && near > ptSegDist(gx, gz, sd.x1, sd.z1, sd.x2, sd.z2)) continue;
      if (near < bestD) {
        bestD = near;
        best = i;
      }
    }
    if (best < 0) return [gx, gz];
    const sd = SOLIDS[best];
    const ax = sd.x2 - sd.x1, az = sd.z2 - sd.z1;
    const al = Math.hypot(ax, az);
    const off = sd.r + 1.5;
    if (al < 0.01) {
      const cr = vx * (sd.z1 - u.z) - vz * (sd.x1 - u.x);
      const px = cr > 0 ? vz / len : -vz / len;
      const pz = cr > 0 ? -vx / len : vx / len;
      return [sd.x1 + px * (off + 0.2), sd.z1 + pz * (off + 0.2)];
    }
    const nx = ax / al, nz = az / al;
    const w1x = sd.x1 - nx * off, w1z = sd.z1 - nz * off;
    const w2x = sd.x2 + nx * off, w2z = sd.z2 + nz * off;
    const c1 = Math.hypot(w1x - u.x, w1z - u.z) + Math.hypot(gx - w1x, gz - w1z);
    const c2 = Math.hypot(w2x - u.x, w2z - u.z) + Math.hypot(gx - w2x, gz - w2z);
    return c1 < c2 ? [w1x, w1z] : [w2x, w2z];
  }

  private moveToward(u: Unit, gx: number, gz: number, dt: number, mul = 1, arrive = 0.25) {
    const sp = u.speed * this.speedMult(u) * mul;
    if (sp <= 0) return false;
    const [tx, tz] = this.avoid(u, gx, gz);
    const dx = tx - u.x, dz = tz - u.z;
    const d = Math.hypot(dx, dz);
    if (d < (tx === gx && tz === gz ? arrive : 0.05)) return false;
    const s = Math.min(d, sp * dt);
    u.x += (dx / d) * s;
    u.z += (dz / d) * s;
    u.yaw = turnToward(u.yaw, Math.atan2(dx, dz), 9 * dt);
    u.moving = true;
    return true;
  }

  private moveAway(u: Unit, fx: number, fz: number, dt: number, mul = 1) {
    const dx = u.x - fx, dz = u.z - fz;
    const d = Math.hypot(dx, dz) || 1;
    let gx = u.x + (dx / d) * 6, gz = u.z + (dz / d) * 6;
    const r = Math.hypot(gx, gz);
    if (r > ARENA.sandR) {
      // along the wall instead of into it: slide tangentially
      const tx = -u.z / (Math.hypot(u.x, u.z) || 1), tz = u.x / (Math.hypot(u.x, u.z) || 1);
      const side = tx * (u.x - fx) + tz * (u.z - fz) >= 0 ? 1 : -1;
      gx = u.x + tx * side * 6;
      gz = u.z + tz * side * 6;
    }
    return this.moveToward(u, gx, gz, dt, mul, 0.05);
  }

  // ------------------------------------------------------------------ line of sight (enemy side)

  /** A spot each caster is walking to so it sees the player again (re-picked every ~0.6 s). */
  private losGoal = new Map<string, { x: number; z: number; left: number }>();
  /** Casters whose cast already failed for lack of sight; cleared when they see the player again (one banner per break). */
  private sightLost = new Set<string>();

  /** Casts that need sight of the player at every moment. Heals and melee swings do not. */
  private static LOS_CASTS = new Set(["fireball", "shadowbolt", "epoly", "scream", "shock"]);

  /** True when the cast was cancelled because the player stepped out of sight. */
  private breakCastOnLostSight(u: Unit): boolean {
    const c = u.cast;
    if (!c || !Sim.LOS_CASTS.has(c.id) || hasLOS(u, this.player)) return false;
    u.cast = null;
    this.emit({ t: "castEnd", unit: u.id, id: c.id, ok: false, why: "Target not in line of sight" });
    this.emit({ t: "enemyFail", unit: u.id, id: c.id });
    if (!this.sightLost.has(u.id)) {
      this.sightLost.add(u.id);
      this.emit({ t: "lostSight", unit: u.id, name: u.name });
    }
    return true;
  }

  /** Pick the nearest reachable spot that sees the player and sits inside the caster's range band. */
  private pickSightSpot(u: Unit, maxR: number, out: { x: number; z: number; left: number }) {
    const p = this.player;
    let best = 1e9;
    let bx = p.x, bz = p.z;
    for (let ring = 3; ring <= 12; ring += 3) {
      for (let k = 0; k < 16; k++) {
        const a = (k / 16) * TAU;
        const x = u.x + Math.cos(a) * ring, z = u.z + Math.sin(a) * ring;
        if (Math.hypot(x, z) > ARENA.wallR - 1.8) continue;
        if (pointInSolid(x, z, ARENA.unitRad + 0.6) >= 0) continue;
        const dp = dist(x, z, p.x, p.z);
        if (dp > maxR || dp < 6) continue;
        if (segmentHitsPillar(x, z, p.x, p.z) >= 0) continue;
        const score = ring + (segmentHitsPillar(u.x, u.z, x, z, 0.4) >= 0 ? 6 : 0);
        if (score < best) {
          best = score;
          bx = x;
          bz = z;
        }
      }
    }
    out.x = bx;
    out.z = bz;
    out.left = 0.6;
  }

  /** A caster with no sight of the player walks to a spot that has it, round the stone, and never casts through it. */
  private regainLOS(u: Unit, dt: number) {
    const p = this.player;
    let g = this.losGoal.get(u.id);
    if (!g) {
      g = { x: u.x, z: u.z, left: 0 };
      this.losGoal.set(u.id, g);
    }
    g.left -= dt;
    if (g.left <= 0 || segmentHitsPillar(g.x, g.z, p.x, p.z) >= 0) this.pickSightSpot(u, u.kind === "pyromancer" ? 19 : 16, g);
    this.moveToward(u, g.x, g.z, dt, 1, 0.3);
    if (!u.moving) this.faceTo(u, p.x, p.z, dt);
  }

  // ------------------------------------------------------------------ enemy resource bars (rage / energy / mana)
  // Kept apart from the rest of the sim: the HUD reads `resourceOf(unit)`. Numbers live in data.ts RESOURCE.

  /** The enemy's resource bar, made on first use. The player has none here: it uses mana/maxMana. */
  resourceOf(u: Unit) {
    let r = this.res.get(u.id);
    if (!r && u.kind !== "player") {
      const d = RESOURCE[u.kind as EnemyKind];
      r = { kind: d.kind, v: d.start, max: d.max };
      this.res.set(u.id, r);
    }
    return r ?? null;
  }
  private resourceTick(u: Unit, dt: number) {
    if (u.dead || this.phase !== "fight") return;
    const r = this.resourceOf(u);
    if (r) r.v = Math.max(0, Math.min(r.max, r.v + RESOURCE[u.kind as EnemyKind].regen * dt));
  }
  private resourceOnDamage(src: Unit | null, dst: Unit, real: number) {
    if (real <= 0) return;
    const a = src && src !== dst ? this.resourceOf(src) : null;
    if (a && src) a.v = Math.min(a.max, a.v + RESOURCE[src.kind as EnemyKind].onHit);
    const b = this.resourceOf(dst);
    if (b) b.v = Math.min(b.max, b.v + RESOURCE[dst.kind as EnemyKind].onHurt);
  }
  private resourceSpend(u: Unit) {
    const r = this.resourceOf(u);
    if (r) r.v = Math.max(0, r.v - RESOURCE[u.kind as EnemyKind].spend);
  }

  private startEnemyCast(u: Unit, id: string, name: string, school: School, time: number, interruptible: boolean, kind: CastState["kind"] = "cast") {
    u.cast = { id, name, school, left: time, total: time, interruptible, kind, ox: u.x, oz: u.z };
    this.resourceSpend(u);
    this.emit({ t: "castStart", unit: u.id, id, name, total: time, school, kind, interruptible });
  }

  private cd(u: Unit, id: string, seconds: number) {
    u.cds[id] = seconds * u.cdMult;
  }

  private ready(u: Unit, id: string) {
    return (u.cds[id] ?? 0) <= 0;
  }

  private tickEnemy(u: Unit, dt: number) {
    const p = this.player;
    u.moving = false;
    if (u.dead) return;
    // health-triggered defensives
    const frac = u.hp / u.maxHp;
    // tell / cast progress
    if (u.cast) {
      if (this.breakCastOnLostSight(u)) return;
      u.cast.left -= dt;
      if (u.kind !== "gladiator" || u.cast.kind !== "channel") this.faceTo(u, p.x, p.z, dt);
      if (u.cast.kind === "channel") this.tickChannel(u, dt);
      if (u.cast.left <= 0) this.finishEnemyCast(u);
      if (!this.canAct(u)) u.cast = null;
      return;
    }
    if (u.leap) {
      this.tickLeap(u, dt);
      return;
    }
    if (u.charging) {
      this.tickCharge(u, dt);
      return;
    }
    if (!this.canAct(u)) return;
    u.swingCd = Math.max(0, u.swingCd - dt);
    u.think -= dt;
    if (u.think > 0 && !(u.kind === "warrior" || u.kind === "gladiator")) {
      // movement continues between thoughts for casters
    }
    const d = dist(u.x, u.z, p.x, p.z);
    const los = hasLOS(u, p);
    if (los) this.sightLost.delete(u.id);
    if (!p.dead) {
      switch (u.kind) {
        case "warrior":
          this.aiMelee(u, d, los, dt, false);
          break;
        case "gladiator":
          if (frac <= 0.5 && !u.usedWall) {
            u.usedWall = true;
            this.addAura(u, { id: "wall", kind: "wall", name: "Shield Wall", left: 6, total: 6, amount: 0.6, src: u.id, harmful: false });
            this.emit({ t: "notice", msg: "Shield Wall: damage reduced", tone: "warn" });
          }
          if (frac <= 0.25 && !u.usedEnrage) {
            u.usedEnrage = true;
            this.addAura(u, { id: "enrage", kind: "enrage", name: "Enrage", left: 999, total: 999, amount: 0.25, src: u.id, harmful: false });
            this.emit({ t: "notice", msg: "The Gladiator is enraged", tone: "warn" });
          }
          this.aiMelee(u, d, los, dt, true);
          break;
        case "pyromancer":
          this.aiCaster(u, d, los, dt, "fire");
          break;
        case "priestess":
          this.aiCaster(u, d, los, dt, "priest");
          break;
      }
    }
  }

  private aiMelee(u: Unit, d: number, los: boolean, dt: number, elite: boolean) {
    const p = this.player;
    const reach = 2.7;
    const immune = this.has(p, "immune");
    // Pummel: interrupt a player cast within 3 m
    if (p.cast && d < 3 && this.ready(u, "pummel")) {
      this.cd(u, "pummel", 15);
      this.emit({ t: "enemyAct", unit: u.id, act: "pummel" });
      const c = p.cast;
      if (!immune && c) {
        p.cast = null;
        p.lock[c.school] = 4;
        this.emit({ t: "castEnd", unit: p.id, id: c.id, ok: false, why: "Interrupted" });
        this.emit({ t: "notice", msg: `Pummel! ${SCHOOL_NAME[c.school]} locked`, tone: "warn" });
      }
      return;
    }
    // Throat Strike: silence when the player starts a cast within 5 m
    if (elite && p.cast && d < 5 && this.ready(u, "throat") && !immune) {
      this.cd(u, "throat", 20);
      this.emit({ t: "enemyAct", unit: u.id, act: "throat" });
      this.applyCC(u, p, "silence", ENEMY_ABILITIES.throat.dur!, { id: "silence", kind: "silence", name: "Throat Strike", amount: 0 });
      return;
    }
    if (elite) {
      if (frac(u) < 0.7 && this.ready(u, "bheal") && !(u.lock.holy ?? 0)) {
        this.cd(u, "bheal", 20);
        this.startEnemyCast(u, "bheal", "Battle Heal", "holy", 2, true);
        return;
      }
      if (d < 5 && this.ready(u, "whirl")) {
        this.cd(u, "whirl", 12);
        u.whirl = 0.5;
        this.startEnemyCast(u, "whirl", "Whirlwind", "physical", 2, false, "channel");
        return;
      }
      if (d < 10 && los && this.ready(u, "shock") && !(u.lock.physical ?? 0)) {
        this.cd(u, "shock", 18);
        this.startEnemyCast(u, "shock", "Shockwave", "physical", 1.5, true);
        return;
      }
    }
    // Leap Strike: telegraphed jump to where the player stands NOW. Dodge the ring.
    if (d >= 6 && d <= 16 && los && this.ready(u, "leap") && this.canMove(u)) {
      this.cd(u, "leap", elite ? 9 : 11);
      u.leapTo = { x: p.x, z: p.z };
      this.startEnemyCast(u, "leap", ENEMY_ABILITIES.leap.name, "physical", 0.7, false, "tell");
      this.emit({ t: "enemyAct", unit: u.id, act: "chargeTell" });
      this.emit({ t: "enemyLeapTell", unit: u.id, from: { x: u.x, z: u.z }, to: { x: p.x, z: p.z }, tell: 0.7 });
      return;
    }
    // Charge
    if (d >= 8 && d <= 25 && los && this.ready(u, "charge") && this.canMove(u)) {
      this.cd(u, "charge", 12);
      u.chargeLine = { x0: u.x, z0: u.z, x1: p.x, z1: p.z };
      this.startEnemyCast(u, "charge", "Charge", "physical", 0.55, false, "tell");
      this.emit({ t: "enemyAct", unit: u.id, act: "chargeTell" });
      return;
    }
    if (d > reach - 0.3) {
      this.moveToward(u, p.x, p.z, dt, 1, reach - 0.4);
      return;
    }
    this.faceTo(u, p.x, p.z, dt);
    if (this.ready(u, "conc")) {
      this.cd(u, "conc", 22);
      this.startEnemyCast(u, "conc", "Concussion Blow", "physical", 0.8, false, "tell");
      this.emit({ t: "enemyAct", unit: u.id, act: "windup" });
      return;
    }
    if (this.ready(u, "ham")) {
      this.cd(u, "ham", 8);
      this.startEnemyCast(u, "ham", "Hamstring", "physical", 0.8, false, "tell");
      this.emit({ t: "enemyAct", unit: u.id, act: "windup" });
      return;
    }
    if (u.swingCd <= 0) {
      u.swingCd = 1.3;
      this.startEnemyCast(u, "swing", "Swing", "physical", 0.45, false, "tell");
      this.emit({ t: "enemyAct", unit: u.id, act: "windup" });
    }
  }

  private aiCaster(u: Unit, d: number, los: boolean, dt: number, flavour: "fire" | "priest") {
    const p = this.player;
    const frac = u.hp / u.maxHp;
    const minR = flavour === "fire" ? 11 : 9;
    const maxR = flavour === "fire" ? 20 : 17;
    if (flavour === "fire" && frac < 0.35 && !u.usedBarrier) {
      u.usedBarrier = true;
      this.addAura(u, { id: "fbarrier", kind: "absorb", name: "Blazing Barrier", left: 12, total: 12, amount: 300, src: u.id, harmful: false });
      const dx = u.x - p.x, dz = u.z - p.z;
      const l = Math.hypot(dx, dz) || 1;
      const from = { x: u.x, z: u.z };
      if (this.canMove(u)) {
        u.x += (dx / l) * 10;
        u.z += (dz / l) * 10;
        this.clampUnit(u);
      }
      this.emit({ t: "enemyBlink", unit: u.id, from, to: { x: u.x, z: u.z } });
      this.emit({ t: "notice", msg: "Blazing Barrier", tone: "warn" });
      return;
    }
    if (!los) {
      this.regainLOS(u, dt);
      return;
    }
    this.faceTo(u, p.x, p.z, dt);
    const gcdOk = u.gcd <= 0;
    if (flavour === "priest") {
      if (d < 8 && this.ready(u, "scream") && !(u.lock.shadow ?? 0)) {
        this.cd(u, "scream", 30);
        this.startEnemyCast(u, "scream", "Psychic Scream", "shadow", 0.5, true);
        return;
      }
      if (frac < 0.6 && this.ready(u, "heal") && !(u.lock.holy ?? 0) && gcdOk) {
        this.cd(u, "heal", 12);
        u.gcd = ENEMY_GCD;
        this.startEnemyCast(u, "heal", "Heal", "holy", 2.2, true);
        return;
      }
    }
    // keep distance
    if (d < minR && this.canMove(u)) {
      this.moveAway(u, p.x, p.z, dt, d < 8 ? 1 : 0.8);
    } else if (d > maxR) {
      this.moveToward(u, p.x, p.z, dt, 1, 0.1);
    }
    if (!gcdOk || d > 30) return;
    if (flavour === "fire") {
      if (this.ready(u, "fireblast") && d <= 25) {
        this.cd(u, "fireblast", 6);
        u.gcd = ENEMY_GCD;
        this.emit({ t: "enemyAct", unit: u.id, act: "fireblast" });
        this.dealDamage(u, p, ENEMY_ABILITIES.fireblast.dmg!, "fire");
        return;
      }
      if (this.ready(u, "poly") && !(u.lock.arcane ?? 0) && !this.has(p, "poly") && this.rng() < 0.4) {
        this.cd(u, "poly", 25);
        u.gcd = ENEMY_GCD;
        this.startEnemyCast(u, "epoly", "Polymorph", "arcane", 1.5, true);
        return;
      }
      if (!(u.lock.fire ?? 0)) {
        u.gcd = ENEMY_GCD;
        this.startEnemyCast(u, "fireball", "Fireball", "fire", 1.9, true);
      }
    } else {
      if (!this.has(p, "dot") && this.ready(u, "pain") && !(u.lock.shadow ?? 0)) {
        this.cd(u, "pain", 12);
        u.gcd = ENEMY_GCD;
        this.emit({ t: "enemyAct", unit: u.id, act: "pain" });
        this.addAura(p, { id: "swpain", kind: "dot", name: "Shadow Word: Pain", left: ENEMY_ABILITIES.pain.dur!, total: ENEMY_ABILITIES.pain.dur!, amount: ENEMY_ABILITIES.pain.dmg!, src: u.id, harmful: true, tick: 2 });
        return;
      }
      if (!(u.lock.shadow ?? 0)) {
        u.gcd = ENEMY_GCD;
        this.startEnemyCast(u, "shadowbolt", "Shadow Bolt", "shadow", 1.6, true);
      }
    }
  }

  private tickCharge(u: Unit, dt: number) {
    const c = u.charging!;
    const p = this.player;
    c.left -= dt;
    u.x += c.dx * 20 * dt;
    u.z += c.dz * 20 * dt;
    u.yaw = Math.atan2(c.dx, c.dz);
    u.moving = true;
    this.clampUnit(u);
    if (dist(u.x, u.z, p.x, p.z) <= 1.8 && !p.dead && p.iframe <= 0) {
      u.charging = null;
      u.chargeLine = null;
      this.emit({ t: "chargeHit", unit: u.id });
      this.dealDamage(u, p, 40, "physical");
      this.applyCC(u, p, "stun", ENEMY_ABILITIES.charge.dur!, { id: "stun", kind: "stun", name: "Charge", amount: 0 });
    } else if (c.left <= 0) {
      u.charging = null;
      u.chargeLine = null;
    }
  }

  private tickLeap(u: Unit, dt: number) {
    const l = u.leap!;
    const p = this.player;
    l.left -= dt;
    const k = Math.min(1, 1 - Math.max(0, l.left) / l.total);
    u.x = l.x0 + (l.x1 - l.x0) * k;
    u.z = l.z0 + (l.z1 - l.z0) * k;
    u.yaw = Math.atan2(l.x1 - l.x0, l.z1 - l.z0);
    u.moving = true;
    if (l.left > 0) return;
    u.leap = null;
    this.clampUnit(u);
    const hit = !p.dead && dist(u.x, u.z, p.x, p.z) <= 2.8;
    this.emit({ t: "enemyLand", unit: u.id, x: u.x, z: u.z, hit });
    if (hit) {
      this.dealDamage(u, p, ENEMY_ABILITIES.leap.dmg!, "physical");
      if (p.iframe <= 0) this.applyCC(u, p, "stun", ENEMY_ABILITIES.leap.dur!, { id: "stun", kind: "stun", name: "Leap Strike", amount: 0 });
    }
  }

  private tickChannel(u: Unit, dt: number) {
    if (u.cast?.id !== "whirl") return;
    u.whirl -= dt;
    if (u.whirl <= 0) {
      u.whirl += 0.5;
      const p = this.player;
      if (dist(u.x, u.z, p.x, p.z) <= 5) this.dealDamage(u, p, ENEMY_ABILITIES.whirl.dmg!, "physical");
      this.emit({ t: "whirlTick", unit: u.id });
    }
  }

  private finishEnemyCast(u: Unit) {
    const c = u.cast!;
    const p = this.player;
    u.cast = null;
    const d = dist(u.x, u.z, p.x, p.z);
    const pillars = !hasLOS(u, p);
    this.emit({ t: "castEnd", unit: u.id, id: c.id, ok: true });
    const lose = (why: string) => {
      this.emit({ t: "castEnd", unit: u.id, id: c.id, ok: false, why });
      this.emit({ t: "enemyFail", unit: u.id, id: c.id });
    };
    switch (c.id) {
      case "swing":
        this.emit({ t: "enemyAct", unit: u.id, act: "swing" });
        if (d <= 3.1) this.dealDamage(u, p, ENEMY_ABILITIES.swing.dmg!, "physical");
        else this.emit({ t: "enemyMiss", unit: u.id });
        break;
      case "ham":
        this.emit({ t: "enemyAct", unit: u.id, act: "swing" });
        if (d <= 3.1) {
          this.dealDamage(u, p, ENEMY_ABILITIES.ham.dmg!, "physical");
          if (!this.has(p, "immune") && p.iframe <= 0) this.addAura(p, { id: "ham", kind: "slow", name: "Hamstring", left: ENEMY_ABILITIES.ham.dur!, total: ENEMY_ABILITIES.ham.dur!, amount: 0.5, src: u.id, harmful: true });
        } else this.emit({ t: "enemyMiss", unit: u.id });
        break;
      case "conc":
        this.emit({ t: "enemyAct", unit: u.id, act: "swing" });
        if (d <= 3.1) this.applyCC(u, p, "stun", ENEMY_ABILITIES.conc.dur!, { id: "stun", kind: "stun", name: "Concussion Blow", amount: 0 });
        else this.emit({ t: "enemyMiss", unit: u.id });
        break;
      case "charge": {
        const dx = p.x - u.x, dz = p.z - u.z;
        const l = Math.hypot(dx, dz) || 1;
        u.charging = { dx: dx / l, dz: dz / l, left: Math.min(1.6, (l + 3) / 20) };
        break;
      }
      case "leap": {
        const to = u.leapTo ?? { x: p.x, z: p.z };
        u.leapTo = null;
        const l = Math.hypot(to.x - u.x, to.z - u.z);
        const total = Math.max(0.3, Math.min(0.6, l / 22));
        u.leap = { x0: u.x, z0: u.z, x1: to.x, z1: to.z, left: total, total };
        this.emit({ t: "enemyLeap", unit: u.id, from: { x: u.x, z: u.z }, to, dur: total });
        break;
      }
      case "whirl":
        break;
      case "shock":
        if (d <= 10 && !pillars) {
          this.emit({ t: "shockwave", unit: u.id });
          this.applyCC(u, p, "stun", ENEMY_ABILITIES.shock.dur!, { id: "stun", kind: "stun", name: "Shockwave", amount: 0 });
          this.dealDamage(u, p, ENEMY_ABILITIES.shock.dmg!, "physical");
        } else lose("miss");
        break;
      case "bheal":
        this.heal(u, ENEMY_ABILITIES.bheal.dmg!);
        break;
      case "heal":
        this.heal(u, ENEMY_ABILITIES.heal.dmg!);
        break;
      case "scream":
        if (d <= 8.5 && !pillars) this.applyCC(u, p, "disorient", ENEMY_ABILITIES.scream.dur!, { id: "fear", kind: "fear", name: "Psychic Scream", amount: 0 });
        break;
      case "fireball":
      case "shadowbolt":
      case "epoly":
        if (pillars) {
          lose("Target not in line of sight");
          this.emit({ t: "notice", msg: `${u.name}: no line of sight`, tone: "info" });
        } else if (c.id === "epoly") {
          this.applyCC(u, p, "incapacitate", ENEMY_ABILITIES.epoly.dur!, { id: "poly", kind: "poly", name: "Polymorph", amount: 0 });
        } else {
          this.spawnProj(c.id === "fireball" ? "fireball" : "shadowbolt", u, p, ENEMY_ABILITIES[c.id].dmg!, c.id === "fireball" ? "fire" : "shadow", c.id === "fireball" ? 22 : 26);
        }
        break;
    }
  }
}

function frac(u: Unit) {
  return u.hp / u.maxHp;
}
