// Frostcall Arena sound bank. Every combat event is a LAYERED sound: recorded takes from the sfxkit (pools with no
// immediate repeat, pitch +-8 %, volume +-15 %) plus procedural WebAudio layers (cracks, zaps, whooshes, booms) that
// cost no download and are re-rolled on every play. Everything lands on the sfx bus, under a limiter.
import type { Object3D } from "three";
import { SFX_CATALOG, type Sfx, type SfxCatalog, type SfxEntry } from "@/lib/sfxkit/player";

type Pt = { x: number; y: number; z: number };
export type At = Object3D | Pt;
type Layer = { id: string; p?: number; v?: number };
type Stoppable = { stop(fade?: number): void };

const J = (x: number, r: number) => x * (1 + (Math.random() * 2 - 1) * r);
const R = (a: number, b: number) => a + Math.random() * (b - a);
const pitchJ = (p = 1) => J(p, 0.08);
const volJ = (v = 1) => J(v, 0.15);

/** A set of variants that never returns the same one twice in a row. */
class Pool {
  private last = -1;
  constructor(readonly v: Layer[]) {}
  next(): Layer {
    let k = Math.floor(Math.random() * this.v.length);
    if (this.v.length > 1 && k === this.last) k = (k + 1) % this.v.length;
    this.last = k;
    return this.v[k];
  }
}

/**
 * Round 3 adds frost/arcane releases, airy whooshes, channel / orb / blizzard loops, jump, land and dash takes.
 * Round 2: 42 more recorded takes (CC0, public/sfx/aaa, credits in public/sfx/SOURCES.json), each its own catalog id
 * "aaa_<name>" so a pool can pick any of them. The catalog is the sfxkit one plus these; Game.ts hands it to the player.
 */
const AAA_FILES: Record<string, string[]> = {
  ice: ["ice_shatter_1", "ice_shatter_2", "ice_break_3", "ice_spell_4", "ice_hit_1", "ice_hit_2", "ice_hit_3"],
  frost: ["frost_crack_1", "frost_crack_2", "frost_crack_3", "ice_cast_spell", "ice_arrow"],
  arcane: ["arcane_snap_1", "arcane_snap_2"],
  air: ["air_whoosh_1", "air_whoosh_2", "air_whoosh_3", "dash_1", "dash_2"],
  move: ["jump_1", "jump_2", "jump_3", "land_1"],
  loops: ["chan_loop_1", "chan_loop_2", "chan_loop_3", "orb_loop_1", "orb_loop_2", "blizz_loop_1", "blizz_loop_2"],
  magic: ["spell_cast_1", "spell_whoosh_2", "spell_item_3", "spell_impact_1", "spell_impact_2", "zap_1", "zap_2"],
  fire: ["fireball_whoosh", "fire_boom_1", "fire_boom_2"],
  sword: ["sword_slash_1", "sword_slash_2", "sword_slash_3", "sword_hit_flesh", "sword_hit_armor", "sword_clash"],
  body: ["punch_1", "punch_2", "thud_1"],
  male: ["m_grunt_1", "m_grunt_2", "m_grunt_3", "m_hurt_4", "m_hurt_5", "m_hurt_6", "m_grunt_7", "m_die_1", "m_die_2"],
  female: ["f_hurt_1", "f_hurt_2", "f_ouch_3", "f_grunt_4", "f_grunt_5", "f_grunt_6", "f_die_1"],
  crowd: ["crowd_cheer_4", "crowd_gasp_1", "crowd_gasp_2"],
};
const A = (n: string) => `aaa_${n}`;
const AAA_IDS = Object.values(AAA_FILES).flat().map(A);
const AAA_SOUNDS: SfxEntry[] = Object.entries(AAA_FILES).flatMap(([cat, names]) => names.map((n) => ({
  id: A(n), category: "aaa", tags: ["aaa", cat], files: [`aaa/${n}.ogg`], loop: false,
  bus: "sfx" as const, volume: cat === "crowd" ? 0.7 : 0.8, voices: cat === "loops" ? 8 : 4, spatial: cat !== "crowd",
  licence: "CC0-1.0", credit: "freesound.org CC0, see public/sfx/SOURCES.json",
})));
export const AAA_CATALOG: SfxCatalog = { ...SFX_CATALOG, sounds: [...SFX_CATALOG.sounds, ...AAA_SOUNDS] };

/** A layer of one aaa take. */
const L = (n: string, v = 1, p = 1): Layer => ({ id: A(n), v, p });

// recorded layers, each a pool of 4 to 8 variants: real takes first, round-1 files at another pitch fill the rest
const P = {
  body: new Pool([
    L("punch_1", 1), L("punch_2", 1), L("thud_1", 0.9), L("thud_1", 0.8, 1.25), L("sword_hit_flesh", 0.8),
    { id: "impact_flesh", p: 0.8, v: 0.9 }, { id: "impact_flesh", p: 1.2, v: 0.7 }, { id: "impact_wood", p: 0.75, v: 0.6 },
  ]),
  ice: new Pool([
    L("ice_hit_1", 0.9), L("ice_hit_2", 0.9), L("ice_hit_3", 0.9), L("ice_shatter_1", 0.8), L("ice_shatter_2", 0.9), L("ice_break_3", 1), L("ice_shatter_2", 0.7, 1.3), L("ice_break_3", 0.9, 0.8),
    { id: "impact_glass", p: 1.0, v: 0.55 }, { id: "impact_glass", p: 1.4, v: 0.45 },
  ]),
  metal: new Pool([
    L("sword_hit_armor", 0.9), L("sword_clash", 1), L("sword_hit_armor", 0.8, 0.8), L("sword_clash", 1, 1.2),
    { id: "impact_metal", p: 0.7, v: 0.6 }, { id: "impact_metal", p: 1.1, v: 0.5 },
  ]),
  fire: new Pool([
    L("fire_boom_1", 0.9), L("fire_boom_2", 1), L("fire_boom_1", 0.8, 0.75), L("fire_boom_2", 0.9, 1.25),
    { id: "impact_concrete", p: 0.55, v: 0.7 }, { id: "splash_big", p: 0.6, v: 0.45 },
  ]),
  /** Frost release: a crisp crack. */
  frostRel: new Pool([L("frost_crack_1", 0.9), L("frost_crack_2", 0.9), L("frost_crack_3", 1), L("ice_cast_spell", 0.9), L("ice_arrow", 0.8)]),
  /** Arcane release: a snap. */
  arcRel: new Pool([L("arcane_snap_1", 0.9), L("arcane_snap_2", 1), L("arcane_snap_2", 0.8, 1.2)]),
  /** Airy whooshes (noise, not tonal). */
  air: new Pool([L("air_whoosh_1", 0.9), L("air_whoosh_2", 1), L("air_whoosh_3", 0.8), L("air_whoosh_1", 0.8, 1.25)]),
  dash: new Pool([L("dash_1", 1), L("dash_2", 1), L("dash_1", 0.9, 1.2), L("air_whoosh_2", 0.8, 0.9)]),
  jump: new Pool([L("jump_1", 1), L("jump_2", 1), L("jump_3", 1), L("jump_2", 0.9, 1.1)]),
  land: new Pool([L("land_1", 1), L("land_1", 0.9, 0.85), L("thud_1", 0.6, 1.3)]),
  /** Staff-cast breath and the arcane / frost launch. */
  cast: new Pool([L("spell_cast_1", 0.8), L("spell_whoosh_2", 0.9), L("ice_spell_4", 1), L("spell_item_3", 0.9, 0.9), L("spell_whoosh_2", 0.8, 1.2)]),
  /** Spell lands: arcane, magic impacts, zaps (counterspell, silence, polymorph pop). */
  zap: new Pool([L("zap_1", 0.9), L("zap_2", 1), L("spell_impact_1", 0.8), L("spell_impact_2", 0.8), L("zap_1", 0.8, 1.35), L("zap_2", 0.9, 0.8)]),
  /** Fireball leaving a staff. */
  fireLaunch: new Pool([L("fireball_whoosh", 0.9), L("fireball_whoosh", 0.8, 0.8), L("fireball_whoosh", 0.8, 1.25), L("spell_whoosh_2", 0.7, 0.6)]),
  /** Blade and fist swings. */
  slash: new Pool([L("sword_slash_1", 0.9), L("sword_slash_2", 1), L("sword_slash_3", 1), L("sword_slash_2", 0.9, 0.8), L("sword_slash_1", 0.8, 1.2)]),
  /** Blade lands on a body. */
  slice: new Pool([L("sword_hit_flesh", 1), L("sword_hit_armor", 0.8, 1.2), L("sword_hit_flesh", 0.9, 0.8), L("punch_1", 0.8, 0.8)]),
  big: new Pool([
    L("fire_boom_1", 0.9, 0.6), L("fire_boom_2", 0.9, 0.6), L("thud_1", 1, 0.7), L("spell_impact_2", 0.7, 0.7),
    { id: "car_crash", p: 0.6, v: 0.45 }, { id: "impact_concrete", p: 0.4, v: 1 },
  ]),
  boom: new Pool([L("fire_boom_1", 1, 0.5), L("fire_boom_2", 1, 0.5), L("thud_1", 1, 0.6), { id: "monster_roar", p: 0.5, v: 0.3 }, { id: "impact_concrete", p: 0.35, v: 1 }]),
  cheer: new Pool([L("crowd_cheer_4", 0.9), L("crowd_cheer_4", 0.8, 1.12), { id: "crowd_cheer", v: 0.8 }, { id: "crowd_cheer", p: 1.1, v: 0.7 }, { id: "crowd_roar_goal", v: 0.6 }, { id: "crowd_applause", v: 0.6 }]),
  ooh: new Pool([L("crowd_gasp_1", 1), L("crowd_gasp_2", 1), L("crowd_gasp_1", 0.9, 1.1), L("crowd_gasp_2", 0.9, 0.9), { id: "crowd_ooh", v: 0.8 }]),
  scream: new Pool([L("f_die_1", 0.9, 1.15), L("f_die_1", 0.8, 0.95), { id: "ghost_wail", p: 1.3, v: 0.8 }, { id: "creature_attack", p: 1.4, v: 0.6 }, L("m_die_1", 0.8, 1.2)]),
};

/** One voice (a person): hurt cries, effort grunts for attacks, a death cry. Each unit type gets different recordings. */
interface Voice { hurt: Pool; effort: Pool; die: Pool }
const V: Record<string, Voice> = {
  // the mage: a young man, grunts and cries from three different recordings
  player: {
    hurt: new Pool([L("m_hurt_4", 1), L("m_grunt_1", 1), L("m_hurt_6", 0.9), L("m_grunt_3", 1), L("m_hurt_4", 0.9, 1.1)]),
    effort: new Pool([L("m_grunt_3", 0.8), L("m_grunt_1", 0.7, 1.1)]),
    die: new Pool([L("m_die_1", 1, 1.1)]),
  },
  // the warrior: gruff and rough
  warrior: {
    hurt: new Pool([L("m_grunt_7", 1, 0.85), L("m_grunt_2", 1, 0.9), L("m_hurt_5", 0.9, 0.85), L("m_grunt_7", 0.9, 0.75)]),
    effort: new Pool([L("m_grunt_2", 0.9, 0.85), L("m_grunt_7", 1, 0.8), L("m_hurt_5", 0.7, 0.8)]),
    die: new Pool([L("m_die_1", 1, 0.9), L("m_die_2", 1, 1)]),
  },
  // the fire mage: a woman, sharp
  pyromancer: {
    hurt: new Pool([L("f_hurt_1", 1), L("f_hurt_2", 1), L("f_grunt_5", 1), L("f_hurt_1", 0.9, 1.1)]),
    effort: new Pool([L("f_grunt_4", 1), L("f_grunt_5", 0.9, 1.1), L("f_grunt_6", 0.9)]),
    die: new Pool([L("f_die_1", 1, 1.05)]),
  },
  // the dark priest: a second woman, softer
  priestess: {
    hurt: new Pool([L("f_ouch_3", 1), L("f_grunt_6", 1), L("f_hurt_2", 0.9, 0.9), L("f_ouch_3", 0.9, 0.9)]),
    effort: new Pool([L("f_grunt_6", 0.9, 0.9), L("f_ouch_3", 0.6, 1.1)]),
    die: new Pool([L("f_die_1", 1, 0.9)]),
  },
  // the elite gladiator: deep, from another man again
  gladiator: {
    hurt: new Pool([L("m_hurt_6", 1, 0.8), L("m_grunt_2", 1, 0.75), L("m_hurt_5", 0.9, 0.72), L("m_grunt_1", 0.9, 0.78)]),
    effort: new Pool([L("m_grunt_2", 1, 0.72), L("m_hurt_6", 0.8, 0.75), L("m_grunt_1", 0.9, 0.7)]),
    die: new Pool([L("m_die_2", 1, 0.8), L("m_die_1", 1, 0.75)]),
  },
};
const voiceOf = (k: string): Voice => V[k] ?? V.warrior;

/** Voice (pitch, chance per hit) of each enemy type. */
const VOICE: Record<string, { p: number; chance: number }> = {
  warrior: { p: 1, chance: 0.3 }, pyromancer: { p: 1, chance: 0.35 }, priestess: { p: 1, chance: 0.4 }, gladiator: { p: 1, chance: 0.2 },
};
const ARMORED = new Set(["warrior", "gladiator"]);

export const BANK_IDS = [
  "impact_flesh", "impact_wood", "impact_concrete", "impact_glass", "impact_metal", "splash_small", "splash_big", "car_bump", "car_crash",
  "spray_boost", "nitro_boost", "wind_rush", "puzzle_group_complete", "checkpoint", "puzzle_error", "pickup", "ui_click",
  "player_hurt", "player_death", "people_ouch", "people_effort", "people_hey", "ghost_wail", "creature_attack", "monster_roar",
  "step_concrete", "step_concrete_run", "crowd_cheer", "crowd_roar_goal", "crowd_applause", "crowd_ooh", "crowd_boo",
  "whistle_referee", "sting_gameover", "amb_stadium",
  ...AAA_IDS,
];

export interface DamageInfo { dstKind: string; srcKind: string; school: string; crit: boolean; amount: number; absorbed: number; dot: boolean; at?: At }

export class SoundBank {
  private c: AudioContext | null = null;
  private bus!: AudioNode;
  private verb!: ConvolverNode;
  private white!: AudioBuffer;
  private brown!: AudioBuffer;
  private ends = new Float64Array(32);
  private endI = 0;
  private lastDuck = -9;
  private lastHurt = -9;
  private lastVoice = new Map<string, number>();
  private cast = new Map<string, Stoppable>();
  private lastWhirl = new Map<string, number>();
  private orb: { stop: (f?: number) => void } | null = null;
  private readonly chanIds = ["aaa_chan_loop_1", "aaa_chan_loop_2", "aaa_chan_loop_3"];
  private readonly orbIds = ["aaa_orb_loop_2", "aaa_orb_loop_1"];
  private readonly blizzIds = ["aaa_blizz_loop_1", "aaa_blizz_loop_2"];
  private lastCrowd = -9;
  private lastStepSide = 0;

  constructor(private sfx: Sfx) {}

  /** Call once the Sfx exists: builds the limiter, the arena reverb and the noise buffers, so nothing is made mid-fight. */
  init() {
    const c = this.sfx.ctx;
    if (!c || this.c) return;
    this.c = c;
    const priv = this.sfx as unknown as { master: GainNode | null; buses: Map<string, GainNode> };
    this.bus = priv.buses.get("sfx") ?? c.destination;
    // master limiter: nothing clips, big hits glue instead of jumping out
    if (priv.master) {
      const lim = c.createDynamicsCompressor();
      lim.threshold.value = -9; lim.knee.value = 8; lim.ratio.value = 14; lim.attack.value = 0.002; lim.release.value = 0.18;
      priv.master.disconnect();
      priv.master.connect(lim);
      lim.connect(c.destination);
    }
    const sr = c.sampleRate, len = sr * 2;
    this.white = c.createBuffer(1, len, sr);
    this.brown = c.createBuffer(1, len, sr);
    const w = this.white.getChannelData(0), b = this.brown.getChannelData(0);
    let n = 0;
    for (let i = 0; i < len; i++) { w[i] = Math.random() * 2 - 1; n = (n + 0.02 * w[i]) / 1.02; b[i] = n * 3.5; }
    // arena tail: 1.1 s decaying stereo noise
    const il = Math.floor(sr * 1.1), imp = c.createBuffer(2, il, sr);
    for (let ch = 0; ch < 2; ch++) { const d = imp.getChannelData(ch); for (let i = 0; i < il; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / il, 2.6); }
    this.verb = c.createConvolver();
    this.verb.buffer = imp;
    const wet = c.createGain();
    wet.gain.value = 0.55;
    this.verb.connect(wet);
    wet.connect(this.bus);
  }

  /** Per frame: loops with an Object3D follow it inside the Sfx player; nothing to do here. */
  update() { /* kept for Game.ts */ }

  dispose() { this.orb?.stop(0); this.orb = null; this.blizz?.stop(0); this.blizz = null; for (const c of this.cast.values()) c.stop(0); this.cast.clear(); }

  // ------------------------------------------------------------------ plumbing

  private get ok() { return !!this.c; }

  /** Recorded layer from a pool (or a fixed layer). */
  private lib(l: Layer, o: { v?: number; p?: number; at?: At; delay?: number } = {}) {
    this.sfx.play(l.id, {
      volume: volJ((l.v ?? 1) * (o.v ?? 1)), pitch: pitchJ((l.p ?? 1) * (o.p ?? 1)), at: o.at, delay: o.delay, jitter: { pitch: 0, volume: 0 },
    });
  }
  private libId(id: string, o: { v?: number; p?: number; at?: At; delay?: number } = {}) { this.lib({ id }, o); }

  /** Voice budget for the procedural layers (phones): at most 26 alive at once. */
  private slot(t: number, dur: number): boolean {
    let live = 0;
    for (let i = 0; i < this.ends.length; i++) if (this.ends[i] > t) live++;
    if (live >= 26) return false;
    this.ends[this.endI] = t + dur;
    this.endI = (this.endI + 1) % this.ends.length;
    return true;
  }

  /** Entry node of one procedural sound: optional 3D panner, reverb send. */
  private route(at: At | undefined, wet: number): GainNode {
    const c = this.c!;
    const g = c.createGain();
    let tail: AudioNode = g;
    if (at) {
      const p = c.createPanner();
      p.panningModel = "equalpower"; p.distanceModel = "inverse"; p.refDistance = 2.5; p.maxDistance = 80; p.rolloffFactor = 1.1;
      const t = c.currentTime;
      let x: number, y: number, z: number;
      if ("matrixWorld" in at) { const e = at.matrixWorld.elements; x = e[12]; y = e[13]; z = e[14]; } else { x = at.x; y = at.y; z = at.z; }
      if (p.positionX) { p.positionX.setValueAtTime(x, t); p.positionY.setValueAtTime(y, t); p.positionZ.setValueAtTime(z, t); }
      g.connect(p);
      tail = p;
    }
    tail.connect(this.bus);
    if (wet > 0) { const s = c.createGain(); s.gain.value = wet; g.connect(s); s.connect(this.verb); }
    return g;
  }

  private env(t: number, a: number, peak: number, d: number, hold = 0): GainNode {
    const g = this.c!.createGain(), pk = Math.max(1e-4, peak);
    g.gain.setValueAtTime(1e-4, t);
    g.gain.exponentialRampToValueAtTime(pk, t + Math.max(0.001, a));
    if (hold > 0) g.gain.setValueAtTime(pk, t + a + hold);
    g.gain.exponentialRampToValueAtTime(1e-4, t + a + hold + Math.max(0.005, d));
    return g;
  }
  private shaper(k: number): WaveShaperNode {
    const ws = this.c!.createWaveShaper(), cv = new Float32Array(256), th = Math.tanh(k);
    for (let i = 0; i < 256; i++) cv[i] = Math.tanh(k * ((i / 255) * 2 - 1)) / th;
    ws.curve = cv;
    return ws;
  }

  private tone(out: AudioNode, t: number, o: { w?: OscillatorType; f0: number; f1?: number; a?: number; peak: number; d: number; hold?: number; drive?: number; lp?: number; hp?: number; bp?: [number, number] }) {
    const c = this.c!, osc = c.createOscillator();
    osc.type = o.w ?? "sine";
    osc.frequency.setValueAtTime(Math.max(10, o.f0), t);
    const total = (o.a ?? 0.004) + (o.hold ?? 0) + o.d;
    if (o.f1) osc.frequency.exponentialRampToValueAtTime(Math.max(10, o.f1), t + total);
    let n: AudioNode = osc;
    if (o.drive) { const s = this.shaper(o.drive); n.connect(s); n = s; }
    if (o.bp) { const f = c.createBiquadFilter(); f.type = "bandpass"; f.frequency.value = o.bp[0]; f.Q.value = o.bp[1]; n.connect(f); n = f; }
    if (o.lp) { const f = c.createBiquadFilter(); f.type = "lowpass"; f.frequency.value = o.lp; n.connect(f); n = f; }
    if (o.hp) { const f = c.createBiquadFilter(); f.type = "highpass"; f.frequency.value = o.hp; n.connect(f); n = f; }
    const e = this.env(t, o.a ?? 0.004, o.peak, o.d, o.hold ?? 0);
    n.connect(e); e.connect(out);
    osc.start(t); osc.stop(t + total + 0.05);
  }

  private noise(out: AudioNode, t: number, o: { b?: boolean; type?: BiquadFilterType; f0: number; f1?: number; q?: number; a?: number; peak: number; d: number; hold?: number; drive?: number }) {
    const c = this.c!, s = c.createBufferSource();
    s.buffer = o.b ? this.brown : this.white;
    s.loop = true;
    const total = (o.a ?? 0.003) + (o.hold ?? 0) + o.d;
    const f = c.createBiquadFilter();
    f.type = o.type ?? "bandpass"; f.Q.value = o.q ?? 0.8;
    f.frequency.setValueAtTime(Math.max(20, o.f0), t);
    if (o.f1) f.frequency.exponentialRampToValueAtTime(Math.max(20, o.f1), t + total);
    let n: AudioNode = f;
    s.connect(f);
    if (o.drive) { const w = this.shaper(o.drive); f.connect(w); n = w; }
    const e = this.env(t, o.a ?? 0.003, o.peak, o.d, o.hold ?? 0);
    n.connect(e); e.connect(out);
    s.start(t, Math.random() * 1.8); s.stop(t + total + 0.05);
  }

  // ------------------------------------------------------------------ recipes (procedural layers)
  // Round 3: synth is only used for SHORT NOISE TRANSIENTS (ticks, clicks, crackle grains, a sub thump) under a recorded
  // layer. No pitched oscillator sweeps, no sustained tones, no filter-swept whooshes: the cheap "woouuuh" is gone.

  /** Sharp ice crack: a scatter of bright noise ticks (no pitched ping). */
  private crack(out: AudioNode, t: number, g: number, p: number, big = false) {
    const n = (big ? 6 : 3) + Math.floor(Math.random() * 3);
    let at = 0;
    for (let i = 0; i < n; i++) {
      at += 0.004 + Math.random() * 0.02;
      this.noise(out, t + at, { type: "bandpass", f0: R(2600, 7500) * p, q: R(3, 9), a: 0.001, peak: g * (0.9 - i * 0.08), d: R(0.015, 0.05) });
    }
  }
  /** Low body thump: a very short sub drop plus a noise knock. */
  private thump(out: AudioNode, t: number, f: number, g: number, d = 0.2) {
    this.tone(out, t, { f0: f * 1.7, f1: f * 0.45, peak: g, d: Math.min(d, 0.2), a: 0.003 });
    this.noise(out, t, { type: "lowpass", f0: 520, f1: 120, a: 0.002, peak: g * 0.6, d: 0.07 });
  }
  /** Sub thump with a short rumble tail (the recorded boom carries the rest). */
  private boom(out: AudioNode, t: number, g: number, p: number, len = 0.9) {
    this.tone(out, t, { f0: 80 * p, f1: 40, peak: g * 0.8, d: Math.min(0.3, len * 0.4), a: 0.004 });
    this.noise(out, t, { b: true, type: "lowpass", f0: 300, f1: 90, peak: g * 0.7, d: Math.min(0.45, len * 0.5), a: 0.004 });
  }
  /** Electric snap: a burst of noise sparks (the recorded zap carries the tone). */
  private zap(out: AudioNode, t: number, g: number, p: number) {
    this.noise(out, t, { type: "bandpass", f0: 4200 * p, q: 1.5, peak: g * 0.5, d: 0.07, a: 0.001 });
    for (let i = 0; i < 5; i++) this.noise(out, t + R(0.01, 0.14), { type: "highpass", f0: 5000, peak: g * 0.35, d: 0.02, a: 0.001 });
  }
  /** Glittering crackle: a few short high noise grains. */
  private shimmer(out: AudioNode, t: number, g: number, p: number, dur: number, n = 7) {
    for (let i = 0; i < n; i++) this.noise(out, t + R(0, dur), { type: "bandpass", f0: R(4500, 9000) * p, q: R(4, 9), peak: g * R(0.12, 0.25), d: R(0.012, 0.03), a: 0.001 });
  }
  /** Metal ring: a noise click (the recorded clash carries the ring). */
  private clang(out: AudioNode, t: number, g: number, p: number) {
    this.noise(out, t, { type: "bandpass", f0: R(1800, 3200) * p, q: 3, peak: g * 0.5, d: 0.04, a: 0.001 });
    this.noise(out, t, { type: "highpass", f0: 3500, peak: g * 0.4, d: 0.03, a: 0.001 });
  }
  /** Blade through air: a very short click of air under the recorded slash. */
  private swing(out: AudioNode, t: number, g: number, p: number) {
    this.noise(out, t, { type: "bandpass", f0: 2400 * p, q: 1.6, a: 0.01, peak: g * 0.5, d: 0.07 });
  }
  /** Fire crackle: a handful of short noise pops. */
  private roar(out: AudioNode, t: number, g: number, _p: number, dur: number) {
    for (let i = 0; i < 8; i++) this.noise(out, t + R(0, Math.min(dur, 0.5)), { type: "bandpass", f0: R(1200, 4500), q: 3, peak: g * 0.35, d: 0.025, a: 0.001 });
  }
  private explode(out: AudioNode, t: number, g: number, p: number) {
    this.boom(out, t, g, p, 0.8);
    this.noise(out, t, { type: "lowpass", f0: 3000, f1: 600, peak: g * 0.6, d: 0.18, a: 0.002, drive: 2 });
    for (let i = 0; i < 5; i++) this.noise(out, t + R(0.03, 0.3), { type: "bandpass", f0: R(1200, 4000), q: 3, peak: g * 0.3, d: 0.03, a: 0.001 });
  }
  /** Dark shadow knock: brown-noise thump (no falling saw). */
  private whomp(out: AudioNode, t: number, g: number, _p: number) {
    this.noise(out, t, { b: true, type: "lowpass", f0: 500, f1: 100, peak: g * 0.8, d: 0.2 });
    this.tone(out, t, { f0: 90, f1: 45, peak: g * 0.5, d: 0.18, a: 0.003 });
  }

  // ------------------------------------------------------------------ events

  private go(dur: number, at: At | undefined, wet: number, fn: (out: AudioNode, t: number) => void, delay = 0) {
    const c = this.c;
    if (!c) return;
    const t = c.currentTime + delay;
    if (!this.slot(t, dur)) return;
    fn(this.route(at, wet), t);
  }

  private duck(db: number, s: number) {
    const now = this.c?.currentTime ?? 0;
    if (now - this.lastDuck < 0.35) return;
    this.lastDuck = now;
    this.sfx.duck("music", db, s, 0.5);
  }

  /** Sound-bank `at` for a loop that follows nothing: undefined. A recorded loop at low volume, with a safety stop. */
  private loopVoice(id: string, o: { v?: number; p?: number; at?: At; fade?: number; max?: number }): Stoppable | null {
    const v = this.sfx.play(id, { loop: true, volume: o.v ?? 1, pitch: o.p ?? 1, at: o.at, jitter: { pitch: 0, volume: 0 } });
    if (!v) return null;
    let done = false;
    const stop = (fade = o.fade ?? 0.12) => { if (done) return; done = true; v.stop(fade); };
    if (o.max) setTimeout(() => stop(0.3), o.max * 1000);
    return { stop };
  }

  /**
   * The player's spell starts its wind-up, or an enemy starts a cast. Magic schools get a soft RECORDED channel loop
   * (low in the mix) that lives while the bar fills; physical wind-ups get a grunt and a swing.
   */
  castStart(unit: string, id: string, school: string, total: number, at?: At) {
    if (!this.ok) return;
    this.cast.get(unit)?.stop(0.05);
    this.cast.delete(unit);
    const dur = Math.max(0.3, total);
    if (school === "physical") {
      if (id === "charge") this.lib(voiceOf(unit.split("-")[0]).effort.next(), { at, p: 0.9, v: 1 });
      else { this.lib(voiceOf(unit.split("-")[0]).effort.next(), { at, v: 0.8 }); this.lib(P.slash.next(), { at, v: 0.7, delay: 0.04 }); }
      return;
    }
    // a soft breath of magic as the cast starts, then the channel loop under it
    this.lib(P.cast.next(), { at, v: 0.3 });
    const pitch = school === "frost" ? 1.1 : school === "arcane" ? 1 : school === "fire" ? 0.8 : school === "shadow" ? 0.65 : 1.25;
    const loop = this.loopVoice(this.chanIds[school === "fire" ? 2 : school === "arcane" ? 1 : 0], { at, v: 0.2, p: pitch, max: dur + 1 });
    if (school === "shadow") this.libId("ghost_wail", { at, v: 0.3, p: 0.8 });
    if (!loop) return;
    // a few crackle grains while it builds (short noise ticks)
    const c = this.c!, t = c.currentTime;
    if (school !== "holy" && this.slot(t, dur)) {
      const out = this.route(at, 0.2), n = Math.round(dur * 5);
      for (let i = 0; i < n; i++) this.noise(out, t + R(0.05, dur), { type: "highpass", f0: R(4500, 8000), peak: R(0.03, 0.09), d: 0.018, a: 0.001 });
    }
    this.cast.set(unit, loop);
    this.duck(-1.5, Math.min(dur, 2));
  }
  castEnd(unit: string, ok: boolean) {
    const s = this.cast.get(unit);
    if (!s) return;
    this.cast.delete(unit);
    s.stop(ok ? 0.1 : 0.06);
  }

  /** The player's spell leaves the staff: a crisp recorded release (frost crack, arcane snap), instants too. */
  release(id: string, at?: At) {
    if (!this.ok) return;
    const crisp = (v = 0.9) => this.lib(P.frostRel.next(), { at, v });
    switch (id) {
      case "frostbolt":
        crisp(0.85); this.lib(P.air.next(), { at, v: 0.5, p: 1.1, delay: 0.02 });
        this.go(0.3, at, 0.3, (o, t) => this.crack(o, t, 0.35, 1.3));
        this.duck(-2, 0.3);
        break;
      case "icelance":
        crisp(1); this.lib(P.air.next(), { at, v: 0.55, p: 1.35, delay: 0.01 });
        this.go(0.3, at, 0.25, (o, t) => this.crack(o, t, 0.4, 1.5));
        this.duck(-2, 0.3);
        break;
      case "nova":
        this.lib(P.ice.next(), { v: 1.1 }); this.lib(P.ice.next(), { delay: 0.05, p: 0.8 }); crisp(0.9);
        this.go(0.8, at, 0.45, (o, t) => { this.boom(o, t, 0.8, 1.1, 0.7); this.crack(o, t, 0.8, 0.9, true); this.shimmer(o, t + 0.05, 1, 1, 0.4, 8); });
        this.duck(-4, 0.5);
        break;
      case "polymorph":
        this.lib(P.arcRel.next(), { v: 0.9 }); this.libId("puzzle_group_complete", { v: 0.5, p: 1.2, delay: 0.05 });
        this.go(0.4, at, 0.35, (o, t) => this.shimmer(o, t, 1, 1.4, 0.3, 6));
        break;
      case "counterspell":
        this.lib(P.arcRel.next(), { v: 1, p: 1.15 }); this.lib(P.zap.next(), { v: 0.6, p: 1.2 });
        this.go(0.3, at, 0.3, (o, t) => { this.zap(o, t, 0.7, 1.5); this.clang(o, t, 0.5, 1.8); });
        break;
      case "blink":
        this.lib(P.air.next(), { at, v: 0.7, p: 1.2 }); this.lib(P.arcRel.next(), { at, v: 0.45, p: 1.3, delay: 0.1 });
        this.go(0.4, at, 0.3, (o, t) => this.shimmer(o, t + 0.1, 0.9, 1.5, 0.25, 5));
        break;
      case "barrier":
        this.lib(P.ice.next(), { v: 0.7, p: 1.3 }); this.lib(P.arcRel.next(), { v: 0.5, p: 0.9, delay: 0.04 }); this.libId("checkpoint", { v: 0.35, p: 1.3, delay: 0.05 });
        this.go(0.8, at, 0.5, (o, t) => { this.shimmer(o, t, 1.2, 0.9, 0.6, 10); this.crack(o, t, 0.4, 1.1); });
        break;
      case "iceblock":
        this.lib(P.ice.next(), { v: 1, p: 0.7 }); this.lib(P.body.next(), { v: 0.5, p: 0.7, delay: 0.06 }); crisp(0.7);
        this.go(0.9, at, 0.4, (o, t) => { this.crack(o, t, 0.9, 0.7, true); this.crack(o, t + 0.1, 0.7, 0.9, true); this.boom(o, t, 0.8, 0.8, 0.6); });
        this.duck(-4, 0.5);
        break;
      case "coldsnap":
        this.lib(P.frostRel.next(), { v: 0.9 }); this.libId("puzzle_group_complete", { p: 1.5, v: 0.5, delay: 0.04 });
        this.go(0.6, at, 0.4, (o, t) => { this.shimmer(o, t, 1.2, 1.3, 0.5, 9); this.zap(o, t, 0.3, 1.6); });
        break;
      case "orb":
        this.lib(P.frostRel.next(), { v: 0.8, p: 0.8 }); this.lib(P.air.next(), { v: 0.55, p: 0.8, delay: 0.03 });
        this.go(0.6, at, 0.4, (o, t) => { this.boom(o, t, 0.5, 1.4, 0.5); this.shimmer(o, t, 1, 0.8, 0.4, 6); });
        break;
    }
  }

  /** Frozen orb: a recorded crackling-ice loop that follows the orb (no tone, no wind). Returns a handle to stop it. */
  orbLoop(follow: Object3D): Stoppable | null {
    if (!this.c) return null;
    this.orb?.stop(0.1);
    const a = this.loopVoice(this.orbIds[0], { at: follow, v: 0.7, p: R(0.95, 1.05), fade: 0.3 });
    const b = this.loopVoice(this.orbIds[1], { at: follow, v: 0.4, p: R(0.9, 1.1), fade: 0.3 });
    if (!a && !b) return null;
    const stop = (fade = 0.3) => {
      a?.stop(fade); b?.stop(fade);
      if (this.orb && this.orb.stop === stop) this.orb = null;
    };
    this.orb = { stop };
    return { stop };
  }

  // ---- movement, targeting and announcer stings (called from Game.ts and the HUD)

  /** The hero leaves the ground: a light effort breath, no sweep. */
  jump() {
    if (!this.ok) return;
    this.lib(P.jump.next(), { v: 0.6 });
    this.go(0.12, undefined, 0.05, (o, t) => this.noise(o, t, { b: true, type: "lowpass", f0: 400, peak: 0.25, d: 0.06, a: 0.003 }));
  }
  /** The hero lands on sand: a soft thud and a puff of grit. */
  land(hard = false) {
    if (!this.ok) return;
    this.lib(P.land.next(), { v: hard ? 0.9 : 0.6 });
    this.go(0.25, undefined, 0.05, (o, t) => this.noise(o, t, { type: "bandpass", f0: R(900, 1600), q: 0.7, peak: hard ? 0.4 : 0.25, d: 0.1, a: 0.004 }));
  }
  /** Shift dash: a short airy swish. */
  dash() {
    if (!this.ok) return;
    this.lib(P.dash.next(), { v: 0.85 });
    this.lib(P.air.next(), { v: 0.3, p: 1.3, delay: 0.02 });
  }
  /** Click-to-target: a soft UI tick. */
  target() {
    this.libId("ui_click", { v: 0.35, p: 1.5 });
  }

  private blizz: Stoppable | null = null;
  /** Blizzard ground zone: a low recorded icy-wind loop with crackles. Returns a handle; also stopped by blizzardStop(). */
  blizzardStart(at?: At): Stoppable | null {
    if (!this.ok) return null;
    this.blizz?.stop(0.1);
    const a = this.loopVoice(this.blizzIds[0], { at, v: 0.45, p: R(0.95, 1.05), fade: 0.5, max: 12 });
    const b = this.loopVoice(this.blizzIds[1], { at, v: 0.25, p: R(0.9, 1.1), fade: 0.5, max: 12 });
    if (!a && !b) return null;
    this.lib(P.ice.next(), { at, v: 0.4, p: 0.9 });
    const stop = (fade = 0.5) => { a?.stop(fade); b?.stop(fade); if (this.blizz === handle) this.blizz = null; };
    const handle: Stoppable = { stop };
    this.blizz = handle;
    return handle;
  }
  blizzardStop() { this.blizz?.stop(0.5); this.blizz = null; }

  /**
   * Announcer sting for a big banner: "interrupt", "charge", "shatter", "round", "victory", "defeat", "warn".
   * Recorded layers over a sub thump; never a tonal sweep.
   */
  announce(kind: string) {
    if (!this.ok) return;
    switch (kind) {
      case "interrupt":
        this.lib(P.arcRel.next(), { v: 1, p: 1.2 }); this.lib(P.metal.next(), { v: 0.6, p: 1.3, delay: 0.02 });
        this.go(0.3, undefined, 0.3, (o, t) => this.zap(o, t, 0.7, 1.5));
        break;
      case "charge":
        this.lib(P.big.next(), { v: 0.6, p: 0.9 }); this.lib(P.air.next(), { v: 0.5, p: 0.7 });
        this.go(0.5, undefined, 0.3, (o, t) => this.boom(o, t, 0.5, 0.9, 0.4));
        break;
      case "shatter":
        this.lib(P.ice.next(), { v: 1.1 }); this.lib(P.frostRel.next(), { v: 0.8, delay: 0.03 });
        this.go(0.5, undefined, 0.4, (o, t) => { this.crack(o, t, 0.8, 0.9, true); this.shimmer(o, t, 1, 1, 0.3, 6); });
        break;
      case "round":
        this.lib(P.big.next(), { v: 0.7 }); this.lib(P.metal.next(), { v: 0.5, p: 0.8, delay: 0.04 });
        this.go(0.6, undefined, 0.4, (o, t) => this.boom(o, t, 0.6, 0.9, 0.5));
        break;
      case "victory":
        this.libId("crowd_applause", { v: 0.8 }); this.lib(P.cheer.next(), { delay: 0.1 }); this.lib(P.big.next(), { v: 0.5, p: 1.1 });
        this.sfx.duck("ambience", 3, 2);
        break;
      case "defeat":
        this.libId("sting_gameover", { v: 0.8 }); this.libId("crowd_boo", { v: 0.6, delay: 0.2 });
        this.go(0.6, undefined, 0.5, (o, t) => this.boom(o, t, 0.7, 0.6, 0.5));
        break;
      case "warn":
        this.lib(P.metal.next(), { v: 0.5, p: 1.5 }); this.lib(P.zap.next(), { v: 0.5, p: 0.9, delay: 0.03 });
        break;
      default: return;
    }
    this.duck(-3, 0.6);
  }

  /** A projectile leaves an enemy staff. */
  enemyProj(kind: string, at?: At) {
    if (!this.ok) return;
    if (kind === "fireball") {
      this.go(0.6, at, 0.35, (o, t) => this.roar(o, t, 0.6, 1, 0.55));
      this.lib(P.fireLaunch.next(), { at, v: 0.9 });
    } else if (kind === "shadowbolt") {
      this.go(0.4, at, 0.4, (o, t) => this.whomp(o, t, 0.5, 1));
      this.lib(P.air.next(), { at, v: 0.5, p: 0.6 });
      this.libId("ghost_wail", { p: 0.7, v: 0.4, at });
    }
  }

  /** The spell lands. Frost = crystalline burst, fire = explosion, shadow = dark whomp. */
  projHit(kind: string, at: At | undefined, crit: boolean) {
    if (!this.ok) return;
    if (kind === "frostbolt" || kind === "icelance") {
      this.go(0.4, at, 0.3, (o, t) => { this.crack(o, t, 0.6, kind === "icelance" ? 1.3 : 1, crit); this.shimmer(o, t, 0.6, 1.2, 0.2, 4); });
      this.lib(P.ice.next(), { at, v: kind === "icelance" ? 0.9 : 0.75 });
      if (crit || kind === "icelance") this.lib(P.ice.next(), { at, v: 0.6, p: 1.15, delay: 0.03 });
    } else if (kind === "fireball") {
      this.go(1.1, at, 0.45, (o, t) => this.explode(o, t, 0.85, 1));
      this.lib(P.fire.next(), { at }); this.lib(P.fire.next(), { at, delay: 0.04, v: 0.7 });
      this.duck(-4, 0.5);
    } else {
      this.go(0.8, at, 0.4, (o, t) => this.whomp(o, t, 0.8, 1));
      this.lib(P.zap.next(), { at, p: 0.7, v: 0.8 });
      this.lib(P.body.next(), { at, p: 0.7 });
    }
  }
  projBlock(at?: At) {
    if (!this.ok) return;
    this.go(0.5, at, 0.4, (o, t) => { this.clang(o, t, 0.6, 0.6); this.crack(o, t, 0.4, 0.7); });
    this.lib(P.metal.next(), { at, v: 0.6, p: 0.7 });
  }

  /** Damage landed: body + material + sweetener, bigger on crits (frost crit = shatter). Voices are rare. */
  damage(d: DamageInfo) {
    if (!this.ok || d.dot) return;
    const toPlayer = d.dstKind === "player";
    const now = this.c!.currentTime;
    const big = d.crit || d.amount >= (toPlayer ? 120 : 140);
    if (d.amount <= 0) {
      // absorbed by the barrier: a short icy ping
      if (d.absorbed > 0) { this.go(0.3, d.at, 0.3, (o, t) => this.crack(o, t, 0.3, 1.5)); this.lib(P.ice.next(), { at: d.at, v: 0.35, p: 1.4 }); }
      return;
    }
    const at = toPlayer ? undefined : d.at;
    // 1. body
    this.lib(P.body.next(), { at, v: big ? 1.1 : 0.85 });
    // 2. material by school / attacker
    let dur = 0.5;
    switch (d.school) {
      case "frost":
        this.lib(P.ice.next(), { at, v: big ? 1 : 0.75 });
        this.go(dur, at, 0.3, (o, t) => { this.thump(o, t, 90, 0.5); this.crack(o, t, big ? 0.9 : 0.55, R(0.9, 1.3), big); });
        break;
      case "fire":
        this.lib(P.fire.next(), { at, v: 0.7 });
        this.go(dur, at, 0.3, (o, t) => { this.thump(o, t, 70, 0.6); this.noise(o, t, { type: "bandpass", f0: 1800, f1: 400, q: 1, peak: 0.4, d: 0.25 }); });
        break;
      case "shadow":
        this.lib(P.body.next(), { at, v: 0.5, p: 0.6 });
        this.go(0.6, at, 0.4, (o, t) => this.whomp(o, t, 0.55, 1));
        break;
      case "physical":
        if (ARMORED.has(d.srcKind)) { this.lib(P.metal.next(), { at, v: big ? 1 : 0.8 }); this.go(0.6, at, 0.35, (o, t) => { this.clang(o, t, big ? 0.9 : 0.6, R(0.8, 1.2)); this.thump(o, t, 80, 0.6); }); }
        else { this.lib(P.slice.next(), { at, v: big ? 1 : 0.8 }); this.go(dur, at, 0.2, (o, t) => this.thump(o, t, 100, 0.7)); }
        break;
      default: // arcane, holy
        this.lib(P.zap.next(), { at, v: big ? 1 : 0.75 });
        this.go(dur, at, 0.3, (o, t) => { this.zap(o, t, 0.5, 1.1); this.thump(o, t, 95, 0.45); });
    }
    // 3. sweetener: a short sparkle on ordinary hits
    if (!big && Math.random() < 0.6) this.go(0.3, at, 0.3, (o, t) => this.shimmer(o, t + 0.01, 0.5, R(1, 1.6), 0.12, 3));
    // crits and shatters: a bigger layered hit plus a low boom
    if (big) {
      this.go(1, at, 0.4, (o, t) => { this.boom(o, t, d.crit ? 0.8 : 0.55, R(0.9, 1.15), 0.7); if (d.school === "frost") { this.crack(o, t + 0.02, 0.9, 0.8, true); this.shimmer(o, t + 0.04, 1.2, 1, 0.5, 8); } });
      this.lib(P.big.next(), { at, v: 0.5 });
      this.duck(-5, 0.45);
      if (now - this.lastCrowd > 1.5 && !toPlayer) { this.lastCrowd = now; this.lib(P.ooh.next(), { v: 0.7, delay: 0.1 }); }
    }
    // 4. hurt voice: rare, a different grunt each time, per-unit and global cooldown
    this.voice(d, big, now);
  }

  private voice(d: DamageInfo, big: boolean, now: number) {
    const toPlayer = d.dstKind === "player";
    if (toPlayer) {
      if (now - this.lastHurt < 0.9) return;
      if (!big && Math.random() > 0.35) return;
      this.lastHurt = now;
      const l = V.player.hurt.next();
      this.lib(l, { v: big ? 0.95 : 0.7, p: big ? 0.92 : 1, delay: 0.03 });
      return;
    }
    const vc = VOICE[d.dstKind];
    if (!vc) return;
    const last = this.lastVoice.get(d.dstKind) ?? -9;
    if (now - last < 1.3 || Math.random() > (big ? 0.7 : vc.chance)) return;
    this.lastVoice.set(d.dstKind, now);
    this.lib(voiceOf(d.dstKind).hurt.next(), { at: d.at, p: vc.p * R(0.95, 1.06), v: big ? 1 : 0.8, delay: 0.04 });
  }

  heal(at?: At, enemy = false) {
    if (!this.ok) return;
    this.libId("pickup", { at, v: 0.5, p: enemy ? 0.9 : 1.2 });
    this.libId("checkpoint", { at, v: 0.3, p: enemy ? 0.8 : 1.1, delay: 0.05 });
    this.go(0.4, at, 0.45, (o, t) => this.shimmer(o, t, 0.6, enemy ? 0.8 : 1.1, 0.3, 4));
  }

  /** Crowd control lands. */
  cc(cat: string, at?: At) {
    if (!this.ok) return;
    switch (cat) {
      case "root": this.go(0.5, at, 0.35, (o, t) => { this.crack(o, t, 0.8, 0.8, true); this.thump(o, t, 70, 0.4); }); this.lib(P.ice.next(), { at, v: 0.8, p: 0.85 }); break;
      case "stun": this.go(0.8, at, 0.35, (o, t) => { this.boom(o, t, 0.6, 1.2, 0.4); this.clang(o, t, 0.5, 1.5); }); this.lib(P.metal.next(), { at, v: 0.7 }); break;
      case "silence": this.go(0.5, at, 0.3, (o, t) => this.zap(o, t, 0.5, 0.9)); this.lib(P.zap.next(), { at, v: 0.8 }); break;
      case "disorient": this.lib(P.scream.next(), { at, v: 0.8 }); break;
      default: break; // polymorph has its own sound
    }
  }
  polyOn(at?: At) {
    if (!this.ok) return;
    this.go(0.4, at, 0.4, (o, t) => this.shimmer(o, t, 1, 1.6, 0.3, 6));
    this.lib(P.arcRel.next(), { at, v: 0.7, p: 1.1 });
    this.libId("puzzle_group_complete", { at, v: 0.5, p: 1.7 });
  }
  polyBreak(at?: At) {
    if (!this.ok) return;
    this.go(0.3, at, 0.3, (o, t) => this.crack(o, t, 0.6, 1.4));
    this.lib(P.frostRel.next(), { at, v: 0.6, p: 1.2 });
  }
  interrupt(at?: At) {
    if (!this.ok) return;
    this.go(0.3, at, 0.35, (o, t) => { this.zap(o, t, 0.8, 1.6); this.clang(o, t, 0.5, 2); this.crack(o, t, 0.5, 1.4); });
    this.lib(P.arcRel.next(), { at, v: 0.9, p: 1.2 });
    this.lib(P.metal.next(), { at, v: 0.7, p: 1.3 });
    this.lib(P.zap.next(), { at, v: 0.9, p: 1.1 });
    this.lib(P.ooh.next(), { v: 0.8, delay: 0.08 });
    this.duck(-4, 0.4);
  }
  immune(at?: At) {
    if (!this.ok) return;
    this.go(0.4, at, 0.3, (o, t) => this.clang(o, t, 0.4, 1.7));
  }

  // enemy actions ----------------------------------------------------
  enemyAct(act: string, kind: string, at?: At) {
    if (!this.ok) return;
    switch (act) {
      case "windup": case "swing": case "pummel": case "throat":
        this.go(0.4, at, 0.25, (o, t) => this.swing(o, t, 0.7, kind === "gladiator" ? 0.7 : 1));
        this.lib(P.slash.next(), { at, v: 0.8, delay: 0.05 });
        this.lib(voiceOf(kind).effort.next(), { at, v: 0.85 });
        if (act !== "windup") this.go(0.6, at, 0.3, (o, t) => this.clang(o, t, 0.5, 0.8), 0.12);
        break;
      case "chargeTell":
        this.lib(voiceOf(kind).effort.next(), { at, p: 0.85, v: 1 });
        this.lib(P.air.next(), { at, v: 0.7, p: 0.7 });
        break;
      case "fireblast":
        this.go(0.9, at, 0.4, (o, t) => this.explode(o, t, 0.6, 1.2));
        this.lib(P.fire.next(), { at, v: 0.6 });
        break;
      case "pain":
        this.go(0.7, at, 0.4, (o, t) => this.whomp(o, t, 0.5, 1.3));
        break;
    }
  }
  scream(at?: At) {
    if (!this.ok) return;
    this.lib(P.scream.next(), { at, v: 1 });
    this.lib(P.air.next(), { at, v: 0.4, p: 0.8, delay: 0.05 });
  }
  /** Whirlwind: every tick is a recorded blade swish (no looping synth); a grunt every so often. */
  whirlTick(unit: string, at?: At) {
    const c = this.c;
    if (!c) return;
    const now = c.currentTime;
    const last = this.lastWhirl.get(unit) ?? -9;
    if (now - last > 1.2) this.lib(voiceOf(unit.split("-")[0]).effort.next(), { at, v: 0.9 });
    this.lastWhirl.set(unit, now);
    this.lib(P.slash.next(), { at, v: 0.6, p: R(0.9, 1.15) });
    this.go(0.2, at, 0.2, (o, t) => this.clang(o, t, 0.3, R(1, 1.4)));
  }
  chargeHit(at?: At) {
    if (!this.ok) return;
    this.go(0.6, at, 0.4, (o, t) => { this.boom(o, t, 0.9, 1, 0.6); this.noise(o, t, { type: "lowpass", f0: 1500, f1: 200, peak: 0.6, d: 0.2 }); });
    this.lib(P.big.next(), { v: 0.8 }); this.lib(P.body.next(), { v: 1, p: 0.6 });
    this.duck(-5, 0.5);
  }
  shockwave(at?: At) {
    if (!this.ok) return;
    this.go(0.8, at, 0.5, (o, t) => this.boom(o, t, 1, 0.8, 1));
    this.lib(P.boom.next(), { v: 0.9 });
    this.duck(-6, 0.7);
  }
  enemyBlink(at?: At) {
    if (!this.ok) return;
    this.lib(P.air.next(), { at, v: 0.7, p: 1.1 });
    this.lib(P.arcRel.next(), { at, v: 0.4, p: 0.9, delay: 0.1 });
  }

  // arena moments ------------------------------------------------------
  kill(kind: string, at?: At) {
    if (!this.ok) return;
    this.go(1.2, at, 0.5, (o, t) => this.boom(o, t, 0.5, 0.9, 0.9));
    this.lib(voiceOf(kind).die.next(), { at, v: 1 });
    this.lib(P.cheer.next(), { v: 1, delay: 0.15 });
    this.sfx.duck("ambience", 3, 2.2);
    this.duck(-3, 0.8);
  }
  crowdCheer() { this.lib(P.cheer.next(), { v: 0.7 }); this.sfx.duck("ambience", 2, 1.8); }
  whistle(boss: boolean) {
    this.libId("whistle_referee");
    this.go(1.4, undefined, 0.5, (o, t) => this.boom(o, t, 0.5, 0.8, 1), 0.05);
    this.lib({ id: "crowd_roar_goal", v: 0.8 }, { delay: 0.2 });
    this.sfx.duck("ambience", 3, 2);
    if (boss) { this.libId("monster_roar", { delay: 0.5, p: 0.8 }); this.go(1.5, undefined, 0.6, (o, t) => this.boom(o, t, 0.9, 0.7, 1.3), 0.5); }
  }
  roundEnd(won: boolean) {
    if (!this.ok) return;
    if (won) { this.libId("crowd_applause"); this.lib(P.cheer.next(), { delay: 0.2 }); this.lib(P.big.next(), { v: 0.4, p: 1.2, delay: 0.1 }); this.sfx.duck("ambience", 3, 3); }
    else { this.libId("crowd_boo"); this.libId("sting_gameover", { delay: 0.6 }); this.go(1.8, undefined, 0.6, (o, t) => this.boom(o, t, 0.8, 0.6, 1.5)); }
  }
  playerDeath() {
    if (!this.ok) return;
    this.libId("player_death", { v: 1 });
    this.go(1.5, undefined, 0.5, (o, t) => this.boom(o, t, 0.7, 0.7, 1.2));
    this.duck(-6, 1);
  }
  fail() { this.libId("puzzle_error", { v: 0.5, p: 1.1 }); }
  coin(at?: At) {
    if (!this.ok) return;
    this.libId("pickup", { v: 0.8, p: R(1.15, 1.4) });
    this.go(0.3, at, 0.3, (o, t) => this.shimmer(o, t, 0.6, R(1.3, 1.7), 0.2, 4));
  }
  ui(kind: "click" | "confirm" = "click") {
    this.libId("ui_click", { v: kind === "click" ? 0.6 : 0.8, p: kind === "click" ? 1 : 1.2 });
    if (this.ok && kind === "confirm") this.libId("checkpoint", { v: 0.3, p: 1.4 });
  }

  /** Footsteps on sand: a soft grit shuffle under a muffled thud, alternating feet. */
  step(run: boolean) {
    if (!this.ok) return;
    this.lastStepSide ^= 1;
    const side = this.lastStepSide ? 1 : 0.88;
    this.go(0.25, undefined, 0.08, (o, t) => {
      this.noise(o, t, { type: "bandpass", f0: R(1100, 2200) * side, f1: 500, q: 0.7, a: 0.008, peak: run ? 0.5 : 0.35, d: R(0.07, 0.13) });
      this.noise(o, t, { b: true, type: "lowpass", f0: 260, peak: run ? 0.5 : 0.35, d: 0.08, a: 0.002 });
      for (let i = 0; i < 2; i++) this.noise(o, t + R(0.01, 0.07), { type: "highpass", f0: 4500, peak: 0.07, d: 0.015, a: 0.001 });
    });
    if (Math.random() < 0.5) this.libId(run ? "step_concrete_run" : "step_concrete", { v: 0.18, p: R(0.55, 0.7) });
  }
}
