Greasy Fork is available in English.
Lightweight DPS Meter for Milky Way Idle
// ==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();
})();