azzo / PokeIdle Hunt Coach

// ==UserScript==
// @name         PokeIdle Hunt Coach
// @name:pt-BR   PokeIdle Hunt Coach
// @namespace    https://openuserjs.org/users/azzo
// @version      0.1.4
// @description  Shows the pokémon you have in the field on pokeidle.io automatically (no clicking), tells you whether your XP per hour is good or bad and suggests the best hunt for it: uses the Hunt Analyser, your trainer level and the XP you actually measure, and learns per pokémon. Read-only: it never clicks or automates anything.
// @description:pt-BR  Mostra sozinho o pokémon que você tem em campo no pokeidle.io (sem clicar em nada), diz se o seu XP por hora está bom ou ruim e sugere a melhor hunt para ele: usa o Hunt Analyser, o nível do treinador e o XP realmente medido, e aprende por pokémon. Somente leitura: não clica nem automatiza nada.
// @license      MIT
// @copyright    2026, azzo (https://openuserjs.org/users/azzo)
// @match        https://pokeidle.io/*
// @run-at       document-idle
// @noframes
// @grant        none
// ==/UserScript==
 
// ==OpenUserJS==
// @author azzo
// ==/OpenUserJS==
 
(function () {
  'use strict';
 
  // Motor de análise: sem DOM, testável em Node.
  const E = (function () {
    const K = 2;       // peso a priori: quantas amostras "valem" o fator global
    const ALPHA = 0.4; // suavização (EMA) do fator de cada hunt
 
    // "1,2M" "350k" "12.345" "300 M" "1.234,5" -> número
    function parseNum(s) {
      const m = String(s == null ? '' : s).replace(/\s+/g, '').match(/(\d[\d.,]*)([kmbt])?/i);
      if (!m) return NaN;
      let t = m[1];
      const suf = (m[2] || '').toLowerCase();
      const dot = t.lastIndexOf('.'), com = t.lastIndexOf(',');
      if (dot >= 0 && com >= 0) {
        const d = Math.max(dot, com);
        t = t.slice(0, d).replace(/[.,]/g, '') + '.' + t.slice(d + 1);
      } else if (dot >= 0 || com >= 0) {
        const parts = t.split(dot >= 0 ? '.' : ',');
        t = parts.length > 2 || (parts[1].length === 3 && !suf) ? parts.join('') : parts[0] + '.' + parts[1];
      }
      return parseFloat(t) * ({ k: 1e3, m: 1e6, b: 1e9, t: 1e12 }[suf] || 1);
    }
 
    // Texto de uma linha do Hunt Analyser -> {name, level, xp, coins}
    function parseRow(text) {
      const lines = String(text).split(/\n+/).map((l) => l.trim()).filter(Boolean);
      const name = lines.find((l) => !/^[\d.,\s]+[kmbt]?(\s*(xp|coins?|\/h).*)?$/i.test(l)) || lines[0] || '';
      const flat = String(text).replace(/\s+/g, ' ');
      const NUM = '(\\d[\\d.,]*\\s?[kmbt]?)';
      const grab = (re) => { const m = flat.match(re); return m ? parseNum(m[1]) : NaN; };
      let xp = grab(new RegExp(NUM + '\\s*xp', 'i'));
      if (isNaN(xp)) xp = grab(new RegExp('xp\\D{0,12}' + NUM, 'i'));
      let coins = grab(new RegExp(NUM + '\\s*(?:coins?|moedas?|\\$)', 'i'));
      if (isNaN(coins)) coins = grab(new RegExp('(?:coins?|moedas?|\\$)\\D{0,12}' + NUM, 'i'));
      const level = grab(/(?:nv|lv|n[ií]vel)\.?\s*(\d+)/i);
      const kills = grab(new RegExp(NUM + '\\s*(?:kills?|abates?)', 'i'));
      const risk = /☠|one.?shot|1\s*hit/i.test(text) ? 'death' : /⚠/.test(text) ? 'warn' : '';
      return { name, level, xp, coins, kills, risk };
    }
 
    // Palavras da interface que NUNCA são nome de pokémon
    const UI_WORDS = /^(?:em campo|equipe|team|ativo|active|shiny|brilhante|lend[aá]rio|m[ií]tico|legendary|mythic|nv|lv|n[ií]vel|level|xp|hp|ver|remover|trocar|evoluir|vender|soltar|mover|detalhes|poder|power|qualidade|quality|iv|tm|aoe|vazio|empty|slot|favorito|equipar|desequipar|usar|ok|sim|n[aã]o|sprite|icon|[ií]cone|img|image|imagem|pok[eé]mon|pok[eé]mon em campo|selecionar|selecionado|campo)$/i;
    const NAME_OK = /^[\p{L}][\p{L}'’.\- ♀♂]{1,23}\d?$/u;
    function isName(l) {
      const t = String(l).trim();
      return t.length >= 3 && (t.match(/\p{L}/gu) || []).length >= 3 && NAME_OK.test(t) && !UI_WORDS.test(t) && !/^(?:p\s?[1-5]\b|iv\b|nv\b|lv\b)/i.test(t);
    }
 
    // Texto de um pokémon (slot da equipe ou "em campo") -> {name, level, power, iv, shiny}
    function parseMon(text) {
      const raw = String(text == null ? '' : text);
      const lines = raw.split(/\n+/).map((l) => l.trim()).filter(Boolean);
      const shiny = /shiny|✨|★/i.test(raw);
      const clean = (l) => l.replace(/\s*(?:nv|lv|n[ií]vel)\.?\s*\d+.*$/i, '').replace(/shiny|✨|★/gi, '').trim();
      const name = lines.map(clean).find(isName) || '';
      const flat = raw.replace(/\s+/g, ' ');
      const g = (re) => { const m = flat.match(re); return m ? +m[1] : NaN; };
      return { name, level: g(/(?:nv|lv|n[ií]vel)\.?\s*(\d+)/i), power: g(/\bP\s?([1-5])\b/), iv: g(/\bIV\s*(\d+)/i), shiny };
    }
 
    // Quão bom é o XP/h atual? Compara com a melhor hunt liberada e com todas as outras.
    function rate(ranked, cur) {
      if (!ranked.length || !(cur >= 0)) return null;
      const best = ranked[0].score, ratio = best > 0 ? cur / best : NaN;
      const below = ranked.filter((r) => r.score < cur).length;
      const pct = ranked.length > 1 ? below / (ranked.length - 1) : 1;
      const label = ratio >= 0.9 ? 'ÓTIMO' : ratio >= 0.7 ? 'BOM' : ratio >= 0.45 ? 'REGULAR' : 'RUIM';
      return { ratio, pct, label, best };
    }
 
    // Fator global = mediana de (real/previsto) de todas as hunts já medidas.
    function globalRatio(stats) {
      const rs = Object.values(stats).filter((s) => s.n > 0).map((s) => s.ratio).sort((a, b) => a - b);
      if (!rs.length) return 1;
      const m = rs.length >> 1;
      return rs.length % 2 ? rs[m] : (rs[m - 1] + rs[m]) / 2;
    }
 
    // Fator da hunt: média ponderada entre o que foi medido nela e o fator global.
    function factor(stats, hunt) {
      const g = globalRatio(stats), s = stats[hunt];
      return s && s.n ? (s.n * s.ratio + K * g) / (s.n + K) : g;
    }
 
    // Registra uma medição: previsto pelo Analyser vs. XP/h real.
    function learn(stats, hunt, pred, real) {
      if (!(pred > 0) || !(real >= 0)) return stats;
      const r = real / pred, s = stats[hunt] || { n: 0, ratio: r, real };
      s.ratio = s.n ? ALPHA * r + (1 - ALPHA) * s.ratio : r;
      s.real = s.n ? ALPHA * real + (1 - ALPHA) * s.real : real;
      s.n += 1;
      stats[hunt] = s;
      return stats;
    }
 
    // Hunts elegíveis, ordenadas por XP/h ajustado (previsto x fator aprendido).
    function rank(rows, stats, trainerLevel, opts) {
      const o = opts || {}, g = globalRatio(stats);
      return rows
        .filter((r) => r.xp > 0 && !(r.level > trainerLevel) && !(o.hideDeath && r.risk === 'death'))
        .map((r) => {
          const f = factor(stats, r.name), st = stats[r.name];
          return Object.assign({}, r, { factor: f, score: r.xp * f, n: (st && st.n) || 0, slow: !!(st && st.n && st.ratio < 0.6 * g) });
        })
        .sort((a, b) => b.score - a.score);
    }
 
    // Compara a hunt atual com a melhor ajustada e com a que o Analyser apontou.
    function audit(ranked, current, liveRate) {
      if (!ranked.length) return { verdict: 'sem-dados' };
      const best = ranked[0];
      const gameTop = ranked.slice().sort((a, b) => b.xp - a.xp)[0];
      const cur = ranked.find((r) => r.name === current);
      const base = { best, gameTop, cur, analyserDisagrees: gameTop.name !== best.name };
      if (!cur) return Object.assign(base, { verdict: 'fora' });
      const curRate = liveRate > 0 ? liveRate : cur.score; // medido vale mais que estimado
      const gap = best.score / curRate - 1;
      base.curRate = curRate;
      base.gap = gap;
      base.analyserErr = liveRate > 0 ? liveRate / cur.xp : NaN;
      base.verdict = best.name === current || gap < 0.05 ? 'ok' : 'melhor-existe';
      return base;
    }
 
    const api = { parseNum, parseRow, parseMon, isName, rate, globalRatio, factor, learn, rank, audit };
    return api;
  })();
 
  const VERSION = '0.1.4', KEY = 'phc_v2';
  const MIN_WINDOW = 5 * 60e3, MAX_WINDOW = 30 * 60e3, H = 3.6e6;
  const LABELS = {
    level: 'o NÍVEL do treinador', xp: 'o XP atual (número tipo 1.234 / 5.000, não %)', hunt: 'o NOME da hunt atual',
    rows: 'UMA LINHA da lista do Hunt Analyser (com o painel aberto)', active: 'o pokémon EM CAMPO (nome e nível)'
  };
  const SHORT = { level: 'Treinador', xp: 'XP', hunt: 'Hunt atual', rows: 'Analyser', active: 'Em campo' };
  let S = { v: VERSION, sel: {}, mons: {}, team: [], selMon: '', log: [], ui: {} };
  let session = null, xpNow = null, xpKind = '', huntNow = '', lvlNow = NaN, activeKey = '', activeBy = '', owner = '', assumed = false;
  let msg = '', lastSave = 0, storageOk = true, diagText = '', resetArmed = false, scanErr = '';
  let TX = new Map(), ALL = [], D = {};
 
  // ---------- util ----------
  const load = () => { try { return JSON.parse(localStorage.getItem(KEY)); } catch (e) { return null; } };
  const save = () => { try { localStorage.setItem(KEY, JSON.stringify(S)); storageOk = true; } catch (e) { storageOk = false; } };
  const $ = (s) => { try { return s ? document.querySelector(s) : null; } catch (e) { return null; } };
  const flat = (s) => String(s).replace(/\s+/g, ' ').trim();
  const fin = (n) => (Number.isFinite(n) ? n : null);
  const keyOf = (n) => String(n).toLowerCase().replace(/\s+/g, ' ').trim();
  const norm = (s) => String(s).replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '').toLowerCase();
  const escRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const fmt = (n) => !Number.isFinite(n) ? '–' : n >= 1e9 ? (n / 1e9).toFixed(2) + 'B' : n >= 1e6 ? (n / 1e6).toFixed(2) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(Math.round(n));
  const dur = (h) => !Number.isFinite(h) || h <= 0 ? '–' : h < 1 ? Math.round(h * 60) + ' min' : h.toFixed(1) + ' h';
  const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
  const monKey = (m) => (m.name ? keyOf(m.name) : Number.isFinite(m.iv) ? 'iv' + m.iv : '');
  const newMon = (name) => ({ name, level: null, power: null, iv: null, shiny: false, stats: {}, rows: [], rowsT: 0, lvlAt: null });
  function ensureMon(name) { const k = keyOf(name); if (!S.mons[k]) S.mons[k] = newMon(name); return S.mons[k]; }
  function upsertMon(m) {
    const key = monKey(m);
    if (!key) return '';
    if (m.name && Number.isFinite(m.iv) && S.mons['iv' + m.iv] && !S.mons[key]) { S.mons[key] = S.mons['iv' + m.iv]; delete S.mons['iv' + m.iv]; } // descobriu o nome: junta os dados
    const mon = S.mons[key] || (S.mons[key] = newMon(m.name || 'Pokémon em campo'));
    if (m.name) mon.name = m.name;
    if (Number.isFinite(m.level)) mon.level = m.level;
    if (Number.isFinite(m.power)) mon.power = m.power;
    if (Number.isFinite(m.iv)) mon.iv = m.iv;
    mon.shiny = !!m.shiny;
    return key;
  }
  function cssPath(el) {
    const parts = [];
    while (el && el.nodeType === 1 && el !== document.body && parts.length < 8) {
      if (el.id && !/\d{3,}/.test(el.id)) { parts.unshift('#' + CSS.escape(el.id)); break; }
      const sib = el.parentElement ? [...el.parentElement.children].filter((c) => c.tagName === el.tagName) : [];
      parts.unshift(el.tagName.toLowerCase() + (sib.length > 1 ? ':nth-of-type(' + (sib.indexOf(el) + 1) + ')' : ''));
      el = el.parentElement;
    }
    return parts.join(' > ');
  }
 
  // ---------- detecção automática (lê a tela do jogo sem você marcar nada) ----------
  const SKIP = /^(SCRIPT|STYLE|NOSCRIPT|SVG|PATH|IMG|LINK|META|BR|HR)$/i;
  function leafText(el) {
    const out = [];
    (function w(n) {
      for (const c of n.childNodes) {
        if (c.nodeType === 3) { const t = c.nodeValue.trim(); if (t) out.push(t); }
        else if (c.nodeType === 1 && c.tagName === 'IMG') { const a = (c.getAttribute('alt') || '').trim(); if (a) out.push(a); }
        else if (c.nodeType === 1 && !SKIP.test(c.tagName)) w(c);
      }
    })(el);
    return out.join('\n');
  }
  const tx = (el) => { let v = TX.get(el); if (v === undefined) { v = leafText(el); TX.set(el, v); } return v; };
  const ft = (el) => flat(tx(el));
  const vis = (el) => el.getClientRects().length > 0;
  const sameChild = (el, t) => [...el.children].some((c) => ft(c) === t);
  const dist = (a, b) => { for (let p = a, i = 0; p; p = p.parentElement, i++) if (p.contains(b)) return i; return Infinity; };
  const RE_LV = /\b(?:nv|lv|n[ií]vel|level)\.?\s*(\d{1,4})\b/i;
  const RE_PI = /\bP\s?[1-5]\b|\bIV\s*\d+/i;
  const RE_RATE = /\/\s*h\b|coins?\b/i;
  const RE_XP = /\d\s?[kmbt]?\s*xp\b|\bxp\b\D{0,14}\d/i;
  const LABEL = /equipe|team|party/i;
  const MARK = /(?:^|[^A-Za-z])(?:active|Active|selected|Selected|current|Current|ativo|Ativo|campo|field|lead|principal)(?![a-z])/;
 
  function findTeam() {
    let best = null;
    for (const el of ALL) {
      const kids = el.children;
      if (kids.length < 2 || kids.length > 14 || !vis(el)) continue;
      const strict = [], loose = [];
      for (const k of kids) {
        const t = ft(k);
        if (!t || t.length > 140 || !RE_LV.test(t)) continue;
        const m = E.parseMon(tx(k));
        if (!m.name || !Number.isFinite(m.level)) continue;
        if (RE_PI.test(t)) strict.push({ el: k, mon: m });
        else if (!RE_RATE.test(t) && t.length <= 80) loose.push({ el: k, mon: m });
      }
      const uniq = (arr) => arr.filter((x, i) => arr.findIndex((y) => y.mon.name === x.mon.name && y.mon.level === x.mon.level && y.mon.iv === x.mon.iv) === i);
      const sU = uniq(strict), lU = uniq(loose);
      const mem = sU.length >= 2 ? sU : lU.length >= 2 ? lU : null;
      if (!mem || mem.length > 6) continue;
      let labeled = false;
      for (let p = el, i = 0; p && i < 3 && !labeled; p = p.parentElement, i++) {
        const prev = p.previousElementSibling;
        labeled = LABEL.test(ft(p).slice(0, 40)) || !!(prev && LABEL.test(ft(prev).slice(0, 40)));
      }
      if (mem === lU && !labeled) continue;
      const score = mem.length * 10 + (mem === sU ? 8 : 0) + (labeled ? 8 : 0) - (kids.length - mem.length) * 2;
      if (!best || score > best.score) best = { el, members: mem, score };
    }
    return best;
  }
  function findActive(team) {
    // 1) rótulo "Em campo": o pokémon é o menor bloco ao redor do rótulo que tenha nível e poder/IV
    for (const el of ALL) {
      if (el.children.length > 2 || !/^[^\p{L}]*(?:em campo|in field)[^\p{L}]*$/iu.test(ft(el))) continue;
      for (let p = el, i = 0; p && i < 6; p = p.parentElement, i++) {
        const t = ft(p);
        if (t.length > 220) break;
        if (RE_LV.test(t) && RE_PI.test(t)) {
          const m = E.parseMon(tx(p));
          if (Number.isFinite(m.level) && monKey(m)) return { key: monKey(m), mon: m, by: 'rótulo "Em campo"' };
          break;
        }
      }
    }
    if (!team) return null;
    // 2) marca no slot da equipe (classe/aria) ou o painel que repete nome + nível
    const marked = team.members.filter((m) => MARK.test(m.el.getAttribute('class') || '') || m.el.getAttribute('aria-selected') === 'true' || m.el.hasAttribute('aria-current'));
    if (marked.length === 1) return { key: monKey(marked[0].mon), mon: marked[0].mon, by: 'marca no slot' };
    const found = new Map();
    for (const el of ALL) {
      if (team.el.contains(el) || el.children.length > 5) continue;
      const t = ft(el);
      if (t.length > 80 || !RE_LV.test(t) || !vis(el)) continue;
      for (const m of team.members) {
        if (m.mon.name && new RegExp('(^|[^\\p{L}])' + escRe(m.mon.name) + '([^\\p{L}]|$)', 'iu').test(t) && +t.match(RE_LV)[1] === m.mon.level) found.set(keyOf(m.mon.name), m.mon);
      }
    }
    return found.size === 1 ? { key: [...found.keys()][0], mon: [...found.values()][0], by: 'painel do pokémon' } : null;
  }
  function findTrainer(team) {
    const mons = new Set((team ? team.members : []).map((m) => keyOf(m.mon.name)));
    for (const el of ALL) {
      if (el.children.length > 5 || (team && team.el.contains(el))) continue;
      const t = ft(el);
      if (t.length < 4 || t.length > 60 || RE_PI.test(t) || !vis(el)) continue;
      let name = '', level;
      let m = t.match(/^(.{1,32}?)\s*[^\p{L}\p{N}\s]{1,3}\s*(?:nv|lv|n[ií]vel|level)\.?\s*(\d{1,4})$/iu);
      if (m) { name = m[1]; level = +m[2]; }
      else { m = t.match(/(?:treinador|trainer)\b[^\d]{0,16}?(\d{1,4})$/i); if (m) level = +m[1]; }
      if (level == null || mons.has(keyOf(name)) || sameChild(el, t)) continue;
      return { el, level, name };
    }
    return null;
  }
  function findRows() {
    let best = null;
    for (const el of ALL) {
      const kids = el.children;
      if (kids.length < 3) continue;
      let n = 0, seen = 0;
      for (let i = 0; i < kids.length && i < 40; i++) { seen++; if (RE_XP.test(ft(kids[i]))) n++; }
      if (n >= 3 && n >= seen * 0.6 && (!best || n > best.n || (n === best.n && kids.length < best.el.children.length))) best = { el, n, mode: 'labeled' };
    }
    if (best) return best;
    for (const tb of document.querySelectorAll('table')) { // tabela com colunas (cabeçalho "XP")
      const head = tb.querySelector('thead tr') || tb.querySelector('tr');
      if (!head) continue;
      const hs = [...head.children].map((c) => flat(leafText(c)));
      const xi = hs.findIndex((h) => /\bxp\b/i.test(h));
      const body = tb.tBodies[0] || tb;
      if (xi < 0 || body.children.length < 3) continue;
      const ni = Math.max(0, hs.findIndex((h) => /hunt|nome|name|[áa]rea|zona|local|rota/i.test(h)));
      return { el: body, mode: 'cols', xi, ni, ci: hs.findIndex((h) => /coins?|gold|ouro|moedas?/i.test(h)), li: hs.findIndex((h) => /^(nv|lv|n[ií]vel|level|req)/i.test(h)), n: body.children.length };
    }
    return null;
  }
  function parseRows(R) {
    const out = [];
    for (const k of R.el.children) {
      let r;
      if (R.mode === 'cols') {
        const c = k.children;
        if (c.length <= R.xi) continue;
        const lv = R.li >= 0 && c[R.li] ? (flat(leafText(c[R.li])).match(/\d+/) || [])[0] : undefined;
        r = { name: leafText(c[R.ni]).split('\n')[0].trim(), level: lv ? +lv : NaN, xp: E.parseNum(leafText(c[R.xi])), coins: R.ci >= 0 && c[R.ci] ? E.parseNum(leafText(c[R.ci])) : NaN, kills: NaN, risk: '' };
      } else r = E.parseRow(leafText(k));
      if (r.name && r.xp > 0) out.push({ name: r.name, level: r.level, xp: r.xp, coins: r.coins, kills: r.kills, risk: r.risk });
    }
    return out.slice(0, 800);
  }
  function findHunt(rows, fresh) {
    if (rows && rows.mode === 'labeled') {
      const m = [...rows.el.children].slice(0, 900).filter((k) => MARK.test(k.getAttribute('class') || '') || k.hasAttribute('aria-current') || k.getAttribute('aria-selected') === 'true');
      if (m.length === 1) { const r = E.parseRow(leafText(m[0])); if (r.name) return { name: r.name, by: 'linha marcada' }; }
    }
    const known = new Map();
    Object.values(S.mons).forEach((m) => (m.rows || []).forEach((r) => known.set(norm(r.name), r.name)));
    (fresh || []).forEach((r) => known.set(norm(r.name), r.name));
    if (!known.size) return null;
    const names = [...known.keys()].filter((n) => n.length >= 4).sort((a, b) => b.length - a.length);
    let loose = null;
    for (const el of ALL) {
      if (el.children.length > 3 || (rows && rows.el.contains(el)) || !vis(el)) continue;
      const t = ft(el);
      if (t.length < 2 || t.length > 60 || sameChild(el, t)) continue;
      const p = el.parentElement, nt = norm(t);
      if (p && [...p.children].filter((c) => known.has(norm(ft(c)))).length >= 3) continue; // lista de hunts, não o rótulo da atual
      if (known.has(nt)) return { name: known.get(nt), by: 'rótulo na tela' };
      if (!loose) { const hit = names.find((n) => nt.includes(n) && nt.length <= n.length + 24); if (hit) loose = { name: known.get(hit), by: 'rótulo na tela' }; }
    }
    return loose;
  }
  const xpPair = (t) => { const m = t.match(/(\d[\d.,]*\s?[kmbt]?)\s*\/\s*(\d[\d.,]*\s?[kmbt]?)/i); return m ? { cur: E.parseNum(m[1]), max: E.parseNum(m[2]) } : null; };
  function findXp(trainer, activeEl) {
    const c = [];
    for (const el of ALL) {
      if (el.children.length > 5 || !vis(el)) continue;
      const t = ft(el);
      if (t.length < 3 || t.length > 70 || !xpPair(t) || /\bhp\b|\bpp\b|\bps\b|vida/i.test(t)) continue;
      let hit = /\b(?:xp|exp)\b/i.test(t);
      for (let up = el, i = 0; !hit && i < 3 && up.parentElement; i++) {
        up = up.parentElement;
        const pt = ft(up);
        if (pt.length > 160 || /\bhp\b/i.test(pt)) break;
        hit = /\b(?:xp|exp)\b/i.test(pt);
      }
      if (!hit || sameChild(el, t)) continue;
      const dt = trainer && trainer.el ? dist(el, trainer.el) : Infinity, dm = activeEl ? dist(el, activeEl) : Infinity;
      c.push({ el, kind: dt <= 3 && dt < dm ? 'trainer' : 'mon' }); // fica do lado de quem estiver mais perto na tela
    }
    return c;
  }
  function scan() {
    TX = new Map();
    ALL = [...document.body.querySelectorAll('*')].filter((e) => !SKIP.test(e.tagName));
    const team = findTeam(), active = findActive(team), trainer = findTrainer(team), rows = findRows();
    let activeEl = null;
    if (team && active) { const m = team.members.find((x) => keyOf(x.mon.name) === active.key); activeEl = m ? m.el : null; }
    const parsed = rows ? parseRows(rows) : null;
    D = { team, active, trainer, rows, parsed, hunt: findHunt(rows, parsed), xp: findXp(trainer, activeEl) };
  }
 
  // ---------- leitura (manual, se você marcou; senão automático) ----------
  const txt = (s) => { const e = $(s); return e ? leafText(e).replace(/\n/g, ' ').trim() : ''; };
  function readXp() {
    if (S.sel.xp) {
      const t = txt(S.sel.xp), p = xpPair(t);
      if (p) return { p, kind: 'mon' };
      if (/%/.test(t)) return null;
      const n = E.parseNum(t);
      return isNaN(n) ? null : { p: { cur: n, max: NaN }, kind: 'mon' };
    }
    const c = D.xp.find((x) => x.kind === (S.ui.xpKind || 'mon')) || D.xp[0];
    return c ? { p: xpPair(ft(c.el)), kind: c.kind } : null;
  }
 
  // ---------- medição de XP/h real (por pokémon) ----------
  const predOf = (key, hunt) => { const r = ((S.mons[key] || {}).rows || []).find((x) => x.name === hunt); return r ? r.xp : undefined; };
  const start = (key, hunt, xp, kind, now) => { session = { key, hunt, kind, t0: now, gained: 0, last: xp, pred: predOf(key, hunt) }; };
  function commit() {
    if (!session) return;
    const dt = Date.now() - session.t0, mon = S.mons[session.key];
    if (mon && dt >= MIN_WINDOW && session.gained > 0) {
      const real = session.gained / (dt / H);
      if (session.pred > 0) E.learn(mon.stats, session.hunt, session.pred, real);
      S.log.push({ t: Date.now(), mon: mon.name, hunt: session.hunt, kind: session.kind, pred: session.pred || null, real, mins: Math.round(dt / 6e4), lvl: fin(lvlNow) });
      S.log = S.log.slice(-200);
      save();
    }
    session = null;
  }
  function tick() {
    const now = Date.now();
    try { scan(); scanErr = ''; } catch (e) { scanErr = String(e && e.message || e); D = { xp: [] }; }
    // treinador
    const lv = S.sel.level ? (txt(S.sel.level).match(/\d+/) || [NaN])[0] : D.trainer ? D.trainer.level : NaN;
    if (Number.isFinite(+lv)) S.trainerLast = lvlNow = +lv; else lvlNow = S.trainerLast != null ? S.trainerLast : NaN;
    // equipe (só para o aprendizado; a HUD mostra apenas o pokémon em campo)
    if (D.team) S.team = D.team.members.map((m) => upsertMon(m.mon)).filter(Boolean);
    // pokémon em campo: sempre automático
    let act = null;
    if (S.sel.active) { const m = E.parseMon(txt(S.sel.active)); if (monKey(m)) act = { mon: m, by: 'marcado por você' }; }
    else if (D.active) act = D.active;
    if (act) { activeKey = upsertMon(act.mon); activeBy = act.by; assumed = false; S.activeLast = activeKey; }
    else if (S.activeLast && S.mons[S.activeLast]) { activeKey = S.activeLast; activeBy = 'último visto'; assumed = true; }
    else { activeKey = ''; activeBy = ''; assumed = false; }
    S.selMon = activeKey;
    owner = activeKey;
    // Analyser
    const rr = S.sel.rows ? (() => { const b = $(S.sel.rows); return b ? parseRows({ el: b, mode: 'labeled' }) : null; })() : D.parsed || null;
    if (rr && rr.length) {
      if (!owner) { owner = keyOf('Pokémon atual'); ensureMon('Pokémon atual'); S.selMon = activeKey = owner; }
      const m = S.mons[owner];
      m.rows = rr; m.rowsT = now; m.lvlAt = m.level; m.trainerAt = fin(lvlNow);
    }
    // hunt atual e XP
    huntNow = S.sel.hunt ? txt(S.sel.hunt).split(/\s{2,}/)[0] : D.hunt ? D.hunt.name : '';
    const xr = readXp();
    xpNow = xr && xr.p; xpKind = xr ? xr.kind : '';
    if (owner && huntNow && xpNow) {
      if (!session || session.hunt !== huntNow || session.key !== owner || session.kind !== xpKind) { commit(); start(owner, huntNow, xpNow, xpKind, now); }
      else {
        const d = xpNow.cur - session.last.cur;
        if (d >= 0) session.gained += d;
        else if (session.last.max > 0) session.gained += (session.last.max - session.last.cur) + xpNow.cur; // subiu de nível
        else start(owner, huntNow, xpNow, xpKind, now);
        session.last = xpNow;
        if (!session.pred) session.pred = predOf(owner, huntNow);
        if (now - session.t0 > MAX_WINDOW) { commit(); start(owner, huntNow, xpNow, xpKind, now); }
      }
    }
    if (now - lastSave > 60e3) { save(); lastSave = now; }
    if (!diagText) render();
  }
 
  // ---------- marcação manual (só se a detecção automática falhar) ----------
  function rowContainer(el, min) {
    while (el.parentElement) {
      const p = el.parentElement;
      if (p.children.length >= min && [...p.children].every((c) => c.tagName === p.children[0].tagName)) return p;
      el = p;
    }
    return el;
  }
  function pick(key) {
    const bar = document.createElement('div');
    bar.textContent = 'Hunt Coach: clique em ' + LABELS[key] + ' — Esc cancela';
    bar.style.cssText = 'position:fixed;top:8px;left:50%;transform:translateX(-50%);z-index:2147483647;background:#ffcb05;color:#222;padding:8px 14px;border-radius:8px;font:600 13px system-ui';
    document.documentElement.appendChild(bar);
    const done = () => { bar.remove(); document.removeEventListener('click', on, true); document.removeEventListener('keydown', onKey, true); };
    const on = (e) => {
      if (e.composedPath().includes(host)) return;
      e.preventDefault(); e.stopPropagation();
      const el = key === 'rows' ? rowContainer(e.target, 3) : e.target;
      S.sel[key] = cssPath(el); done(); save(); tick();
    };
    const onKey = (e) => { if (e.key === 'Escape') done(); };
    document.addEventListener('click', on, true);
    document.addEventListener('keydown', onKey, true);
  }
  async function copyText(s) {
    try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(s); return true; } } catch (e) {}
    try {
      const ta = document.createElement('textarea');
      ta.value = s; ta.style.cssText = 'position:fixed;top:-1000px;opacity:0';
      document.documentElement.appendChild(ta); ta.focus(); ta.select();
      const ok = document.execCommand('copy');
      ta.remove();
      return !!ok;
    } catch (e) { return false; }
  }
  const brief = (el) => (el ? { path: cssPath(el), text: ft(el).slice(0, 120) } : null);
  async function diag() {
    const s = JSON.stringify({ v: VERSION, scanErr, sel: S.sel, trainerLevel: fin(lvlNow),
      detected: {
        trainer: D.trainer ? Object.assign(brief(D.trainer.el), { level: D.trainer.level }) : null,
        team: D.team ? { container: brief(D.team.el), members: D.team.members.map((m) => ({ text: ft(m.el).slice(0, 100), parsed: m.mon })) } : null,
        active: D.active || null,
        xp: (D.xp || []).map((x) => Object.assign(brief(x.el), { kind: x.kind })),
        hunt: D.hunt || null,
        rows: D.rows ? { container: brief(D.rows.el), mode: D.rows.mode, count: D.rows.n, sample: [...D.rows.el.children].slice(0, 5).map((c) => ft(c).slice(0, 140)) } : null },
      activeKey, activeBy, selMon: S.selMon, parsedRows: ((S.mons[S.selMon] || {}).rows || []).slice(0, 6),
      stats: (S.mons[S.selMon] || {}).stats, log: S.log.slice(-10) }, null, 1);
    diagText = '';
    if (await copyText(s)) msg = 'Diagnóstico copiado: cole na conversa com o Claude.';
    else { msg = ''; diagText = s; }
    render();
  }
 
  // ---------- painel (HUD) ----------
  const host = document.createElement('div');
  host.id = 'phc-host';
  const root = host.attachShadow({ mode: 'open' });
  root.innerHTML = '<style>' +
    ':host{all:initial}*{box-sizing:border-box}#p{position:fixed;left:12px;bottom:12px;z-index:2147483646;width:340px;max-height:82vh;overflow:auto;background:#1b1118;color:#f4eaf0;font:12px/1.45 system-ui;border:1px solid #4a3040;border-radius:12px;box-shadow:0 6px 24px #000a}' +
    '.hd{display:flex;align-items:center;gap:6px;padding:7px 10px;background:linear-gradient(90deg,#ffcb05,#f59e0b);color:#2b1a00;border-radius:11px 11px 0 0;cursor:pointer}.hd b{font-size:13px;color:#2b1a00}.hd .ver{opacity:.7;font-size:10px}.hd .car{margin-left:auto}' +
    '.bd{padding:8px 10px}label{display:block;color:#b9a3b0;font-size:10px;text-transform:uppercase;letter-spacing:.04em;margin:4px 0 2px}' +
    '.team{display:grid;grid-template-columns:1fr 1fr;gap:5px}.mon{background:#2a1a24;border:1px solid #4a3040;border-radius:8px;padding:5px 7px;cursor:pointer;position:relative}.mon:hover{border-color:#8a5a70}.mon.sel{border-color:#ffcb05;background:#35212c}' +
    '.mn{font-weight:700;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:16px}.ms{font-size:10.5px;color:#d8c6cf}.mr{font-size:10.5px;color:#ffcb05;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
    '.dot{position:absolute;right:6px;top:4px;font-size:12px;color:#7be495}.dot.off{color:#6b4a5c}' +
    '.chips{margin:5px 0;display:flex;flex-wrap:wrap;gap:4px}.chip{background:#2a1a24;border:1px solid #4a3040;border-radius:999px;padding:1px 7px;font-size:11px}.chip.warn{border-color:#f59e0b;color:#ffd78a}.chip.okc{border-color:#2f6b45;color:#9be8b3}.chip.no{border-color:#7a3a3a;color:#ff9c9c}' +
    '.fn{font-size:15px;font-weight:700;color:#fff}.vt{font-size:13px}.v-otimo{border-color:#2f8f57}.v-bom{border-color:#7bbf4f}.v-regular{border-color:#f59e0b}.v-ruim{border-color:#d9534f}.t-otimo{color:#7be495}.t-bom{color:#b6e66f}.t-regular{color:#ffd166}.t-ruim{color:#ff8a8a}' +
    '.card{background:#2a1a24;border:1px solid #4a3040;border-radius:8px;padding:7px 9px;margin:6px 0}.card.best{border-color:#ffcb05}.big{font-size:14px;font-weight:700;color:#fff}.sm{font-size:11px;color:#b9a3b0}' +
    'table{width:100%;border-collapse:collapse;margin:4px 0}td,th{padding:2px 3px;text-align:right}td:first-child,th:first-child{text-align:left}th{color:#b9a3b0;font-weight:500;font-size:10px}' +
    'button{background:#3a2431;color:#f4eaf0;border:1px solid #6b4a5c;border-radius:6px;padding:3px 7px;margin:2px 3px 0 0;font:11px system-ui;cursor:pointer}button:hover{background:#4a2f40}' +
    '.ok{color:#7be495}.bad{color:#ff8a8a}.dim{color:#b9a3b0}b{color:#fff}hr{border:0;border-top:1px solid #3a2431;margin:6px 0}' +
    '</style><div id="p"></div>';
  const panel = root.getElementById('p');
  root.addEventListener('click', (e) => {
    const a = e.target.closest('[data-a]');
    if (!a) return;
    const k = a.dataset.a;
    if (k === 'toggle') { S.ui.collapsed = !S.ui.collapsed; save(); render(); }
    else if (k === 'cfg') { S.ui.cfg = !S.ui.cfg; save(); render(); }
    else if (k.startsWith('xpk:')) { S.ui.xpKind = k.slice(4); save(); tick(); }
    else if (k.startsWith('pick:')) pick(k.slice(5));
    else if (k === 'auto') { S.sel = {}; save(); tick(); }
    else if (k === 'diag') diag();
    else if (k === 'reset') {
      if (resetArmed) { resetArmed = false; Object.values(S.mons).forEach((m) => { m.stats = {}; }); S.log = []; save(); render(); }
      else { resetArmed = true; render(); setTimeout(() => { resetArmed = false; render(); }, 5000); }
    }
    else if (k === 'sel') a.select();
    else if (k === 'close') { diagText = ''; render(); }
  });
 
  function render() {
    let h = '<div class="hd" data-a="toggle"><b>Hunt Coach</b><span class="ver">v' + VERSION + '</span><span class="car">' + (S.ui.collapsed ? '▲' : '▼') + '</span></div>';
    if (S.ui.collapsed) { panel.innerHTML = h; return; }
    h += '<div class="bd">';
    const mon = S.mons[activeKey];
    const live = session && (Date.now() - session.t0) / 6e4 >= 1 ? session.gained / ((Date.now() - session.t0) / H) : NaN;
    const mins = session ? (Date.now() - session.t0) / 6e4 : 0;
    const manual = (k) => !!S.sel[k];
    const found = { level: manual('level') ? !!txt(S.sel.level) : !!D.trainer, active: !!mon && activeBy !== 'último visto', xp: !!xpNow, hunt: !!huntNow, rows: manual('rows') ? !!$(S.sel.rows) : !!D.rows };
 
    // 1) pokémon em campo — aparece sozinho
    h += '<label>Pokémon em campo</label>';
    if (!mon) h += '<div class="card"><div class="sm">Ainda não consegui ver qual pokémon está em campo. Se ele aparece na tela, clique em <b>Diagnóstico</b> e cole o resultado na conversa que eu ajusto.</div></div>';
    else h += '<div class="card"><div class="fn">' + (mon.shiny ? '✨ ' : '') + esc(mon.name) + '</div><div class="sm">Nv ' + (mon.level != null ? mon.level : '?') + (mon.power != null ? ' · Poder P' + mon.power : '') + (mon.iv != null ? ' · IV ' + mon.iv : '') + '</div></div>';
    h += '<div class="chips"><span class="chip ' + (Number.isFinite(lvlNow) ? 'okc' : 'no') + '">Treinador Nv ' + (Number.isFinite(lvlNow) ? lvlNow : '?') + '</span>' +
      (xpNow ? '<span class="chip okc">XP ' + fmt(xpNow.cur) + (xpNow.max > 0 ? ' / ' + fmt(xpNow.max) : '') + (D.xp && D.xp.some((x) => x.kind === 'trainer') && D.xp.some((x) => x.kind === 'mon') ? ' (' + (xpKind === 'trainer' ? 'treinador' : 'pokémon') + ')' : '') + '</span>' : '<span class="chip no">XP não lido</span>') +
      (huntNow ? '<span class="chip okc">Hunt: ' + esc(huntNow) + '</span>' : '<span class="chip no">hunt atual não lida</span>') + '</div>';
    if (D.xp && D.xp.some((x) => x.kind === 'trainer') && D.xp.some((x) => x.kind === 'mon')) h += '<div>Medir XP do: <button data-a="xpk:mon">' + (xpKind === 'mon' ? '● ' : '') + 'Pokémon</button><button data-a="xpk:trainer">' + (xpKind === 'trainer' ? '● ' : '') + 'Treinador</button></div>';
 
    // 2) XP/h está bom ou ruim? + sugestão de hunt
    const ranked = mon ? E.rank(mon.rows || [], mon.stats, lvlNow) : [];
    const a = E.audit(ranked, huntNow, mins >= 5 ? live : NaN);
    if (mon && !(mon.rows || []).length) h += '<div class="card"><div class="sm">Para sugerir a hunt de <b>' + esc(mon.name) + '</b> preciso dos números do <b>Hunt Analyser</b>. Abra o painel dele uma vez: eu leio sozinho e guardo.</div></div>';
    else if (mon && !a.best) h += '<div class="card"><div class="sm">Nenhuma hunt liberada para o seu nível de treinador na lista lida.</div></div>';
    else if (mon) {
      const ageMin = Math.round((Date.now() - mon.rowsT) / 6e4), b = a.best;
      if (mon.lvlAt !== mon.level) h += '<div class="chips"><span class="chip warn">⚠ números de quando ele era Nv ' + (mon.lvlAt != null ? mon.lvlAt : '?') + ': abra o Analyser de novo</span></div>';
      else if (ageMin > 60) h += '<div class="chips"><span class="chip warn">números de ' + (ageMin >= 120 ? Math.round(ageMin / 60) + ' h' : ageMin + ' min') + ' atrás</span></div>';
      const rt = a.verdict === 'ok' || a.verdict === 'melhor-existe' ? E.rate(ranked, a.curRate) : null;
      if (rt) {
        const cls = { 'ÓTIMO': 'otimo', 'BOM': 'bom', 'REGULAR': 'regular', 'RUIM': 'ruim' }[rt.label];
        const measured = mins >= 5 && Number.isFinite(live);
        h += '<div class="card v-' + cls + '"><div class="vt">XP por hora: <b class="t-' + cls + '">' + rt.label + '</b></div><div class="sm">' + fmt(a.curRate) + ' XP/h ' + (measured ? '(medido há ' + Math.round(mins) + ' min)' : '(estimado pelo Analyser)') +
          ' · ' + Math.round(rt.ratio * 100) + '% da melhor hunt · melhor que ' + Math.round(rt.pct * 100) + '% das hunts liberadas</div></div>';
      } else if (huntNow) h += '<div class="card"><div class="sm">A hunt atual (' + esc(huntNow) + ') não está na lista lida do Analyser, então não consigo dizer se o XP/h está bom.</div></div>';
      else h += '<div class="card"><div class="sm">Não consegui ler a hunt atual para avaliar o XP/h dela.</div></div>';
      const better = a.verdict === 'melhor-existe';
      h += '<div class="card best"><div class="sm">SUGESTÃO DE HUNT</div><div class="big">🥇 ' + esc(b.name) + '</div><div class="sm">' + fmt(b.score) + ' XP/h ajustado · Analyser ' + fmt(b.xp) + ' · ' + (b.n ? b.n + ' medição(ões)' : 'nunca medida') + '</div>' +
        (better ? '<div class="bad">Troque para ela: rende ~+' + Math.round(a.gap * 100) + '% sobre a atual.</div>' : a.verdict === 'ok' ? '<div class="ok">Você já está numa das melhores: pode ficar.</div>' : '') +
        (xpNow && xpNow.max > 0 ? '<div class="sm">Próximo nível em ~' + dur((xpNow.max - xpNow.cur) / b.score) + ' nessa hunt</div>' : '') + '</div>';
      h += a.analyserDisagrees ? '<div>O Analyser aponta <b>' + esc(a.gameTop.name) + '</b>, mas as suas medições mudaram o ranking.</div>' : '';
      const medals = ['🥇', '🥈', '🥉', '4', '5'];
      h += '<table><tr><th>#</th><th style="text-align:left">Hunt</th><th>Ajust.</th><th>Analyser</th><th>n</th></tr>' + ranked.slice(0, 5).map((r, i) => '<tr><td>' + medals[i] + '</td><td style="text-align:left">' + esc(r.name) + (r.slow ? ' ⚠' : '') + '</td><td>' + fmt(r.score) + '</td><td>' + fmt(r.xp) + '</td><td>' + (r.n || '–') + '</td></tr>').join('') + '</table>';
      if (ranked.some((r) => r.slow)) h += '<div class="dim">⚠ = rendeu bem menos que o previsto nas suas medições (mortes ou muita espera).</div>';
    }
    if (session) h += '<div class="dim">Medindo ' + esc((S.mons[session.key] || {}).name || '') + ' em ' + esc(session.hunt) + ': ' + Math.round(mins) + ' min' + (mins < 5 ? ' (precisa de 5 para valer)' : '') + (Number.isFinite(live) ? ' · ≈ ' + fmt(live) + ' XP/h' : '') + '</div>';
 
    // 3) rodapé: o que foi lido; marcação manual só se algo ficar ✗
    h += '<hr><div class="chips">' + Object.keys(SHORT).map((k) => '<span class="chip ' + (found[k] ? 'okc' : 'no') + '">' + (found[k] ? '✓ ' : '✗ ') + SHORT[k] + (manual(k) ? ' (manual)' : '') + '</span>').join('') + '</div>';
    if (S.ui.cfg || ['level', 'active', 'xp'].some((k) => !found[k])) {
      h += '<div class="dim">Só marque à mão o que ficou ✗ (botão e depois clique no item no jogo). "Analyser" e "Hunt" aparecem sozinhos quando você abre o Hunt Analyser.</div>' +
        Object.keys(LABELS).filter((k) => S.ui.cfg || (!found[k] && k !== 'rows' && k !== 'hunt')).map((k) => '<button data-a="pick:' + k + '">Marcar ' + SHORT[k] + '</button>').join('') + (Object.keys(S.sel).length ? '<button data-a="auto">Voltar ao automático</button>' : '');
    }
    if (!storageOk) h += '<div class="bad">Não consegui salvar o aprendizado neste navegador.</div>';
    if (scanErr) h += '<div class="bad">Erro na leitura da tela: ' + esc(scanErr) + '</div>';
    if (msg) h += '<div class="ok">' + esc(msg) + '</div>';
    if (diagText) h += '<textarea data-a="sel" readonly rows="6" style="width:100%;font:10px monospace">' + esc(diagText) + '</textarea><div class="dim">Não consegui copiar sozinho: clique na caixa, use Ctrl+A, Ctrl+C e cole na conversa.</div><button data-a="close">Fechar</button>';
    h += '<div><button data-a="cfg">⚙ ' + (S.ui.cfg ? 'Fechar config' : 'Config') + '</button><button data-a="diag">Diagnóstico</button><button data-a="reset">' + (resetArmed ? 'Clique de novo para confirmar' : 'Zerar aprendizado') + '</button></div>';
    panel.innerHTML = h + '</div>';
  }
 
  const saved = load();
  if (saved) {
    S = Object.assign(S, saved);
    if (S.v !== VERSION) { // atualização: volta ao automático e descarta "pokémon" falsos salvos por versões antigas
      S.v = VERSION; S.sel = {}; delete S.activeOverride; S.team = []; S.selMon = ''; S.activeLast = '';
      Object.keys(S.mons || {}).forEach((k) => { if (!(E.isName(S.mons[k].name) || /^iv\d+$/.test(k))) delete S.mons[k]; });
    }
  }
  document.documentElement.appendChild(host);
  try { if (localStorage.getItem('phc_debug')) window.__phc = { tick, get D() { return D; }, get S() { return S; } }; } catch (e) {}
  tick();
  setInterval(tick, 10e3);
  window.addEventListener('pagehide', commit);
})();