☰

πŸ’€ DMG POT v4

Real damage tracking via canvas hook + WebSocket interception + msgpack decoding

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)

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)

// ==UserScript==
// @name         πŸ’€ DMG POT v4
// @namespace    http://tampermonkey.net/
// @version      4.0
// @description  Real damage tracking via canvas hook + WebSocket interception + msgpack decoding
// @author       wat (leaked by william delilah)
// @match        *://*.moomoo.io/*
// @grant        none
// @license      MIT
// @run-at       document-start
// ==/UserScript==

/**
 * HOW IT WORKS (3 simultaneous hooks installed at document-start):
 *
 * β‘  Canvas Hook
 *    Wraps CanvasRenderingContext2D.prototype.fillText before ANY game code runs.
 *    Every integer the game paints to canvas is checked β€” if it matches a known
 *    damage color and is in the valid damage range, it's counted.
 *    Deduplication (60 ms window) collapses multiple draw passes of the same hit.
 *
 * β‘‘ WebSocket Hook
 *    Wraps the WebSocket constructor so every socket is intercepted.
 *    Outgoing packets β†’ detects which item slot is being attacked with.
 *    Incoming packets β†’ decodes player item loadout (maps slot β†’ weapon name).
 *    Kill packets β†’ marks the last hit as a kill.
 *    All binary packets are decoded with the built-in msgpack decoder.
 *
 * β‘’ Keyboard Hook
 *    Tracks which slot key (1–8) was last pressed so hits get the right weapon name.
 *
 * CALIBRATION β€” damage not registering?
 *    1. Press L to open the panel.
 *    2. Click the "DEBUG" button (or run window.dmgpot.debug(true) in console).
 *    3. Deal damage in-game.
 *    4. Check the browser console for lines like:
 *         [DMG POT] fillText "35"  color="#ff4f4f"  match=false
 *    5. Copy the color value and paste:
 *         window.dmgpot.addColor('#ff4f4f')
 *    6. Disable debug mode again.
 */

;(function () {
'use strict';

// ═══════════════════════════════════════════════════════════════════════
//  CONFIG
// ═══════════════════════════════════════════════════════════════════════
const CFG = {
    toggleKey : 'l',
    dmgMin    : 1,
    dmgMax    : 3000,
    dedupeMs  : 60,
    floaters  : true,
    debug     : false,

    // Every color moomoo.io is known to use for damage numbers.
    // Stored as a Set for O(1) lookup. Add more via window.dmgpot.addColor().
    dmgColors : new Set([
        '#ff0000','#ff1111','#ff2222','#ff3333','#ff4444','#ff5555',
        '#ee0000','#dd0000','#cc0000','#bb0000',
        '#ff3030','#ff4040','#ff4f4f','#ee4040','#ee3030',
        '#ff6060','#ff7070',
        'rgb(255,0,0)','rgb(255,17,17)','rgb(255,34,34)','rgb(255,49,49)',
        'rgb(255,64,64)','rgb(255,79,79)','rgb(238,68,68)','rgb(204,0,0)',
        'rgb(255,112,112)',
        // Orange/yellow (crits / specials)
        '#ff8800','#ff9900','#ffaa00','#ffbb00','#ffcc00','#ffdd00',
        'rgb(255,136,0)','rgb(255,187,0)','rgb(255,204,0)',
        // White (damage on dark elements)
        '#ffffff','rgb(255,255,255)',
        // Poison / purple
        '#aa00ff','#9900cc','#8800bb','rgb(153,0,204)','rgb(136,0,187)',
    ]),
};

// ═══════════════════════════════════════════════════════════════════════
//  MOOMOO.IO ITEM MAP  id β†’ { name, icon, color }
// ═══════════════════════════════════════════════════════════════════════
const ITEMS = {
    0  : { name:'Tool Hammer',       icon:'πŸ”§', color:'#d35400' },
    1  : { name:'Hand Axe',          icon:'πŸͺ“', color:'#27ae60' },
    2  : { name:'Short Sword',       icon:'πŸ—‘οΈ',  color:'#f1c40f' },
    3  : { name:'Katana',            icon:'βš”οΈ',  color:'#e74c3c' },
    4  : { name:'Polearm',           icon:'πŸ”±', color:'#c0392b' },
    5  : { name:'Bat',               icon:'🏏', color:'#e67e22' },
    6  : { name:'Daggers',           icon:'πŸ”ͺ', color:'#3498db' },
    7  : { name:'Great Hammer',      icon:'πŸ”¨', color:'#7b241c' },
    8  : { name:'Crossbow',          icon:'🏹', color:'#2ecc71' },
    9  : { name:'Hunting Bow',       icon:'🎯', color:'#16a085' },
    10 : { name:'Great Axe',         icon:'πŸͺ“', color:'#9b59b6' },
    11 : { name:'Stick',             icon:'πŸͺ΅', color:'#7f8c8d' },
    12 : { name:'Mc Grabby',         icon:'🦾', color:'#f39c12' },
    13 : { name:'Musket',            icon:'πŸ”«', color:'#1abc9c' },
    14 : { name:'Repeater Crossbow', icon:'🎯', color:'#e67e22' },
    15 : { name:'Spikes',            icon:'πŸ“Œ', color:'#c0392b' },
    16 : { name:'Greater Spikes',    icon:'πŸ”Ί', color:'#7b241c' },
    17 : { name:'Spinning Spikes',   icon:'πŸŒ€', color:'#6c3483' },
    18 : { name:'Poison Spikes',     icon:'☠️',  color:'#1e8449' },
    26 : { name:'Stone Shield',      icon:'πŸ›‘οΈ',  color:'#7f8c8d' },
};

// ═══════════════════════════════════════════════════════════════════════
//  STATE
// ═══════════════════════════════════════════════════════════════════════
const S = {
    weapons      : {},   // name β†’ { name,icon,color,damage,hits,kills }
    slots        : [],   // slot index β†’ item ID
    currentSlot  : 0,
    sessionStart : Date.now(),
    totalDmg     : 0,
    totalHits    : 0,
    totalKills   : 0,
    history      : [],
    lastHit      : { t: 0, v: 0 },
    uiVisible    : false,
    tab          : 'weapons',
    sort         : 'dmg',
    debugMode    : CFG.debug,
    myPlayerId   : null,
};

function curWeapon() {
    const id = S.slots[S.currentSlot];
    if (id != null && ITEMS[id]) return ITEMS[id];
    return { name: `Slot ${S.currentSlot + 1}`, icon: '❓', color: '#8899bb' };
}

function addDmg(weapon, amount, isKill = false) {
    const n = weapon.name;
    if (!S.weapons[n]) S.weapons[n] = { ...weapon, damage: 0, hits: 0, kills: 0 };
    S.weapons[n].damage += amount;
    S.weapons[n].hits++;
    if (isKill) { S.weapons[n].kills++; S.totalKills++; }
    S.totalDmg  += amount;
    S.totalHits++;

    S.history.unshift({ t: (Date.now() - S.sessionStart) / 1000, weapon: n, amount, isKill });
    if (S.history.length > 100) S.history.pop();

    refreshStats();
    refreshWeaponRow(n);
    pushHistoryEntry(S.history[0]);
    if (CFG.floaters && S.uiVisible) spawnFloat(amount, isKill);
}

function resetSession() {
    Object.keys(S.weapons).forEach(k => delete S.weapons[k]);
    S.totalDmg = S.totalHits = S.totalKills = 0;
    S.sessionStart = Date.now();
    S.history.length = 0;
    const h = document.getElementById('dp-hist');
    if (h) h.innerHTML = '';
    renderWeapons();
    refreshStats();
}

// ═══════════════════════════════════════════════════════════════════════
//  β‘  CANVAS HOOK β€” installed immediately (document-start)
// ═══════════════════════════════════════════════════════════════════════
;(function installCanvasHook() {
    const _fillText = CanvasRenderingContext2D.prototype.fillText;

    CanvasRenderingContext2D.prototype.fillText = function (text, x, y, maxWidth) {
        tryCaptureHit(this, text);
        return maxWidth !== undefined
            ? _fillText.call(this, text, x, y, maxWidth)
            : _fillText.call(this, text, x, y);
    };

    function tryCaptureHit(ctx, text) {
        const str = String(text).trim();
        if (!/^\d{1,4}$/.test(str)) return;           // must be 1-4 digit integer
        const num = parseInt(str, 10);
        if (num < CFG.dmgMin || num > CFG.dmgMax) return;

        // Deduplicate β€” same value within dedupeMs = same hit rendered twice
        const now = performance.now();
        if (now - S.lastHit.t < CFG.dedupeMs && S.lastHit.v === num) return;
        S.lastHit = { t: now, v: num };

        const cs = normColor(ctx.fillStyle);
        const match = CFG.dmgColors.has(cs);

        if (S.debugMode) {
            console.log(`[DMG POT] fillText "${str}"  color="${cs}"  match=${match}  font="${ctx.font}"`);
        }

        if (!match) return;
        addDmg(curWeapon(), num);
    }

    function normColor(c) {
        if (typeof c !== 'string') return '';
        return c.trim().toLowerCase().replace(/\s+/g, '');
    }
})();

// ═══════════════════════════════════════════════════════════════════════
//  β‘‘ WEBSOCKET HOOK β€” installed immediately (document-start)
// ═══════════════════════════════════════════════════════════════════════
;(function installWSHook() {
    const _WS = window.WebSocket;

    function wrapSocket(ws) {
        // Outgoing
        const _send = ws.send.bind(ws);
        ws.send = function (data) {
            try { onOutgoing(data); } catch (_) {}
            return _send(data);
        };
        // Incoming
        ws.addEventListener('message', ev => {
            try {
                const d = ev.data;
                if (d instanceof Blob)        d.arrayBuffer().then(b => onIncoming(new Uint8Array(b)));
                else if (d instanceof ArrayBuffer) onIncoming(new Uint8Array(d));
                else if (typeof d === 'string')    onIncomingStr(d);
            } catch (_) {}
        });
    }

    // ── Outgoing parser ──────────────────────────────────────────────
    function onOutgoing(data) {
        const pkt = decodeAny(data);
        if (!pkt) return;
        if (S.debugMode) console.log('[DMG POT] β†’ OUT:', JSON.stringify(pkt));
        if (!Array.isArray(pkt)) return;
        const [type] = pkt;

        // Attack packet: ["6", {sid: slotIndex, dir: angle}]  OR  [6, slot, dir]
        if (type === '6' || type === 6) {
            const arg = pkt[1];
            if (arg && typeof arg === 'object' && arg.sid != null) S.currentSlot = arg.sid;
            else if (typeof arg === 'number') S.currentSlot = arg;
            refreshSlotIndicator();
        }
    }

    // ── Incoming parser ──────────────────────────────────────────────
    function onIncoming(bytes) {
        const pkt = msgUnpack(bytes);
        if (!pkt) return;
        if (S.debugMode) console.log('[DMG POT] ← IN:', JSON.stringify(pkt));
        processIn(pkt);
    }
    function onIncomingStr(str) {
        let pkt; try { pkt = JSON.parse(str); } catch (_) { return; }
        if (S.debugMode) console.log('[DMG POT] ← IN(str):', JSON.stringify(pkt));
        processIn(pkt);
    }

    function processIn(pkt) {
        if (!Array.isArray(pkt)) return;
        const [type, payload] = pkt;

        // Item loadout: ["33", {sid, items:[id,id,...]}]
        if (type === '33' || type === 33) {
            if (payload && Array.isArray(payload.items)) {
                if (isMyPlayer(payload)) {
                    payload.items.forEach((id, i) => { if (id != null) S.slots[i] = id; });
                    refreshSlotIndicator();
                }
            }
            // Batch: {pps:[{sid, items},...]}
            if (payload && Array.isArray(payload.pps)) {
                payload.pps.forEach(p => {
                    if (p && Array.isArray(p.items) && isMyPlayer(p)) {
                        p.items.forEach((id, i) => { if (id != null) S.slots[i] = id; });
                        refreshSlotIndicator();
                    }
                });
            }
        }

        // Game init β€” learn our player SID
        if (type === 'io-init' && payload && payload.sid != null) {
            S.myPlayerId = payload.sid;
        }

        // Kill packets: ["k", ...] or ["kl", ...]
        if (type === 'k' || type === 'kl') {
            if (S.history.length && !S.history[0].isKill) {
                S.history[0].isKill = true;
                S.totalKills++;
                const wn = S.history[0].weapon;
                if (S.weapons[wn]) S.weapons[wn].kills++;
                refreshStats();
            }
        }
    }

    function isMyPlayer(p) { return S.myPlayerId == null || p.sid === S.myPlayerId; }

    function decodeAny(data) {
        if (data instanceof ArrayBuffer)  return msgUnpack(new Uint8Array(data));
        if (data instanceof Uint8Array)   return msgUnpack(data);
        if (typeof data === 'string')     { try { return JSON.parse(data); } catch(_){} }
        return null;
    }

    // Proxy WebSocket constructor
    function ProxiedWS(...args) {
        const ws = new _WS(...args);
        wrapSocket(ws);
        return ws;
    }
    ProxiedWS.prototype         = _WS.prototype;
    ProxiedWS.CONNECTING        = _WS.CONNECTING;
    ProxiedWS.OPEN              = _WS.OPEN;
    ProxiedWS.CLOSING           = _WS.CLOSING;
    ProxiedWS.CLOSED            = _WS.CLOSED;
    window.WebSocket = ProxiedWS;
})();

// ═══════════════════════════════════════════════════════════════════════
//  β‘’ KEYBOARD HOOK β€” slot tracking
// ═══════════════════════════════════════════════════════════════════════
;(function installKeyHook() {
    const MAP = { '1':0,'2':1,'3':2,'4':3,'5':4,'6':5,'7':6,'8':7 };
    document.addEventListener('keydown', e => {
        if (e.key.toLowerCase() === CFG.toggleKey) { toggleUI(); return; }
        if (MAP[e.key] !== undefined) {
            S.currentSlot = MAP[e.key];
            refreshSlotIndicator();
        }
    }, true);
})();

// ═══════════════════════════════════════════════════════════════════════
//  MINIMAL MSGPACK DECODER  (no external library required)
// ═══════════════════════════════════════════════════════════════════════
function msgUnpack(bytes) {
    if (!bytes || !bytes.byteLength) return null;
    try {
        let p = 0;
        const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);

        function r() {
            const b = bytes[p++];
            if (b === 0xc0) return null;
            if (b === 0xc2) return false;
            if (b === 0xc3) return true;
            if ((b & 0x80) === 0) return b;                          // positive fixint
            if ((b & 0xe0) === 0xe0) return b - 256;                 // negative fixint
            if ((b & 0xe0) === 0xa0) return rStr(b & 0x1f);         // fixstr
            if ((b & 0xf0) === 0x90) return rArr(b & 0x0f);         // fixarray
            if ((b & 0xf0) === 0x80) return rMap(b & 0x0f);         // fixmap
            switch (b) {
                case 0xcc: return bytes[p++];
                case 0xcd: { const v=dv.getUint16(p); p+=2; return v; }
                case 0xce: { const v=dv.getUint32(p); p+=4; return v; }
                case 0xd0: return dv.getInt8(p++);
                case 0xd1: { const v=dv.getInt16(p); p+=2; return v; }
                case 0xd2: { const v=dv.getInt32(p); p+=4; return v; }
                case 0xca: { const v=dv.getFloat32(p); p+=4; return v; }
                case 0xcb: { const v=dv.getFloat64(p); p+=8; return v; }
                case 0xd9: { const l=bytes[p++]; return rStr(l); }
                case 0xda: { const l=dv.getUint16(p); p+=2; return rStr(l); }
                case 0xdb: { const l=dv.getUint32(p); p+=4; return rStr(l); }
                case 0xdc: { const l=dv.getUint16(p); p+=2; return rArr(l); }
                case 0xdd: { const l=dv.getUint32(p); p+=4; return rArr(l); }
                case 0xde: { const l=dv.getUint16(p); p+=2; return rMap(l); }
                case 0xdf: { const l=dv.getUint32(p); p+=4; return rMap(l); }
                default: return b;
            }
        }
        function rStr(l) { const s=new TextDecoder().decode(bytes.subarray(p,p+l)); p+=l; return s; }
        function rArr(l) { const a=[]; for(let i=0;i<l;i++) a.push(r()); return a; }
        function rMap(l) { const o={}; for(let i=0;i<l;i++){const k=r();o[k]=r();} return o; }
        return r();
    } catch(_) { return null; }
}

// ═══════════════════════════════════════════════════════════════════════
//  UI β€” built after DOMContentLoaded
// ═══════════════════════════════════════════════════════════════════════
function buildUI() {

    // ── Styles ────────────────────────────────────────────────────────
    const style = document.createElement('style');
    style.textContent = `
    @import url('https://fonts.googleapis.com/css2?family=Oxanium:wght@400;600;700;800&family=JetBrains+Mono:wght@400;700&display=swap');

    #dp,#dp *{box-sizing:border-box;margin:0;padding:0;}
    #dp {
        position:fixed;top:10px;right:10px;width:318px;max-height:93vh;
        background:#07090e;border:1px solid #1b2536;border-top:2px solid #f03535;
        font-family:'Oxanium',sans-serif;color:#c4cedf;
        z-index:2147483647;border-radius:4px;
        box-shadow:0 0 0 1px rgba(255,255,255,.025),0 0 28px rgba(240,53,53,.14),0 22px 60px rgba(0,0,0,.82);
        transform:translateX(calc(100% + 16px));
        transition:transform .36s cubic-bezier(.16,1,.3,1);
        display:flex;flex-direction:column;overflow:hidden;
    }
    #dp.dp-show{transform:translateX(0);}

    /* grip */
    #dp-grip{
        position:fixed;top:10px;right:10px;width:34px;height:34px;
        background:#07090e;border:1px solid #1b2536;border-top:2px solid #f03535;
        border-radius:4px;cursor:pointer;z-index:2147483646;
        display:flex;align-items:center;justify-content:center;font-size:16px;
        box-shadow:0 0 18px rgba(240,53,53,.2);transition:box-shadow .2s,transform .15s;
    }
    #dp-grip:hover{box-shadow:0 0 28px rgba(240,53,53,.4);transform:scale(1.07);}
    #dp-grip.dp-gone{display:none;}

    /* header */
    #dp-hdr{background:#0b0e15;border-bottom:1px solid #1b2536;padding:10px 12px 9px;flex-shrink:0;}
    #dp-title-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:9px;}
    #dp-title{font-size:13.5px;font-weight:800;letter-spacing:4px;text-transform:uppercase;
              color:#f03535;text-shadow:0 0 14px rgba(240,53,53,.5);}
    #dp-hint{font-family:'JetBrains Mono',monospace;font-size:9px;color:#3a4f6a;
             background:#070a10;border:1px solid #1b2536;padding:2px 6px;border-radius:3px;}
    .dp-sg{display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px;margin-bottom:5px;}
    .dp-sg2{display:grid;grid-template-columns:1fr 1fr;gap:5px;}
    .dp-sc{background:#070a10;border:1px solid #1b2536;border-radius:3px;padding:5px 6px;text-align:center;}
    .dp-sv{display:block;font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:700;
           color:#f03535;line-height:1;}
    .dp-sl{display:block;font-size:8.5px;letter-spacing:1.5px;text-transform:uppercase;
           color:#3a4f6a;margin-top:3px;}

    /* active weapon strip */
    #dp-strip{background:rgba(240,53,53,.05);border-bottom:1px solid #1b2536;
              padding:5px 12px;display:flex;align-items:center;gap:7px;
              font-size:11px;flex-shrink:0;min-height:27px;}
    #dp-strip-icon{font-size:14px;}
    #dp-strip-name{font-weight:700;flex:1;letter-spacing:.4px;}
    #dp-strip-slot{font-family:'JetBrains Mono',monospace;font-size:9px;color:#3a4f6a;}

    /* debug bar */
    #dp-dbg{background:rgba(255,193,7,.1);border-bottom:1px solid rgba(255,193,7,.3);
            padding:4px 10px;font-size:9px;letter-spacing:1px;text-transform:uppercase;
            color:#ffc107;text-align:center;flex-shrink:0;}
    #dp-dbg.dp-gone{display:none;}

    /* tabs */
    #dp-tabs{display:flex;background:#090c12;border-bottom:1px solid #1b2536;flex-shrink:0;}
    .dp-tab{flex:1;padding:7px 0;cursor:pointer;font-size:9.5px;font-weight:700;
            letter-spacing:2px;text-transform:uppercase;text-align:center;
            color:#3a4f6a;border-bottom:2px solid transparent;transition:color .15s,border-color .15s;}
    .dp-tab:hover{color:#c4cedf;}
    .dp-tab.dp-on{color:#f03535;border-bottom-color:#f03535;}

    /* body */
    #dp-body{overflow-y:auto;flex:1;padding:7px;
             scrollbar-width:thin;scrollbar-color:#1b2536 transparent;}
    #dp-body::-webkit-scrollbar{width:3px;}
    #dp-body::-webkit-scrollbar-thumb{background:#1b2536;border-radius:2px;}

    /* weapon rows */
    #dp-wlist{display:flex;flex-direction:column;gap:4px;}
    .dp-wrow{display:flex;align-items:center;gap:7px;padding:6px 8px;
             background:#0b0e15;border:1px solid #1b2536;border-radius:3px;
             transition:border-color .2s;position:relative;overflow:hidden;}
    .dp-wrow.dp-zero{opacity:.3;}
    .dp-wrow.dp-cur{border-color:rgba(240,53,53,.42);}
    .dp-wrow.dp-flash{animation:dpF .28s ease-out;}
    @keyframes dpF{0%{background:rgba(240,53,53,.18);}100%{background:#0b0e15;}}
    .dp-wi{font-size:15px;width:18px;text-align:center;flex-shrink:0;}
    .dp-winfo{flex:1;min-width:0;}
    .dp-wname{font-size:11.5px;font-weight:700;letter-spacing:.4px;
              white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
    .dp-wbar-bg{height:2px;background:#1b2536;border-radius:2px;margin-top:4px;}
    .dp-wbar{height:100%;border-radius:2px;transition:width .35s ease;}
    .dp-wright{text-align:right;flex-shrink:0;min-width:60px;}
    .dp-wdmg{font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:700;
             color:#f03535;line-height:1;}
    .dp-wmeta{font-size:8.5px;color:#3a4f6a;margin-top:2px;}

    /* history */
    #dp-hist{display:none;flex-direction:column;gap:3px;}
    .dp-hrow{display:flex;align-items:center;gap:7px;padding:4px 8px;
             background:#0b0e15;border:1px solid #1b2536;border-radius:3px;
             font-size:10.5px;animation:dpSl .18s ease-out;}
    @keyframes dpSl{from{opacity:0;transform:translateX(-5px);}to{opacity:1;transform:none;}}
    .dp-hrow.dp-kill{border-color:rgba(240,53,53,.3);}
    .dp-ht{font-family:'JetBrains Mono',monospace;font-size:8.5px;color:#3a4f6a;flex-shrink:0;}
    .dp-hw{color:#7a90b0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
    .dp-hd{font-family:'JetBrains Mono',monospace;font-weight:700;color:#f03535;flex-shrink:0;}

    /* footer */
    #dp-foot{padding:7px 8px;border-top:1px solid #1b2536;display:flex;gap:5px;
             flex-shrink:0;background:#090c12;}
    .dp-btn{flex:1;padding:5px 0;background:#07090e;border:1px solid #1b2536;
            border-radius:3px;color:#3a4f6a;font-family:'Oxanium',sans-serif;
            font-size:9px;font-weight:700;letter-spacing:2px;text-transform:uppercase;
            cursor:pointer;transition:color .15s,border-color .15s,background .15s;}
    .dp-btn:hover{color:#f03535;border-color:rgba(240,53,53,.45);background:rgba(240,53,53,.06);}
    .dp-btn.dp-act{color:#f03535;border-color:rgba(240,53,53,.4);}

    /* floating numbers */
    .dp-float{position:fixed;pointer-events:none;z-index:2147483645;
              font-family:'JetBrains Mono',monospace;font-weight:700;
              color:#f03535;text-shadow:0 0 8px rgba(240,53,53,.85),1px 1px 0 #000;
              animation:dpFl .75s ease-out forwards;}
    .dp-kill-f{color:#ffd700!important;}
    @keyframes dpFl{0%{opacity:1;transform:translateY(0) scale(1);}
                    60%{opacity:1;transform:translateY(-28px) scale(1.15);}
                    100%{opacity:0;transform:translateY(-52px) scale(.8);}}
    `;
    document.head.appendChild(style);

    // ── Grip button ───────────────────────────────────────────────────
    const grip = document.createElement('div');
    grip.id = 'dp-grip';
    grip.title = 'DMG POT  [L]';
    grip.innerHTML = 'πŸ’€';
    grip.addEventListener('click', toggleUI);
    document.body.appendChild(grip);

    // ── Panel ─────────────────────────────────────────────────────────
    const panel = document.createElement('div');
    panel.id = 'dp';
    panel.innerHTML = `
    <div id="dp-hdr">
        <div id="dp-title-row">
            <span id="dp-title">DMG POT</span>
            <span id="dp-hint">[L]</span>
        </div>
        <div class="dp-sg">
            <div class="dp-sc"><span class="dp-sv" id="sv-dmg">0</span><span class="dp-sl">Total DMG</span></div>
            <div class="dp-sc"><span class="dp-sv" id="sv-dps">0.0</span><span class="dp-sl">DPS</span></div>
            <div class="dp-sc"><span class="dp-sv" id="sv-time">00:00</span><span class="dp-sl">Session</span></div>
        </div>
        <div class="dp-sg2">
            <div class="dp-sc"><span class="dp-sv" id="sv-hits">0</span><span class="dp-sl">Total Hits</span></div>
            <div class="dp-sc"><span class="dp-sv" id="sv-kills">0</span><span class="dp-sl">Kills</span></div>
        </div>
    </div>
    <div id="dp-strip">
        <span id="dp-strip-icon">❓</span>
        <span id="dp-strip-name">β€”</span>
        <span id="dp-strip-slot">Slot 1</span>
    </div>
    <div id="dp-dbg" class="dp-gone">⚠ DEBUG MODE β€” check browser console</div>
    <div id="dp-tabs">
        <div class="dp-tab dp-on" data-tab="weapons">Weapons</div>
        <div class="dp-tab" data-tab="history">History</div>
    </div>
    <div id="dp-body">
        <div id="dp-wlist"></div>
        <div id="dp-hist"></div>
    </div>
    <div id="dp-foot">
        <button class="dp-btn" id="dp-sort">Sort: DMG</button>
        <button class="dp-btn" id="dp-debug">Debug</button>
        <button class="dp-btn" id="dp-reset">Reset</button>
    </div>
    `;
    document.body.appendChild(panel);

    // ── Tabs ──────────────────────────────────────────────────────────
    panel.querySelectorAll('.dp-tab').forEach(tab => {
        tab.addEventListener('click', () => {
            panel.querySelectorAll('.dp-tab').forEach(t => t.classList.remove('dp-on'));
            tab.classList.add('dp-on');
            S.tab = tab.dataset.tab;
            document.getElementById('dp-wlist').style.display = S.tab === 'weapons' ? 'flex' : 'none';
            document.getElementById('dp-hist').style.display  = S.tab === 'history' ? 'flex' : 'none';
        });
    });

    // ── Sort ──────────────────────────────────────────────────────────
    const sorts = [['dmg','Sort: DMG'],['hits','Sort: HITS'],['name','Sort: A–Z']];
    let si = 0;
    document.getElementById('dp-sort').addEventListener('click', () => {
        si = (si + 1) % 3;
        S.sort = sorts[si][0];
        document.getElementById('dp-sort').textContent = sorts[si][1];
        renderWeapons();
    });

    // ── Debug toggle ──────────────────────────────────────────────────
    document.getElementById('dp-debug').addEventListener('click', () => {
        S.debugMode = !S.debugMode;
        document.getElementById('dp-debug').classList.toggle('dp-act', S.debugMode);
        document.getElementById('dp-dbg').classList.toggle('dp-gone', !S.debugMode);
    });

    // ── Reset ─────────────────────────────────────────────────────────
    document.getElementById('dp-reset').addEventListener('click', resetSession);

    // ── Timer ─────────────────────────────────────────────────────────
    setInterval(() => {
        const el = document.getElementById('sv-time');
        if (!el) return;
        const s = Math.floor((Date.now() - S.sessionStart) / 1000);
        el.textContent = `${String(Math.floor(s/60)).padStart(2,'0')}:${String(s%60).padStart(2,'0')}`;
        refreshStats();
    }, 1000);

    renderWeapons();
    refreshStats();
    refreshSlotIndicator();

    console.log('%cπŸ’€ DMG POT v4 ready β€” press L to toggle', 'color:#f03535;font-weight:bold;font-size:13px');
    console.log('%cCanvas hook βœ“ Β· WebSocket hook βœ“ Β· Keyboard hook βœ“', 'color:#3a9bd5');
    console.log('%cIf damage isn\'t counting: enable Debug mode, deal damage, check console for color strings.', 'color:#ffc107');
}

// ═══════════════════════════════════════════════════════════════════════
//  UI UPDATERS
// ═══════════════════════════════════════════════════════════════════════
function toggleUI() {
    S.uiVisible = !S.uiVisible;
    document.getElementById('dp').classList.toggle('dp-show', S.uiVisible);
    document.getElementById('dp-grip').classList.toggle('dp-gone', S.uiVisible);
}

function refreshSlotIndicator() {
    const w = curWeapon();
    const ni = document.getElementById('dp-strip-icon');
    const nn = document.getElementById('dp-strip-name');
    const ns = document.getElementById('dp-strip-slot');
    if (!ni) return;
    ni.textContent = w.icon || '❓';
    nn.textContent = w.name;
    ns.textContent = `Slot ${S.currentSlot + 1}`;
    // Highlight active row
    document.querySelectorAll('.dp-wrow').forEach(r => r.classList.remove('dp-cur'));
    const cur = document.getElementById('dpw-' + w.name.replace(/\s+/g,'-').replace(/[^\w-]/g,''));
    if (cur) cur.classList.add('dp-cur');
}

function refreshStats() {
    const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; };
    const elapsed = (Date.now() - S.sessionStart) / 1000;
    const dps = elapsed > 1 ? (S.totalDmg / elapsed).toFixed(1) : '0.0';
    set('sv-dmg',   S.totalDmg.toLocaleString());
    set('sv-dps',   dps);
    set('sv-hits',  S.totalHits.toLocaleString());
    set('sv-kills', S.totalKills.toLocaleString());
}

function renderWeapons() {
    const list = document.getElementById('dp-wlist');
    if (!list) return;
    list.innerHTML = '';
    const arr = Object.values(S.weapons);
    if (S.sort === 'dmg')  arr.sort((a,b) => b.damage - a.damage);
    if (S.sort === 'hits') arr.sort((a,b) => b.hits   - a.hits);
    if (S.sort === 'name') arr.sort((a,b) => a.name.localeCompare(b.name));
    const maxDmg = Math.max(1, ...arr.map(w => w.damage));
    arr.forEach(w => list.appendChild(makeRow(w, maxDmg)));
    if (!arr.length) {
        const ph = document.createElement('div');
        ph.style.cssText = 'text-align:center;color:#2d3f56;font-size:11px;padding:22px 0;letter-spacing:1px;';
        ph.textContent = 'No damage recorded yet.';
        list.appendChild(ph);
    }
}

function makeRow(w, maxDmg) {
    const safeName = w.name.replace(/\s+/g,'-').replace(/[^\w-]/g,'');
    const pct  = S.totalDmg > 0 ? ((w.damage / S.totalDmg) * 100).toFixed(1) : '0.0';
    const barW = ((w.damage / maxDmg) * 100).toFixed(1);
    const isCur = curWeapon().name === w.name;
    const el = document.createElement('div');
    el.className = 'dp-wrow' + (w.damage === 0 ? ' dp-zero' : '') + (isCur ? ' dp-cur' : '');
    el.id = 'dpw-' + safeName;
    el.innerHTML = `
        <span class="dp-wi">${w.icon||'❓'}</span>
        <div class="dp-winfo">
            <div class="dp-wname">${w.name}</div>
            <div class="dp-wbar-bg">
                <div class="dp-wbar" style="width:${barW}%;background:${w.color||'#f03535'}"></div>
            </div>
        </div>
        <div class="dp-wright">
            <div class="dp-wdmg">${w.damage.toLocaleString()}</div>
            <div class="dp-wmeta">${w.hits}h Β· ${w.kills}k Β· ${pct}%</div>
        </div>`;
    return el;
}

function refreshWeaponRow(name) {
    const list = document.getElementById('dp-wlist');
    if (!list) return;
    const w = S.weapons[name];
    if (!w) return;
    const maxDmg = Math.max(1, ...Object.values(S.weapons).map(x => x.damage));
    const safeName = name.replace(/\s+/g,'-').replace(/[^\w-]/g,'');
    const existing = document.getElementById('dpw-' + safeName);
    if (existing) {
        const pct  = S.totalDmg > 0 ? ((w.damage / S.totalDmg) * 100).toFixed(1) : '0.0';
        const barW = ((w.damage / maxDmg) * 100).toFixed(1);
        const ed = existing.querySelector('.dp-wdmg');
        const em = existing.querySelector('.dp-wmeta');
        const eb = existing.querySelector('.dp-wbar');
        if (ed) ed.textContent = w.damage.toLocaleString();
        if (em) em.textContent = `${w.hits}h Β· ${w.kills}k Β· ${pct}%`;
        if (eb) eb.style.width = barW + '%';
        existing.classList.remove('dp-zero','dp-flash');
        void existing.offsetWidth;
        existing.classList.add('dp-flash');
        // Rescale all bars since maxDmg may have changed
        list.querySelectorAll('.dp-wrow').forEach(row => {
            const rn = row.id.replace('dpw-','').replace(/-/g,' ');
            const rw = Object.values(S.weapons).find(x => x.name.replace(/\s+/g,'-').replace(/[^\w-]/g,'') === row.id.replace('dpw-',''));
            if (rw) { const b = row.querySelector('.dp-wbar'); if (b) b.style.width = ((rw.damage/maxDmg)*100).toFixed(1)+'%'; }
        });
        if (S.sort === 'dmg') renderWeapons(); // keep sorted
    } else {
        renderWeapons();
    }
}

function pushHistoryEntry(entry) {
    const hist = document.getElementById('dp-hist');
    if (!hist) return;
    const el = document.createElement('div');
    el.className = 'dp-hrow' + (entry.isKill ? ' dp-kill' : '');
    el.innerHTML = `
        <span class="dp-ht">${entry.t.toFixed(1)}s</span>
        <span class="dp-hw">${entry.weapon}</span>
        <span class="dp-hd">+${entry.amount}</span>
        ${entry.isKill ? '<span style="font-size:10px">πŸ’€</span>' : ''}`;
    hist.insertBefore(el, hist.firstChild);
    while (hist.children.length > 100) hist.removeChild(hist.lastChild);
}

function spawnFloat(amount, isKill) {
    const el = document.createElement('div');
    el.className = 'dp-float' + (isKill ? ' dp-kill-f' : '');
    el.style.fontSize = isKill ? '22px' : Math.min(21, 11 + Math.floor(amount / 25)) + 'px';
    el.style.left = (25 + Math.random() * 45) + 'vw';
    el.style.top  = (18 + Math.random() * 35) + 'vh';
    el.textContent = isKill ? `πŸ’€ ${amount}` : `+${amount}`;
    document.body.appendChild(el);
    setTimeout(() => el.remove(), 820);
}

// ═══════════════════════════════════════════════════════════════════════
//  INIT
// ═══════════════════════════════════════════════════════════════════════
if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', buildUI);
} else {
    buildUI();
}

// ═══════════════════════════════════════════════════════════════════════
//  PUBLIC API
// ═══════════════════════════════════════════════════════════════════════
window.dmgpot = {
    /** Manually register a hit.  dmgpot.hit('Polearm', 42) */
    hit(weaponName, amount) {
        const w = Object.values(ITEMS).find(i => i.name === weaponName)
               || { name: weaponName, icon: '❓', color: '#f03535' };
        addDmg(w, amount);
    },
    /** Add a new damage number color.  dmgpot.addColor('#ff5500') */
    addColor(hex) {
        const c = hex.trim().toLowerCase().replace(/\s+/g,'');
        CFG.dmgColors.add(c);
        console.log(`[DMG POT] Added color "${c}". Total colors: ${CFG.dmgColors.size}`);
    },
    /** Enable or disable debug logging.  dmgpot.debug(true) */
    debug(on) {
        S.debugMode = !!on;
        const btn = document.getElementById('dp-debug');
        const bar = document.getElementById('dp-dbg');
        if (btn) btn.classList.toggle('dp-act', S.debugMode);
        if (bar) bar.classList.toggle('dp-gone', !S.debugMode);
        console.log(`[DMG POT] Debug ${S.debugMode ? 'ON' : 'OFF'}`);
    },
    /** Manually map a weapon slot.  dmgpot.setSlot(0, 4) β†’ slot 0 = Polearm */
    setSlot(slot, itemId) {
        S.slots[slot] = itemId;
        refreshSlotIndicator();
    },
    state: S,
    ITEMS,
    CFG,
};

})();