KikiMeter

Lightweight DPS Meter for Milky Way Idle

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Advertisement:

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

Advertisement:

// ==UserScript==
// @name         KikiMeter
// @namespace    https://github.com/ZhuLiMoon
// @version      3.1.0
// @description  Lightweight DPS Meter for Milky Way Idle
// @author       ZhuLiMoon
// @match        https://www.milkywayidle.com/*
// @match        https://milkywayidle.com/*
// @match        https://www.milkywayidlecn.com/*
// @match        https://milkywayidlecn.com/*
// @match        https://test.milkywayidle.com/*
// @run-at       document-start
// @grant        none
// @license      MIT
// ==/UserScript==

/*
 * --- Algorithme d'attribution (v3.0.0) ---
 *
 * Méthode MWITools / Combat Suite (rétro-ingénierie 16/07/2026, validée ±0.1%).
 *
 * Signal    : la PRÉSENCE d'un joueur dans pMap de battle_updated. Le serveur
 *             groupe chaque message par acteur — le joueur présent est celui
 *             qui a causé les hpDiff des monstres du même message (attaque,
 *             AoE, tick DoT, pierce, reflect… sans distinction nécessaire).
 * Multi     : si plusieurs joueurs dans pMap, le lanceur = celui dont cMP a
 *             baissé (castPlayer). Jamais observé sans baisse de MP.
 * Baseline  : monstersHP init depuis new_battle.currentHitpoints (pas de
 *             dégâts fantômes sur monstre déjà blessé).
 * Healing   : delta positif de cHP d'un joueur dans pMap.
 * Kills     : cHP monstre 0 → attribué au joueur du même message.
 * Hook      : getter MessageEvent.prototype.data (voit tout, avant le jeu).
 * → window.__MWI_DPS.diagnostics() pour surveiller en live.
 */

(function () {
  'use strict';

  const MWI = (window.__MWI_DPS = window.__MWI_DPS || {});

  // Classe CSS du conteneur d'onglets du jeu. Si le jeu la change, modifier ici.
  const TAB_CONTAINER_CLASS = 'TabsComponent_tabsContainer__3BDUp';

  // Couleur d'accentuation KikiMeter (onglet actif, titre, bordure).
  const ACCENT = '#007474';

  // Couleur réservée au healing (ne figure PAS dans la palette DPS).
  const HPS_COLOR = '#43A047';

  // Palette DPS uniquement — le vert est retiré car réservé au badge HPS.
  const PALETTE = ['#E53935','#42A5F5','#FB8C00','#AB47BC','#00ACC1','#FDD835','#8D6E63','#E91E63'];

  // ─── Settings ─────────────────────────────────────────────────────────────────
  const Settings = (() => {
    const KEY = 'kikimeter:settings:v3';
    const defaults = { unit: 'raw', colors: {}, showHealing: true, showGraph: true, autoReset: true };
    let state = { ...defaults };
    try {
      const s = JSON.parse(localStorage.getItem(KEY) || '{}');
      if (s && typeof s === 'object') state = { ...defaults, ...s };
    } catch (e) {}
    function save() { try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) {} }
    return {
      getUnit:        ()      => state.unit,
      setUnit:        (u)     => { state.unit = u; save(); },
      getColor:       (n)     => state.colors[n],
      setColor:       (n, c)  => { state.colors[n] = c; save(); },
      getShowHealing: ()      => state.showHealing,
      setShowHealing: (v)     => { state.showHealing = v; save(); },
      getShowGraph:   ()      => state.showGraph,
      setShowGraph:   (v)     => { state.showGraph = v; save(); },
      getAutoReset:   ()      => state.autoReset,
      setAutoReset:   (v)     => { state.autoReset = v; save(); },
      getDebugMode:   ()      => state.debugMode || false,
      setDebugMode:   (v)     => { state.debugMode = v; save(); },
    };
  })();

  // ─── Helpers ──────────────────────────────────────────────────────────────────
  function formatDamage(n) {
    const u = Settings.getUnit();
    if (u === 'k') return (n / 1000).toFixed(1) + 'k';
    if (u === 'm') return (n / 1_000_000).toFixed(2) + 'm';
    return Math.round(n).toString();
  }
  function formatDuration(s) {
    const sec = Math.floor(s % 60), min = Math.floor(s / 60) % 60, hr = Math.floor(s / 3600);
    const p = (n) => String(n).padStart(2, '0');
    return hr > 0 ? (hr + ':' + p(min) + ':' + p(sec)) : (min + ':' + p(sec));
  }
  // Crée un élément DOM avec des styles inline directement appliqués.
  function el(tag, styles) {
    const e = document.createElement(tag);
    if (styles) Object.assign(e.style, styles);
    return e;
  }

  // ─── Session ──────────────────────────────────────────────────────────────────
  // DPS = dégâts cumulés / temps écoulé depuis le dernier reset.
  // Kills attribués au joueur acteur du message où le monstre atteint cHP=0.
  const Session = (() => {
    let startTs = performance.now();
    let teamDamage = 0;
    const playerDamage  = new Map();
    const playerHealing = new Map();
    const playerKills   = new Map();

    // Buffer circulaire pour le graph DPS : 150 points de 2s = 5 min glissantes.
    // bossBuckets[] mémorise pour chaque bucket si le combat en cours était un boss,
    // ce qui permet au render de colorier chaque segment teal (normal) ou rouge (boss).
    const BUCKETS = 150, BUCKET_MS = 2000;
    const dmgBuckets  = new Array(BUCKETS).fill(0);
    const bossBuckets = new Array(BUCKETS).fill(false);
    let curBucket = 0, bucketTs = performance.now();
    let _isBoss = false;

    function elapsed() { return (performance.now() - startTs) / 1000; }

    function tickBuckets(ts) {
      while (ts - bucketTs >= BUCKET_MS) {
        curBucket = (curBucket + 1) % BUCKETS;
        dmgBuckets[curBucket]  = 0;
        bossBuckets[curBucket] = _isBoss;
        bucketTs += BUCKET_MS;
      }
    }

    return {
      reset() {
        startTs = performance.now(); teamDamage = 0;
        playerDamage.clear(); playerHealing.clear(); playerKills.clear();
        dmgBuckets.fill(0); bossBuckets.fill(false);
        curBucket = 0; bucketTs = performance.now(); _isBoss = false;
      },
      setBoss(v)             { _isBoss = v; bossBuckets[curBucket] = v; },
      addTeamDamage(a, ts)   { teamDamage += a; tickBuckets(ts); dmgBuckets[curBucket] += a; bossBuckets[curBucket] = _isBoss; },
      addPlayerDamage(n, a)  { playerDamage.set(n,  (playerDamage.get(n)  || 0) + a); },
      addPlayerHealing(n, a) { playerHealing.set(n, (playerHealing.get(n) || 0) + a); },
      addPlayerKill(n)       { playerKills.set(n,   (playerKills.get(n)   || 0) + 1); },
      getTeamDps()       { const e = elapsed(); return e < 1 ? 0 : teamDamage / e; },
      getTeamDamage()    { return teamDamage; },
      getTeamKills()     { let t = 0; playerKills.forEach(v => t += v); return t; },
      getPlayerDps(n)    { const e = elapsed(); return e < 1 ? 0 : (playerDamage.get(n)  || 0) / e; },
      getPlayerDamage(n) { return playerDamage.get(n)  || 0; },
      getPlayerHps(n)    { const e = elapsed(); return e < 1 ? 0 : (playerHealing.get(n) || 0) / e; },
      getPlayerKills(n)  { return playerKills.get(n)   || 0; },
      getElapsedSeconds(){ return elapsed(); },
      getAllPlayerNames() { return Array.from(new Set([...playerDamage.keys(), ...playerKills.keys()])); },
      // Avance les buckets au temps actuel — appelé à chaque render tick (250ms).
      // Sans ça, curBucket ne s'avance qu'à la réception de dégâts. En idle (entre
      // deux vagues), le bucket courant garde sa valeur accumulée et s'affiche comme
      // le point "now" pendant toute la pause → pic artificiel dans le graphe.
      advanceBuckets() { tickBuckets(performance.now()); },
      getGraphPoints()   {
        // Retourne {dps, isBoss} par bucket, du plus ancien au plus récent.
        const pts = [];
        for (let i = 1; i <= BUCKETS; i++) {
          const idx = (curBucket + i) % BUCKETS;
          pts.push({ dps: dmgBuckets[idx] / (BUCKET_MS / 1000), isBoss: bossBuckets[idx] });
        }
        return pts;
      },
    };
  })();

  // ─── Diagnostics ──────────────────────────────────────────────────────────────
  const Diagnostics = (() => {
    let nominal=0, coldPlayer=0, coldMonsterAmbig=0, coldMonsterAmbigDmg=0;
    let collision=0, collisionSingleHit=0;
    let orphan=0, orphanDmg=0;
    const MAX=5; let orphLog=0;
    const w=(cnt,msg)=>{if(cnt<MAX){console.warn('[KikiMeter] '+msg);return cnt+1;}return cnt;};
    return {
      recordNominal()              { nominal++; },
      recordColdPlayer()           { coldPlayer++; },
      recordColdMonsterAmbig(a)    { coldMonsterAmbig++; coldMonsterAmbigDmg+=a; },
      recordCollision(n)           { collision++; },
      recordCollisionSingleHit(n,a){ collisionSingleHit++; },
      recordOrphan(a)              { orphan++; orphanDmg+=a; orphLog=w(orphLog,'Orphan dmg '+a+' #'+orphan); },
      summary() { return {nominal,coldPlayer,coldMonsterAmbig,coldMonsterAmbigDmg,collision,collisionSingleHit,orphan,orphanDmg}; },
    };
  })();

  // ─── Capture ──────────────────────────────────────────────────────────────────
  const Capture = (() => {
    let active=false, log=[], counts={};
    return {
      start()  { active=true; log=[]; counts={}; console.info('[KikiMeter] Capture started.'); },
      stop()   { active=false; console.info('[KikiMeter] Capture stopped — '+log.length+' msgs.'); },
      record(type,payload) { if(!active)return; counts[type]=(counts[type]||0)+1; log.push({t:Date.now(),payload}); },
      download(fn) {
        const blob=new Blob([JSON.stringify({typeCounts:counts,combatLog:log})],{type:'application/json'});
        const url=URL.createObjectURL(blob);
        const a=Object.assign(document.createElement('a'),{href:url,download:fn||('mwi-capture-'+Date.now()+'.json')});
        document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url);
      },
      size() { return log.length; },
    };
  })();

  // ─── HistoryStore ─────────────────────────────────────────────────────────────
  // Persiste les 10 dernières sessions en localStorage.
  // Une session est enregistrée automatiquement avant chaque reset si elle contient
  // au moins 30 secondes de données et des dégâts mesurables.
  const HistoryStore = (() => {
    const KEY = 'kikimeter:history:v1';
    const MAX = 10;
    function load() { try { return JSON.parse(localStorage.getItem(KEY)||'[]'); } catch(e) { return []; } }
    function save(entry) {
      const h = load();
      h.unshift(entry);
      if (h.length > MAX) h.pop();
      try { localStorage.setItem(KEY, JSON.stringify(h)); } catch(e) {}
    }
    return {
      push(entry) { save(entry); },
      getAll()    { return load(); },
      clear()     { try { localStorage.removeItem(KEY); } catch(e) {} },
    };
  })();

  // ─── SocketHook v3 ────────────────────────────────────────────────────────────
  // Moteur d'attribution réécrit sur la méthode MWITools / Combat Suite
  // (rétro-ingénierie du 16/07/2026, validée à ±0.1% contre vérité terrain).
  //
  // PRINCIPE FONDAMENTAL — la sparsité de pMap :
  // Le serveur groupe chaque battle_updated PAR ACTEUR. Le pMap d'un message ne
  // contient que le(s) joueur(s) concerné(s) : celui qui attaque, dont le DoT
  // tick, dont le reflect se déclenche. La PRÉSENCE dans pMap EST l'attribution.
  //   → un seul joueur dans pMap : tous les hpDiff monstres de ce message sont à
  //     lui (auto-attaque, AoE multi-cibles, tick firestorm, pierce, reflect…)
  //   → plusieurs joueurs : le lanceur est celui dont le MP a baissé (castPlayer).
  // Aucun besoin d'atkCounter, dmgCounter, fenêtres temporelles, ledgers DoT,
  // profils d'abilities ni poids : ces ~400 lignes d'heuristiques (v2.x)
  // compensaient une mauvaise lecture du protocole (l'exigence d'atkCounter++).
  //
  // BASELINE : monstersHP initialisé depuis new_battle.monsters[].currentHitpoints
  // → les PV perdus avant le premier message ne sont jamais comptés
  //   (fini le bug "cold monster" qui créditait +16304 fantômes).
  //
  // HOOK : getter de MessageEvent.prototype.data (technique MWITools/Combat
  // Suite) — capture aussi les sockets créés avant l'injection du script et
  // voit chaque message avant le client du jeu (UI mise à jour en synchrone).
  const SocketHook = (() => {
    const bus = new EventTarget();
    let installed = false;

    // État de bataille (réinitialisé à chaque new_battle)
    let monstersHP    = [];   // baseline PV par index monstre
    let monstersAlive = [];   // pour détecter les kills (cHP>0 → 0)
    let playersMP     = [];   // baseline MP par index joueur (signal castPlayer)
    let playersHP     = [];   // PV joueurs (détection heal)
    let currentAction = [];   // ability courante par joueur, décalée d'un message :
                              // les dégâts d'un cast à temps d'incantation sont
                              // crédités au sort N-1 (breakdown par sort futur).
    const keyToName   = new Map();
    let haveBattle    = false; // battle_updated ignoré tant que new_battle pas vu
                               // (mi-combat : la vague suivante ~10s re-synchronise)
    let lastCombatStartTime = null; // identifiant d'instance de combat : identique
                                    // pour toutes les vagues/battleId d'une même
                                    // instance, change quand on relance un combat.

    function resetBattleState() {
      monstersHP = []; monstersAlive = []; playersMP = []; playersHP = [];
      currentAction = []; keyToName.clear(); haveBattle = false;
      lastCombatStartTime = null;
    }

    function processNewBattle(p) {
      // Détection de NOUVELLE INSTANCE de combat (≠ nouvelle vague) :
      // combatStartTime est constant sur toutes les vagues d'une instance
      // (validé sur 8 captures, y compris 12 battleId de farming = 1 seul
      // combatStartTime) et change uniquement quand le joueur relance.
      // → émis AVANT newBattle pour que le reset précède les nouveaux noms.
      if (p.combatStartTime) {
        if (lastCombatStartTime !== null && p.combatStartTime !== lastCombatStartTime) {
          bus.dispatchEvent(new CustomEvent('newCombatInstance'));
        }
        lastCombatStartTime = p.combatStartTime;
      }
      monstersHP    = (p.monsters||[]).map(m => (m.currentHitpoints!==undefined?m.currentHitpoints:(m.cHP||0)));
      monstersAlive = monstersHP.map(hp => hp > 0);
      playersMP     = (p.players||[]).map(pl => (pl.currentManapoints!==undefined?pl.currentManapoints:(pl.cMP||0)));
      playersHP     = (p.players||[]).map(pl => (pl.currentHitpoints!==undefined?pl.currentHitpoints:(pl.cHP||0)));
      keyToName.clear();
      const names = [];
      (p.players||[]).forEach((pl,i)=>{
        const n = (pl.character && pl.character.name) || pl.name;
        if (n) { names.push(n); keyToName.set(String(i), n); }
      });
      // preparingAbilityHrid : l'intention de cast au début de vague — le sort
      // en incantation dont les dégâts atterriront dans les premiers messages.
      currentAction = (p.players||[]).map(pl =>
        pl.preparingAbilityHrid ? pl.preparingAbilityHrid :
        (pl.isPreparingAutoAttack ? 'auto' : 'idle'));
      haveBattle = true;

      // Les monstres normaux ont enrageTimerDuration = 180_000_000_000 ns (3 min).
      // Les boss ont plus (ex : Crystal Colossus = 600s). Seul signal boss fiable.
      const NORMAL_ENRAGE_NS = 180_000_000_000;
      const isBoss = (p.monsters||[]).some(m => (m.enrageTimerDuration||0) > NORMAL_ENRAGE_NS);
      bus.dispatchEvent(new CustomEvent('newBattle',{detail:{names, isBoss}}));
    }

    function processBattleUpdated(p) {
      if (!haveBattle) return;
      const mMap = p.mMap || {}, pMap = p.pMap || {};
      const idx = Object.keys(pMap);
      const ts = performance.now();

      // 1. castPlayer : joueur dont le MP a baissé → désambiguïse les rares
      //    messages multi-joueurs (ManaSpring/Bloom groupés par le serveur).
      let castPlayer = null;
      for (const k of idx) {
        const i = +k, mp = pMap[k].cMP || 0;
        if (playersMP[i] !== undefined && mp < playersMP[i]) castPlayer = k;
        playersMP[i] = mp;
      }

      // 2. Healing : cHP↑ d'un joueur présent dans pMap.
      for (const k of idx) {
        const i = +k, hp = pMap[k].cHP || 0;
        if (playersHP[i] !== undefined && hp > playersHP[i]) {
          const name = keyToName.get(k);
          if (name) bus.dispatchEvent(new CustomEvent('healing',{detail:{name, amount: hp - playersHP[i]}}));
        }
        playersHP[i] = hp;
      }

      // 3. Attribution : présence dans pMap (cœur de la méthode).
      const attributedKey = (idx.length === 1) ? idx[0] : castPlayer;

      let tickDmg = 0, killed = 0;
      for (const mk in mMap) {
        const i = +mk, mv = mMap[mk];
        if (monstersHP[i] === undefined) continue; // hors baseline (ne devrait pas arriver)
        const hpDiff = monstersHP[i] - (mv.cHP || 0);
        monstersHP[i] = mv.cHP || 0;
        if (hpDiff > 0) tickDmg += hpDiff;
        if (monstersAlive[i] && (mv.cHP||0) <= 0) { monstersAlive[i] = false; killed++; }
      }

      if (tickDmg > 0) {
        bus.dispatchEvent(new CustomEvent('damage',{detail:{amount:tickDmg, ts}}));
        if (attributedKey !== null) {
          const name = keyToName.get(attributedKey);
          if (name) {
            bus.dispatchEvent(new CustomEvent('playerDamage',{detail:{name, amount:tickDmg, ts}}));
            Diagnostics.recordNominal();
          }
        } else {
          // Multi-joueurs sans baisse de MP : cas jamais observé sur les
          // captures de validation (dropped=0). Compté pour surveillance.
          Diagnostics.recordOrphan(tickDmg);
        }
      }

      if (killed > 0 && attributedKey !== null) {
        const name = keyToName.get(attributedKey);
        if (name) for (let n = 0; n < killed; n++)
          bus.dispatchEvent(new CustomEvent('kill',{detail:{name}}));
      }

      // 4. currentAction mis à jour APRÈS l'attribution — décalage d'un message,
      //    exactement comme MWITools (résout "l'abilityHrid a déjà avancé").
      for (const k of idx) {
        const i = +k, pv = pMap[k];
        currentAction[i] = pv.abilityHrid ? pv.abilityHrid : (pv.isAutoAtk ? 'auto' : 'idle');
      }
    }

    function handleMessage(message) {
      let obj;
      try { obj = JSON.parse(message); } catch (e) { return; }
      if (!obj || !obj.type) return;
      Capture.record(obj.type, obj);
      if (obj.type === 'new_battle')            processNewBattle(obj);
      else if (obj.type === 'battle_updated')   processBattleUpdated(obj);
      else if (obj.type === 'init_character_data') {
        // Connexion / changement de personnage → reset propre.
        resetBattleState();
        bus.dispatchEvent(new CustomEvent('socketReconnected'));
      }
    }

    function install() {
      if (installed) return; installed = true;
      const desc = Object.getOwnPropertyDescriptor(MessageEvent.prototype, 'data');
      const oriGet = desc.get;
      desc.get = function hookedGet() {
        const socket = this.currentTarget;
        if (!(socket instanceof WebSocket)) return oriGet.call(this);
        const u = socket.url || '';
        if (u.indexOf('api.milkywayidle.com/ws') === -1 &&
            u.indexOf('api-test.milkywayidle.com/ws') === -1 &&
            u.indexOf('api.milkywayidlecn.com/ws') === -1 &&
            u.indexOf('api-test.milkywayidlecn.com/ws') === -1) {
          return oriGet.call(this);
        }
        const message = oriGet.call(this);
        // Anti-boucle : fige la valeur sur CET event pour que les lectures
        // suivantes (par le jeu) ne repassent pas dans le hook.
        Object.defineProperty(this, 'data', { value: message });
        try { handleMessage(message); } catch (e) { /* jamais bloquer le jeu */ }
        return message;
      };
      Object.defineProperty(MessageEvent.prototype, 'data', desc);
    }

    return {install, bus};
  })();

  // ─── UnitOverlay : badges DPS + HPS sous chaque personnage ───────────────────
  // Deux badges par joueur : un DPS (couleur du joueur) juste sous la barre de vie,
  // un HPS (vert fixe) juste sous le badge DPS, affiché uniquement si showHealing.
  const UnitOverlay = (() => {
    const entries    = new Map();  // name -> { dpsBadge, hpsBadge, anchorEl }
    const autoColors = new Map();
    let container = null, nextIdx = 0;

    function colorFor(name) {
      const m=Settings.getColor(name); if(m)return m;
      if(!autoColors.has(name)) autoColors.set(name,PALETTE[nextIdx++%PALETTE.length]);
      return autoColors.get(name);
    }
    function refreshColors() { entries.forEach(({dpsBadge},n)=>{ dpsBadge.style.background=colorFor(n); }); }

    function ensureContainer() {
      if(!container){
        container=document.createElement('div');
        Object.assign(container.style,{position:'fixed',top:'0',left:'0',width:'0',height:'0',zIndex:'5',pointerEvents:'none'});
        document.body.appendChild(container);
      }
    }

    function makeBadge(bg) {
      const b=document.createElement('div');
      Object.assign(b.style,{
        position:'absolute',font:'bold 11px monospace',textAlign:'center',
        whiteSpace:'nowrap',boxSizing:'border-box',color:'#000',
        background:bg,padding:'2px 0',borderRadius:'0 0 4px 4px',
        boxShadow:'0 1px 3px rgba(0,0,0,.5)',
      });
      return b;
    }

    function attachBadges(names) {
      ensureContainer(); container.innerHTML=''; entries.clear();
      document.querySelectorAll('[class*="CombatUnit_combatUnit__"]').forEach(unit=>{
        const nameEl=unit.querySelector('[class*="CombatUnit_name__"]');
        if(!nameEl||!names.includes(nameEl.textContent))return;
        const name=nameEl.textContent;
        const anchor=unit.querySelector('[class*="ProgressBar_progressBar__"]')||unit;
        const dpsBadge=makeBadge(colorFor(name));
        const hpsBadge=makeBadge(HPS_COLOR);
        hpsBadge.style.display='none';
        container.appendChild(dpsBadge);
        container.appendChild(hpsBadge);
        entries.set(name,{dpsBadge,hpsBadge,anchorEl:anchor});
      });
      reposition();
    }

    // Observer permanent : quand les éléments CombatUnit réapparaissent dans le DOM
    // (retour sur l'onglet Combat depuis Marketplace ou une autre page, sans qu'une
    // nouvelle vague ait démarré), les ancres ont été recréées par le jeu mais nos
    // badges pointaient encore vers les anciennes — reposition() les masquait.
    // On redéclenche attachBadges() dès qu'un CombatUnit entre dans le DOM et que
    // nos entrées actuelles pointent vers des éléments détachés (= état "stale").
    // Throttle à 300ms pour éviter de tourner en boucle pendant un combat actif.
    let reattachTimer = null;
    function startReattachObserver(getLastNames) {
      const obs = new MutationObserver(() => {
        if (reattachTimer) return;
        // Déclenche uniquement si au moins une entrée existante est détachée
        // (sinon on est en plein combat actif et tout va bien).
        const hasStale = entries.size > 0 &&
          Array.from(entries.values()).some(({anchorEl}) => !document.contains(anchorEl));
        if (!hasStale) return;
        // Vérifie qu'un CombatUnit est bien revenu dans le DOM avant de réagir.
        const combatUnitBack = !!document.querySelector('[class*="CombatUnit_combatUnit__"]');
        if (!combatUnitBack) return;
        reattachTimer = setTimeout(() => {
          reattachTimer = null;
          const names = getLastNames();
          if (names.length) attachBadges(names);
        }, 300);
      });
      obs.observe(document.body, { childList: true, subtree: true });
    }

    function reposition() {
      entries.forEach(({dpsBadge,hpsBadge,anchorEl})=>{
        // Ancre retirée du DOM (combat terminé, navigation) → getBoundingClientRect
        // d'un élément détaché renvoie {0,0} et épinglait le badge en haut-gauche.
        // Ancre PRÉSENTE mais NON RENDUE (onglet Combat masqué : Labyrinthe,
        // Marketplace… pendant un combat actif) → rect {0,0,w:0,h:0} : même bug,
        // badge fantôme en haut-gauche de la page. On masque dans les deux cas.
        const r = document.contains(anchorEl) ? anchorEl.getBoundingClientRect() : null;
        if (!r || (r.width === 0 && r.height === 0)) {
          dpsBadge.style.display = 'none';
          hpsBadge.style.display = 'none';
          return;
        }
        dpsBadge.style.display = 'block';
        const H_DPS=dpsBadge.offsetHeight||18;
        Object.assign(dpsBadge.style,{left:r.left+'px',top:(r.bottom+2)+'px',width:r.width+'px'});
        Object.assign(hpsBadge.style,{left:r.left+'px',top:(r.bottom+2+H_DPS+1)+'px',width:r.width+'px'});
        // hpsBadge.style.display est géré séparément par updateHps() selon showHealing/hps,
        // mais on vient de le passer à 'none' juste au-dessus si on était dans le cas
        // "ancre détachée" ; ici l'ancre est valide donc on laisse updateHps() décider.
      });
    }

    function updateDps(name,text)  { const e=entries.get(name); if(e) e.dpsBadge.textContent=text; }
    function updateHps(name,hps)   {
      const e=entries.get(name); if(!e)return;
      // Même garde que reposition() : ancre détachée OU non rendue (onglet masqué).
      if (!document.contains(e.anchorEl)) return;
      const _r = e.anchorEl.getBoundingClientRect();
      if (_r.width === 0 && _r.height === 0) return;
      const show=Settings.getShowHealing()&&hps>0.1;
      e.hpsBadge.style.display=show?'block':'none';
      if(show) e.hpsBadge.textContent=hps.toFixed(1)+' hps';
    }

    return {attachBadges,updateDps,updateHps,reposition,refreshColors,colorFor,startReattachObserver};
  })();

  // ─── DPS Graph ────────────────────────────────────────────────────────────────
  const BOSS_COLOR = '#FF3F34';

  function buildGraph() {
    const canvas = document.createElement('canvas');
    // Dimensions augmentées pour accueillir les axes.
    // Coordonnées internes doublées pour rendu net sur écrans haute densité.
    const CW = 440, CH = 160;
    canvas.width  = CW;
    canvas.height = CH;
    Object.assign(canvas.style, { width:'100%', height:'80px', display:'block', borderRadius:'4px', background:'rgba(0,0,0,0.3)' });

    // Marges internes (en pixels canvas, soit 2× les pixels CSS)
    const PAD = { top:14, right:8, bottom:22, left:50 };
    const DW = CW - PAD.left - PAD.right;   // largeur zone de dessin
    const DH = CH - PAD.top  - PAD.bottom;  // hauteur zone de dessin

    // Calcule un intervalle "joli" pour les graduations Y.
    function niceInterval(maxVal, targetTicks) {
      if (maxVal <= 0) return 100;
      const raw = maxVal / targetTicks;
      const mag = Math.pow(10, Math.floor(Math.log10(raw)));
      const frac = raw / mag;
      let nice;
      if (frac < 1.5)      nice = 1;
      else if (frac < 3.5) nice = 2;
      else if (frac < 7.5) nice = 5;
      else                  nice = 10;
      return nice * mag;
    }

    function render(points) {
      if (!Settings.getShowGraph()) { canvas.style.display = 'none'; return; }
      canvas.style.display = 'block';
      const ctx = canvas.getContext('2d');
      ctx.clearRect(0, 0, CW, CH);

      // Lissage sur ~16 secondes (BUCKET_MS=2s → fenêtre de 8 buckets glissants).
      // Réduit l'effet "pic" visible lors des bursts courts tout en restant réactif.
      const SMOOTH = 8;
      const smoothed = points.map((p, i) => {
        let sum = 0, count = 0;
        for (let k = Math.max(0, i - SMOOTH + 1); k <= i; k++) {
          sum += points[k].dps; count++;
        }
        return { dps: sum / count, isBoss: p.isBoss };
      });

      const N   = smoothed.length;
      const max = Math.max(...smoothed.map(p => p.dps), 1);

      // Intervalle Y et nombre de lignes
      const yInterval = niceInterval(max, 4);
      const yMax      = Math.ceil(max / yInterval) * yInterval;

      function xOf(i) { return PAD.left + (i / (N - 1)) * DW; }
      function yOf(v) { return PAD.top  + DH * (1 - v / yMax); }

      // Grille Y + labels
      ctx.font = '18px monospace';
      ctx.textAlign = 'right';
      for (let v = 0; v <= yMax; v += yInterval) {
        const y = yOf(v);
        // Ligne de grille
        ctx.beginPath();
        ctx.strokeStyle = 'rgba(255,255,255,0.08)';
        ctx.lineWidth   = 1;
        ctx.setLineDash([4, 4]);
        ctx.moveTo(PAD.left, y); ctx.lineTo(PAD.left + DW, y);
        ctx.stroke();
        ctx.setLineDash([]);
        // Label
        ctx.fillStyle = 'rgba(255,255,255,0.45)';
        const label = v >= 1000 ? (v/1000).toFixed(v % 1000 === 0 ? 0 : 1) + 'k' : String(Math.round(v));
        ctx.fillText(label, PAD.left - 4, y + 6);
      }

      // Grille X — une marque toutes les 60 secondes (= 30 buckets de 2s)
      // La grille X est tracée de droite à gauche (le point le plus récent = droite)
      const BUCKETS_PER_MIN = 30;
      ctx.font = '17px monospace';
      ctx.textAlign = 'center';
      for (let minAgo = 1; minAgo < 5; minAgo++) {
        const bucketIdx = N - 1 - minAgo * BUCKETS_PER_MIN;
        if (bucketIdx < 0) break;
        const x = xOf(bucketIdx);
        ctx.beginPath();
        ctx.strokeStyle = 'rgba(255,255,255,0.12)';
        ctx.lineWidth   = 1;
        ctx.setLineDash([2, 4]);
        ctx.moveTo(x, PAD.top); ctx.lineTo(x, PAD.top + DH);
        ctx.stroke();
        ctx.setLineDash([]);
        ctx.fillStyle = 'rgba(255,255,255,0.35)';
        ctx.fillText('-' + minAgo + 'm', x, CH - 4);
      }
      // Label "now" à droite
      ctx.textAlign = 'right';
      ctx.fillStyle = 'rgba(255,255,255,0.3)';
      ctx.fillText('now', CW - 2, CH - 4);

      // Courbe — segments colorés par type de combat (teal normal / rouge boss)
      let segColor = null;
      const coords = smoothed.map((p, i) => ({
        x: xOf(i), y: yOf(p.dps), isBoss: p.isBoss,
      }));
      coords.forEach((pt, i) => {
        const color = pt.isBoss ? BOSS_COLOR : ACCENT;
        if (color !== segColor) {
          if (segColor !== null) ctx.stroke();
          ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 2.5;
          if (i > 0) ctx.moveTo(coords[i-1].x, coords[i-1].y);
          else       ctx.moveTo(pt.x, pt.y);
          segColor = color;
        }
        ctx.lineTo(pt.x, pt.y);
      });
      if (segColor !== null) ctx.stroke();

      // Remplissage sous la courbe
      [false, true].forEach(bossFlag => {
        ctx.fillStyle = bossFlag ? 'rgba(255,63,52,0.12)' : 'rgba(0,116,116,0.12)';
        ctx.beginPath();
        let started = false;
        coords.forEach((pt, i) => {
          if (pt.isBoss !== bossFlag) {
            if (started) { ctx.lineTo(pt.x, PAD.top + DH); ctx.closePath(); ctx.fill(); started = false; }
            return;
          }
          if (!started) { ctx.beginPath(); ctx.moveTo(pt.x, PAD.top + DH); started = true; }
          ctx.lineTo(pt.x, pt.y);
        });
        if (started) { ctx.lineTo(coords[N-1].x, PAD.top + DH); ctx.closePath(); ctx.fill(); }
      });

      // Bordure gauche et basse de la zone de dessin
      ctx.beginPath();
      ctx.strokeStyle = 'rgba(255,255,255,0.15)';
      ctx.lineWidth   = 1;
      ctx.moveTo(PAD.left, PAD.top);
      ctx.lineTo(PAD.left, PAD.top + DH);
      ctx.lineTo(PAD.left + DW, PAD.top + DH);
      ctx.stroke();
    }

    return { canvas, render };
  }

  // ─── KikiMeter Panel ─────────────────────────────────────────────────────────
  const KikiMeter = (() => {
    let panelOpen=false, tabBtn=null, panel=null;
    let teamDpsEl, teamTotalEl, elapsedEl, playersListEl, unitSelect, graphObj;
    let callbacks={};

    function buildPanel() {
      const p=document.createElement('div');
      p.id='kikimeter-panel';
      Object.assign(p.style,{
        display:'none',position:'fixed',zIndex:'9999',width:'256px',
        background:'rgba(10,12,22,0.97)',color:'#e8eaf6',
        borderRadius:'8px',padding:'12px',
        boxShadow:'0 6px 24px rgba(0,0,0,.85)',
        fontSize:'12px',fontFamily:'monospace',lineHeight:'1.6',
        border:'1px solid rgba(0,116,116,0.4)',
      });

      // Titre + bouton history sur la même ligne
      const titleRow=el('div',{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:'8px'});
      const title=el('div',{fontWeight:'bold',fontSize:'14px',color:ACCENT,letterSpacing:'.5px'});
      title.textContent='⚔ KikiMeter';

      const histBtn=document.createElement('button');
      histBtn.textContent='🕐 History';
      Object.assign(histBtn.style,{
        cursor:'pointer',background:'transparent',color:'rgba(255,255,255,.5)',
        border:'1px solid rgba(255,255,255,.18)',borderRadius:'4px',
        padding:'2px 7px',fontSize:'11px',fontFamily:'monospace',
        transition:'color .12s, border-color .12s',
      });
      histBtn.addEventListener('mouseenter',()=>{histBtn.style.color='#e8eaf6';histBtn.style.borderColor='rgba(255,255,255,.45)';});
      histBtn.addEventListener('mouseleave',()=>{histBtn.style.color='rgba(255,255,255,.5)';histBtn.style.borderColor='rgba(255,255,255,.18)';});
      titleRow.append(title, histBtn);
      p.appendChild(titleRow);

      // Section historique (insérée juste sous le titre, cachée par défaut)
      const histSection=el('div',{marginBottom:'0'});
      const histList=el('div',{
        maxHeight:'0',overflow:'hidden',transition:'max-height .25s',
        borderBottom:'1px solid rgba(255,255,255,.1)',
      });
      let histOpen=false;
      function toggleHist() {
        histOpen=!histOpen;
        histBtn.textContent=histOpen?'✕ History':'🕐 History';
        histBtn.style.color=histOpen?'#e8eaf6':'rgba(255,255,255,.5)';
        histBtn.style.borderColor=histOpen?'rgba(255,255,255,.45)':'rgba(255,255,255,.18)';
        if(histOpen){
          renderHistory(histList);
          histList.style.maxHeight='300px';
          histList.style.overflowY='auto';
          histList.style.marginBottom='8px';
        } else {
          histList.style.maxHeight='0';
          histList.style.overflowY='hidden';
          histList.style.marginBottom='0';
        }
      }
      histBtn.addEventListener('click',(e)=>{e.stopPropagation();toggleHist();});
      histSection.appendChild(histList);
      p.appendChild(histSection);

      // Résumé équipe
      const summary=el('div',{marginBottom:'8px',paddingBottom:'8px',borderBottom:'1px solid rgba(255,255,255,.1)'});
      teamDpsEl   =el('div',{fontWeight:'bold',color:'#e8eaf6'});
      teamTotalEl =el('div',{opacity:'.85'});
      elapsedEl   =el('div',{opacity:'.55',fontSize:'11px'});
      summary.append(teamDpsEl,teamTotalEl,elapsedEl);
      p.appendChild(summary);

      // Graph
      graphObj=buildGraph();
      const graphWrap=el('div',{marginBottom:'8px',paddingBottom:'8px',borderBottom:'1px solid rgba(255,255,255,.1)'});
      const graphLbl=el('div',{fontSize:'11px',opacity:'.6',marginBottom:'3px'});
      graphLbl.textContent='DPS — last 5 min';
      graphWrap.append(graphLbl,graphObj.canvas);
      p.appendChild(graphWrap);

      // Zone joueurs
      playersListEl=el('div',{marginBottom:'8px',paddingBottom:'8px',borderBottom:'1px solid rgba(255,255,255,.1)'});
      p.appendChild(playersListEl);

      // Options
      const opts=el('div',{marginBottom:'8px',paddingBottom:'8px',borderBottom:'1px solid rgba(255,255,255,.1)'});

      const fmtRow=el('div',{marginBottom:'5px',display:'flex',alignItems:'center',gap:'6px'});
      const fmtLbl=el('span'); fmtLbl.textContent='Display:';
      unitSelect=document.createElement('select');
      [['raw','Units'],['k','k (thousands)'],['m','M (millions)']].forEach(([v,l])=>{
        const o=document.createElement('option');o.value=v;o.textContent=l;unitSelect.appendChild(o);
      });
      unitSelect.value=Settings.getUnit();
      Object.assign(unitSelect.style,{background:'#111827',color:'#fff',border:'1px solid #374151',borderRadius:'3px',padding:'1px 4px'});
      unitSelect.addEventListener('change',()=>Settings.setUnit(unitSelect.value));
      fmtRow.append(fmtLbl,unitSelect);
      opts.appendChild(fmtRow);

      opts.appendChild(buildToggle('Show healing (HPS)',Settings.getShowHealing(),(v)=>Settings.setShowHealing(v)));
      opts.appendChild(buildToggle('Auto-reset on new combat',Settings.getAutoReset(),(v)=>Settings.setAutoReset(v)));
      opts.appendChild(buildToggle('Show DPS graph',Settings.getShowGraph(),(v)=>{Settings.setShowGraph(v);graphWrap.style.display=v?'':'none';}));
      if(!Settings.getShowGraph()) graphWrap.style.display='none';
      p.appendChild(opts);

      // Boutons d'action : reset + copier dans le presse-papier
      const actionRow=el('div',{display:'flex',gap:'6px',marginBottom:'10px'});

      const resetBtn=document.createElement('button');
      resetBtn.textContent='Reset session';
      Object.assign(resetBtn.style,{
        flex:'1',cursor:'pointer',background:'#3d0f0f',color:'#fca5a5',
        border:'1px solid #7f1d1d',borderRadius:'4px',padding:'6px',fontSize:'12px',fontFamily:'monospace',
      });
      resetBtn.addEventListener('click',()=>{if(callbacks.onReset)callbacks.onReset();});

      const copyBtn=document.createElement('button');
      copyBtn.textContent='📋 Copy';
      Object.assign(copyBtn.style,{
        flex:'1',cursor:'pointer',background:'#0f2d3d',color:'#93c5fd',
        border:'1px solid #1d4f7f',borderRadius:'4px',padding:'6px',fontSize:'12px',fontFamily:'monospace',
      });
      copyBtn.addEventListener('click',()=>{if(callbacks.onCopy)callbacks.onCopy(copyBtn);});

      actionRow.append(resetBtn,copyBtn);
      p.appendChild(actionRow);

      document.body.appendChild(p);

      // Fermer le panneau en cliquant ailleurs
      document.addEventListener('click',(e)=>{
        if(panelOpen&&!p.contains(e.target)&&e.target!==tabBtn){
          panelOpen=false; p.style.display='none';
          if(tabBtn) tabBtn.style.filter='none';
        }
      },true);
      return p;
    }

    function buildToggle(label,initial,onChange) {
      const row=el('div',{display:'flex',alignItems:'center',gap:'6px',marginBottom:'3px'});
      const cb=document.createElement('input'); cb.type='checkbox'; cb.checked=initial;
      cb.style.accentColor=ACCENT;
      cb.addEventListener('change',()=>onChange(cb.checked));
      const lbl=el('span',{fontSize:'12px'}); lbl.textContent=label;
      row.append(cb,lbl); return row;
    }

    // Bouton onglet — réplique le style "pastille pleine" des autres onglets du jeu
    // (fond coloré + coins arrondis), pas un soulignement. Fermé = fond ACCENT plein,
    // comme les onglets natifs (Combat Sim, Dispatch, Loot...) qui sont tous des
    // boutons pleins colorés, jamais transparents.
    function buildTabBtn() {
      const btn=document.createElement('button');
      btn.id='kikimeter-tab-btn';
      btn.textContent='KikiMeter';
      Object.assign(btn.style,{
        display:'inline-flex',alignItems:'center',justifyContent:'center',
        cursor:'pointer',
        background:ACCENT,
        color:'#fff',
        border:'none',
        borderRadius:'4px',
        padding:'6px 14px',
        margin:'0 2px',
        fontWeight:'600',
        fontSize:'13px',
        fontFamily:'inherit',
        whiteSpace:'nowrap',
        transition:'filter .12s',
        outline:'none',
        flexShrink:'0',
        letterSpacing:'.2px',
      });
      btn.addEventListener('mouseenter',()=>{ btn.style.filter='brightness(1.15)'; });
      btn.addEventListener('mouseleave',()=>{ btn.style.filter='none'; });
      btn.addEventListener('click',(e)=>{
        e.stopPropagation();
        panelOpen=!panelOpen;
        if(panelOpen){
          const r=btn.getBoundingClientRect();
          Object.assign(panel.style,{display:'block',top:(r.bottom+4)+'px',right:(window.innerWidth-r.right)+'px',left:'auto'});
          btn.style.filter='brightness(1.15)';
        } else {
          panel.style.display='none';
          btn.style.filter='none';
        }
      });
      return btn;
    }

    // Garantit qu'il n'existe jamais plus d'un bouton KikiMeter dans tout le
    // document, peu importe combien de fois inject() a été appelé. On retrouve
    // l'instance existante par id plutôt que par référence JS, ce qui survit
    // même si la variable tabBtn a été perdue/désynchronisée entre deux appels.
    // C'est ce mécanisme, à lui seul, qui empêche les doublons — peu importe
    // quel conteneur d'onglets est ciblé à un instant donné.
    function dedupeAndGetButton() {
      const all = document.querySelectorAll('#kikimeter-tab-btn');
      all.forEach((b, i) => { if (i > 0) b.remove(); });
      return all[0] || null;
    }

    // Cherche la barre d'onglets de la section Combat.
    // Confirmé par console le 02/07/2026 : "Combat Zones" est présent dans Container 0
    // aussi bien en navigation qu'en combat actif ("Battle #XXXX"). Il est absent de
    // tous les autres containers (Marketplace, Inventory, Chat...). C'est le marqueur
    // natif le plus fiable — indépendant de tout autre script installé.
    function findCombatTabBar() {
      const containers = document.querySelectorAll('div.' + TAB_CONTAINER_CLASS);
      for (const c of containers) {
        if (c.textContent.includes('Combat Zones')) return c;
      }
      return null;
    }

    // Injecte (ou déplace) le bouton dans la barre d'onglets combat.
    // Si aucune barre combat n'est trouvée (autre page du jeu), masque le bouton
    // sans le détruire — il réapparaît automatiquement au retour sur Combat.
    function inject() {
      const container = findCombatTabBar();

      const existing = dedupeAndGetButton();

      if (!container) {
        // Pas sur la page Combat → masquer le bouton s'il existe.
        if (existing) existing.style.display = 'none';
        return false;
      }

      if (existing) {
        existing.style.display = '';  // le rendre visible si on revient sur Combat
        if (existing.parentElement !== container) container.appendChild(existing);
        tabBtn = existing;
        return true;
      }
      tabBtn = buildTabBtn();
      container.appendChild(tabBtn);
      return true;
    }

    function init(cb) {
      callbacks = cb;
      panel = buildPanel();
      tabBtn = null;
      inject();

      let throttleTimer = null;
      const reinjector = new MutationObserver(() => {
        if (throttleTimer) return;
        throttleTimer = setTimeout(() => { throttleTimer = null; inject(); }, 200);
      });
      reinjector.observe(document.body, { childList: true, subtree: true });
    }

    // Rendu de la liste des joueurs dans le panneau.
    // Ne reconstruit le DOM que si la liste change (évite le clignotement des swatches).
    function renderPlayers(names, getDps, getDmg, getHps, getKills, getSharePct, getColor) {
      if(!playersListEl)return;
      const existing=Array.from(playersListEl.querySelectorAll('[data-player]')).map(e=>e.dataset.player);
      if(JSON.stringify(names)!==JSON.stringify(existing)) playersListEl.innerHTML='';

      names.forEach(name=>{
        let row=playersListEl.querySelector('[data-player="'+CSS.escape(name)+'"]');
        if(!row){
          row=el('div',{marginBottom:'10px'});
          row.dataset.player=name;

          // Ligne : pastille couleur + nom + %DPS
          const nameRow=el('div',{display:'flex',justifyContent:'space-between',alignItems:'center'});
          const dot=el('span',{display:'inline-block',width:'9px',height:'9px',borderRadius:'50%',background:getColor(name),marginRight:'6px',flexShrink:'0',border:'1px solid rgba(255,255,255,.3)'});
          const nameSpan=el('span',{flexGrow:'1',fontWeight:'bold'}); nameSpan.textContent=name;
          const pctSpan=el('span',{opacity:'.7',fontSize:'11px',color:'#a5b4fc'}); pctSpan.dataset.pct='';
          nameRow.append(dot,nameSpan,pctSpan);
          row.appendChild(nameRow);

          // Ligne stats DPS
          const dmgRow=el('div',{fontSize:'11px',marginTop:'1px',color:'#c7d2fe'});
          dmgRow.dataset.dmgstats='';
          row.appendChild(dmgRow);

          // Ligne stats HPS (couleur verte, masquée si showHealing=false ou hps=0)
          const hpsRow=el('div',{fontSize:'11px',marginTop:'1px',color:HPS_COLOR,display:'none'});
          hpsRow.dataset.hpsstats='';
          row.appendChild(hpsRow);

          // Ligne kills
          const killRow=el('div',{fontSize:'11px',marginTop:'1px',color:'#fbbf24'});
          killRow.dataset.kills='';
          row.appendChild(killRow);

          // Swatches couleur DPS
          const swatchRow=el('div',{display:'flex',gap:'3px',marginTop:'5px',flexWrap:'wrap'});
          PALETTE.forEach(color=>{
            const sw=el('div',{
              width:'14px',height:'14px',borderRadius:'3px',background:color,
              cursor:'pointer',flexShrink:'0',
              border:color===getColor(name)?'2px solid #fff':'1px solid rgba(255,255,255,.2)',
            });
            sw.addEventListener('click',()=>{
              if(callbacks.onColorPick) callbacks.onColorPick(name,color);
              swatchRow.querySelectorAll('div').forEach((s,j)=>{
                s.style.border=PALETTE[j]===color?'2px solid #fff':'1px solid rgba(255,255,255,.2)';
              });
              dot.style.background=color;
            });
            swatchRow.appendChild(sw);
          });
          row.appendChild(swatchRow);
          playersListEl.appendChild(row);
        }

        // Mise à jour des données uniquement (sans reconstruire le DOM)
        const pctEl  =row.querySelector('[data-pct]');
        const dmgEl  =row.querySelector('[data-dmgstats]');
        const hpsEl  =row.querySelector('[data-hpsstats]');
        const killEl =row.querySelector('[data-kills]');

        if(pctEl)  pctEl.textContent=getSharePct(name).toFixed(0)+'%';
        if(dmgEl)  dmgEl.textContent=getDps(name).toFixed(1)+' dps · '+formatDamage(getDmg(name));

        const hps=getHps(name);
        if(hpsEl){
          const showHps=Settings.getShowHealing()&&hps>0.1;
          hpsEl.style.display=showHps?'block':'none';
          if(showHps) hpsEl.textContent=hps.toFixed(1)+' hps';
        }

        const kills=getKills(name);
        if(killEl){
          killEl.style.display=kills>0?'block':'none';
          if(kills>0) killEl.textContent='⚔ '+kills+' kill'+(kills>1?'s':'');
        }
      });
    }

    function updateSummary(dps,total,elapsed) {
      if(teamDpsEl)   teamDpsEl.textContent  ='Team DPS: '+dps.toFixed(1);
      if(teamTotalEl) teamTotalEl.textContent ='Total: '+formatDamage(total);
      if(elapsedEl)   elapsedEl.textContent   ='Session: '+formatDuration(elapsed);
    }
    function updateGraph(points) { if(graphObj) graphObj.render(points); }

    // Formate une entrée d'historique comme texte lisible pour le presse-papier.
    function entryToText(entry) {
      const d = new Date(entry.date);
      const dateStr = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
      const dur = formatDuration(entry.durationSeconds);
      const total = entry.teamDamage;
      let out = '=== KikiMeter — ' + dateStr + ' | ' + dur + ' ===\n';
      out += 'Team: ' + entry.teamDps.toFixed(1) + ' dps | ' + formatDamage(total);
      if (entry.teamKills > 0) out += ' | ' + entry.teamKills + ' kills';
      out += '\n';
      (entry.players || []).forEach(p => {
        const pct = total > 0 ? ((p.damage / total) * 100).toFixed(0) : '0';
        const name = p.name.padEnd(12).slice(0, 12);
        out += name + ': ' + p.dps.toFixed(1).padStart(6) + ' dps | ';
        out += formatDamage(p.damage).padStart(7) + ' (' + pct + '%)';
        if (p.kills > 0) out += ' | ' + p.kills + 'k';
        if (p.hps > 0.1)  out += ' | ' + p.hps.toFixed(1) + ' hps';
        out += '\n';
      });
      return out;
    }

    // Rendu de la liste d'historique dans le conteneur dépliable.
    function renderHistory(container) {
      container.innerHTML = '';
      const entries = HistoryStore.getAll();
      if (!entries.length) {
        const empty = el('div', { opacity:'.5', fontSize:'11px', padding:'6px 0' });
        empty.textContent = 'No sessions recorded yet.';
        container.appendChild(empty);
        return;
      }
      entries.forEach((entry, idx) => {
        const d = new Date(entry.date);
        const dateStr = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});

        const block = el('div', {
          marginBottom:'8px', paddingBottom:'8px',
          borderBottom: idx < entries.length-1 ? '1px solid rgba(255,255,255,.07)' : 'none',
        });

        // En-tête : date + durée + bouton copie
        const header = el('div', { display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:'2px' });
        const dateEl = el('span', { fontSize:'11px', color:'#a5b4fc', fontWeight:'bold' });
        dateEl.textContent = dateStr + ' · ' + formatDuration(entry.durationSeconds);
        const copyEntryBtn = el('button', {
          fontSize:'10px', cursor:'pointer', background:'transparent',
          color:'#93c5fd', border:'none', padding:'0 2px',
        });
        copyEntryBtn.textContent = '📋';
        copyEntryBtn.title = 'Copy this session';
        copyEntryBtn.addEventListener('click', (e) => {
          e.stopPropagation();
          navigator.clipboard.writeText(entryToText(entry)).catch(() => {});
          copyEntryBtn.textContent = '✓';
          setTimeout(() => { copyEntryBtn.textContent = '📋'; }, 1500);
        });
        header.append(dateEl, copyEntryBtn);
        block.appendChild(header);

        // Résumé équipe
        const teamLine = el('div', { fontSize:'11px', opacity:'.75', marginBottom:'3px' });
        teamLine.textContent = 'Team: ' + entry.teamDps.toFixed(1) + ' dps · ' + formatDamage(entry.teamDamage)
          + (entry.teamKills > 0 ? ' · ' + entry.teamKills + ' kills' : '');
        block.appendChild(teamLine);

        // Joueurs
        (entry.players || []).forEach(p => {
          const pct = entry.teamDamage > 0 ? ((p.damage / entry.teamDamage)*100).toFixed(0) : '0';
          const pLine = el('div', { fontSize:'11px', display:'flex', justifyContent:'space-between' });
          const nameEl = el('span', { color:'#e8eaf6' }); nameEl.textContent = p.name;
          const statsEl = el('span', { opacity:'.7' });
          statsEl.textContent = p.dps.toFixed(1) + ' · ' + formatDamage(p.damage) + ' (' + pct + '%)';
          pLine.append(nameEl, statsEl);
          block.appendChild(pLine);
        });

        container.appendChild(block);
      });

      // Bouton vider l'historique
      const clearBtn = document.createElement('button');
      clearBtn.textContent = 'Clear history';
      Object.assign(clearBtn.style, {
        width:'100%', cursor:'pointer', background:'transparent', color:'rgba(255,255,255,.3)',
        border:'1px solid rgba(255,255,255,.15)', borderRadius:'4px',
        padding:'4px', fontSize:'11px', marginTop:'4px',
      });
      clearBtn.addEventListener('click', () => { HistoryStore.clear(); renderHistory(container); });
      container.appendChild(clearBtn);
    }

    return {init,renderPlayers,updateSummary,updateGraph};
  })();

  // ─── Wiring ───────────────────────────────────────────────────────────────────
  SocketHook.install();

  function start() {
    let currentPlayerNames=[];
    let currentIsBoss=false;

    function saveCurrentSession() {
      if (Session.getElapsedSeconds() < 30 || Session.getTeamDamage() === 0) return;
      const names = currentPlayerNames.length ? currentPlayerNames : Session.getAllPlayerNames();
      HistoryStore.push({
        date:            new Date().toISOString(),
        durationSeconds: Math.floor(Session.getElapsedSeconds()),
        teamDps:         Session.getTeamDps(),
        teamDamage:      Session.getTeamDamage(),
        teamKills:       Session.getTeamKills(),
        players:         names.map(n => ({
          name:   n,
          damage: Session.getPlayerDamage(n),
          dps:    Session.getPlayerDps(n),
          kills:  Session.getPlayerKills(n),
          hps:    Session.getPlayerHps(n),
        })),
      });
    }

    function resetSession(reason) {
      saveCurrentSession();
      Session.reset();
      console.info('[KikiMeter] Session reset ('+reason+')');
    }

    function buildClipboardText() {
      const names = currentPlayerNames.length ? currentPlayerNames : Session.getAllPlayerNames();
      const total = Session.getTeamDamage();
      const d = new Date();
      const dateStr = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
      let out = '=== KikiMeter — ' + dateStr + ' | ' + formatDuration(Session.getElapsedSeconds()) + ' ===\n';
      out += 'Team: ' + Session.getTeamDps().toFixed(1) + ' dps | ' + formatDamage(total);
      if (Session.getTeamKills() > 0) out += ' | ' + Session.getTeamKills() + ' kills';
      out += '\n';
      names.forEach(n => {
        const pct  = total > 0 ? ((Session.getPlayerDamage(n) / total)*100).toFixed(0) : '0';
        const name = n.padEnd(12).slice(0,12);
        out += name + ': ' + Session.getPlayerDps(n).toFixed(1).padStart(6) + ' dps | ';
        out += formatDamage(Session.getPlayerDamage(n)).padStart(7) + ' (' + pct + '%)';
        if (Session.getPlayerKills(n) > 0)  out += ' | ' + Session.getPlayerKills(n) + 'k';
        if (Settings.getShowHealing() && Session.getPlayerHps(n) > 0.1)  out += ' | ' + Session.getPlayerHps(n).toFixed(1) + ' hps';
        out += '\n';
      });
      return out;
    }

    KikiMeter.init({
      onReset: () => resetSession('manual reset from panel'),
      onColorPick: (name, color) => { Settings.setColor(name, color); UnitOverlay.refreshColors(); },
      onCopy: (btn) => {
        navigator.clipboard.writeText(buildClipboardText()).then(() => {
          btn.textContent = '✓ Copied!';
          setTimeout(() => { btn.textContent = '📋 Copy'; }, 2000);
        }).catch(() => {
          btn.textContent = '✗ Error';
          setTimeout(() => { btn.textContent = '📋 Copy'; }, 2000);
        });
      },
    });

    // Observer de réattachement des badges : quand on revient sur l'onglet Combat
    // depuis une autre page sans nouvelle vague, les CombatUnit reviennent dans le
    // DOM mais nos ancres étaient stale. L'observer redéclenche attachBadges().
    UnitOverlay.startReattachObserver(() => currentPlayerNames);

    SocketHook.bus.addEventListener('newBattle',ev=>{
      currentPlayerNames=ev.detail.names;
      currentIsBoss=ev.detail.isBoss;
      Session.setBoss(ev.detail.isBoss);
      setTimeout(()=>UnitOverlay.attachBadges(ev.detail.names),100);
    });
    SocketHook.bus.addEventListener('damage',      ev => Session.addTeamDamage(ev.detail.amount,ev.detail.ts));
    SocketHook.bus.addEventListener('playerDamage',ev => Session.addPlayerDamage(ev.detail.name,ev.detail.amount));
    SocketHook.bus.addEventListener('healing',     ev => Session.addPlayerHealing(ev.detail.name,ev.detail.amount));
    SocketHook.bus.addEventListener('kill',        ev => { if(ev.detail.name) Session.addPlayerKill(ev.detail.name); });
    SocketHook.bus.addEventListener('socketReconnected',()=>resetSession('new WebSocket connection (character switch?)'));
    SocketHook.bus.addEventListener('newCombatInstance',()=>{
      if (Settings.getAutoReset()) resetSession('new combat instance');
    });

    setInterval(()=>{
      Session.advanceBuckets();
      const teamDmg=Session.getTeamDamage();
      const names=currentPlayerNames.length?currentPlayerNames:Session.getAllPlayerNames();

      KikiMeter.updateSummary(Session.getTeamDps(),teamDmg,Session.getElapsedSeconds());
      KikiMeter.updateGraph(Session.getGraphPoints());
      KikiMeter.renderPlayers(
        names,
        n=>Session.getPlayerDps(n),
        n=>Session.getPlayerDamage(n),
        n=>Session.getPlayerHps(n),
        n=>Session.getPlayerKills(n),
        n=>(teamDmg>0?(Session.getPlayerDamage(n)/teamDmg)*100:0),
        n=>UnitOverlay.colorFor(n),
      );

      currentPlayerNames.forEach(name=>{
        const dps=Session.getPlayerDps(name).toFixed(1);
        const total=formatDamage(Session.getPlayerDamage(name));
        UnitOverlay.updateDps(name,dps+' dps ('+total+')');
        UnitOverlay.updateHps(name,Session.getPlayerHps(name));
      });
      UnitOverlay.reposition();
    },250);

    Object.assign(MWI,{
      bus:              SocketHook.bus,
      getSessionDps:    Session.getTeamDps,
      getSessionDamage: Session.getTeamDamage,
      getTeamKills:     Session.getTeamKills,
      getPlayerDps:     Session.getPlayerDps,
      getPlayerDamage:  Session.getPlayerDamage,
      getPlayerHps:     Session.getPlayerHps,
      getPlayerKills:   Session.getPlayerKills,
      resetSession:     ()=>resetSession('console call'),
      diagnostics:      Diagnostics.summary,
      captureStart:     Capture.start,
      captureStop:      Capture.stop,
      captureDownload:  Capture.download,
      captureSize:      Capture.size,
    });
  }

  if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',start,{once:true});
  else start();
})();