/* ============================================================
   combat.jsx — battle screen: tank + turn resolution
   ============================================================ */
const { useState, useEffect, useRef, useReducer } = React;

// Descripción de cada tipo de agua del tanque (tooltip al pasar el ratón sobre su etiqueta)
const WATER_TIP = {
  clear:   { title:'AGUA CLARA',  color:'#7fc8ff', icon:'ic_drop',  body:'El núcleo PERFECTO se AMPLÍA dentro de esta zona: pesca aquí para lograr críticos con facilidad.' },
  saline:  { title:'AGUA SALINA', color:'#1fd6c0', icon:'ic_swirl', body:'La corriente EMPUJA los objetos sueltos: aprovéchala para juntarlos y preparar redadas.' },
  thermal: { title:'AGUA TERMAL', color:'#ff8a3c', icon:'ic_flame', body:'Objetos y arpón corren MÁS rápido; cruzarla con el asta aplica QUEMADURA al enemigo.' },
  black:   { title:'AGUA NEGRA',  color:'#b06bff', icon:'ic_fog',   body:'Turbidez: pierdes lectura del tanque, pero CAPTURAR dentro da oro/recompensa extra.' },
  living:  { title:'AGUA VIVA',   color:'#5affb4', icon:'ic_heart', body:'Capturar dentro te CURA: róbale ese medio al enemigo.' },
  dead:    { title:'AGUA MUERTA', color:'#8aa6bf', icon:'ic_swirl', body:'APAGA Resonancia y neutrales y las capturas rinden a la mitad: evita tiros largos por aquí.' },
};

function Combat({ run, encounter, control = 'pendulum', cardDesign = 'token', swingSpeed = 1.7, sizeScale = 1, onWin, onLose, onUseConsumable, onUseGadget }) {
  const char = run.char;
  const canvasRef = useRef(null);
  const engineRef = useRef(null);
  const arenaRef = useRef(null);
  const playerDom = useRef(null);
  const enemyDoms = useRef({});
  const onResolveRef = useRef(() => {});

  // authoritative state in refs
  const playerRef = useRef({
    hp: run.hp, maxHp: char.maxHp, block: 0, strength: char.baseStrength || 0, statuses: {},
    thorns: 0,   // flat damage returned when block absorbs a hit (from cards/relics; combat-long)
  });
  const enemiesRef = useRef(encounter.map(e => ({ ...e, statuses: {}, block: 0, strength: 0, intentIdx: 0, dead: false })));
  const throwsRef = useRef(2);
  const resCountRef = useRef({});
  const turnAffRef = useRef({});
  const swallowNextRef = useRef(false);
  const doubleRef = useRef(false);
  const turnCountRef = useRef(0);
  // build-relic state
  const echoArmedRef = useRef(false);      // echo: first catch of the turn doubles
  const ghostPendingRef = useRef(null);    // ECO FANTASMA: trayectoria a repetir como arpón espectral el próximo turno
  const bonusThrowRef = useRef(false);     // trident: +1 throw granted this turn
  const throwIdxRef = useRef(0);           // resocore: items resolved so far this throw
  const lastBreathUsedRef = useRef(false); // lastbreath: once-per-combat survive
  const chargeRef = useRef(1);             // COLOSO 'charge': x1.5 when a throw catches a single object
  const eatenRef = useRef(0);              // cards eaten by surviving Gulpers (applied to next hand)
  const placedGadgetsRef = useRef([]);     // gadgets deployed in the tank (persist across turns)
  const tankLootRef = useRef(0);           // real loot tokens spawned this turn (for the 'tanque limpio' achievement)
  const morenaMarkRef = useRef(null);      // Mordida Preparada: token marked to be devoured if not fished
  const morenaPendingRef = useRef(false);  // Mordida Preparada: marked token survived the turn → devoured at START of next turn
  const tunedMarkRef = useRef(null);       // Afinación (Acto VI): tuned neutral → bonus if fished, eco corrupto if not
  const routeJamRef = useRef({ route: null, turns: 0 });   // DISTORSIÓN: forced curvy route for N turns
  const modeJamRef = useRef({ mode: 'pendulum', turns: 0 });   // modo de control forzado (TIMÓN FANTASMA=péndulo, CALIBRE=power)
  const gustRef = useRef(0);               // VENDAVAL: péndulo acelerado N turnos
  const shortRef = useRef(0);              // ARPÓN CORTO: alcance reducido N turnos
  const inkSourRef = useRef(false);        // TRAMPA DE TINTA: clear zone soured into Agua Negra next turn (T2)
  const livingHealRef = useRef(false);     // Cantimplora Viva: cura mayor solo la 1ª vez en el combate (T2)
  const pressureRef = useRef(0);           // ACTO 8: presión dinámica de la Sima (sube por turno, ventila al capturar)
  const lastCaptureRef = useRef(0);        // nº de capturas del tiro previo (para ventilar la presión)
  const osmoSetRef = useRef(null);         // Salmuera: tipos de agua robados este combate (logro osmosis)
  const gidRef = useRef(0);
  const [gdrag, setGdrag] = useState(null); // {id, idx, x, y} drag ghost while placing a gadget
  const dmgTakenRef = useRef(0);           // total HP lost this combat (for 'intachable')
  const critCountRef = useRef(0);          // crits this throw (for 'perfecto3')
  // per-character HARPOON GRAMMAR (identidad de arpón) — all reset each throw
  const preyAffRef = useRef(null);         // TUNTUN: affinity of the first token (the "presa")
  const preyOpenRef = useRef(false);       // TUNTUN: PERFECTO on the prey opens it → chained tokens get bonus
  const voltioLastAffRef = useRef(null);   // VOLTIO: last caught affinity (circuit jump)
  const oxidoJunkRef = useRef(false);      // ÓXIDO: first junk of the throw becomes Metralla
  const lossCauseRef = useRef('El abismo');
  function award(id) { if (window.GAME.unlockAch(id)) { setAchToast(window.GAME.ACH[id].name); setTimeout(() => setAchToast(a => a === window.GAME.ACH[id].name ? null : a), 2600); if (window.pushUnlock) window.pushUnlock('LOGRO DESBLOQUEADO', window.GAME.ACH[id].name, '#b06bff', 'ic_star'); } }

  const [, force] = useReducer(x => x + 1, 0);
  const [phase, setPhase] = useState('intro');   // intro|player|resolving|enemy|won|lost
  const phaseRef = useRef('intro');
  const setPhaseBoth = (p) => { phaseRef.current = p; setPhase(p); };
  const [floats, setFloats] = useState([]);
  const [banner, setBanner] = useState('');
  const [lastCatch, setLastCatch] = useState(null);
  const [catchQueue, setCatchQueue] = useState([]);   // caught-this-throw, drains as each resolves
  const catchIdRef = useRef(0);
  const [threats, setThreats] = useState(null);
  const [tankFx, setTankFx] = useState(null);   // animated toast when enemy effects hit the tank
  const [tankRead, setTankRead] = useState(null);
  const [hoverToken, setHoverToken] = useState(null);
  const waterTipRef = useRef(false);   // ¿tooltip de tipo de agua visible? (evita ocultarlo en cada move)
  const [flash, setFlash] = useState(null);
  const [coachStep, setCoachStep] = useState(() => { try { return localStorage.getItem('tuntun_onboard_v2') ? null : 0; } catch (e) { return null; } });
  function completeCoach() { try { localStorage.setItem('tuntun_onboard_v2', '1'); } catch (e) {} setCoachStep(null); }
  function advanceCoach(step) {
    setCoachStep(s => s == null ? s : Math.max(s, step));
  }
  const [deckOpen, setDeckOpen] = useState(false);
  const [bossRule, setBossRule] = useState(null);
  const [achToast, setAchToast] = useState(null);
  const [route, setRoute] = useState('recto');         // selected harpoon trajectory this turn
  const [routeModal, setRouteModal] = useState(false); // mini-modal para cambiar la ruta/arpón
  const [combo, setCombo] = useState(0);               // catches chained this throw (juice overlay shows when ≥2)
  const [statusPulse, setStatusPulse] = useState(0);   // bumps to flash the top player-status banner
  function flashPlayerStatus() { setStatusPulse(n => n + 1); }
  const [relicFlash, setRelicFlash] = useState({});   // relicId → flashing count (glow when >0)
  function flashRelic(id) {
    if (!id || !(run.relics || []).includes(id)) return;
    setRelicFlash(f => ({ ...f, [id]: (f[id] || 0) + 1 }));
    setTimeout(() => setRelicFlash(f => { const n = { ...f }; n[id] = Math.max(0, (n[id] || 1) - 1); return n; }), 550);
  }
  const rootRef = useRef(null);

  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
  function flashScreen(color) { const id = Math.random(); setFlash({ color, id }); setTimeout(() => setFlash(f => f && f.id === id ? null : f), 340); }
  function shakeStage() { const el = rootRef.current; if (!el) return; el.classList.remove('shaking'); void el.offsetWidth; el.classList.add('shaking'); setTimeout(() => el && el.classList.remove('shaking'), 340); }
  function punchStage() { const el = rootRef.current; if (!el) return; el.classList.remove('punching'); void el.offsetWidth; el.classList.add('punching'); setTimeout(() => el && el.classList.remove('punching'), 280); }
  function resScale(prior) {
    if (prior <= 2) return prior;
    if (prior <= 4) return 2 + (prior - 2) * 0.5;
    return 3 + (prior - 4) * 0.25;
  }
  function calcResBonus(prior, affs, resPer) {
    const scaled = resScale(prior);
    let bonus = scaled * resPer;
    if (char.flag === 'filoreso' && affs.includes('filo')) bonus += scaled;
    return Math.round(bonus);
  }
  function normalizeWater(w) {
    const out = { level:1, flow:{ x:0, y:0, strength:0 }, clarity:1, heat:0, corruption:0, vitality:0, ...(w || {}) };
    out.flow = { x:0, y:0, strength:0, ...(out.flow || {}) };
    out.level = clamp(out.level, 0.45, 1);
    out.clarity = clamp(out.clarity, 0, 1.35);
    out.heat = clamp(out.heat, 0, 1.5);
    out.corruption = clamp(out.corruption, 0, 1.5);
    out.vitality = clamp(out.vitality, 0, 1.5);
    out.flow.strength = clamp(out.flow.strength, 0, 1.8);
    return out;
  }
  function alterWaterState(w, delta) {
    const out = normalizeWater(w);
    if (delta.level != null) out.level = delta.level;
    out.clarity += delta.clarity || 0;
    out.heat += delta.heat || 0;
    out.corruption += delta.corruption || 0;
    out.vitality += delta.vitality || 0;
    if (delta.flow) {
      out.flow.x = delta.flow.x != null ? delta.flow.x : out.flow.x;
      out.flow.y = delta.flow.y != null ? delta.flow.y : out.flow.y;
      out.flow.strength += delta.flow.strength || 0;
    }
    return normalizeWater(out);
  }
  function waterSummary(w) {
    w = normalizeWater(w);
    const bits = [];
    if (w.level < 0.98) bits.push({ label:'BAJAMAR', color:'#7fc8ff' });
    if (w.flow.strength > 0.25) bits.push({ label:'CORRIENTE', color:'#1fd6c0' });
    if (w.clarity > 1.04) bits.push({ label:'CLARA', color:'#7fc8ff' });
    if (w.clarity <= 0.62) bits.push({ label:'TURBIA', color:'#b06bff' });
    if (w.heat > 0.25) bits.push({ label:'TERMAL', color:'#ff8a3c' });
    if (w.vitality > 0.25) bits.push({ label:'VIVA', color:'#5affb4' });
    if (w.corruption > 0.35) bits.push({ label:'MUERTA', color:'#8aa6bf' });
    return bits;
  }
  function waterPotency(kind, eng) {
    const e = eng || engineRef.current;
    const w = normalizeWater(e && e.water);
    const hits = (e && e._waterHits) || {};
    let p = 0;
    if (kind === 'clear') p = (w.clarity - 1) / 0.35;
    else if (kind === 'saline') p = (w.flow && w.flow.strength || 0) / 1.15;
    else if (kind === 'thermal') p = w.heat / 1.15;
    else if (kind === 'black') p = Math.max((0.68 - w.clarity) / 0.68, w.corruption * 0.35);
    else if (kind === 'living') p = w.vitality / 1.15;
    else if (kind === 'dead') p = w.corruption / 1.15;
    if (hits[kind]) p = Math.max(p, 0.65);
    return clamp(p, 0, 1);
  }
  function buildTankRead(list, mods, water) {
    const aff = {};
    let hazards = 0, junk = 0, active = 0, neutrals = 0;
    list.forEach(it => {
      if (!it) return;
      if (it.neutral) { neutrals++; return; }
      if (it.hazard) hazards++;
      if (it.junk) junk++;
      if (!it.hazard && !it.junk) {
        active++;
        if (it.aff) aff[it.aff] = (aff[it.aff] || 0) + 1;
        if (it.aff2) aff[it.aff2] = (aff[it.aff2] || 0) + 1;
      }
    });
    const best = Object.entries(aff).sort((a, b) => b[1] - a[1])[0] || null;
    const resPer = window.GAME.RES_BASE + ((run.relics || []).includes('resonator') ? 1 : 0);
    const bestBonus = best ? calcResBonus(Math.max(0, best[1] - 1), [best[0]], resPer) : 0;
    return { aff, best, bestBonus, hazards, junk, active, neutrals, mods, water: normalizeWater(water), waterBits: waterSummary(water) };
  }

  /* ---------- helpers ---------- */
  function aliveEnemies() { return enemiesRef.current.filter(e => !e.dead && e.hp > 0); }
  function frontEnemy() { return aliveEnemies()[0]; }

  function relPos(el) {
    if (!el || !arenaRef.current) return { x: 0, y: 0 };
    const a = arenaRef.current.getBoundingClientRect();
    const scale = a.width / arenaRef.current.offsetWidth || 1;
    const r = el.getBoundingClientRect();
    return { x: (r.left - a.left + r.width / 2) / scale, y: (r.top - a.top + r.height / 2) / scale };
  }
  let floatId = 0;
  function popNum(el, text, color, dy = 0) {
    const p = relPos(el);
    const id = 'f' + (floatId++) + Math.random();
    setFloats(f => [...f, { id, x: p.x + (Math.random()*20-10), y: p.y - 30 + dy, text, color }]);
    setTimeout(() => setFloats(f => f.filter(x => x.id !== id)), 950);
  }
  function animate(el, cls, dur = 320) {
    if (!el) return; el.classList.add(cls); setTimeout(() => el.classList.remove(cls), dur);
  }

  /* ---------- damage / effects ---------- */
  function damageEnemy(enemy, amount) {
    if ((run.relics || []).includes('glassheart')) { amount *= 1.25; flashRelic('glassheart'); }   // build: more outgoing damage
    if ((enemy.statuses.vuln || 0) > 0) amount *= 1.5;   // VULNERABLE: +50% incoming
    let d = Math.max(0, Math.round(amount));
    if (enemy.block > 0) { const ab = Math.min(enemy.block, d); enemy.block -= ab; d -= ab; }
    enemy.hp -= d;
    const el = enemyDoms.current[enemy.id];
    animate(el, 'hurt');
    popNum(el, '-' + d, '#ff5a3c');
    if (window.SFX) window.SFX.enemyHit();
    if (enemy.hp <= 0) {
      enemy.hp = 0; enemy.dead = true;
      if (window.GAME.markSeen && enemy.key && window.GAME.markSeen(enemy.key) && window.pushUnlock)   // bestiario
        window.pushUnlock('NUEVO EN EL BESTIARIO', enemy.name, '#2ce8d8', enemy.boss ? 'ic_skull' : 'ic_star');
      animate(el, 'death-pop', 520);
      if (window.SFX) window.SFX.death();
      if (el) setTimeout(() => { el.style.opacity = '0.15'; }, 480);
    }
  }
  // FRÁGIL: gain 40% less block while affected (player or enemy)
  function frailAdj(v, entity) { return (entity.statuses && entity.statuses.frail > 0) ? Math.max(0, Math.round(v * 0.6)) : v; }
  function damagePlayer(amount, source) {
    if (source) lossCauseRef.current = source;
    const p = playerRef.current;
    const relics = run.relics || [];
    if (relics.includes('glassheart') && amount > 0) amount += 1;   // build: chip damage cost
    if ((p.statuses.vuln || 0) > 0 && amount > 0) amount *= 1.5;   // VULNERABLE: +50% incoming
    let d = Math.max(0, Math.round(amount));
    if (p.block > 0) { const ab = Math.min(p.block, d); p.block -= ab; d -= ab; if (ab>0) popNum(playerDom.current, 'BLOCK', '#2ce8d8', -22); }
    p.hp -= d;
    if (d > 0) dmgTakenRef.current += d;   // for 'intachable' (flawless boss)
    // lastbreath: survive the first lethal blow of the combat on 1 HP
    if (p.hp <= 0 && relics.includes('lastbreath') && !lastBreathUsedRef.current) {
      lastBreathUsedRef.current = true; p.hp = 1; flashRelic('lastbreath');
      popNum(playerDom.current, '¡AGUANTA!', '#ff6b8b', -44); if (window.SFX) window.SFX.heal && window.SFX.heal();
    }
    animate(playerDom.current, 'hurt');
    if (d > 0) { popNum(playerDom.current, '-' + d, '#ff3b3b'); flashScreen('radial-gradient(circle, rgba(255,40,40,0) 40%, rgba(255,40,40,.5) 100%)'); shakeStage(); if (window.SFX) window.SFX.playerHit(); }
    else if (window.SFX) window.SFX.block();
  }

  async function applyEffect(it, crit, inDead) {
    const e = it.eff, p = playerRef.current;
    const relics = run.relics || [];
    if (inDead && relics.includes('piedrainerte')) inDead = false;   // Piedra Inerte: el Agua Muerta no te apaga a TI
    const chainPos = throwIdxRef.current;   // 0 = first catch of the throw, ≥1 = chained
    let mult = 1;
    if (doubleRef.current) { mult = 2; doubleRef.current = false; popNum(playerDom.current, 'x2!', '#ffd23f', -40); }
    if (chargeRef.current > 1) mult *= chargeRef.current;   // COLOSO charge (single-catch throw)
    const critMul = crit ? (relics.includes('trueeye') ? 2 : 1.5) : 1;   // trueeye: PERFECTO x2

    // RESONANCE — same-affinity items caught earlier this throw amplify this one (dual-aff counts for both)
    const affs = it.aff2 ? [it.aff, it.aff2] : [it.aff];
    const resPer = window.GAME.RES_BASE + (relics.includes('resonator') ? 1 : 0);
    let prior = 0; affs.forEach(a => { prior = Math.max(prior, resCountRef.current[a] || 0); });
    if (relics.includes('resocore')) prior = Math.max(prior, throwIdxRef.current);  // resocore: any chained object resonates
    // AGUA MUERTA (T2): capturar dentro AMORTIGUA la Resonancia a la MITAD (sigue contando en la cadena)
    const resBonus = inDead ? Math.round(calcResBonus(prior, affs, resPer) * 0.5) : calcResBonus(prior, affs, resPer);
    affs.forEach(a => { resCountRef.current[a] = (resCountRef.current[a] || 0) + 1; });
    affs.forEach(a => { turnAffRef.current[a] = (turnAffRef.current[a] || 0) + 1; });
    throwIdxRef.current += 1;
    if (resBonus > 0) {
      advanceCoach(3);
      popNum(playerDom.current, 'RESO+' + resBonus, it.affColor || '#ffd23f', -44); if (window.SFX) window.SFX.reso();
      // anti-mono: Coral Wardens harden when you chain same-affinity
      enemiesRef.current.forEach(en => { if (!en.dead && en.hp > 0 && en.mod === 'ward') {
        const w = frailAdj(en.ward || 6, en); en.block += w; popNum(enemyDoms.current[en.id], '+' + w, '#2ce8d8', -24); } });
    }
    // CADENA ROTA — pescar un token ANCLADO/bloqueado con ¡PERFECTO! rompe la cadena y te recompensa
    if (crit && it._locked) { const bl = frailAdj(6, p); p.block += bl; popNum(playerDom.current, 'CADENA ROTA +' + bl, '#2ce8d8', -68); if (window.SFX) window.SFX.block && window.SFX.block(); }
    if (crit) { popNum(playerDom.current, '¡PERFECTO!', '#ffd23f', -58); if (window.SFX) window.SFX.crit(); flashScreen('radial-gradient(circle, rgba(255,210,63,.35) 0%, rgba(255,210,63,0) 60%)'); shakeStage(); punchStage();
      critCountRef.current++; if (critCountRef.current >= 3) award('perfecto3');
      if (critCountRef.current === 2) {   // CORTE LIMPIO: 2 PERFECTO en un tiro disuelven la chatarra/erizos
        engineRef.current.purgeJunk();
        setThreats(t => t ? { ...t, junk: 0, hazard: 0 } : t);
        popNum(playerDom.current, 'CORTE LIMPIO', '#ffd23f', -72);
        flashScreen('radial-gradient(circle, rgba(255,210,63,.4) 0%, rgba(255,210,63,0) 65%)'); shakeStage();
      } }
    if (prior >= 4) award('resonante');   // 5+ same-affinity chained in one throw

    const filoBonus = (relics.includes('sawtooth') && affs.includes('filo')) ? 2 : 0;
    const venomBonus = (relics.includes('venomlord') && affs.includes('toxina')) ? 2 : 0;
    const grazeMul = (it.graze && !crit) ? 0.5 : 1;                                          // graze: half unless PERFECTO
    const staticBonus = (relics.includes('ballastgreed') && it.move === 'static') ? 3 : 0;   // ballastgreed: quiet objects +3
    const chainBonus = (e.perChain || 0) * Math.min(chainPos, 6);   // perChain escala con eslabones previos (cap 6 para que el AoE no sea infinito)

    // ---- HARPOON GRAMMAR por personaje (identidad de arpón, prototipo) ----
    let charDmg = 0;
    const cid = char.id;
    if (cid === 'tuntun') {                              // Caza Sangrienta: presa + cadena ofensiva
      if (chainPos === 0) { preyAffRef.current = it.aff; if (crit) preyOpenRef.current = true; }
      else { if (preyAffRef.current && affs.includes(preyAffRef.current)) charDmg += 2;   // misma presa
             if (preyOpenRef.current) charDmg += 1; }                                     // presa abierta (PERFECTO)
    } else if (cid === 'voltio') {                       // Cadena Viva: circuito de misma afinidad
      if (voltioLastAffRef.current && affs.includes(voltioLastAffRef.current)) { charDmg += 2;
        const al = aliveEnemies(); const t = al[Math.floor(Math.random()*al.length)];
        if (t) { popNum(enemyDoms.current[t.id], '⚡CIRCUITO', '#2ce8d8', -34); } }
      voltioLastAffRef.current = it.aff;
    } else if (cid === 'brinco') {                       // Carambola: la cadena del dardo errante pega más cuanto más larga
      charDmg += chainPos;                               // +1 por cada captura previa en este tiro (cada una = un rebote más)
      if (chainPos >= 2) popNum(playerDom.current, '¡CARAMBOLA!', '#1fd6c0', -38);
    } else if (cid === 'oxido' && it.junk && !oxidoJunkRef.current) {   // Chatarrero: 1ª chatarra = Metralla
      oxidoJunkRef.current = true;
      const dmg = Math.round((4 + p.strength) * (doubleRef.current ? 2 : 1));
      aliveEnemies().forEach(t => damageEnemy(t, dmg));
      run.gold = (run.gold || 0) + 3;
      popNum(playerDom.current, 'METRALLA +3 ORO', '#ffd23f', -52); shakeStage();
    }
    const shoalBonus = relics.includes('shoal') ? Math.max(0, run.deck.length - 10) : 0;     // shoal: +1 dmg per card over 10
    // TEMPORADA 2 — bonus si el arpón cruzó cualquier zona de agua este tiro
    const eng0 = engineRef.current;
    const crossedWater = !!(eng0 && eng0._waterHits && Object.keys(eng0._waterHits).length);
    const waterBonus = (e.ifWaterBonus && crossedWater) ? e.ifWaterBonus : 0;
    // ACTOS 9/10 — bonos de polaridad y refracción
    const prismHit = !!(eng0 && eng0._prismHit);
    const polarBonus = (relics.includes('chargedcore') && eng0 && eng0.polarity) ? 3 : 0;   // Núcleo Cargado
    const salPower = waterPotency('saline', eng0);
    const salBonus = (relics.includes('saldekraken') && salPower > 0) ? 2 + Math.round(salPower * 3) : 0;   // Sal de Kraken
    const prismBonus = (e.ifPrism && prismHit ? e.ifPrism : 0) + (relics.includes('prismlens') && prismHit ? 6 : 0);
    if (prismBonus && prismHit) flashRelic('prismlens');
    const R = (base, extra) => Math.round((base + resBonus + (extra||0)) * critMul * grazeMul) * mult;

    // relic glow feedback (top-centre bar lights up)
    if (filoBonus) flashRelic('sawtooth');
    if (venomBonus) flashRelic('venomlord');
    if (resBonus > 0 && relics.includes('resonator')) flashRelic('resonator');
    if (relics.includes('resocore') && prior > 0) flashRelic('resocore');
    if (crit && relics.includes('trueeye')) flashRelic('trueeye');
    if (shoalBonus) flashRelic('shoal');
    if (staticBonus) flashRelic('ballastgreed');
    if (salBonus) flashRelic('saldekraken');

    animate(playerDom.current, 'lunge');

    if (e.damage != null) {
      const dead = enemiesRef.current.filter(en => en.dead).length;
      const dmg = R(e.damage, p.strength + filoBonus + staticBonus + shoalBonus + chainBonus + charDmg + waterBonus + polarBonus + prismBonus + salBonus + (e.perDead ? e.perDead * dead : 0));
      if (waterBonus) popNum(playerDom.current, 'AGUA+' + waterBonus, '#7fc8ff', -46);
      if (prismBonus) popNum(playerDom.current, 'PRISMA+' + prismBonus, '#9ae6ff', -46);
      const targets = e.all ? aliveEnemies() : [frontEnemy()].filter(Boolean);
      targets.forEach(t => damageEnemy(t, dmg));
    }
    if (e.block != null) { const vv=frailAdj(R(e.block, staticBonus + chainBonus), p); p.block += vv; popNum(playerDom.current, '+' + vv, '#2ce8d8', -20); if (window.SFX) window.SFX.block(); }
    if (e.thorns != null) { p.thorns += e.thorns; popNum(playerDom.current, 'ESPINAS+' + e.thorns, '#2ce8d8', -34); }
    if (e.blockDamage != null) {                       // tidecrash: turn your wall into a hit
      const t = frontEnemy();
      if (t) { const dmg = R(0, Math.round(p.block * e.blockDamage)); if (dmg > 0) damageEnemy(t, dmg); }
    }
    if (e.detonatePoison != null) {                    // rupture: blow up stacked poison
      const t = frontEnemy();
      if (t) { const psn = t.statuses.poison || 0;
        if (psn > 0) { const dmg = Math.round(psn * e.detonatePoison * critMul) * mult; damageEnemy(t, dmg); t.statuses.poison = 0; popNum(enemyDoms.current[t.id], 'DETONA', '#9ae600', -28); } }
    }
    if (e.poison != null) { const t = frontEnemy(); if (t) { const v=R(e.poison, venomBonus); t.statuses.poison = (t.statuses.poison||0) + v; popNum(enemyDoms.current[t.id], 'PSN+'+v, '#9ae600', -24); if (window.SFX) window.SFX.poison(); } }
    if (e.burn != null) { const tgts = e.all ? aliveEnemies() : [frontEnemy()].filter(Boolean); const v=R(e.burn, venomBonus); tgts.forEach(t => { t.statuses.burn = (t.statuses.burn||0) + v; popNum(enemyDoms.current[t.id], 'BURN+'+v, '#ff9f1c', -24); }); if (tgts.length && window.SFX) window.SFX.poison(); }
    if (e.heal != null) { const v=R(e.heal); p.hp = Math.min(p.maxHp, p.hp + v); popNum(playerDom.current, '+' + v, '#ff6b8b'); if (window.SFX) window.SFX.heal(); }
    if (e.vamp != null) {
      const dmg = R(e.vamp, p.strength + filoBonus); const t = frontEnemy();
      if (t) damageEnemy(t, dmg);
      const heal = Math.round(e.vamp*0.6*critMul)*mult; p.hp = Math.min(p.maxHp, p.hp + heal);
      popNum(playerDom.current, '+' + heal, '#ff6b8b', -20);
    }
    if (e.strength != null) { p.strength += e.strength*mult; popNum(playerDom.current, 'FZA+' + e.strength*mult, '#ff4fa3', -20); }
    if (e.weaken != null) { const t = frontEnemy(); if (t) { t.statuses.weak = (t.statuses.weak||0) + e.weaken*mult; popNum(enemyDoms.current[t.id], 'WEAK', '#8aa6bf', -24); } }
    if (e.expose != null) { const tgts = e.all ? aliveEnemies() : [frontEnemy()].filter(Boolean); tgts.forEach(t => { t.statuses.vuln = (t.statuses.vuln||0) + e.expose*mult; popNum(enemyDoms.current[t.id], 'VULN', '#ff7a4d', -24); }); }
    if (e.frailty != null) { const tgts = e.all ? aliveEnemies() : [frontEnemy()].filter(Boolean); tgts.forEach(t => { t.statuses.frail = (t.statuses.frail||0) + e.frailty*mult; popNum(enemyDoms.current[t.id], 'FRÁGIL', '#c98ab5', -24); }); }
    if (e.doubleNext || e.wildcard) { doubleRef.current = true; popNum(playerDom.current, e.wildcard?'WILD':'x2', '#b06bff', -40); }
    if (e.bomb != null) {
      const dmg = R(e.bomb); aliveEnemies().forEach(t => damageEnemy(t, dmg));
      if (char.flag !== 'bombproof' && (e.self || 0) > 0) {
        const self = e.self || 0; lossCauseRef.current = it.name; p.hp -= self; dmgTakenRef.current += self;
        popNum(playerDom.current, '-' + self, '#ff3b3b', -20);
      }
    }
    if (e.selfHit != null) { // junk mine
      if (char.flag !== 'bombproof') damagePlayer(e.selfHit, it.name);
      else popNum(playerDom.current, 'BLOQ', '#9db8c9', -20);
    }
    // build relics
    if (relics.includes('tidepact') && affs.includes('marea') && (turnAffRef.current.marea % 3 === 0)) {
      p.block += 4; popNum(playerDom.current, 'MAREA +4', '#2ce8d8', -34); flashRelic('tidepact');
    }
    if (relics.includes('beastblood') && affs.includes('bestia')) {
      p.hp = Math.min(p.maxHp, p.hp + 2); popNum(playerDom.current, '+2', '#ff6b8b', -30); flashRelic('beastblood');
    }
    // trident: 3 distinct affinities this turn → +1 throw (once per turn)
    if (relics.includes('trident') && !bonusThrowRef.current && Object.keys(turnAffRef.current).length >= 3) {
      bonusThrowRef.current = true; throwsRef.current += 1; flashRelic('trident');
      popNum(playerDom.current, '+1 LANZA', '#2ce8d8', -52); if (window.SFX) window.SFX.reso && window.SFX.reso();
    }
    // VOLTIO chain: each chained catch electrocutes a random enemy
    if (char.flag === 'chain' && chainPos >= 1) {
      const al = aliveEnemies(); const t = al[Math.floor(Math.random() * al.length)];
      if (t) { damageEnemy(t, 4); popNum(enemyDoms.current[t.id], '⚡4', '#2ce8d8', -30); }
    }
    // TEMPORADA 2 — efectos de carta sobre el AGUA del tanque (manipulan zonas en vivo)
    if (e.pushCenter && eng0) { eng0._tideWave(eng0.W / 2, eng0.floor * 0.5); popNum(playerDom.current, 'EMPUJE', '#1fd6c0', -44); }
    if (e.clearWater && eng0) {   // Filtro Simple: limpia el estado del tanque y disipa zonas hostiles si quedan
      if (eng0.alterWater) eng0.alterWater({ clarity:0.35, heat:-0.35, corruption:-0.55, vitality:0.05, flow:{ strength:-0.25 }, color:'#7fc8ff' });
      const i = eng0.waterZones.findIndex(z => z.kind === 'saline' || z.kind === 'black' || z.kind === 'thermal');
      if (i >= 0) { eng0.waterZones.splice(i, 1); popNum(playerDom.current, 'AGUA LIMPIA', '#7fc8ff', -44); }
      else popNum(playerDom.current, 'AGUA LIMPIA', '#7fc8ff', -44);
    }
    if (e.distillWater && eng0) {  // Destilar: convierte el tanque hostil en agua clara aprovechable
      if (eng0.alterWater) eng0.alterWater({ clarity:0.45, heat:-0.25, corruption:-0.45, flow:{ strength:-0.15 }, color:'#7fc8ff' });
      const z = eng0.waterZones.find(zz => zz.kind === 'saline' || zz.kind === 'black' || zz.kind === 'thermal');
      if (z) { z.kind = 'clear'; z.dx = 0; z.dy = 0; popNum(playerDom.current, 'DESTILADA', '#7fc8ff', -44); }
      else popNum(playerDom.current, 'AGUA CLARA', '#7fc8ff', -44);
    }
    // FASE 2 — Burbuja Tibia: si el asta cruzó AGUA TERMAL este tiro, aplica QUEMADURA
    if (e.ifThermalBurn && eng0 && eng0._waterHits && eng0._waterHits.thermal) {
      const t = frontEnemy(); if (t) { t.statuses.burn = (t.statuses.burn || 0) + e.ifThermalBurn; popNum(enemyDoms.current[t.id], 'BURN+' + e.ifThermalBurn, '#ff9f1c', -28); }
    }
    // FASE 2 — Vapor de Guerra: convierte el AGUA TERMAL del tanque en daño a TODOS y borra esas zonas
    if (e.vaporWar && eng0) {
      const had = (eng0.water && eng0.water.heat > 0.2) || eng0.waterZones.some(z => z.kind === 'thermal');
      if (had) { const dmg = R(10, p.strength); aliveEnemies().forEach(t => damageEnemy(t, dmg));
        if (eng0.alterWater) eng0.alterWater({ heat:-1.1, clarity:-0.1, color:'#ff8a3c' });
        eng0.waterZones = eng0.waterZones.filter(z => z.kind !== 'thermal');
        popNum(playerDom.current, '¡VAPOR!', '#ff8a3c', -48); shakeStage(); }
      else popNum(playerDom.current, 'sin termal', '#8aa6bf', -40);
    }
    // FASE 3 — Manantial Vivo: vivifica el agua del tanque
    if (e.makeLiving && eng0) {
      if (eng0.alterWater) eng0.alterWater({ vitality:0.65, corruption:-0.35, clarity:0.08, color:'#5affb4' });
      popNum(playerDom.current, 'MANANTIAL', '#5affb4', -44);
    }
    // FASE 3 — Desalinizar: agua hostil → agua viva / clara
    if (e.desalinate && eng0) {
      if (eng0.alterWater) eng0.alterWater({ vitality:0.75, corruption:-0.85, clarity:0.25, flow:{ strength:-0.35 }, color:'#5affb4' });
      let nn = 0; eng0.waterZones.forEach(z => { if (z.kind === 'saline' || z.kind === 'black' || z.kind === 'dead') { z.kind = 'living'; z.dx = 0; z.dy = 0; nn++; } });
      popNum(playerDom.current, nn ? 'DESALINIZA' : 'DESALINIZA', '#5affb4', -44);
    }
    // ACTO 8 — Válvula de Escape: ventila la presión de la Sima
    if (e.ventPressure && pressureRef.current > 0) {
      pressureRef.current = Math.max(0, pressureRef.current - e.ventPressure);
      if (eng0) eng0.pressure = pressureRef.current;
      popNum(playerDom.current, 'VENTILA', '#7fc8ff', -44);
    }
    // ACTO 8 — Implosión: daño a TODOS que escala con la presión
    if (e.pressureBlast != null) {
      const dmg = R(e.pressureBlast, p.strength + Math.round((eng0 ? eng0.pressure : 0) * 9));
      aliveEnemies().forEach(t => damageEnemy(t, dmg));
      popNum(playerDom.current, '¡IMPLOSIÓN!', '#ff6b6b', -48); shakeStage();
    }
    // ACTO 9 — Lanza Polar: invierte la carga del arpón este tiro
    if (e.flipCharge && eng0) { eng0.charge = -(eng0.charge || 1); popNum(playerDom.current, 'CARGA ⇄', '#b06bff', -44); }
    // ACTO 9 — Rejilla Magnética: atrae al centro los tokens de polo opuesto a tu carga
    if (e.polarPull && eng0) {
      const cx = eng0.W/2, cy = eng0.floor*0.5; let nn = 0;
      eng0.objs.forEach(o => { if (!o.caught && !o.anchor && o.pole && o.pole !== eng0.charge) { o.x += (cx-o.x)*0.5; o.y += (cy-o.y)*0.5; o.vx*=0.3; o.vy*=0.3; nn++; } });
      popNum(playerDom.current, nn ? 'IMÁN' : 'sin polo opuesto', nn ? '#5a96ff' : '#8aa6bf', -44);
    }
    // ACTO 10 — Romper Cristal: añicos de los prismas del tanque (+3 daño por prisma)
    if (e.shatterPrism && eng0 && eng0.prisms.length) {
      const k = eng0.prisms.length; eng0.prisms = [];
      const dmg = R(0, k * 3); const tt = frontEnemy(); if (tt && dmg > 0) damageEnemy(tt, dmg);
      popNum(playerDom.current, 'AÑICOS x' + k, '#9ae6ff', -46); shakeStage();
    }
    force();
  }

  // NEUTRAL objectives — resolved separately from cards (no Resonance/echo). Physical effects
  // (Perla crit-boost, Núcleo de Marea wave) already fired in the engine on catch; here we pay out
  // the combat-side effects. totalCaught = how many tokens this throw landed (for Marca de Redada).
  async function applyNeutral(it, crit, totalCaught, inDead) {
    const p = playerRef.current;
    if (inDead && (run.relics || []).includes('piedrainerte')) { inDead = false; flashRelic('piedrainerte'); }
    if (inDead) {
      popNum(playerDom.current, 'NEUTRAL APAGADO', '#8aa6bf', -44);
      force(); return;
    }
    if (it._decoy && !(char.id === 'perla' && crit)) {   // hollow decoy → no effect... unless Perla PURIFIES it with PERFECTO
      popNum(playerDom.current, 'SEÑUELO VACÍO', '#6f8aa0', -44); force(); return;
    }
    if (it._decoy && char.id === 'perla' && crit) { const bl = frailAdj(6, p); p.block += bl;
      popNum(playerDom.current, 'PURIFICADO +' + bl, '#2ce8d8', -56); }   // Guardia Nácar limpia la corrupción
    if (it.ntype === 'refresh') {
      throwsRef.current += 1; popNum(playerDom.current, '+1 LANZA', '#7fd6e6', -44);
      if (window.SFX && window.SFX.reso) window.SFX.reso();
    } else if (it.ntype === 'pearl') {
      popNum(playerDom.current, 'PERLA CRÍTICA', '#ffd23f', -44);
      flashScreen('radial-gradient(circle, rgba(255,210,63,.3) 0%, rgba(255,210,63,0) 60%)');
      if (window.SFX && window.SFX.crit) window.SFX.crit();
    } else if (it.ntype === 'charge') {
      const dmg = crit ? 24 : 12; aliveEnemies().forEach(t => damageEnemy(t, dmg));
      popNum(playerDom.current, 'CARGA ABISAL', '#ff5a3c', -44);
      flashScreen('radial-gradient(circle, rgba(255,90,60,0) 40%, rgba(255,90,60,.5) 100%)'); shakeStage(); punchStage();
      if (window.SFX && window.SFX.hazard) window.SFX.hazard();
    } else if (it.ntype === 'tide') {
      popNum(playerDom.current, 'ONDA DE MAREA', '#2ce8d8', -44);
      if (window.SFX && window.SFX.block) window.SFX.block();
    } else if (it.ntype === 'raid') {
      if (totalCaught >= 4) {
        const gold = 18, heal = 6; run.gold = (run.gold || 0) + gold;
        p.hp = Math.min(p.maxHp, p.hp + heal);
        popNum(playerDom.current, '¡REDADA! +' + gold + ' ORO', '#ffd23f', -44);
        popNum(playerDom.current, '+' + heal, '#ff6b8b', -26);
        flashScreen('radial-gradient(circle, rgba(255,210,63,.3) 0%, rgba(255,210,63,0) 60%)'); punchStage();
        if (window.SFX && window.SFX.win) window.SFX.win();
      } else {
        popNum(playerDom.current, 'redada fallida...', '#6f8aa0', -44);
      }
    }
    // recovered a Hurto de Brillo target → bonus on top of its effect
    if (it._stolen) { run.gold = (run.gold || 0) + 10; popNum(playerDom.current, '+10 ORO (recuperado)', '#ffd23f', -64); }
    // fished an AFINADO target → bonus block (PERFECTO purifies harder: +heal). Cancels the eco.
    if (it._tuned) {
      tunedMarkRef.current = null;
      const bl = frailAdj(crit ? 9 : 6, p); p.block += bl;
      popNum(playerDom.current, (crit ? 'PURIFICADO +' : 'AFINADO +') + bl, '#ffd23f', -64);
      if (crit) { p.hp = Math.min(p.maxHp, p.hp + 4); popNum(playerDom.current, '+4', '#ff6b8b', -48); }
    }
    force();
  }

  /* ---------- throw resolution ---------- */
  async function handleThrow(items) {
    // ECO FANTASMA — ¿es este la resolución del arpón espectral? (no consume lanza ni termina el turno)
    const isGhost = !!(engineRef.current && engineRef.current._isGhost);
    if (engineRef.current) engineRef.current._isGhost = false;
    // tras un tiro NORMAL con Eco Fantasma, el motor dejó la trayectoria guardada → encólala para el próximo turno
    if (!isGhost && engineRef.current && engineRef.current._ghostPath) {
      ghostPendingRef.current = engineRef.current._ghostPath; engineRef.current._ghostPath = null;
    }
    setPhaseBoth('resolving');
    lastCaptureRef.current = items.length;   // ACTO 8: capturas de este tiro (para ventilar la presión el próximo turno)
    // Campo Galvánico — the shaft crossed the electric zone this shot → DÉBIL
    if (engineRef.current && engineRef.current._galvHit) {
      engineRef.current._galvHit = false;
      const p = playerRef.current; p.statuses.weak = (p.statuses.weak || 0) + 1;
      popNum(playerDom.current, 'DÉBIL', '#8aa6bf', -30); flashPlayerStatus();
    }
    // FASE 2 — AGUA TERMAL: el calor del tanque quema (Caldera Coral escala con heat y salta a TODOS)
    if (engineRef.current && engineRef.current._thermalHit && items.length) {
      engineRef.current._thermalHit = false;
      const caldera = (run.relics || []).includes('calderacoral');
      const thermalPower = waterPotency('thermal', engineRef.current);
      const v = caldera ? (5 + Math.round(thermalPower * 5)) : (3 + Math.round(thermalPower * 2));
      const tgts = caldera ? aliveEnemies() : [frontEnemy()].filter(Boolean);
      tgts.forEach(t => { t.statuses.burn = (t.statuses.burn || 0) + v; popNum(enemyDoms.current[t.id], 'BURN+' + v, '#ff9f1c', -30); });
      if (caldera) flashRelic('calderacoral');
    }
    // FASE 2 — AGUA NEGRA: pescar en turbidez da oro; Tinta Embotellada escala con lo negra que esté
    if (engineRef.current && engineRef.current._blackHit && items.some(it => !it.neutral && !it.junk && !it.hazard)) {
      engineRef.current._blackHit = false;
      const blackPower = waterPotency('black', engineRef.current);
      const extra = (run.relics || []).includes('tintaembotellada') ? (8 + Math.round(blackPower * 4)) : (4 + Math.round(blackPower * 2));
      run.gold = (run.gold || 0) + extra; popNum(playerDom.current, 'TINTA +' + extra + ' ORO', '#ffd23f', -42);
      if (extra > 4) flashRelic('tintaembotellada');
    }
    // SALMUERA (T2) — ÓSMOSIS: el tiro toma la PROPIEDAD del primer agua cruzado
    if (char.flag === 'osmotic' && engineRef.current && engineRef.current._firstWater && items.length) {
      const fw = engineRef.current._firstWater; const p = playerRef.current; const t = frontEnemy();
      if (fw === 'clear') { const v = frailAdj(5, p); p.block += v; popNum(playerDom.current, 'ÓSMOSIS +' + v + ' BLOQ', '#7fc8ff', -50); }
      else if (fw === 'saline') { if (t) { damageEnemy(t, 6); popNum(enemyDoms.current[t.id], 'ÓSMOSIS 6', '#1fd6c0', -34); } }
      else if (fw === 'thermal') { if (t) { t.statuses.burn = (t.statuses.burn||0)+4; popNum(enemyDoms.current[t.id], 'ÓSMOSIS BURN', '#ff9f1c', -34); } }
      else if (fw === 'black') { run.gold = (run.gold||0)+3; popNum(playerDom.current, 'ÓSMOSIS +3 ORO', '#ffd23f', -50); }
      else if (fw === 'living') { p.hp = Math.min(p.maxHp, p.hp+5); popNum(playerDom.current, 'ÓSMOSIS +5', '#5affb4', -50); }
      else if (fw === 'dead') { ['weak','vuln','frail'].forEach(k=>{ if (p.statuses[k]>0) p.statuses[k]=0; }); popNum(playerDom.current, 'ÓSMOSIS PURGA', '#8aa6bf', -50); }
    }
    // LOGROS de mecánica (T2) — feats de un tiro
    const _eng = engineRef.current;
    if (_eng && items.length) {
      if (_eng.bounceMode && _eng._bouncesPassed && _eng._bouncesPassed() >= 5) award('carambola');
      if (_eng.bounceMode && items.length >= 8) award('cosecha');   // 8 capturas en un tiro de rebote → desbloquea VAIVÉN
      if (_eng.netMode && items.filter(c => c.it && !c.it.junk && !c.it.hazard && !c.it.neutral).length >= 5) award('redada');
      if (_eng._prismHit) award('prismatico');
      if (_eng._waterHits && Object.keys(_eng._waterHits).length >= 4) award('multiagua');
      if (pressureRef.current >= 1.45) award('barotrauma');
      if (char.flag === 'osmotic' && _eng._firstWater) { if (!osmoSetRef.current) osmoSetRef.current = new Set(); osmoSetRef.current.add(_eng._firstWater); if (osmoSetRef.current.size >= 3) award('osmosis'); }
      // TANQUE LIMPIO — quedó el tanque sin loot real tras este tiro (excluye chatarra/peligro/neutrales/señuelos)
      const remainingLoot = (_eng.objs || []).filter(o => o.it && !o.it.junk && !o.it.hazard && !o.it.neutral && !o._decoy && !o._stolen && !o.caught).length;
      if (remainingLoot === 0 && tankLootRef.current >= 6) award('tanquelimpio');
    }
    // FASE 3 — AGUA VIVA: el agua viva cura; Cantimplora escala con la vitalidad del tanque
    if (engineRef.current && engineRef.current._livingHit && items.some(it => !it.neutral && !it.junk && !it.hazard)) {
      engineRef.current._livingHit = false;
      const p = playerRef.current; const livePower = waterPotency('living', engineRef.current); let v = 3 + Math.round(livePower * 3);
      if ((run.relics || []).includes('cantimploraviva') && !livingHealRef.current) { v = 9 + Math.round(livePower * 5); livingHealRef.current = true; flashRelic('cantimploraviva'); }
      p.hp = Math.min(p.maxHp, p.hp + v); popNum(playerDom.current, 'AGUA VIVA +' + v, '#5affb4', -42);
    }
    advanceCoach(1);
    if (items.some(c => c.crit)) advanceCoach(2);
    resCountRef.current = {};
    throwIdxRef.current = 0;
    critCountRef.current = 0;
    preyAffRef.current = null; preyOpenRef.current = false; voltioLastAffRef.current = null; oxidoJunkRef.current = false;
    chargeRef.current = (char.flag === 'charge' && items.length === 1) ? 1.5 : 1;   // COLOSO single-catch bonus
    const relics = run.relics || [];
    setLastCatch(items.length ? items[items.length-1].it : null);
    if (!items.length && isGhost) { setBanner('El arpón fantasma se disipó sin presa.'); }
    if (!items.length && !isGhost) {
      setBanner('¡Fallaste el tiro! El enemigo se reposiciona.'); if (window.SFX) window.SFX.empty();
      if (relics.includes('undertow')) { playerRef.current.block += 5; popNum(playerDom.current, 'RESACA +5', '#2ce8d8', -20); flashRelic('undertow'); }
      const t = frontEnemy();   // soft fail: tempo loss, no permanent junk (the enemy braces)
      if (t) { t.block += 2; popNum(enemyDoms.current[t.id], '+2', '#2ce8d8', -24); }
      force();
    }
    // build the catch queue (left rail) — drains as each item resolves
    const q = items.map(c => ({ id: ++catchIdRef.current, it: c.it, crit: c.crit, status: 'pending' }));
    setCatchQueue(q);
    setCombo(0);
    let comboPeak = 0;
    await sleep(240);
    for (let i = 0; i < items.length; i++) {
      const c = items[i], qid = q[i].id;
      setCatchQueue(prev => prev.map(x => x.id === qid ? { ...x, status: 'active' } : x));
      setBanner(c.it.name + (c.crit ? '  ¡PERFECTO!' : ''));
      // COMBO juice — grows with each chained catch this throw
      const cn = i + 1;
      if (cn >= 2) { setCombo(cn); comboPeak = Math.max(comboPeak, cn);
        if (window.SFX && window.SFX.combo) window.SFX.combo(cn);
        if (cn >= 4) punchStage(); }
      await sleep(190);   // hold the highlight a beat before it executes (readable pacing)
      if (c.it.neutral) {
        await applyNeutral(c.it, c.crit, items.length, c.inDead);
      } else {
        await applyEffect(c.it, c.crit, c.inDead);
        // echo: the first catch of the turn resolves a second time
        if (relics.includes('echo') && echoArmedRef.current) {
          echoArmedRef.current = false; flashRelic('echo');
          popNum(playerDom.current, 'ECO', '#b06bff', -56);
          await sleep(220);
          await applyEffect(c.it, c.crit, c.inDead);
        }
      }
      // Punta Sanguijuela (harpoon mod): each catch heals 1
      if ((run.harpoonMods || []).includes('siphon')) { const pp = playerRef.current;
        if (pp.hp < pp.maxHp) { pp.hp = Math.min(pp.maxHp, pp.hp + 1); popNum(playerDom.current, '+1', '#9ae600', -14); } }
      force();
      if (playerRef.current.hp <= 0) return doLose();
      // NOTE: do NOT bail out when all enemies die — keep resolving the rest of the catch
      // (heals, block, buffs you already hooked still pay off). Win is declared after the queue drains.
      setCatchQueue(prev => prev.map(x => x.id === qid ? { ...x, status: 'done' } : x));
      await sleep(aliveEnemies().length === 0 ? 300 : 560);   // snappier once the fight's already won
      setCatchQueue(prev => prev.filter(x => x.id !== qid));
    }
    setCatchQueue([]);
    setBanner('');
    // ---- HARPOON GRAMMAR — payoffs that depend on the whole throw ----
    if (items.length) { const cid = char.id, p = playerRef.current;
      if (cid === 'tuntun' && items.length >= 3) {       // DESGARRO: most-wounded enemy takes a hit
        const al = aliveEnemies().slice().sort((a,b) => (a.hp/a.maxHp) - (b.hp/b.maxHp))[0];
        if (al) { damageEnemy(al, 6 + p.strength); popNum(enemyDoms.current[al.id], '¡DESGARRO!', '#ff5a3c', -40); shakeStage(); }
      } else if (cid === 'perla' && items.length <= 2) {  // precisión defensiva: capturas pequeñas → bloqueo
        const bl = frailAdj(4, p); p.block += bl; popNum(playerDom.current, 'GUARDIA +' + bl, '#2ce8d8', -36);
      } else if (cid === 'coloso' && items.length === 1 && items[0].crit) {  // IMPACTO TITÁNICO
        const t = frontEnemy(); if (t) damageEnemy(t, 12 + p.strength);
        const bl = frailAdj(6, p); p.block += bl;
        popNum(playerDom.current, '¡IMPACTO TITÁNICO!', '#ffd23f', -56); shakeStage(); punchStage();
      }
      force();
    }
    // chain fanfare on a big combo, then fade the overlay
    if (comboPeak >= 4 && window.SFX && window.SFX.chain) window.SFX.chain(comboPeak);
    if (comboPeak >= 2) setTimeout(() => setCombo(0), 750); else setCombo(0);
    force();
    await sleep(250);
    if (aliveEnemies().length === 0) return doWin();
    // ECO FANTASMA — el disparo espectral NO gasta lanza ni cierra turno: devuelve el control al jugador
    if (isGhost) {
      if (engineRef.current.objs.length > 0) { setPhaseBoth('player'); engineRef.current.resume(); }
      else endPlayerTurn();
      return;
    }
    throwsRef.current -= 1;
    force();
    await sleep(250);
    if (aliveEnemies().length === 0) return doWin();
    if (throwsRef.current > 0 && engineRef.current.objs.length > 0) {
      setPhaseBoth('player');
      engineRef.current.resume();
    } else {
      endPlayerTurn();
    }
  }
  onResolveRef.current = handleThrow;

  /* ---------- enemy turn ---------- */
  async function endPlayerTurn() {
    engineRef.current.lock();
    // spite: leftover (un-fished) Urchins explode on the front enemy
    if ((run.relics || []).includes('spite')) {
      const left = (engineRef.current.objs || []).filter(o => o.it && o.it.hazard).length;
      const t = frontEnemy();
      if (left > 0 && t) {
        setBanner('¡ESPINAS VENGATIVAS!'); flashRelic('spite'); damageEnemy(t, 4 * left); force();
        await sleep(420);
        if (aliveEnemies().length === 0) { engineRef.current.clearAll(); return doWin(); }
      }
    }
    // Afinación — if the tuned neutral was NOT fished, it corrupts into an ECO: the resonant enemies harden
    const tn = tunedMarkRef.current;
    if (tn && !tn.caught && (engineRef.current.objs || []).indexOf(tn) !== -1) {
      tunedMarkRef.current = null;
      const casters = aliveEnemies();
      casters.forEach(en => { const w = frailAdj(5, en); en.block += w; popNum(enemyDoms.current[en.id], 'ECO +' + w, '#b06bff', -26); });
      setBanner('El objetivo afinado se corrompió en ECO: el enemigo resuena.');
      if (window.SFX && window.SFX.relic) window.SFX.relic();
      force(); await sleep(560);
    }
    tunedMarkRef.current = null;
    // Mordida Preparada — if the marked token was NOT fished, the Morena devours it at the START of next turn (telegraphed across two turns)
    const mk = morenaMarkRef.current;
    if (mk && !mk.caught && (engineRef.current.objs || []).indexOf(mk) !== -1) {
      morenaPendingRef.current = true;
      setBanner('¡La Morena acecha tu objeto marcado! Lo devorará al próximo turno.');
      if (window.SFX && window.SFX.warn) window.SFX.warn();
      force(); await sleep(560);
    } else {
      morenaPendingRef.current = false;   // fished in time → threat cleared
    }
    morenaMarkRef.current = null;
    // Gulpers left alive devour a token (animated: the card flies into the Gulper) → fewer cards next hand
    const eaten = engineRef.current.gulperFeast();
    if (eaten > 0) {
      eatenRef.current += eaten;
      setBanner('¡El Tragón devora ' + eaten + ' carta' + (eaten > 1 ? 's' : '') + '!');
      if (window.SFX && window.SFX.swallow) window.SFX.swallow();
      force(); await sleep(780);
    }
    engineRef.current.clearAll();
    setBanner('');
    await enemyTurn();
  }

  async function tickStatuses(entity, isPlayer) {
    const s = entity.statuses;
    const relics = run.relics || [];
    if (s.poison > 0) {
      if (isPlayer) damagePlayer(s.poison, 'Veneno'); else damageEnemy(entity, s.poison);
      // toxinsurge: the player's poison on enemies no longer decays → it stacks
      if (!(!isPlayer && relics.includes('toxinsurge'))) s.poison -= 1;
      await sleep(380);
    }
    if (s.burn > 0) {
      const burn = (!isPlayer && relics.includes('pyre')) ? Math.round(s.burn * 1.5) : s.burn;  // pyre: +50%
      if (isPlayer) damagePlayer(burn, 'Quemadura'); else damageEnemy(entity, burn);
      s.burn = Math.max(0, s.burn - 2); await sleep(380);
    }
  }

  async function enemyTurn() {
    setPhaseBoth('enemy');
    await sleep(300);
    for (const enemy of enemiesRef.current) {
      if (enemy.dead || enemy.hp <= 0) continue;
      await tickStatuses(enemy, false);
      force();
      if (enemy.dead || enemy.hp <= 0) continue;
      if (aliveEnemies().length === 0) return doWin();
      const intent = enemy.currentIntent || enemy.pattern[enemy.intentIdx % enemy.pattern.length];
      const el = enemyDoms.current[enemy.id];
      if (intent.type === 'attack') {
        animate(el, 'lunge-l');
        await sleep(160);
        let dmg = intent.value + (enemy.strength||0);
        if ((playerRef.current.statuses.weak||0) > 0) dmg = Math.round(dmg*0.75);
        const blockBefore = playerRef.current.block;
        damagePlayer(dmg, enemy.name);
        // RIPOSTE — block that absorbs a hit strikes back (Perla passive + thorns cards + Escama Espinosa)
        const absorbed = Math.min(blockBefore, Math.round(dmg));
        if (absorbed > 0) {
          const flat = (playerRef.current.thorns || 0) + ((run.relics || []).includes('thornmail') ? 4 : 0);
          const rip = Math.round(absorbed * (char.riposte || 0)) + flat;
          if (rip > 0 && !enemy.dead && enemy.hp > 0) { damageEnemy(enemy, rip); popNum(el, 'ESPINAS', '#2ce8d8', -38); if ((run.relics||[]).includes('thornmail')) flashRelic('thornmail'); }
        }
      } else if (intent.type === 'block') {
        const bv = frailAdj(intent.value, enemy); enemy.block += bv; popNum(el, '+' + bv, '#2ce8d8', -20);
      } else if (intent.type === 'buff') {
        enemy.strength += intent.value; popNum(el, 'FZA+' + intent.value, '#ff4fa3', -20);
      } else if (intent.type === 'rally') {
        aliveEnemies().forEach(a => { a.strength += intent.value; popNum(enemyDoms.current[a.id], 'FZA+' + intent.value, '#ffd23f', -20); });
        flashPlayerStatus(); if (window.SFX) window.SFX.buff && window.SFX.buff();
      } else if (intent.type === 'aegis') {
        aliveEnemies().forEach(a => { a.block += intent.value; popNum(enemyDoms.current[a.id], '+' + intent.value, '#2ce8d8', -20); });
        if (window.SFX) window.SFX.block && window.SFX.block();
      } else if (intent.type === 'healAlly') {
        const tgt = aliveEnemies().slice().sort((a, b) => (a.hp/a.maxHp) - (b.hp/b.maxHp))[0];   // most wounded ally (may be self)
        if (tgt) { tgt.hp = Math.min(tgt.maxHp, tgt.hp + intent.value); popNum(enemyDoms.current[tgt.id], '+' + intent.value, '#9ae600', -24);
          if (window.SFX) window.SFX.heal && window.SFX.heal(); }
      } else if (intent.type === 'venom') {
        const pp = playerRef.current; pp.statuses.poison = (pp.statuses.poison||0) + intent.value;
        popNum(playerDom.current, 'VENENO+' + intent.value, '#9ae600', -20); flashPlayerStatus(); if (window.SFX) window.SFX.poison && window.SFX.poison();
      } else if (intent.type === 'debuff') {
        const dt = intent.dtype || 'weak';
        playerRef.current.statuses[dt] = (playerRef.current.statuses[dt]||0) + intent.value;
        popNum(playerDom.current, dt.toUpperCase(), { weak:'#8aa6bf', vuln:'#ff7a4d', frail:'#c98ab5' }[dt], -20);
        flashPlayerStatus();
      } else if (intent.type === 'activable') {
        const a = window.GAME.activableInfo(intent.kind);
        enemy._actPending = intent.kind; enemy._actLast = turnCountRef.current;   // resolves on YOUR next tank
        if (intent.kind === 'song') {   // Sirena: pick a song, never the same twice running
          const songs = ['oxidado','torcido','falso'].filter(s => s !== enemy._lastSong);
          enemy._song = songs[Math.floor(Math.random()*songs.length)]; enemy._lastSong = enemy._song;
        }
        animate(el, 'lunge-l'); popNum(el, '⚙ ' + a.label, a.color, -28);
        setBanner('¡' + enemy.name + ' prepara ' + a.label + '!');
        flashScreen('radial-gradient(circle, ' + a.color + '22 0%, ' + a.color + '00 60%)');
        if (window.SFX) window.SFX.buff && window.SFX.buff();
      } else if (intent.type === 'swallow') {
        animate(el, 'lunge-l'); await sleep(160);
        swallowNextRef.current = true;
        if (window.SFX) window.SFX.swallow();
        damagePlayer(6 + (enemy.strength||0), enemy.name);
        popNum(el, 'TRAGAR', '#b06bff', -28);
      }
      if (enemy.regen && !enemy.dead && enemy.hp > 0 && enemy.hp < enemy.maxHp) {   // REINA CORAL regenerates
        enemy.hp = Math.min(enemy.maxHp, enemy.hp + enemy.regen); popNum(el, '+' + enemy.regen, '#9ae600', -24);
      }
      enemy.intentIdx++;
      force();
      if (playerRef.current.hp <= 0) return doLose();
      await sleep(520);
    }
    // decay timed debuffs (player + enemies)
    ['weak','vuln','frail'].forEach(k => { if (playerRef.current.statuses[k] > 0) playerRef.current.statuses[k] -= 1; });
    enemiesRef.current.forEach(e => ['weak','vuln','frail'].forEach(k => { if (e.statuses[k] > 0) e.statuses[k] -= 1; }));
    await sleep(200);
    startPlayerTurn();
  }

  /* ---------- player turn ---------- */
  async function startPlayerTurn() {
    const p = playerRef.current;
    p.block = 0;
    await tickStatuses(p, true);
    if (p.hp <= 0) return doLose();
    if (char.flag === 'startblock') p.block += frailAdj(6, p);
    if ((run.relics || []).includes('corrientebrujula') && turnCountRef.current === 0) { p.block += frailAdj(4, p); popNum(playerDom.current, 'BRÚJULA +4', '#b06bff', -24); }   // Brújula de Corrientes
    if ((run.meta || window.GAME.getMeta()).includes('coralskin')) p.block += frailAdj(3, p);   // META Piel de Coral
    doubleRef.current = false;
    echoArmedRef.current = true;       // echo re-arms each turn
    bonusThrowRef.current = false;     // trident bonus throw available again
    const relics = run.relics || [];
    throwsRef.current = 2 + (relics.includes('lure') && turnCountRef.current === 0 ? 1 : 0) + ((window.GAME.getMeta().includes('deepkeel') && turnCountRef.current === 0) ? 1 : 0);
    if (swallowNextRef.current) { throwsRef.current = Math.max(1, throwsRef.current - 1); swallowNextRef.current = false; }
    turnCountRef.current++;
    resCountRef.current = {};
    turnAffRef.current = {};
    setPhaseBoth('player');
    // reactive intents — enemies telegraph based on your persistent state
    const ctx = { pStrength: p.strength, pHpFrac: p.hp / p.maxHp, deckSize: run.deck.length, allies: aliveEnemies().length, turn: turnCountRef.current };
    enemiesRef.current.forEach(en => { if (!en.dead && en.hp > 0) en.currentIntent = window.GAME.chooseIntent(en, { ...ctx, selfHpFrac: en.hp / en.maxHp }); });
    // ÁRBITRO de activables — solo N setups fuertes pueden telegrafiarse por ronda (Acto I: 1, II: 2, III: libre).
    // Los extras se rebajan a un ataque del patrón para no apilar alteraciones del tanque.
    const actCap = window.GAME.activableCap(run.act);
    const casters = enemiesRef.current.filter(en => !en.dead && en.hp > 0 && en.currentIntent && en.currentIntent.type === 'activable');
    if (casters.length > actCap) {
      casters.slice(actCap).forEach(en => {
        const atk = en.pattern.find(pp => pp.type === 'attack') || { type:'attack', value:8 };
        en.currentIntent = { ...atk }; en._prevNonAtk = false;   // demote: attack instead of a 2nd strong setup
      });
    }
    // enemies manipulate YOUR tank
    const mods = window.GAME.aggregateMods(enemiesRef.current, relics, run.floor, (run.ascMods && run.ascMods.ambient) || 0);
    // resolve ARMED ACTIVABLES (telegraphed last turn) into this tank
    let clampArmed = false, pulseArmed = false, stealArmed = false, collapseArmed = false;
    let galvArmed = false, magArmed = false, inflateArmed = false, deadArmed = false, torcidoArmed = false, falsoArmed = false, devourArmed = false, blackoutArmed = false, tuneArmed = false, commandeerArmed = false, scrambleArmed = false;
    let calibrateArmed = false, gustArmed = false, shortArmed = false, pinballArmed = false, repulseArmed = false;   // nuevas habilidades de arpón/puntería
    let aguaClaraArmed = false, aguaSalinaArmed = false, aguaTermalArmed = false, aguaNegraArmed = false, inkTrapArmed = false;   // TEMPORADA 2 — zonas de agua
    let aguaVivaArmed = false, aguaMuertaArmed = false, derivaArmed = false, destilarArmed = false;   // FASE 3
    let crushArmed = false;   // ACTO 8 — Golpe de Presión
    let reverseArmed = false, prismArmed = false;   // ACTO 9 / 10
    let drainArmed = false;   // BAJAMAR
    const inkClearUsedLast = engineRef.current ? engineRef.current._clearHit : false;   // ¿se aprovechó la Clara de la Trampa de Tinta el turno pasado?
    let modeJ = false, routeJ = false;   // harpoon jammed (pendulum / curvy route) this turn?
    enemiesRef.current.forEach(en => {
      if (en.dead || en.hp <= 0 || !en._actPending) return;
      if (en._actPending === 'clamp') { mods.anchors = (mods.anchors || 0) + 1; clampArmed = true; }
      else if (en._actPending === 'pulse') { mods.currentMul = Math.max(mods.currentMul || 1, 2.6); pulseArmed = true; }
      else if (en._actPending === 'steal') { stealArmed = true; }
      else if (en._actPending === 'collapse') { mods.junk = (mods.junk || 0) + 2; collapseArmed = true; }
      else if (en._actPending === 'galvanic') { galvArmed = true; }
      else if (en._actPending === 'magnetic') { magArmed = true; }
      else if (en._actPending === 'inflate') { inflateArmed = true; }
      else if (en._actPending === 'deadhand') { deadArmed = true; }
      else if (en._actPending === 'devour') { devourArmed = true; }
      else if (en._actPending === 'blackout') { blackoutArmed = true; }
      else if (en._actPending === 'tune') { tuneArmed = true; }
      else if (en._actPending === 'commandeer') { commandeerArmed = true; }
      else if (en._actPending === 'calibrate') { calibrateArmed = true; }
      else if (en._actPending === 'gust') { gustArmed = true; }
      else if (en._actPending === 'shortshot') { shortArmed = true; }
      else if (en._actPending === 'pinball') { pinballArmed = true; }
      else if (en._actPending === 'repulse') { repulseArmed = true; }
      else if (en._actPending === 'scramble') { scrambleArmed = true; }
      else if (en._actPending === 'aguaclara') { aguaClaraArmed = true; }
      else if (en._actPending === 'aguasalina') { aguaSalinaArmed = true; }
      else if (en._actPending === 'aguatermal') { aguaTermalArmed = true; }
      else if (en._actPending === 'aguanegra') { aguaNegraArmed = true; }
      else if (en._actPending === 'inktrap') { inkTrapArmed = true; }
      else if (en._actPending === 'aguaviva') { aguaVivaArmed = true; }
      else if (en._actPending === 'aguamuerta') { aguaMuertaArmed = true; }
      else if (en._actPending === 'deriva') { derivaArmed = true; }
      else if (en._actPending === 'destilar') { destilarArmed = true; }
      else if (en._actPending === 'crush') { crushArmed = true; }
      else if (en._actPending === 'reverse') { reverseArmed = true; }
      else if (en._actPending === 'prism') { prismArmed = true; }
      else if (en._actPending === 'drain') { drainArmed = true; }
      else if (en._actPending === 'song') {   // Sirena: one of three songs
        if (en._song === 'oxidado') { mods.junk = (mods.junk || 0) + 2; collapseArmed = true; }
        else if (en._song === 'torcido') { torcidoArmed = true; }
        else { falsoArmed = true; }   // falso → decoy neutral (no recovery)
      }
      en._actPending = null;
    });
    setThreats(mods);
    // Mordida Preparada — the marked token survived last turn → the Morena devours it NOW (start of next turn), eating a card from THIS hand
    if (morenaPendingRef.current) {
      morenaPendingRef.current = false;
      eatenRef.current += 1;
      if (window.SFX && window.SFX.swallow) window.SFX.swallow();
    }
    // draw a shuffled HAND from your deck (not the whole deck) + enemy-injected clutter on top
    const HAND = 14;
    const deckItems = run.deck.map(card => window.GAME.item(card)).filter(Boolean);
    for (let i = deckItems.length - 1; i > 0; i--) { const j = Math.floor(Math.random()*(i+1)); [deckItems[i], deckItems[j]] = [deckItems[j], deckItems[i]]; }
    const list = deckItems.slice(0, HAND);
    // Gulpers that survived last turn ate cards → replace that many with eaten 'ballast'
    for (let i = 0; i < eatenRef.current && i < list.length; i++) list[i] = window.GAME.junkItem('ballast');
    if (eatenRef.current > 0) { setBanner('El Tragón se comió ' + eatenRef.current + ' carta(s).'); eatenRef.current = 0; }
    for (let i = 0; i < (mods.junk || 0); i++) list.push(window.GAME.junkItem(Math.random() < 0.5 ? 'mine' : 'ballast'));
    for (let i = 0; i < (mods.hazard || 0); i++) list.push(window.GAME.junkItem('urchin'));
    // NEUTRAL objectives (Peglin-style) — occasional micro-targets that change a shot's value
    window.GAME.neutralRoll(run.floor, run.act).forEach(t => list.push(window.GAME.neutralItem(t)));
    // Hurto / Canto falso / Afinación need a neutral to act on — guarantee one this turn
    if ((stealArmed || falsoArmed || tuneArmed) && !list.some(it => it.neutral)) list.push(window.GAME.neutralItem(Math.random() < 0.5 ? 'pearl' : 'refresh'));
    setHoverToken(null);
    const pestList = [];
    const PT = ['bumper', 'leech', 'gulper'];
    for (let i = 0; i < (mods.pests || 0); i++) pestList.push(PT[Math.floor(Math.random()*PT.length)]);
    // ACTO 8 — PRESIÓN dinámica: sube cada turno; capturar mucho VENTILA; el Golpe de Presión la dispara.
    // Presión alta estrecha el tanque (squeeze) y acelera todo (eng.pressure).
    const pressureAct = run.act === 8;
    if (pressureAct) {
      const step = relics.includes('pressurehull') ? 0.08 : 0.16;   // Casco de Quilla: sube la mitad
      let pr = pressureRef.current + step;                 // sube por turno
      if (lastCaptureRef.current >= 3) pr -= 0.34;          // ventilaste con una buena redada
      if (crushArmed) pr += 0.5;                            // Golpe de Presión
      pressureRef.current = Math.max(0, Math.min(1.5, pr));
      if (pressureRef.current > 0.4) mods.squeeze = true;   // a presión alta el tanque se estrecha
      if (crushArmed) setBanner('¡GOLPE DE PRESIÓN! El tanque se estrecha y todo corre más rápido.');
    }
    const waterDir = Math.random() < 0.5 ? 1 : -1;
    const sourInkNow = inkSourRef.current && !inkTrapArmed && !inkClearUsedLast;
    let waterState = normalizeWater({
      level: drainArmed ? 0.5 : 1,
      flow: {
        x: waterDir,
        y: 0,
        strength: Math.max(0, ((mods.currentMul || 1) - 1) * 0.38)
      },
      clarity: (mods.fog || mods.blackout || blackoutArmed) ? 0.86 : 1,
      heat: mods.turb ? 0.28 : 0,
      corruption: 0,
      vitality: 0
    });
    const applyWaterKind = (kind, amount) => {
      const a = amount == null ? 1 : amount;
      if (kind === 'clear') waterState = alterWaterState(waterState, { clarity:0.42 * a, corruption:-0.2 * a });
      else if (kind === 'saline') waterState = alterWaterState(waterState, { flow:{ x:waterDir, y:0, strength:0.62 * a }, clarity:-0.04 * a });
      else if (kind === 'thermal') waterState = alterWaterState(waterState, { heat:0.72 * a, clarity:-0.05 * a });
      else if (kind === 'black') waterState = alterWaterState(waterState, { clarity:-0.72 * a, corruption:0.18 * a });
      else if (kind === 'living') waterState = alterWaterState(waterState, { vitality:0.72 * a, corruption:-0.22 * a, clarity:0.05 * a });
      else if (kind === 'dead') waterState = alterWaterState(waterState, { corruption:0.82 * a, vitality:-0.25 * a, clarity:-0.14 * a });
    };
    // SALMUERA prepara agua clara: su identidad es leer y robar el estado dominante del tanque.
    if (char.flag === 'osmotic') applyWaterKind('clear', 0.35);
    // Las cartas de agua en mano modifican el medio completo, no crean burbujas locales.
    {
      const WCARD_KIND = { sondeoclaro:'clear', empujesalino:'saline', cortemarea:'clear', redosmotica:'clear',
        burbujatibia:'thermal', vaporguerra:'thermal', redclara:'clear', catalizador:'clear',
        manantialvivo:'living', desalinizar:'living', destilar:'clear', filtrosimple:'clear' };
      const kinds = [];
      list.forEach(it => { const k = WCARD_KIND[it && it.id]; if (k && !kinds.includes(k)) kinds.push(k); });
      kinds.slice(0, 2).forEach(k => applyWaterKind(k, 0.42));
    }
    // Acto VII enseña el tanque como medio: cada turno domina un estado global distinto.
    if (run.act === 7) {
      const KINDS = ['clear','saline','thermal','black','living','dead'];
      applyWaterKind(KINDS[turnCountRef.current % KINDS.length], 0.62);
    }
    if (aguaClaraArmed) applyWaterKind('clear', 0.8);
    if (aguaSalinaArmed) applyWaterKind('saline', 1);
    if (aguaTermalArmed) applyWaterKind('thermal', 1);
    if (aguaNegraArmed || sourInkNow) applyWaterKind('black', 1);
    if (inkTrapArmed) applyWaterKind('clear', 0.7);
    if (aguaVivaArmed) applyWaterKind('living', 1);
    if (aguaMuertaArmed) applyWaterKind('dead', 1);
    if (pressureAct) waterState = alterWaterState(waterState, { heat:Math.min(0.45, pressureRef.current * 0.18) });
    if (relics.includes('corrientebrujula') && turnCountRef.current === 1) {
      waterState = alterWaterState(waterState, { clarity:0.18, flow:{ strength:-0.25 } });
    }
    setTankRead(buildTankRead(list, mods, waterState));
    force();
    engineRef.current.spawn(list, { anchors: mods.anchors, currentMul: mods.currentMul, fog: mods.fog,
      ignoreAnchors: mods.ignoreAnchors, hazardHarmless: mods.hazardHarmless, critWide: mods.critWide || run.metaKeen,
      vortex: mods.vortex, vortexStrength: mods.vortexStrength, floor: run.floor, pests: pestList, sway: mods.sway,
      sweep: mods.sweep, freeze: mods.freeze, turb: mods.turb, squeeze: mods.squeeze, blackout: mods.blackout || blackoutArmed,
      polarity: run.act === 9, waterLevel: waterState.level, water: waterState, deadWaterHarmless: relics.includes('piedrainerte'),
      shortShot: shortArmed, repel: repulseArmed });   // ACTO9 polo · BAJAMAR · ARPÓN CORTO · REPULSIÓN
    tankLootRef.current = engineRef.current.objs.filter(o => o.it && !o.it.junk && !o.it.hazard && !o.it.neutral && !o._decoy).length;   // baseline para LOGRO Tanque Limpio
    engineRef.current.pressure = pressureAct ? pressureRef.current : 0;   // ACTO 8 — aplica la presión (acelera tokens)
    // ACTO 9 — POLARIDAD: la carga del arpón se reinicia cada turno; INVERSIÓN POLAR la voltea este turno
    if (run.act === 9) {
      engineRef.current.charge = reverseArmed ? -1 : 1;
      if (reverseArmed) setBanner('¡INVERSIÓN POLAR! Tu carga se invirtió: lo que atraías ahora repele.');
    }
    // ACTO 10 — REFRACCIÓN: prismas base cada turno + uno extra si el enemigo lo telegrafió
    if (run.act === 10) {
      const eng2 = engineRef.current; const nP = 1 + (Math.random() < 0.5 ? 1 : 0) + (prismArmed ? 1 : 0);
      for (let i = 0; i < nP; i++) eng2.prisms.push({ x: eng2.W * (0.28 + Math.random()*0.44), y: eng2.floor * (0.3 + Math.random()*0.34),
        r: 30, bend: 0.6 + Math.random()*0.4, dir: Math.random() < 0.5 ? 1 : -1, used: false });
      if (prismArmed) setBanner('¡PRISMA! Un cristal extra tuerce tu arpón al cruzarlo.');
    }
    // apply ARMED ACTIVABLE markers onto the fresh tank (visual + behaviour)
    const eng = engineRef.current;
    if (clampArmed) {   // Pinza Ancla: lock the most valuable anchored token (n_locked_token overlay)
      const anchored = eng.objs.filter(o => o.anchor && o.it && !o.it.junk && !o.it.hazard && !o.it.neutral);
      const top = anchored.sort((a, b) => (b.it.val || 0) - (a.it.val || 0))[0];
      if (top) { top._locked = true; top.it._locked = true; setBanner('¡PINZA ANCLA! Tu objeto valioso quedó encadenado (péscalo con ¡PERFECTO! para romper la cadena).'); }
    }
    if (collapseArmed) setBanner('¡DERRUMBE! Cayó chatarra al tanque.');
    if (pulseArmed) {   // Pulso de Corriente: open a pushing current-field zone
      const dir = Math.random() < 0.5 ? 1 : -1;
      eng.currentField = { x: eng.W * (0.32 + Math.random()*0.36), y: eng.floor * (0.34 + Math.random()*0.3),
        r: 78, dx: dir, dy: 0 };
      setBanner('¡PULSO DE CORRIENTE! Una corriente empuja el arpón.');
    }
    if (aguaClaraArmed) {
      setBanner('¡AGUA CLARA! El tanque se aclara: tu núcleo PERFECTO se amplía.');
    }
    if (aguaSalinaArmed) {
      setBanner('¡AGUA SALINA! La corriente empuja todos los objetos del tanque.');
    }
    if (drainArmed) setBanner('¡BAJAMAR! El agua bajó a la mitad: los objetos se hundieron abajo. Reapunta.');
    if (shortArmed) setBanner('¡ARPÓN CORTO! Tu alcance se reduce este turno: acércate a las presas.');
    if (repulseArmed) setBanner('¡REPULSIÓN! Los objetos huyen de tu asta: apunta directo y rápido.');
    if (aguaTermalArmed) {
      setBanner('¡AGUA TERMAL! El tanque se calienta: objetos y arpón corren más.');
    }
    if (aguaNegraArmed) {
      setBanner('¡AGUA NEGRA! El tanque se enturbia: pierdes lectura, pero capturar da recompensa.');
    }
    if (inkTrapArmed) {      // FASE 2 — Trampa de Tinta: Clara ahora; si la ignoras, se agria en Negra el próximo turno
      inkSourRef.current = true;
      setBanner('¡TRAMPA DE TINTA! El tanque se aclara: aprovéchalo o se agriará en Agua Negra.');
    } else if (sourInkNow) {   // hubo Trampa de Tinta el turno pasado
      inkSourRef.current = false;
      if (!inkClearUsedLast) {         // no la aprovechaste → se agria en Agua Negra
        setBanner('La tinta se agrió: ¡AGUA NEGRA!');
      }
    }
    if (aguaVivaArmed) {
      setBanner('¡AGUA VIVA! El tanque queda vivo: capturar te CURA.');
    }
    if (aguaMuertaArmed) {
      setBanner('¡AGUA MUERTA! El tanque se corrompe: se apagan Resonancia y neutrales.');
    }
    if (derivaArmed && eng.waterZones.length) {   // FASE 3 — Raya Cartógrafa: desplaza una zona existente
      const z = eng.waterZones[Math.floor(Math.random() * eng.waterZones.length)];
      z.x = eng.W * (0.25 + Math.random()*0.5); z.y = eng.floor * (0.3 + Math.random()*0.36);
      setBanner('¡DERIVA DE CORRIENTE! Una zona de agua se desplazó.');
    }
    if (destilarArmed) {     // FASE 3 — Alambique: destila DOS zonas de tipos distintos
      const kinds = ['clear','saline','thermal','black','living','dead'];
      for (let i = kinds.length - 1; i > 0; i--) { const j = Math.floor(Math.random()*(i+1)); [kinds[i],kinds[j]] = [kinds[j],kinds[i]]; }
      const dir = Math.random() < 0.5 ? 1 : -1;
      [{ kind: kinds[0], x: 0.3 }, { kind: kinds[1], x: 0.7 }].forEach(s => {
        eng.waterZones.push({ kind: s.kind, x: eng.W * s.x, y: eng.floor * (0.36 + Math.random()*0.26), r: 132,
          dx: s.kind === 'saline' ? dir : 0, dy: 0 }); });
      setBanner('¡DESTILACIÓN! El Alambique marcó dos aguas: pesca en la correcta.');
    }
    if (stealArmed) {   // Hurto de Brillo: seize a neutral (fx_stolen_token overlay) — fish it to recover it
      const neu = eng.objs.find(o => o.it && o.it.neutral && !o.caught && !o._stolen);
      if (neu) { neu._stolen = true; neu.it._stolen = true; setBanner('¡HURTO DE BRILLO! Recupera el objetivo robado pescándolo.'); }
    }
    if (galvArmed) {   // Campo Galvánico: electric zone — crossing it with the shaft applies DÉBIL
      eng.galvanicZone = { x: eng.W * (0.3 + Math.random()*0.4), y: eng.floor * (0.3 + Math.random()*0.32), r: 82 };
      setBanner('¡CAMPO GALVÁNICO! Cruzarlo con el arpón te DEBILITA.');
    }
    if (magArmed) {   // Campo Magnético: pull junk/hazards into a cluster
      eng.magnetZone(eng.W * (0.3 + Math.random()*0.4), eng.floor * (0.34 + Math.random()*0.3));
      setBanner('¡CAMPO MAGNÉTICO! La chatarra se agrupó.');
    }
    if (inflateArmed) {   // Hinchar: turn a token into a bouncing obstacle
      const cand = eng.objs.filter(o => o.it && !o.it.junk && !o.it.hazard && !o.it.neutral && !o.anchor && !o._inflated);
      const t = cand[Math.floor(Math.random()*cand.length)];
      if (t) { t._inflated = true; t.r *= 2.2; setBanner('¡HINCHAR! Un objeto bloquea el tanque (rebota el arpón).'); }
    }
    if (deadArmed) {   // Mano Muerta: shift the launcher origin for this turn (persiste pese a resize vía _originShift)
      const shift = (Math.random()<0.5?-1:1) * eng.W*0.18;
      eng.origin.x = Math.max(eng.W*0.24, Math.min(eng.W*0.76, eng.W/2 + shift));
      eng._originShift = eng.origin.x - eng.W/2;
      setBanner('¡MANO MUERTA! Tu arpón sale desplazado este turno.');
    }
    tunedMarkRef.current = null;
    if (tuneArmed) {   // Afinación: mark a neutral (fx_guard_light); fish it for bonus, else it corrupts into an eco
      const neu = eng.objs.find(o => o.it && o.it.neutral && !o.caught && !o._stolen && !o._decoy && !o._tuned);
      if (neu) { neu._tuned = true; neu.it._tuned = true; tunedMarkRef.current = neu;
        setBanner('¡AFINACIÓN! Pesca el objetivo afinado para un bonus (o se corromperá).'); }
    }
    morenaMarkRef.current = null;
    if (devourArmed) {   // Mordida Preparada: mark the most valuable token; devoured if not fished this turn
      const cand = eng.objs.filter(o => o.it && !o.it.junk && !o.it.hazard && !o.it.neutral && !o._inflated);
      const top = cand.sort((a, b) => (b.it.val || 0) - (a.it.val || 0))[0];
      if (top) { top._marked = true; morenaMarkRef.current = top; setBanner('¡MORDIDA PREPARADA! Pesca el objeto marcado o la Morena se lo come.'); }
    }
    // Timón Fantasma / Distorsión — alteran el ARPÓN durante VARIOS turnos (no solo uno)
    if (commandeerArmed && modeJamRef.current.turns <= 0) modeJamRef.current = { mode: 'pendulum', turns: 3 };
    if (calibrateArmed && modeJamRef.current.turns <= 0) modeJamRef.current = { mode: 'power', turns: 2 };   // CALIBRE FORZADO
    if (scrambleArmed && routeJamRef.current.turns <= 0)
      routeJamRef.current = { route: ['zigzag','spiral','arc','bounce'][Math.floor(Math.random()*4)], turns: 3 };
    if (modeJamRef.current.turns > 0) { const jm = modeJamRef.current; eng.setMode(jm.mode); jm.turns -= 1; modeJ = true;
      if (jm.mode === 'power') setBanner('¡CALIBRE FORZADO! Carga la POTENCIA y suéltala en el punto justo (' + (jm.turns + 1) + ' turno' + (jm.turns ? 's' : '') + ').');
      else setBanner('¡TIMÓN FANTASMA! El arpón oscila solo (' + (jm.turns + 1) + ' turno' + (jm.turns ? 's' : '') + ').'); }
    else eng.setMode(control);
    // VENDAVAL — el péndulo oscila mucho más rápido (timing difícil) durante N turnos
    if (gustArmed && gustRef.current <= 0) gustRef.current = 3;
    if (gustRef.current > 0) { gustRef.current -= 1; eng.swingSpeed = swingSpeed * 2.1;
      setBanner('¡VENDAVAL! El arpón oscila muy rápido: cronometra el disparo (' + (gustRef.current + 1) + ' turno' + (gustRef.current ? 's' : '') + ').'); }
    else eng.swingSpeed = swingSpeed;
    if (routeJamRef.current.turns > 0) { eng.route = routeJamRef.current.route; routeJamRef.current.turns -= 1; routeJ = true;
      setBanner('¡DISTORSIÓN! Trayectoria ' + routeJamRef.current.route.toUpperCase() + ' (' + (routeJamRef.current.turns + 1) + ' turno' + (routeJamRef.current.turns ? 's' : '') + ').'); }
    else eng.route = route;
    if (pinballArmed) { eng.route = 'ricochet'; setBanner('¡PINBALL! Tu arpón rebota por todo el tanque sin control este turno.'); }   // override 1 turno
    if (torcidoArmed) { eng.shoveTowardHazards(); setBanner('¡CANTO TORCIDO! Los objetos derivan hacia los peligros.'); }
    if (falsoArmed) {   // Canto falso: a neutral becomes a decoy (no reward if fished)
      const neu = eng.objs.find(o => o.it && o.it.neutral && !o.caught && !o._stolen && !o._decoy);
      if (neu) { neu._decoy = true; neu.it._decoy = true; setBanner('¡CANTO FALSO! Un objetivo neutral es un señuelo vacío.'); }
    }
    // FEEDBACK — animated toast + tank pulse listing what the enemies just did to the water/objects
    const fxList = [];
    const pushMod = (k) => { const m = MOD_INFO[k]; if (m) fxList.push({ icon: m.icon, label: m.label, color: m.color }); };
    if (mods.anchors > 0) pushMod('anchor');
    if (mods.junk > 0) pushMod('junk');
    if (mods.hazard > 0) pushMod('hazard');
    if (mods.currentMul > 1) pushMod('current');
    if (mods.fog) pushMod('fog');
    if (mods.vortex) pushMod('vortex');
    if (mods.sway) pushMod('sway');
    if (mods.sweep > 0) pushMod('sweep');
    if (mods.freeze > 0) pushMod('freeze');
    if (mods.turb) pushMod('turb');
    if (mods.squeeze) pushMod('squeeze');
    if (mods.blackout || blackoutArmed) pushMod('blackout');
    if (mods.pests > 0) pushMod('pest');
    [['clamp',clampArmed],['pulse',pulseArmed],['steal',stealArmed],['collapse',collapseArmed],['galvanic',galvArmed],
     ['magnetic',magArmed],['inflate',inflateArmed],['deadhand',deadArmed],['devour',devourArmed],['tune',tuneArmed],
     ['commandeer',modeJ],['scramble',routeJ],['aguaclara',aguaClaraArmed],['aguasalina',aguaSalinaArmed],
     ['aguatermal',aguaTermalArmed],['aguanegra',aguaNegraArmed],['inktrap',inkTrapArmed],
     ['aguaviva',aguaVivaArmed],['aguamuerta',aguaMuertaArmed],['deriva',derivaArmed],['destilar',destilarArmed],['crush',crushArmed],['reverse',reverseArmed],['prism',prismArmed],['drain',drainArmed],['calibrate',calibrateArmed],['gust',gustArmed],['shortshot',shortArmed],['pinball',pinballArmed],['repulse',repulseArmed]].forEach(([k, on]) => {
      if (on) { const a = window.GAME.activableInfo(k); fxList.push({ icon: a.icon, label: a.label, color: a.color }); } });
    if (torcidoArmed || falsoArmed) { const a = window.GAME.activableInfo('song'); fxList.push({ icon: a.icon, label: a.label, color: a.color }); }
    if (fxList.length) { setTankFx(fxList); if (eng.pulse) eng.pulse(fxList[0].color); setTimeout(() => setTankFx(null), 1900); }
    else setTankFx(null);
    // re-deploy persistent gadgets into the fresh tank + run their AUTO effects (with visible feedback)
    let firedAuto = 0;
    placedGadgetsRef.current.forEach(g => {
      const o = engineRef.current.placeGadget(g.type, g.x, g.y); if (o) o._gid = g.gid;
      const gd = window.GAME.GADGETS[g.type] || {}, fx = gd.fx;
      if (gd.trigger !== 'auto') return;            // catch-type just sits there until you hook it
      let tag = null, col = gd.color || '#9ae600';
      if (fx === 'totem')      { const bl = frailAdj(5, p); p.block += bl; p.hp = Math.min(p.maxHp, p.hp + 3); tag = '+' + bl + '🛡 +3'; col = '#2ce8d8'; }
      else if (fx === 'regen') { p.hp = Math.min(p.maxHp, p.hp + 4); tag = 'CURA +4'; col = '#9ae600'; }
      else if (fx === 'forge') { p.strength += 1; tag = 'FUERZA +1'; col = '#ff4fa3'; }
      else if (fx === 'sentry'){ const t = frontEnemy(); if (t) { damageEnemy(t, 4); popNum(enemyDoms.current[t.id], 'TORRETA', '#ff5a3c', -30); } tag = ''; }
      else if (fx === 'lure')  { tag = 'IMÁN'; col = '#9ae600'; }
      else if (fx === 'repel') { tag = 'REPELE'; col = '#ffd23f'; }
      if (o) o._wake = 1;                            // one-shot ring pulse on the token so you see it fire
      if (tag) popNum(playerDom.current, (gd.name.split(' ')[0]) + ' ' + tag, col, -30 - firedAuto * 16);
      firedAuto++;
    });
    const autoN = placedGadgetsRef.current.length;
    if (autoN > 0) { engineRef.current.gadgetPull(); engineRef.current.gadgetRepel(); }
    if (firedAuto > 0) { setBanner('Activables en acción'); if (window.SFX && window.SFX.relic) window.SFX.relic(); }
    if (aliveEnemies().length === 0) { return doWin(); }
    // ECO FANTASMA — si quedó una trayectoria encolada el turno pasado, un arpón espectral la barre AHORA
    // (en la misma zona) antes de que juegues. Resuelve sus capturas gratis y luego te devuelve el turno.
    if (ghostPendingRef.current) {
      const gp = ghostPendingRef.current; ghostPendingRef.current = null;
      engineRef.current.lock();
      setBanner('¡ECO FANTASMA! Un arpón espectral repite tu último tiro en la misma zona.');
      if (window.SFX && window.SFX.relic) window.SFX.relic();
      setTimeout(() => { if (engineRef.current) engineRef.current.fireGhost(gp); }, 650);
    } else {
      engineRef.current.resume();
    }
  }

  /* ---------- gadgets (drag from tray → drop on tank → stays/activates) ---------- */
  function deployGadget(id, cx, cy) {
    const gid = ++gidRef.current;
    placedGadgetsRef.current.push({ gid, type: id, x: cx, y: cy });
    const o = engineRef.current && engineRef.current.placeGadget(id, cx, cy); if (o) o._gid = gid;
    if (window.SFX && window.SFX.click) window.SFX.click();
  }
  useEffect(() => {
    if (!gdrag) return;
    const mv = (e) => setGdrag(g => g ? { ...g, x: e.clientX, y: e.clientY } : g);
    const up = (e) => {
      const cv = canvasRef.current;
      if (cv) { const r = cv.getBoundingClientRect();
        if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) {
          deployGadget(gdrag.id, (e.clientX - r.left) * (cv.width / r.width), (e.clientY - r.top) * (cv.height / r.height));
          if (onUseGadget) onUseGadget(gdrag.idx);
        }
      }
      setGdrag(null);
    };
    document.addEventListener('pointermove', mv); document.addEventListener('pointerup', up);
    return () => { document.removeEventListener('pointermove', mv); document.removeEventListener('pointerup', up); };
  }, [gdrag]);

  /* ---------- consumables (HUD one-shot tank manipulation) ---------- */
  function useConsumable(idx) {
    if (phaseRef.current !== 'player') return;
    const id = (run.consumables || [])[idx];
    if (!id) return;
    const eng = engineRef.current;
    if (id === 'magnet') { eng.magnetize(); popNum(playerDom.current, 'IMÁN', '#2ce8d8', -40); }
    else if (id === 'purge') { eng.purgeJunk(); popNum(playerDom.current, 'PURGA', '#ffd23f', -40); setThreats(t => t ? { ...t, junk: 0, hazard: 0 } : t); }
    else if (id === 'calm') { eng.calm(); popNum(playerDom.current, 'CALMA', '#b06bff', -40); }
    else if (id === 'doublebait') { doubleRef.current = true; popNum(playerDom.current, 'CEBO x2', '#ffd23f', -40); }
    if (window.SFX && window.SFX.click) window.SFX.click();
    if (onUseConsumable) onUseConsumable(idx);   // persist consumption on the run
  }

  function doWin() {
    if (dmgTakenRef.current === 0 && encounter.some(e => e.boss)) award('intachable');   // flawless boss
    setPhaseBoth('won'); setCatchQueue([]); engineRef.current.clearAll(); if (window.SFX) window.SFX.win(); setTimeout(() => onWin(playerRef.current.hp), 1100);
  }
  function doLose() {
    const elapsedMs = Math.max(0, Date.now() - (run.startedAt || Date.now()));
    const summary = {
      cause: lossCauseRef.current,
      act: run.act,
      floor: run.floor,
      gold: run.gold,
      deckCount: run.deck.length,
      relicCount: (run.relics || []).length,
      amenaza: run.amenaza || 0,
      elapsedMs,
    };
    setPhaseBoth('lost'); setCatchQueue([]); engineRef.current.clearAll(); if (window.SFX) window.SFX.lose(); setTimeout(() => onLose(summary), 1300);
  }

  /* ---------- mount engine ---------- */
  useEffect(() => {
    const eng = new window.TankEngine(canvasRef.current);
    eng.setMode(control);
    eng.swingSpeed = swingSpeed; eng.sizeScale = sizeScale;
    eng.harpoon = window.GAME.harpoon(char.harpoon);   // per-character catch shape
    eng.mods = run.harpoonMods || [];                  // bought harpoon mods (bounce/long/pierce/barbed)
    eng.oxidoPull = char.id === 'oxido';               // ÓXIDO: el asta atrae chatarra/peligros en vuelo
    eng.bounceMode = char.flag === 'bounce';           // BRINCO (T2): Dardo Errante — rebotes por captura
    eng.bounceBase = 1;
    eng.netMode = char.flag === 'net';                 // NÁCAR (T2): Red de Cabo Doble — dos puntos
    eng.clearWiden = (run.relics || []).includes('lenteclara') ? 1.8 : 1.4;   // Lente Clara: Agua Clara amplía más el PERFECTO
    eng.actTint = run.act >= 6 ? '#1a1326' : run.act >= 5 ? '#0a0414' : run.act >= 4 ? '#160e2e' : run.act >= 3 ? '#26161a' : run.act >= 2 ? '#0c1a32' : '#0e2a40';   // water tint per act
    eng.route = 'recto';
    if ((run.harpoonMods || []).length >= 3) award('armeria');
    eng.onPest = (type) => {                            // Fase 3: pest interference
      if (type === 'leech') { const p = playerRef.current; p.statuses.poison = (p.statuses.poison || 0) + 3;
        popNum(playerDom.current, 'VENENO +3', '#9ae600', -30); flashScreen('radial-gradient(circle, rgba(154,230,0,0) 45%, rgba(120,200,0,.4) 100%)'); flashPlayerStatus(); }
      else if (type === 'bumper') setBanner('¡El Pez Boya rebota tu arpón!');
      else if (type === 'gulper') setBanner('¡Tragón reventado!');
      force();
    };
    eng.onGadget = (type, o) => {                       // Fase 8: deployed gadget caught by the harpoon
      const g = window.GAME.GADGETS[type] || {}, pl = playerRef.current;
      switch (g.fx) {
        case 'allbig': aliveEnemies().forEach(t => damageEnemy(t, 18)); flashScreen('radial-gradient(circle, rgba(255,90,60,0) 40%, rgba(255,90,60,.5) 100%)'); shakeStage(); break;
        case 'frontbig': { const t = frontEnemy(); if (t) damageEnemy(t, 28); shakeStage(); break; }
        case 'poisonall': aliveEnemies().forEach(t => { t.statuses.poison = (t.statuses.poison || 0) + 6; popNum(enemyDoms.current[t.id], 'PSN+6', '#9ae600', -24); }); break;
        case 'shock': aliveEnemies().forEach(t => { t.statuses.weak = (t.statuses.weak || 0) + 2; damageEnemy(t, 6); }); break;
        case 'dispel': engineRef.current.fog = false; engineRef.current.sway = false; aliveEnemies().forEach(t => damageEnemy(t, 4)); break;
        case 'heal': pl.hp = Math.min(pl.maxHp, pl.hp + 22); popNum(playerDom.current, '+22', '#ff6b8b'); break;
      }
      setBanner('¡' + (g.name || 'Activable') + '!');
      placedGadgetsRef.current = placedGadgetsRef.current.filter(x => x.gid !== o._gid);
      force();
      if (aliveEnemies().length === 0) doWin();
    };
    eng.onHover = (it) => setHoverToken(it || null);
    eng.onWaterHover = (kind, x, y) => {
      const t = kind && WATER_TIP[kind];
      if (t) { waterTipRef.current = true; if (window.showTip) window.showTip(t, x, y); }
      else if (waterTipRef.current) { waterTipRef.current = false; if (window.hideTip) window.hideTip(); }
    };
    eng.onResolve = (items) => onResolveRef.current(items);
    eng.onCatch = (it) => setLastCatch(it);
    eng.onHazard = (it) => {
      if (char.flag === 'bombproof') { popNum(playerDom.current, 'BLOQ', '#9db8c9', -20); force(); return; }
      damagePlayer(it.hazardDmg || 5, it.name);
      setBanner('¡' + it.name + '!');
      force();
      if (playerRef.current.hp <= 0) doLose();
    };
    engineRef.current = eng;
    window.__eng = eng;   // debug/test hook
    window.__deploy = (id, cx, cy) => deployGadget(id, cx == null ? eng.W*0.5 : cx, cy == null ? eng.floor-60 : cy);
    const ro = () => eng.resize();
    window.addEventListener('resize', ro);
    // boss tank-rule intro banner
    const bossE = encounter.find(e => e.boss && e.tankRule);
    if (bossE) { setBossRule(bossE.tankRule); setTimeout(() => setBossRule(null), 4200); }
    // intro then first turn
    setTimeout(() => startPlayerTurn(), bossE ? 1600 : 650);
    return () => { window.removeEventListener('resize', ro); eng.destroy(); engineRef.current = null; if (window.__eng === eng) window.__eng = null; };
  }, []);

  useEffect(() => { if (engineRef.current) engineRef.current.route = route; }, [route]);
  useEffect(() => { if (engineRef.current) engineRef.current.setMode(control); }, [control]);
  useEffect(() => { if (engineRef.current) { engineRef.current.swingSpeed = swingSpeed; engineRef.current.sizeScale = sizeScale; } }, [swingSpeed, sizeScale]);

  /* ---------- render ---------- */
  const p = playerRef.current;
  const enemies = enemiesRef.current;
  const myTurn = phase === 'player';

  return (
    <div ref={rootRef} className="fill col" style={{ background: 'linear-gradient(180deg,#13293d,#0b1d2e 60%)' }}>
      {flash && <div className="dmg-flash" style={{ background: flash.color }} />}
      {/* ARENA */}
      <div ref={arenaRef} style={{ position: 'relative', height: 330, overflow: 'hidden',
        background: '#07131f', borderBottom: '4px solid #07131f' }}>
        {/* parallax FAR — per-act generated background (slow drift) */}
        <div className="bg-far" style={{ position:'absolute', top:0, bottom:0, left:'-6%', width:'112%',
          backgroundImage:`url(assets/bg_act${run.act>=10?10:run.act>=9?9:run.act>=8?8:run.act>=7?7:run.act>=6?6:run.act>=5?5:run.act>=4?4:run.act>=3?3:run.act>=2?2:1}.png)`, backgroundSize:'cover', backgroundPosition:'center' }} />
        {/* readability vignette so sprites pop over the art */}
        <div style={{ position:'absolute', inset:0, pointerEvents:'none',
          background:'linear-gradient(180deg, rgba(7,19,31,.35) 0%, rgba(7,19,31,.05) 45%, rgba(7,19,31,.6) 100%)' }} />
        {/* parallax NEAR — distant silhouettes (faster drift) */}
        <div className="bg-near" style={{ position:'absolute', inset:0, opacity:.28,
          backgroundImage:'radial-gradient(120px 60px at 30% 100%, #07131f 60%, transparent), radial-gradient(160px 80px at 75% 100%, #07131f 60%, transparent)' }} />
        {/* seabed */}
        <div style={{ position:'absolute', left:0, right:0, bottom:0, height:40, background:'#0a1b2a', boxShadow:'inset 0 6px 0 rgba(0,0,0,.4)' }} />

        {/* PLAYER */}
        <div style={{ position: 'absolute', left: 70, bottom: 36, display:'flex', flexDirection:'column', alignItems:'center', gap:8 }}>
          <StatusRow statuses={p.statuses} />
          <div ref={playerDom} className="bob" style={{ cursor:'help' }}
            {...window.tipProps({ title: char.name, color:'#2ce8d8', sub: char.title, body: char.passive,
              rows: [{ label:'PV', value: Math.max(0,Math.round(p.hp))+'/'+p.maxHp, color:'#ff6b8b' }] })}>
            <Sprite name={char.sprite || 'tuntun'} scale={6} hue={char.hue} />
          </div>
          <div className="col center" style={{ gap:4 }}>
            <div className="pix" style={{ fontSize:10, color:'#fff', textShadow:'2px 2px 0 #000' }}>{char.name}</div>
            <StatBar hp={p.hp} maxHp={p.maxHp} block={p.block} width={150} />
          </div>
        </div>

        {/* ENEMIES */}
        <div style={{ position: 'absolute', right: 60, bottom: 36, display:'flex', flexDirection:'row', alignItems:'flex-end', gap: 34 }}>
          {enemies.map((en, i) => {
            const intent = !en.dead && en.hp>0 && myTurn ? en.currentIntent : null;
            return (
              <div key={en.id} style={{ display:'flex', flexDirection:'column', alignItems:'center', gap:7,
                transition:'opacity .4s', opacity: en.dead?0.15:1 }}>
                <div style={{ minHeight: 30 }}>{intent && <Intent intent={intent} />}</div>
                <StatusRow statuses={en.statuses} />
                <div ref={el => { if (el) enemyDoms.current[en.id] = el; }} className="bob-slow"
                  {...window.tipProps(window.tipForEnemy(en))} style={{ cursor:'help',
                    filter: en.boss ? 'drop-shadow(0 0 9px rgba(120,230,240,.85)) drop-shadow(0 0 3px rgba(120,230,240,.6)) brightness(1.12)'
                      : (/^(m_|e_)/.test(en.sprite || '') ? 'drop-shadow(0 0 5px rgba(130,215,235,.6)) brightness(1.1)' : 'none') }}>
                  <Sprite name={en.sprite} scale={en.scale} flip={true} hue={en.hue || 0} />
                </div>
                <div className="col center" style={{ gap:4 }}>
                  <div className="pix" style={{ fontSize: en.boss?10:8, color: en.boss?'#ff6b8b':'#fff', textShadow:'2px 2px 0 #000', textAlign:'center', maxWidth:160 }}>{en.name}</div>
                  {en.mod && !en.dead && <ModChip mod={en.mod} />}
                  <StatBar hp={en.hp} maxHp={en.maxHp} block={en.block} width={en.boss?200:120} />
                </div>
              </div>
            );
          })}
        </div>

        {/* floats */}
        {floats.map(f => (
          <div key={f.id} className="float-num" style={{ left: f.x, top: f.y, color: f.color }}>{f.text}</div>
        ))}

        {/* PLAYER STATUS banner (top-left) — debuffs on YOU are always visible up here */}
        {(() => {
          const order = ['poison','burn','weak','vuln','frail'];
          const pdeb = order.filter(k => (p.statuses[k] || 0) > 0);
          if (!pdeb.length) return null;
          const COL = { poison:'#9ae600', burn:'#ff9f1c', weak:'#8aa6bf', vuln:'#ff7a4d', frail:'#c98ab5' };
          const IC  = { poison:'ic_drop', burn:'ic_flame', weak:'ic_down', vuln:'ic_spike', frail:'ic_shield' };
          const LB  = { poison:'VENENO', burn:'QUEMA', weak:'DÉBIL', vuln:'VULN', frail:'FRÁGIL' };
          return (
            <div key={statusPulse} className="pop" style={{ position:'absolute', top:6, left:10, zIndex:59, display:'flex', alignItems:'center', gap:6,
              background:'rgba(7,19,31,.9)', border:'2px solid #ff6b8b', padding:'3px 7px', pointerEvents:'none' }}>
              <span className="pix" style={{ fontSize:7, color:'#ff6b8b' }}>TÚ</span>
              {pdeb.map(k => (
                <div key={k} {...window.tipProps(window.tipForStatus(k, p.statuses[k]))} style={{ pointerEvents:'auto', cursor:'help',
                  display:'flex', alignItems:'center', gap:2, padding:'1px 3px', border:'2px solid '+COL[k], background:'rgba(0,0,0,.4)' }}>
                  <Glyph name={IC[k]} scale={2} color={COL[k]} />
                  <span className="pix" style={{ fontSize:8, color:COL[k] }}>{LB[k]} {p.statuses[k]}</span>
                </div>
              ))}
            </div>
          );
        })()}
        {/* COMBO overlay — grows + brightens as you chain catches in one throw */}
        {combo >= 2 && (() => {
          const col = combo >= 6 ? '#ff4fa3' : combo >= 4 ? '#ffd23f' : '#2ce8d8';
          const sz = Math.min(72, 34 + combo * 6);
          return (
            <div key={combo} className="combo-pop" style={{ position:'absolute', top:'34%', left:'50%', zIndex:74, pointerEvents:'none',
              display:'flex', flexDirection:'column', alignItems:'center', lineHeight:1,
              textShadow:`3px 3px 0 #07131f, 0 0 18px ${col}` }}>
              <span className="pix" style={{ fontSize: Math.min(16, 9+combo), color: col, letterSpacing:2 }}>{combo >= 6 ? '¡CADENA!' : 'COMBO'}</span>
              <span className="pix" style={{ fontSize: sz, color:'#fff', animation: combo>=4?'comboShine .5s steps(4) infinite':'none' }}>×{combo}</span>
            </div>
          );
        })()}
        {/* relics in play — light up when they fire */}
        {(run.relics || []).length > 0 && (
          <div style={{ position:'absolute', top:6, left:'50%', transform:'translateX(-50%)', display:'flex', gap:6, zIndex:58 }}>
            {(run.relics || []).map((id, i) => { const rel = window.GAME.RELICS[id]; if (!rel) return null; const on = relicFlash[id] > 0; return (
              <div key={i} className="col center" {...window.tipProps(window.tipForRelic(rel))} style={{ width:26, height:26, borderRadius:'50%', background:'#0b1d2e', cursor:'help',
                boxShadow: on ? `0 0 0 2px ${rel.color}, 0 0 13px 2px ${rel.color}` : `0 0 0 2px ${rel.color}66`,
                transform: on ? 'scale(1.3)' : 'scale(1)', transition:'all .14s' }}>
                <Glyph name={rel.icon} scale={2} color={on ? '#fff' : rel.color} />
              </div>
            ); })}
          </div>
        )}
        {/* phase banners */}
        {bossRule && (
          <div className="pop" style={{ position:'absolute', top:'8%', left:'50%', transform:'translateX(-50%)', zIndex:70,
            maxWidth:560, padding:'12px 18px', background:'rgba(7,19,31,.92)', border:'3px solid #ff6b8b', pointerEvents:'none' }}>
            <div className="pix" style={{ fontSize:11, color:'#ff6b8b', textShadow:'2px 2px 0 #000', textAlign:'center', lineHeight:1.6 }}>⚠ {bossRule}</div>
          </div>
        )}
        {achToast && (
          <div className="pop" style={{ position:'absolute', top:'16%', left:'50%', transform:'translateX(-50%)', zIndex:80,
            background:'rgba(7,19,31,.95)', border:'3px solid #b06bff', padding:'8px 16px', pointerEvents:'none' }}>
            <div className="pix" style={{ fontSize:11, color:'#b06bff', textShadow:'2px 2px 0 #000' }}>★ LOGRO · {achToast}</div>
          </div>
        )}
        {phase === 'enemy' && <PhaseTag text="TURNO ENEMIGO" color="#ff5a3c" />}
        {phase === 'won' && <PhaseTag text="¡VICTORIA!" color="#ffd23f" big />}
        {phase === 'lost' && <PhaseTag text="DERROTADO..." color="#ff3b3b" big />}
      </div>

      {/* HUD STRIP */}
      <div className="row" style={{ height: 54, alignItems:'center', justifyContent:'space-between',
        padding:'0 18px', background:'#07131f', borderBottom:'4px solid #16242f', gap:12 }}>
        <div className="row" style={{ alignItems:'center', gap:12 }}>
          <span className="pix" style={{ fontSize:9, color: myTurn?'#ffd23f':'#6f8aa0' }}>{myTurn?'TU TURNO':'...'}</span>
          <div className="row" style={{ gap:6, alignItems:'center' }}>
            {Array.from({ length: Math.max(2, throwsRef.current) }).map((_, i) => (
              <div key={i} style={{ opacity: i < throwsRef.current ? 1 : 0.25 }}>
                <Glyph name="ic_bolt" scale={3} color={i<throwsRef.current?'#ffd23f':'#6f8aa0'} />
              </div>
            ))}
            <span className="pix" style={{ fontSize:8, color:'#9db8c9' }}>LANZAR</span>
          </div>
          <TankThreats threats={threats} />
          {(run.consumables || []).length > 0 && (
            <div className="row" style={{ alignItems:'center', gap:6, paddingLeft:8, borderLeft:'2px solid #16242f' }}>
              <span className="pix" style={{ fontSize:7, color:'#6f8aa0' }}>USAR</span>
              {(run.consumables || []).map((id, i) => { const c = window.GAME.consumable(id); return (
                <div key={i} onClick={() => useConsumable(i)} {...window.tipProps({ title: c.name, color: c.color, icon: c.icon, body: c.desc })}
                  style={{ cursor: myTurn ? 'pointer' : 'default', opacity: myTurn ? 1 : 0.4, padding:'2px 3px', border:'2px solid '+c.color, background:'rgba(0,0,0,.4)' }}>
                  <Glyph name={c.icon} scale={2.5} color={c.color} />
                </div>
              ); })}
            </div>
          )}
          <div className="row" style={{ alignItems:'center', gap:5, paddingLeft:8, borderLeft:'2px solid #16242f' }}>
            {/* ROUTE selector — click to change the harpoon trajectory this turn */}
            {(() => {
              if (run.char.flag === 'bounce' || run.char.flag === 'net') return null;   // Brinco/Nácar: tiro fijo
              const boomer = (run.harpoonMods || []).includes('boomerang');
              const baseRoute = boomer ? 'arc' : 'recto';
              const routes = [baseRoute, ...(run.harpoonMods || []).filter(m => window.GAME.isRouteMod(m))];
              if (boomer && routes.length <= 1) return null;   // Vaivén sin más rutas: fijo
              const cur = routes.includes(route) ? route : baseRoute;
              const info = cur === 'recto' ? { name:'RECTO', icon:'ic_bolt', color:'#9db8c9' }
                : cur === 'arc' ? { name:'BUMERÁN', icon:'ic_swirl', color:'#2ce8d8' } : window.GAME.harpoonMod(cur);
              return (
                <div onClick={() => setRouteModal(true)}
                  {...window.tipProps({ title:'RUTA · ' + info.name, color:info.color, icon:info.icon,
                    body: 'Clic para abrir el selector de arpón/ruta y ver qué hace cada una.' })}
                  style={{ cursor:'pointer', display:'flex', alignItems:'center', gap:4, padding:'2px 6px', border:'2px solid '+info.color, background:'rgba(0,0,0,.45)' }}>
                  <span className="pix" style={{ fontSize:7, color:'#6f8aa0' }}>RUTA</span>
                  <Glyph name={info.icon} scale={2.5} color={info.color} />
                  <span className="pix" style={{ fontSize:7, color:info.color }}>{info.name}{routes.length > 1 ? ' ⚙' : ''}</span>
                </div>
              );
            })()}
            {/* passive mods (read-only) */}
            {(run.harpoonMods || []).filter(m => !window.GAME.isRouteMod(m)).map((id, i) => { const m = window.GAME.harpoonMod(id); return (
              <div key={i} {...window.tipProps({ title: m.name, color: m.color, icon: m.icon, body: m.desc })}
                style={{ cursor:'help', padding:'2px 3px', border:'2px solid '+m.color, background:'rgba(0,0,0,.4)' }}>
                <Glyph name={m.icon} scale={2.5} color={m.color} />
              </div>
            ); })}
          </div>
        </div>
        <div className="pix" style={{ fontSize:11, color:'#fff', textShadow:'2px 2px 0 #000', flex:1, textAlign:'center', minHeight:14 }}>
          {banner}
        </div>
        <div className="row" style={{ alignItems:'center', gap:10 }}>
          {lastCatch && <div className="row" style={{ alignItems:'center', gap:6, opacity:.85 }}>
            {(() => { const bi = window.cardArt(lastCatch); return bi
              ? <Sprite name={bi} scale={1.6} />
              : <Glyph name={lastCatch.icon} scale={2.5} color={lastCatch.catColor} />; })()}
            <span className="pix" style={{ fontSize:8, color:'#9db8c9' }}>{lastCatch.name}</span>
          </div>}
          <Btn onClick={() => setDeckOpen(true)} style={{ fontSize:9, padding:'10px 12px' }}>BARAJA · {run.deck.length}</Btn>
          <Btn variant="btn-go" disabled={!myTurn} onClick={() => myTurn && endPlayerTurn()} style={{ fontSize:10, padding:'10px 14px' }}>FIN TURNO</Btn>
        </div>
      </div>

      {deckOpen && (
        <div className="fill col center" style={{ position:'absolute', inset:0, zIndex:310, background:'rgba(7,19,31,.92)', gap:14, padding:24 }}>
          <div className="pix" style={{ fontSize:14, color:'#ffd23f', textShadow:'2px 2px 0 #000' }}>TU BARAJA · {run.deck.length} CARTAS</div>
          <div className="pix" style={{ fontSize:8, color:'#6f8aa0' }}>Cada turno se barajan hasta 14 en el agua</div>
          <div className="row" style={{ gap:14, flexWrap:'wrap', justifyContent:'center', maxWidth:1120, maxHeight:540, overflowY:'auto' }}>
            {Object.values(run.deck.reduce((a,card)=>{ const k=window.GAME.cardKey(card); if(!a[k]) a[k]={ card, n:0 }; a[k].n += 1; return a; },{})).map(({card,n}) => (
              <div key={window.GAME.cardKey(card)} style={{ position:'relative' }}>
                <ItemCard it={window.GAME.item(card)} size="md" design={cardDesign} />
                <div className="pix" style={{ position:'absolute', top:-4, right:6, fontSize:11, color:'#07131f',
                  background:'#ffd23f', padding:'2px 5px', boxShadow:'0 0 0 3px #07131f' }}>×{n}</div>
              </div>
            ))}
          </div>
          <Btn onClick={() => setDeckOpen(false)} style={{ fontSize:11 }}>CERRAR</Btn>
        </div>
      )}

      {/* TANK */}
      <div style={{ position:'relative', flex:1, minHeight:0 }}>
        <canvas ref={canvasRef} style={{ width:'100%', height:'100%', display:'block', cursor: myTurn?'crosshair':'default' }} />
        {/* CAMBIAR ARPÓN — botoncito junto al lanzador (abajo-centro); abre un mini-modal con las rutas */}
        {(() => {
          if (run.char.flag === 'bounce' || run.char.flag === 'net') return null;   // Brinco/Nácar tienen mecánica de tiro fija
          const boomerChar = (run.harpoonMods || []).includes('boomerang');
          const baseRoute = boomerChar ? 'arc' : 'recto';
          const routes = [baseRoute, ...(run.harpoonMods || []).filter(m => window.GAME.isRouteMod(m))];
          if (boomerChar && routes.length <= 1) return null;   // Vaivén sin más rutas: bumerán fijo hasta comprar otra
          const cur = routes.includes(route) ? route : baseRoute;
          const info = cur === 'recto' ? { name:'RECTO', icon:'ic_bolt', color:'#9db8c9' }
            : cur === 'arc' ? { name:'BUMERÁN', icon:'ic_swirl', color:'#2ce8d8' } : window.GAME.harpoonMod(cur);
          return (
            <div onClick={() => setRouteModal(true)}
              style={{ position:'absolute', left:'50%', bottom:6, transform:'translateX(46px)', zIndex:32, cursor:'pointer',
                display:'flex', alignItems:'center', gap:3, padding:'2px 6px', borderRadius:5,
                border:'1px solid '+info.color, background:'rgba(7,19,31,.8)' }}
              {...window.tipProps({ title:'Cambiar arpón · ' + info.name, color:info.color, icon:info.icon, body:'Elige la trayectoria de tu arpón este turno (clic para ver cada una).' })}>
              <Glyph name={info.icon} scale={1.5} color={info.color} />
              <span className="pix" style={{ fontSize:6, color:info.color }}>ARPÓN ⚙</span>
            </div>
          );
        })()}
        {/* MINI-MODAL de selección de ruta/arpón */}
        {routeModal && (() => {
          const baseRoute = (run.harpoonMods || []).includes('boomerang') ? 'arc' : 'recto';
          const routes = [baseRoute, ...(run.harpoonMods || []).filter(m => window.GAME.isRouteMod(m))];
          const cur = routes.includes(route) ? route : baseRoute;
          const RDESC = { recto:'Línea recta. Equilibrado y predecible.', arc:'Vuela en ARCO curvo (bumerán) y engancha a la ida y al volver.' };
          const baseInfo = { recto:{ name:'ARPÓN RECTO', icon:'ic_bolt', color:'#9db8c9' }, arc:{ name:'HOJA BUMERÁN', icon:'ic_swirl', color:'#2ce8d8' } };
          return (
            <div onClick={() => setRouteModal(false)}
              style={{ position:'absolute', inset:0, zIndex:120, background:'rgba(5,12,20,.78)', display:'flex', alignItems:'center', justifyContent:'center', padding:14 }}>
              <div onClick={e => e.stopPropagation()} className="col" style={{ gap:8, background:'#0b1d2e', border:'3px solid #2ce8d8', borderRadius:10, padding:16, maxWidth:520, maxHeight:'92%', overflowY:'auto' }}>
                <div className="row" style={{ justifyContent:'space-between', alignItems:'center', gap:14 }}>
                  <span className="pix" style={{ fontSize:11, color:'#2ce8d8' }}>CAMBIAR ARPÓN · RUTA</span>
                  <span onClick={() => setRouteModal(false)} className="pix" style={{ fontSize:10, color:'#9db8c9', cursor:'pointer' }}>✕</span>
                </div>
                {routes.map(r => {
                  const m = baseInfo[r] ? { ...baseInfo[r], desc:RDESC[r] } : window.GAME.harpoonMod(r);
                  const sel = r === cur;
                  return (
                    <div key={r} onClick={() => { setRoute(r); setRouteModal(false); if (window.SFX && window.SFX.click) window.SFX.click(); }}
                      className="row" style={{ alignItems:'center', gap:10, padding:'8px 10px', cursor:'pointer',
                        border:'2px solid '+(sel?'#ffd23f':m.color), background: sel?'rgba(255,210,63,.12)':'#0e2336' }}>
                      <Glyph name={m.icon} scale={3} color={m.color} />
                      <div className="col" style={{ gap:2, flex:1 }}>
                        <span className="pix" style={{ fontSize:9, color:m.color }}>{m.name}{sel?'  ◄ ACTUAL':''}</span>
                        <span className="body" style={{ fontSize:13, color:'#cfe2ee', lineHeight:1.15 }}>{m.desc}</span>
                      </div>
                    </div>
                  );
                })}
                {routes.length === 1 && (
                  <span className="body" style={{ fontSize:12, color:'#6f8aa0', textAlign:'center', padding:'4px 0' }}>Compra PUNTAS DE RUTA en la Tienda del Abismo para desbloquear más trayectorias.</span>
                )}
              </div>
            </div>
          );
        })()}
        {/* CATCH QUEUE — caught-this-throw on the left rail, drains as each resolves */}
        {catchQueue.length > 0 && (
          <div style={{ position:'absolute', left:10, top:'50%', transform:'translateY(-50%)',
            display:'flex', flexDirection:'column', gap:7, zIndex:30, pointerEvents:'none' }}>
            <div className="pix" style={{ fontSize:7, color:'#6f8aa0', textAlign:'center', marginBottom:2 }}>PESCADO</div>
            {catchQueue.map(c => (
              <div key={c.id} className={'catch-chip ' + c.status}
                style={{ borderColor: c.crit ? '#ffd23f' : (c.it.catColor || '#16242f') }}>
                {(() => { const bi = window.cardArt(c.it); return bi
                  ? <Sprite name={bi} scale={1.5} />
                  : <Glyph name={c.it.icon} scale={3} color={c.it.catColor} />; })()}
                {c.it.val > 0 && <span className="pix" style={{ fontSize:8, color:'#fff', textShadow:'1px 1px 0 #000', marginTop:1 }}>{c.it.val}</span>}
                {c.crit && <span className="pix" style={{ position:'absolute', top:-6, right:-4, fontSize:7, color:'#ffd23f', textShadow:'1px 1px 0 #000' }}>★</span>}
              </div>
            ))}
          </div>
        )}
        {myTurn && (
          <div className="pix blink" style={{ position:'absolute', top:10, left:'50%', transform:'translateX(-50%)',
            fontSize:9, color:'#ffd23f', textShadow:'2px 2px 0 #000', pointerEvents:'none' }}>
            {run.char.flag==='net' ? 'CLIC: fijar ANCLA · CLIC: tender la RED'
              : (control==='power' || (modeJamRef.current.turns>0 && modeJamRef.current.mode==='power')) ? 'CLIC: fijar dirección · CLIC: potencia'
              : run.char.flag==='bounce' ? 'CLIC para lanzar · el DARDO rebota (cada captura suma un rebote)'
              : 'CLIC para lanzar · apunta al NÚCLEO para ¡PERFECTO!'}
          </div>
        )}
        {myTurn && <TankReadPanel read={tankRead} hover={hoverToken} />}
        {/* TANK FX toast — pops when enemies alter the water/objects this turn */}
        {tankFx && tankFx.length > 0 && (
          <div key={'fx' + turnCountRef.current} style={{ position:'absolute', top:34, left:'50%', transform:'translateX(-50%)',
            display:'flex', gap:8, zIndex:46, pointerEvents:'none', flexWrap:'wrap', justifyContent:'center', maxWidth:'82%' }}>
            {tankFx.map((f, i) => (
              <div key={i} className="pop" style={{ display:'flex', alignItems:'center', gap:5, animationDelay:(i*0.06)+'s',
                background:'rgba(7,19,31,.92)', border:'2px solid '+f.color, padding:'4px 9px', boxShadow:'0 3px 0 rgba(0,0,0,.4)' }}>
                <Glyph name={f.icon} scale={2.5} color={f.color} />
                <span className="pix" style={{ fontSize:9, color:f.color, textShadow:'1px 1px 0 #000' }}>{f.label}</span>
              </div>
            ))}
          </div>
        )}
      </div>

      {/* GADGET TRAY (bottom-left) — drag onto the tank to deploy. A la izquierda para que el tooltip se abra hacia el tanque (derecha) y no quede tapado por los iconos. */}
      {(run.gadgets || []).length > 0 && (
        <div style={{ position:'absolute', left:10, bottom:10, display:'flex', flexDirection:'column', gap:7, zIndex:120, alignItems:'center' }}>
          <div className="pix" style={{ fontSize:7, color:'#6f8aa0' }}>ARRASTRA ▸ TANQUE</div>
          {(run.gadgets || []).map((id, i) => { const g = window.GAME.gadget(id); return (
            <div key={i} onPointerDown={(e) => { e.preventDefault(); setGdrag({ id, idx: i, x: e.clientX, y: e.clientY }); }}
              {...window.tipProps({ title: g.name + (g.trigger==='auto'?' · AUTO':' · PÉSCALO'), color: g.color, icon: g.icon, body: g.desc })}
              style={{ position:'relative', width:48, height:48, cursor:'grab', background:'rgba(7,19,31,.92)', border:'3px solid '+g.color, borderRadius:8,
                display:'flex', alignItems:'center', justifyContent:'center', touchAction:'none' }}>
              <Glyph name={g.icon} scale={4} color={g.color} />
              <span className="pix" style={{ position:'absolute', bottom:-2, right:1, fontSize:5, color: g.trigger==='auto'?'#9ae600':'#ffd23f', textShadow:'1px 1px 0 #000' }}>{g.trigger==='auto'?'AUTO':'⌖'}</span>
            </div>
          ); })}
        </div>
      )}
      {gdrag && (() => { const g = window.GAME.gadget(gdrag.id); return (
        <div style={{ position:'fixed', left:gdrag.x-24, top:gdrag.y-24, width:48, height:48, pointerEvents:'none', zIndex:500,
          opacity:.9, display:'flex', alignItems:'center', justifyContent:'center', filter:'drop-shadow(0 0 8px '+g.color+')' }}>
          <Glyph name={g.icon} scale={4} color={g.color} />
        </div>
      ); })()}

      {coachStep != null && <CoachNudge step={coachStep} onSkip={completeCoach} onDone={completeCoach} />}
    </div>
  );
}

function PhaseTag({ text, color, big }) {
  return (
    <div className="pop" style={{ position:'absolute', top:'42%', left:'50%', transform:'translate(-50%,-50%)',
      pointerEvents:'none', zIndex:60 }}>
      <div className="pix" style={{ fontSize: big?40:18, color, textShadow:'4px 4px 0 #000', letterSpacing:2 }}>{text}</div>
    </div>
  );
}

const MOD_INFO = {
  anchor:  { icon:'ic_anchor', color:'#2ce8d8', label:'ANCLA' },
  junk:    { icon:'ic_junk',   color:'#ffd23f', label:'CHATARRA' },
  hazard:  { icon:'ic_spike',  color:'#ff3b3b', label:'ERIZOS' },
  current: { icon:'ic_swirl',  color:'#b06bff', label:'CORRIENTE' },
  fog:     { icon:'ic_fog',    color:'#eaf6ff', label:'NIEBLA' },
  vortex:  { icon:'ic_swirl',  color:'#2ce8d8', label:'VÓRTICE' },
  ward:    { icon:'ic_shield', color:'#2ce8d8', label:'GUARDIÁN' },
  pest:    { icon:'ic_skull',  color:'#b06bff', label:'PLAGA' },
  sway:    { icon:'ic_swirl',  color:'#ff9f1c', label:'OLEAJE' },
  sweep:   { icon:'ic_swirl',  color:'#2ce8d8', label:'RESACA' },
  freeze:  { icon:'ic_shield', color:'#bfe9f2', label:'HIELO' },
  turb:    { icon:'ic_flame',  color:'#ff9f1c', label:'HERVOR' },
  squeeze: { icon:'ic_down',   color:'#b06bff', label:'PRESIÓN' },
  blackout:{ icon:'ic_fog',    color:'#b06bff', label:'APAGÓN' },
};
function ModChip({ mod }) {
  const m = MOD_INFO[mod]; if (!m) return null;
  return (
    <div className="row" {...window.tipProps(window.tipForMod(mod, m))} style={{ alignItems:'center', gap:4, background:'rgba(0,0,0,.45)',
      border:'2px solid '+m.color, padding:'1px 5px', cursor:'help' }}>
      <Glyph name={m.icon} scale={2} color={m.color} />
      <span className="pix" style={{ fontSize:7, color:m.color }}>{m.label}</span>
    </div>
  );
}
function TankThreats({ threats }) {
  if (!threats) return null;
  const items = [];
  if (threats.anchors > 0) items.push(['anchor', threats.anchors]);
  if (threats.junk > 0) items.push(['junk', threats.junk]);
  if (threats.hazard > 0) items.push(['hazard', threats.hazard]);
  if (threats.currentMul > 1) items.push(['current', '']);
  if (threats.fog) items.push(['fog', '']);
  if (threats.vortex) items.push(['vortex', '']);
  if (threats.pests > 0) items.push(['pest', threats.pests]);
  if (threats.sway) items.push(['sway', '']);
  if (threats.sweep > 0) items.push(['sweep', '']);
  if (threats.freeze > 0) items.push(['freeze', threats.freeze]);
  if (threats.turb) items.push(['turb', '']);
  if (threats.squeeze) items.push(['squeeze', '']);
  if (threats.blackout) items.push(['blackout', '']);
  if (!items.length) return null;
  return (
    <div className="row" style={{ alignItems:'center', gap:6, paddingLeft:8, borderLeft:'2px solid #16242f' }}>
      <span className="pix" style={{ fontSize:7, color:'#6f8aa0' }}>TANQUE</span>
      {items.map(([mod, n], i) => {
        const m = MOD_INFO[mod];
        return (
          <div key={i} className="row" {...window.tipProps(window.tipForMod(mod, m))} style={{ alignItems:'center', gap:2, cursor:'help' }}>
            <Glyph name={m.icon} scale={2} color={m.color} />
            {n !== '' && <span className="pix" style={{ fontSize:8, color:m.color }}>{n}</span>}
          </div>
        );
      })}
    </div>
  );
}

function effectPreview(it) {
  if (!it || !it.eff) return 'Sin efecto.';
  const e = it.eff, out = [];
  if (e.damage != null) out.push(`${e.all ? 'daño a todos' : 'daño'} ${e.damage}`);
  if (e.block != null) out.push(`bloqueo ${e.block}`);
  if (e.poison != null) out.push(`veneno ${e.poison}`);
  if (e.burn != null) out.push(`quemadura ${e.burn}`);
  if (e.heal != null) out.push(`cura ${e.heal}`);
  if (e.vamp != null) out.push(`vampiro ${e.vamp}`);
  if (e.strength != null) out.push(`fuerza +${e.strength}`);
  if (e.weaken != null) out.push(`débil ${e.weaken}`);
  if (e.expose != null) out.push(`vulnerable ${e.expose}`);
  if (e.frailty != null) out.push(`frágil ${e.frailty}`);
  if (e.bomb != null) out.push(`bomba ${e.bomb}`);
  if (e.doubleNext || e.wildcard) out.push('duplica siguiente');
  if (e.selfHit != null) out.push(`te daña ${e.selfHit}`);
  return out.length ? out.join(' · ') : (it.desc || 'Sin efecto.');
}

function TankReadPanel({ read, hover }) {
  const wrapRef = useRef(null);
  const [peek, setPeek] = useState(false);   // hover → fade + click-through, so you can see/reach what's behind
  useEffect(() => {
    if (!peek) return;
    const mv = (e) => { const el = wrapRef.current; if (!el) return; const r = el.getBoundingClientRect();
      if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) setPeek(false); };
    document.addEventListener('pointermove', mv);
    return () => document.removeEventListener('pointermove', mv);
  }, [peek]);
  if (!read) return null;
  const affEntries = Object.entries(read.aff || {}).sort((a, b) => b[1] - a[1]).slice(0, 4);
  const best = read.best;
  const bestAff = best && window.GAME.AFF[best[0]];
  return (
    <div ref={wrapRef} onPointerEnter={() => setPeek(true)}
      style={{ position:'absolute', right:12, bottom:10, zIndex:32, width:206,
      pointerEvents: peek ? 'none' : 'auto', opacity: peek ? 0.07 : 0.92, transition:'opacity .14s',
      background:'rgba(7,19,31,.84)', border:'2px solid #16242f', boxShadow:'0 3px 0 rgba(0,0,0,.35)', padding:8 }}>
      {hover ? (
        <div className="col" style={{ gap:6 }}>
          <div className="row" style={{ alignItems:'center', gap:7 }}>
            <Glyph name={hover.icon} scale={2.5} color={hover.catColor || '#eaf6ff'} />
            <span className="pix" style={{ fontSize:9, color:hover.catColor || '#eaf6ff', lineHeight:1.4 }}>{hover.name}</span>
          </div>
          <div className="body" style={{ fontSize:13, color:'#cfe2ee', lineHeight:1.05 }}>{effectPreview(hover)}</div>
          <div className="pix" style={{ fontSize:7, color: hover.neutral ? (hover.color || '#7fd6e6') : ((window.GAME.AFF[hover.aff] || {}).color || '#9db8c9') }}>
            {hover.neutral ? 'OBJETIVO NEUTRAL' : (hover.junk || hover.hazard ? 'PELIGRO / CHATARRA' : `AFINIDAD · ${(window.GAME.AFF[hover.aff] || {}).label || '—'}${hover.aff2 ? ' + ' + window.GAME.AFF[hover.aff2].label : ''}`)}
          </div>
        </div>
      ) : (
        <div className="col" style={{ gap:7 }}>
          <div className="row" style={{ justifyContent:'space-between', alignItems:'center' }}>
            <span className="pix" style={{ fontSize:8, color:'#9db8c9' }}>LECTURA DEL TANQUE</span>
            <span className="pix" style={{ fontSize:8, color:'#6f8aa0' }}>{read.active} útiles</span>
          </div>
          <div className="row" style={{ gap:7, flexWrap:'wrap' }}>
            {affEntries.map(([id, n]) => { const a = window.GAME.AFF[id]; return (
              <span key={id} className="pix" style={{ fontSize:7, color:a.color, border:'2px solid '+a.color, padding:'2px 4px', background:'rgba(0,0,0,.25)' }}>
                {a.label} ×{n}
              </span>
            ); })}
            {read.hazards > 0 && <span className="pix" style={{ fontSize:7, color:'#ff3b3b' }}>RIESGO ×{read.hazards}</span>}
            {read.junk > 0 && <span className="pix" style={{ fontSize:7, color:'#6f8aa0' }}>BASURA ×{read.junk}</span>}
            {read.neutrals > 0 && <span className="pix" style={{ fontSize:7, color:'#ffd23f', border:'2px solid #ffd23f', padding:'2px 4px', background:'rgba(0,0,0,.25)' }}>OPORTUNIDAD ×{read.neutrals}</span>}
          </div>
          {read.waterBits && read.waterBits.length > 0 && (
            <div className="row" style={{ gap:5, flexWrap:'wrap' }}>
              {read.waterBits.map((w, i) => (
                <span key={i} className="pix" style={{ fontSize:7, color:w.color, border:'2px solid '+w.color, padding:'2px 4px', background:'rgba(0,0,0,.25)' }}>
                  AGUA {w.label}
                </span>
              ))}
            </div>
          )}
          {best && (
            <div className="body" style={{ fontSize:13, color:'#cfe2ee', lineHeight:1.05 }}>
              Mejor cadena visible: <span style={{ color: bestAff.color }}>{bestAff.label} ×{best[1]}</span>
              {read.bestBonus > 0 ? ` · remate aprox. RESO +${read.bestBonus}` : ''}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

function CoachNudge({ step, onSkip, onDone }) {
  const steps = [
    { icon:'ic_bolt', color:'#ffd23f', title:'APUNTA Y LANZA', body:'Mueve el ratón por el tanque. Clic lanza el arpón; todo lo que roce vuelve contigo.', anchor:'bottom' },
    { icon:'ic_star', color:'#ffd23f', title:'NÚCLEO DORADO', body:'Ya viste el tiro. Para mejorar, haz pasar la línea por el centro de un objeto: sale PERFECTO y pega más.', anchor:'bottom' },
    { icon:'ic_star', color:'#ffd23f', title:'PERFECTO', body:'Ese golpe fue limpio. Ahora busca objetos del mismo aro: cada eslabón aumenta la Resonancia.', anchor:'bottom' },
    { icon:'ic_sword', color:'#ff5a3c', title:'RESONANCIA', body:'La lectura del tanque marca la mejor cadena visible. Prioriza afinidades repetidas, pero evita peligros.', anchor:'panel' },
  ];
  const s = steps[Math.min(step, steps.length - 1)];
  const done = step >= steps.length - 1;
  const pos = s.anchor === 'panel'
    ? { right: 336, bottom: 22 }
    : { left: '50%', bottom: 72, transform: 'translateX(-50%)' };
  return (
    <div className="pop" style={{ position:'absolute', ...pos, zIndex:180, width: done ? 330 : 390,
      background:'rgba(7,19,31,.96)', border:'3px solid '+s.color, boxShadow:'0 5px 0 rgba(0,0,0,.45)', padding:12 }}>
      <div className="row" style={{ alignItems:'center', gap:9 }}>
        <Glyph name={s.icon} scale={3} color={s.color} />
        <div className="col" style={{ gap:5, flex:1 }}>
          <div className="pix" style={{ fontSize:10, color:s.color, textShadow:'2px 2px 0 #000' }}>{s.title}</div>
          <div className="body" style={{ fontSize:17, color:'#eaf6ff', lineHeight:1.05 }}>{s.body}</div>
        </div>
      </div>
      <div className="row" style={{ justifyContent:'space-between', alignItems:'center', marginTop:10 }}>
        <div className="pix" style={{ fontSize:7, color:'#6f8aa0' }}>{Math.min(step + 1, 4)} / 4</div>
        <div className="row" style={{ gap:8 }}>
          <button className="pix" onClick={onSkip} style={{ fontSize:7, color:'#6f8aa0', background:'transparent', border:0, cursor:'pointer' }}>SALTAR</button>
          {done && <Btn variant="btn-primary" onClick={onDone} style={{ fontSize:9, padding:'8px 12px' }}>ENTENDIDO</Btn>}
        </div>
      </div>
    </div>
  );
}

window.Combat = Combat;
