azzo / PokeIdle Market Auto-Buy

// ==UserScript==
// @name         PokeIdle Market Auto-Buy
// @name:pt-BR   PokeIdle Market Auto-Buy
// @namespace    https://openuserjs.org/users/azzo
// @version      0.2.0
// @description  Opens the pokeidle.io Community Market by itself, keeps searching all the time and buys listings that match your rules (pokémon or item, name, level, power, IV, shiny, quality, min/max price, gold or gems). Starts in simulation mode, with daily spending limits and a re-check before every click.
// @description:pt-BR  Abre sozinho o Mercado da Comunidade do pokeidle.io, pesquisa o tempo todo e compra as listagens que batem com as suas regras (pokémon ou item, nome, nível, poder, IV, shiny, qualidade, preço mínimo e máximo, ouro ou gemas). Começa em modo simulação, com limites diários de gasto e conferência antes de cada clique.
// @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 do Market Auto-Buy: funções puras (sem DOM), testáveis em Node.
  const E = (function () {
    const flat = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
    const strip = (s) => String(s == null ? '' : s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();

    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);
    }

    // Qualquer sinal de dinheiro real / loja / carteira: a linha é ignorada por segurança.
    const DENY = /\$|r\$|us\$|\busd|usdt|\bpix\b|cart[aã]o|cr[eé]dito|d[eé]bito|credit|diamantes?|diamonds?|pacote|\bpack\b|\bloja\b|\bshop\b|recarga|\bsaque\b|withdraw|dep[oó]sito|deposit|solana|wallet|carteira/i;
    const UI = /^(comprar|buy|purchase|vender|sell|cancelar|cancel|ver|detalhes|favoritar|vendedor|seller|pre[cç]o|price|gemas?|gems?|ouro|gold|coins?|moedas?|nv|lv|iv|xp|hp|qtd|quantidade|shiny|em campo|equipe|total|unidade|item|pok[eé]mon)$/i;
    const NUM = '(\\d[\\d.,]*\\s?[kmbt]?)';
    const CURW = '(gemas?|gems?|💎|ouro|gold|coins?|moedas?|🪙)';
    const curOf = (t) => (/gem|💎/i.test(t) ? 'gems' : 'gold');

    // Texto de uma linha do mercado -> {name, kind, level, power, iv, shiny, quality, qty, price, currency, seller, ambiguous}
    function parseListing(text) {
      const raw = String(text == null ? '' : text);
      const lines = raw.split(/\n+/).map((l) => l.trim()).filter(Boolean);
      const f = flat(raw);
      const L = { raw: strip(f), name: '', kind: 'item', level: NaN, power: NaN, iv: NaN, shiny: false, quality: NaN, qty: 1, price: NaN, currency: '', seller: '', ambiguous: '' };
      const g = (re) => { const m = f.match(re); return m ? parseFloat(String(m[1]).replace(',', '.')) : NaN; };
      L.level = g(/(?:nv|lv|n[ií]vel)\.?\s*(\d{1,4})\b/i);
      L.power = g(/\bP\s?([1-5])\b/);
      L.iv = g(/\bIV\s*(\d+)/i);
      L.quality = g(/(?:qualidade|quality)\D{0,8}(\d+(?:[.,]\d+)?)/i);
      const q = f.match(/(?:^|\s)[x×]\s?(\d{1,4})\b/i);
      if (q) L.qty = +q[1];
      L.kind = Number.isFinite(L.level) ? 'pokemon' : 'item';
      L.shiny = L.kind === 'pokemon' && /shiny|✨|★|brilhante/i.test(f);
      const sl = lines.map((l) => l.match(/^(?:vendedor|seller|por|by)\b\s*:?\s*(.{2,32})$/i)).find(Boolean);
      if (sl) L.seller = sl[1].trim();
      // preço: "1.500 Gemas" ou "Ouro: 2,5M"; mais de um valor diferente = ambíguo
      const seen = new Map();
      const add = (v, c) => seen.set(v + '|' + c, { v, c });
      const reNC = new RegExp(NUM + '\\s*' + CURW + '(?![\\p{L}])', 'giu'), reCN = new RegExp('(?<![\\p{L}])' + CURW + '\\s*[:=]?\\s*' + NUM, 'giu');
      lines.forEach((ln, i) => { // por linha: o número de um campo não gruda na moeda de outro
        for (const m of ln.matchAll(reNC)) add(parseNum(m[1]), curOf(m[2]));
        for (const m of ln.matchAll(reCN)) add(parseNum(m[2]), curOf(m[1]));
        const nx = lines[i + 1];
        if (nx) { // número numa linha e a moeda na seguinte (ou o contrário)
          if (new RegExp('^' + NUM + '$', 'i').test(ln) && new RegExp('^' + CURW + '[::]?$', 'iu').test(nx)) add(parseNum(ln), curOf(nx));
          if (new RegExp('^' + CURW + '[::]?$', 'iu').test(ln) && new RegExp('^' + NUM + '$', 'i').test(nx)) add(parseNum(nx), curOf(ln));
        }
      });
      if (DENY.test(f)) L.ambiguous = 'termo de pagamento/loja na linha (ignorada por segurança)';
      else if (seen.size === 0) L.ambiguous = 'sem preço em ouro/gemas';
      else if (seen.size > 1) L.ambiguous = 'mais de um preço na linha';
      else if (/gemas?|gems?|💎/i.test(f) && /ouro|gold|coins?|moedas?|🪙/i.test(f)) L.ambiguous = 'duas moedas na mesma linha';
      else { const p = [...seen.values()][0]; if (p.v > 0) { L.price = p.v; L.currency = p.c; } else L.ambiguous = 'preço inválido'; }
      // nome: primeira linha que parece nome (corta em "Nv", "P2", "IV", números)
      for (const line of lines) {
        if (new RegExp(CURW, 'iu').test(line) && /\d/.test(line)) continue;
        if (/^(?:vendedor|seller|por|by)\b/i.test(line)) continue;
        let c = line.split(/\s+(?:nv\b|lv\b|n[ií]vel\b|p\s?[1-5]\b|iv\b|[x×]\s?\d|\d)/i)[0].replace(/[::]+$/, '').trim();
        if (L.kind === 'pokemon') c = c.replace(/shiny|✨|★|brilhante/gi, '').trim();
        if (c.length < 3 || c.length > 32 || !/^[\p{L}][\p{L}\p{N}'’.\- ♀♂]*$/u.test(c) || UI.test(c) || (c.match(/\p{L}/gu) || []).length < 3) continue;
        L.name = c; break;
      }
      if (!L.ambiguous && !L.name) L.ambiguous = 'sem nome legível na linha';
      return L;
    }

    const terms = (s) => String(s == null ? '' : s).split(',').map((t) => strip(t).trim()).filter(Boolean);
    const no = (why) => ({ ok: false, why });

    // A linha atende a regra? (qualquer dado desconhecido num filtro "mínimo" reprova: na dúvida, não compra)
    function matchRule(r, L) {
      if (!r.on) return no('regra desligada');
      if (L.ambiguous) return no(L.ambiguous);
      if (r.kind === 'pokemon' && L.kind !== 'pokemon') return no('não é pokémon');
      if (r.kind === 'item' && L.kind !== 'item') return no('não é item');
      const names = terms(r.names);
      const nm = strip(L.name);
      if (names.length && !names.some((t) => nm.includes(t))) return no('nome não confere');
      if (L.currency !== r.currency) return no('moeda diferente');
      if (!(r.max > 0)) return no('regra sem preço máximo');
      if (L.price > r.max) return no('acima do preço máximo');
      if (r.min > 0 && L.price < r.min) return no('abaixo do preço mínimo');
      if (L.kind === 'pokemon') {
        if (r.lvMin > 0 && !(L.level >= r.lvMin)) return no('nível abaixo do mínimo');
        if (r.lvMax > 0 && !(L.level <= r.lvMax)) return no('nível acima do máximo');
        if (r.powerMin > 0 && !(L.power >= r.powerMin)) return no('poder abaixo do mínimo ou desconhecido');
        if (r.ivMin > 0 && !(L.iv >= r.ivMin)) return no('IV abaixo do mínimo ou desconhecido');
        if (r.qualMin > 0 && !(L.quality >= r.qualMin)) return no('qualidade abaixo do mínimo ou desconhecida');
        if (r.shiny === 'only' && !L.shiny) return no('não é shiny');
        if (r.shiny === 'no' && L.shiny) return no('é shiny');
      }
      const must = terms(r.must), forbid = terms(r.forbid);
      if (must.some((t) => !L.raw.includes(t))) return no('falta texto obrigatório');
      if (forbid.some((t) => L.raw.includes(t))) return no('tem texto proibido');
      return { ok: true, why: 'ok' };
    }

    // Limites de segurança (por dia). Limite 0 = não compra naquela moeda.
    function checkLimits(cfg, day, L, r) {
      const cap = L.currency === 'gems' ? cfg.capGems : cfg.capGold;
      if (!(cap > 0)) return no('limite diário de ' + (L.currency === 'gems' ? 'gemas' : 'ouro') + ' está em 0');
      if ((day[L.currency] || 0) + L.price > cap) return no('estouraria o limite diário');
      if (cfg.capBuysDay > 0 && (day.buys || 0) >= cfg.capBuysDay) return no('limite de compras do dia atingido');
      if (r.maxBuys > 0 && (r.bought || 0) >= r.maxBuys) return no('limite de compras da regra atingido');
      const blocked = terms(cfg.blocked);
      if (blocked.some((t) => L.raw.includes(t))) return no('vendedor/texto bloqueado');
      return { ok: true, why: 'ok' };
    }

    // O diálogo de confirmação bate com a listagem? (preço e moeda iguais, sem termos de pagamento)
    function dialogMatches(text, L) {
      const f = flat(text);
      if (DENY.test(f)) return false;
      const nums = [...f.matchAll(new RegExp(NUM, 'g'))].map((m) => parseNum(m[1]));
      if (!nums.some((n) => Math.abs(n - L.price) < 1e-6)) return false;
      const wantsGems = L.currency === 'gems';
      if (wantsGems && !/gem|💎/i.test(f)) return false;
      if (!wantsGems && !/ouro|gold|coin|moeda|🪙/i.test(f)) return false;
      return !L.name || strip(f).includes(strip(L.name));
    }

    const api = { parseNum, parseListing, matchRule, checkLimits, dialogMatches, DENY, strip, terms };
    return api;
  })();

  const VERSION = '0.2.0', KEY = 'pmab_v1';
  const BUYTXT = /^(comprar|buy|purchase)(?:\s+(?:agora|now))?$/i;
  const CONFIRM = /^(confirmar(?: compra)?|confirm|sim|yes|ok|finalizar)$/i;
  const CANCEL = /^(cancelar|cancel|n[aã]o|no|voltar|fechar|close|×|x)$/i;
  const NAV = /^[^\p{L}]*(?:mercado(?: da comunidade)?|community market|market)[^\p{L}]*$/iu;
  const SEARCHW = /^[^\p{L}]*(?:buscar|pesquisar|procurar|search|filtrar|aplicar(?: filtros?)?|🔍)[^\p{L}]*$/iu;
  const REFRESHW = /^[^\p{L}]*(?:atualizar|refresh|recarregar|reload|🔄|↻|⟳)[^\p{L}]*$/iu;
  const DEFAULT_CFG = { sim: true, autoConfirm: false, autoOpen: true, autoSearch: true, searchSec: 5, searchByName: false, minGapSec: 4, maxPerMin: 3, capGems: 0, capGold: 0, capBuysDay: 5, blocked: '', beep: true };
  const MKT = /mercado|market|comunidade|community|rmt/i;
  const today = () => { const d = new Date(Date.now()); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
  const newRule = () => ({ on: true, label: 'Nova regra', kind: 'any', names: '', currency: 'gems', min: 0, max: 0, lvMin: 0, lvMax: 0, powerMin: 0, ivMin: 0, qualMin: 0, shiny: 'any', must: '', forbid: '', maxBuys: 0, bought: 0, open: true });
  let S = { v: VERSION, rules: [newRule()], cfg: Object.assign({}, DEFAULT_CFG), day: { date: today(), gems: 0, gold: 0, buys: 0 }, log: [], ui: { tab: 'rules', collapsed: false } };
  // estado só desta aba: NUNCA persistido (depois de recarregar a página, volta sempre parado e em simulação)
  const SES = { armed: false, ack: false, busy: false, looping: false, lastBuy: 0, buyTimes: [], rows: [], seen: new WeakMap(), simSeen: new WeakMap(), cool: new WeakMap(),
    panel: null, lastOpen: 0, openFails: 0, navWarned: false, lastSearch: 0, searches: 0, searchWarned: false, nameIdx: 0, obs: null, obsEl: null, qt: 0, lastLoopAt: 0, loops: 0 };
  let TX = new Map(), storageOk = true, msg = '', diagText = '';

  // ---------- util ----------
  const flat = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
  const fmt = (n) => !Number.isFinite(n) ? '–' : n >= 1e9 ? (n / 1e9).toFixed(2) + 'B' : n >= 1e6 ? (n / 1e6).toFixed(2) + 'M' : n >= 1e4 ? (n / 1e3).toFixed(1) + 'k' : String(Math.round(n * 100) / 100);
  const load = () => { try { return JSON.parse(localStorage.getItem(KEY)); } catch (e) { return null; } };
  const save = () => { try { S.log = S.log.slice(-120); localStorage.setItem(KEY, JSON.stringify(S)); storageOk = true; } catch (e) { storageOk = false; } };
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  async function waitFor(fn, ms) { const t0 = Date.now(); for (;;) { const v = fn(); if (v) return v; if (Date.now() - t0 > ms) return null; await sleep(100); } }
  const SKIP = /^(SCRIPT|STYLE|NOSCRIPT|SVG|PATH|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 vis = (el) => el.getClientRects().length > 0;
  const disabled = (b) => b.disabled || b.getAttribute('aria-disabled') === 'true' || /\bdisabled\b/.test(b.getAttribute('class') || '');
  function addLog(kind, text) { S.log.push({ t: Date.now(), kind, text }); save(); }
  function beep() {
    if (!S.cfg.beep) return;
    try { const A = window.AudioContext || window.webkitAudioContext, c = new A(), o = c.createOscillator(), g = c.createGain(); o.connect(g); g.connect(c.destination); o.frequency.value = 880; g.gain.value = 0.06; o.start(); o.stop(c.currentTime + 0.18); } catch (e) {}
  }
  function rollDay() { if (S.day.date !== today()) S.day = { date: today(), gems: 0, gold: 0, buys: 0 }; }
  const cname = (c) => (c === 'gems' ? 'gemas' : 'ouro');
  const desc = (L) => (L.name || '?') + (L.kind === 'pokemon' ? ' Nv ' + L.level + (Number.isFinite(L.power) ? ' P' + L.power : '') + (Number.isFinite(L.iv) ? ' IV ' + L.iv : '') + (L.shiny ? ' ✨' : '') : '') + ' por ' + fmt(L.price) + ' ' + cname(L.currency);

  // ---------- leitura do mercado (sem seletores: ancora no botão "Comprar") ----------
  function buyButtons() {
    return [...document.querySelectorAll('button,[role="button"],a,input[type="button"],input[type="submit"],[class*="btn" i],[class*="button" i]')].filter((b) => {
      if (host.contains(b)) return false;
      const t = flat(b.tagName === 'INPUT' ? b.value : leafText(b));
      return t.length <= 22 && BUYTXT.test(t) && vis(b);
    });
  }
  const SHOPW = /loja|shop|store|diamante|diamond|pacote|\bpack\b|recarga|\bpix\b/i;
  // A linha está numa tela de mercado? Só contam os TÍTULOS acima dela (nunca o texto da própria linha);
  // o título mais próximo decide: "Mercado/Market" libera, "Loja/Diamantes/Pacote" bloqueia, ambos ou nenhum = não compra.
  function inMarket(el, btns) {
    for (let c = el, p = el.parentElement; p && c !== document.body; c = p, p = p.parentElement) {
      const heads = [];
      for (const sb of p.children) { if (sb === c) break; if (!btns.some((b) => sb.contains(b))) heads.push(flat(tx(sb)).slice(0, 80)); }
      for (const h of heads.reverse()) { const m = MKT.test(h), sh = SHOPW.test(h); if (m && !sh) return true; if (sh && !m) return false; }
    }
    return false;
  }
  function findRows() {
    const btns = buyButtons(), rows = [], single = btns.length === 1; // com uma só listagem não dá para comparar irmãos
    SES.btns = btns;
    for (const b of btns) {
      let best = null;
      for (let p = b.parentElement, i = 0; p && i < 8 && p !== document.body; p = p.parentElement, i++) {
        if (btns.filter((x) => p.contains(x)).length !== 1 || flat(tx(p)).length > 260) break;
        // sozinha na tela: a linha termina antes de entrar o resto do painel (campos, títulos, outros botões)
        if (single && [...p.querySelectorAll('input,select,textarea,h1,h2,h3,h4,h5,h6,button,[role="button"],a')].some((x) => x !== b && !b.contains(x) && !x.contains(b) && vis(x) && !host.contains(x))) break;
        best = p;
      }
      if (best && !rows.some((r) => r.el === best)) rows.push({ el: best, btn: b });
    }
    return rows;
  }
  function analyze(r) {
    const L = E.parseListing(tx(r.el));
    r.market = inMarket(r.el, SES.btns);
    if (!L.ambiguous && !r.market) L.ambiguous = 'fora do mercado (a tela não parece o mercado)';
    r.L = L; r.rule = null; r.why = L.ambiguous || 'nenhuma regra serve';
    if (L.ambiguous) return r;
    if (disabled(r.btn)) { r.why = 'botão desativado'; return r; }
    for (const rule of S.rules) {
      const m = E.matchRule(rule, L);
      if (!m.ok) { if (r.why === 'nenhuma regra serve') r.why = '"' + rule.label + '": ' + m.why; continue; }
      const lim = E.checkLimits(S.cfg, S.day, L, rule);
      if (!lim.ok) { r.why = '"' + rule.label + '": ' + lim.why; r.blockedBy = lim.why; continue; }
      r.rule = rule; r.why = 'apta (regra "' + rule.label + '")'; break;
    }
    r.eligible = !!r.rule;
    return r;
  }

  const sigOf = (r) => desc(r.L) + '|' + r.L.seller;
  const isNew = (map, r) => { const k = sigOf(r); if (map.get(r.btn) === k) return false; map.set(r.btn, k); return true; };
  const coolHit = (r) => { const c = SES.cool.get(r.btn); return !!c && c.sig === sigOf(r) && Date.now() - c.t < 20e3; };
  // ---------- compra ----------
  function findDialog(btn, known) {
    const els = [...document.querySelectorAll('[role="dialog"],[role="alertdialog"],[class*="modal" i],[class*="dialog" i],[class*="popup" i],[class*="confirm" i],[class*="overlay" i]')].filter((e) => vis(e) && !host.contains(e) && !e.contains(btn));
    for (const e of els.reverse()) {
      const bs = [...e.querySelectorAll('button,[role="button"],[class*="btn" i],[class*="button" i]')].filter(vis);
      const confirm = bs.find((b) => { const t = flat(leafText(b)); return CONFIRM.test(t) || (BUYTXT.test(t) && !known.has(b)); });
      if (confirm) return { el: e, confirm, cancel: bs.find((b) => CANCEL.test(flat(leafText(b)))) };
    }
    return null;
  }
  function registerPurchase(rule, L) {
    rollDay(); S.day[L.currency] = (S.day[L.currency] || 0) + L.price; S.day.buys += 1; rule.bought = (rule.bought || 0) + 1;
    addLog('COMPRA', desc(L));
  }
  async function doBuy(c) {
    TX = new Map();
    if (!c.el.isConnected || !vis(c.btn) || disabled(c.btn)) return addLog('CANCELADO', 'o botão sumiu ou foi desativado');
    const L2 = E.parseListing(tx(c.el));
    const same = L2.price === c.L.price && L2.currency === c.L.currency && L2.name === c.L.name;
    const m = E.matchRule(c.rule, L2), lim = E.checkLimits(S.cfg, S.day, L2, c.rule);
    if (!same || !m.ok || !lim.ok) return addLog('CANCELADO', 'a linha mudou antes do clique (' + (!same ? 'preço/nome diferente' : m.ok ? lim.why : m.why) + ')');
    addLog('COMPRANDO', desc(L2));
    SES.cool.set(c.btn, { t: Date.now(), sig: sigOf(c) });
    const known = new Set(buyButtons());
    c.btn.click();
    SES.buyTimes.push(Date.now());
    const dlg = await waitFor(() => findDialog(c.btn, known), 2500);
    if (dlg) {
      const dtext = flat(leafText(dlg.el));
      if (!E.dialogMatches(dtext, L2)) {
        if (dlg.cancel) dlg.cancel.click(); else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
        return addLog('CANCELADO', 'o diálogo de confirmação não bate com a listagem: "' + dtext.slice(0, 90) + '"');
      }
      if (!S.cfg.autoConfirm) { beep(); return addLog('AGUARDANDO VOCÊ', 'confirme no jogo: ' + desc(L2)); }
      dlg.confirm.click();
      await sleep(900);
    } else await sleep(1200); // sem diálogo: o clique pode ter comprado direto (conta como gasto, por segurança)
    registerPurchase(c.rule, L2);
  }
  async function attempt(c) {
    const now = Date.now();
    if (now - SES.lastBuy < S.cfg.minGapSec * 1000) return;
    SES.buyTimes = SES.buyTimes.filter((t) => now - t < 60e3);
    if (SES.buyTimes.length >= S.cfg.maxPerMin) return;
    if (S.cfg.sim) { if (isNew(SES.simSeen, c)) { addLog('SIMULAÇÃO', 'compraria ' + desc(c.L) + ' (regra "' + c.rule.label + '")'); } return; }
    if (!SES.ack) return;
    SES.busy = true;
    try { await doBuy(c); } catch (e) { addLog('ERRO', String(e && e.message || e)); SES.armed = false; renderCtl(); }
    finally { SES.busy = false; SES.lastBuy = Date.now(); }
  }
  // ---------- navegação automática: abrir o mercado e pesquisar sozinho ----------
  const label = (b) => flat(b.tagName === 'INPUT' ? b.value : leafText(b)) || (b.getAttribute('aria-label') || b.getAttribute('title') || '').trim();
  const clickables = (root) => [...root.querySelectorAll('button,[role="button"],a,input[type="button"],input[type="submit"],[class*="btn" i],[class*="button" i]')].filter((b) => vis(b) && !host.contains(b) && !disabled(b));
  // botão de pesquisa do painel: "Buscar/Pesquisar/…" tem prioridade sobre "Atualizar"; se houver mais de um candidato, não clica
  function findSearchBtn(panel) {
    const bs = clickables(panel).filter((b) => !BUYTXT.test(label(b)));
    for (const re of [SEARCHW, REFRESHW]) { const f = bs.filter((b) => re.test(label(b))); if (f.length === 1) return { btn: f[0], n: 1 }; if (f.length > 1) return { btn: null, n: f.length }; }
    return { btn: null, n: 0 };
  }
  function findInput(panel) {
    const ins = [...panel.querySelectorAll('input[type="text"],input[type="search"],input:not([type]),textarea')].filter((x) => vis(x) && !host.contains(x) && !x.disabled && !x.readOnly);
    if (ins.length === 1) return ins[0];
    const hint = ins.filter((x) => /buscar|pesquisar|procurar|search|nome|name|filtro|filter/i.test((x.placeholder || '') + ' ' + (x.getAttribute('aria-label') || '') + ' ' + (x.name || '')));
    return hint.length === 1 ? hint[0] : null;
  }
  function locatePanel() { // lista vazia: acha o painel pelo título "Mercado" (não pelo item do menu)
    for (const e of document.querySelectorAll('h1,h2,h3,h4,h5,[class*="title" i],[class*="heading" i]')) {
      if (host.contains(e) || !vis(e) || e.children.length > 2 || !NAV.test(flat(e.textContent))) continue;
      for (let p = e.parentElement, i = 0; p && i < 6; p = p.parentElement, i++) if (findSearchBtn(p).n === 1 || findInput(p)) return p;
    }
    return null;
  }
  function ensurePanel(rows) {
    if (SES.panel && SES.panel.isConnected && vis(SES.panel)) return SES.panel;
    const mk = rows.filter((r) => r.market);
    let p = null;
    if (mk.length) { for (let a = mk[0].el.parentElement, i = 0; a && i < 10 && !p; a = a.parentElement, i++) if (findSearchBtn(a).n === 1 || findInput(a)) p = a; if (!p) p = mk[0].el.parentElement; }
    if (!p) p = locatePanel();
    SES.panel = p;
    return p;
  }
  function findNav() { // o item "Mercado" do menu: texto exato, o menor elemento, nunca título nem algo dentro do próprio painel
    const c = [...document.querySelectorAll('button,[role="button"],[role="tab"],a,li,div,span,[class*="tab" i],[class*="menu" i],[class*="nav" i]')].filter((e) =>
      !host.contains(e) && e.children.length <= 3 && e.textContent.length <= 40 && NAV.test(flat(e.textContent)) && vis(e) && !/^H[1-6]$/.test(e.tagName) && !(SES.panel && SES.panel.contains(e)));
    const inner = c.filter((e) => !c.some((o) => o !== e && e.contains(o)));
    const isBtn = (e) => /^(BUTTON|A)$/.test(e.tagName) || e.getAttribute('role') === 'tab' || e.getAttribute('role') === 'button' || getComputedStyle(e).cursor === 'pointer';
    return inner.find(isBtn) || inner[0] || null;
  }
  async function maybeOpen(rows) {
    if (ensurePanel(rows)) { SES.openFails = 0; return true; }
    if (!S.cfg.autoOpen) return false;
    const now = Date.now();
    if (now - SES.lastOpen < 6000 || (SES.openFails >= 3 && now - SES.lastOpen < 60e3)) return false;
    SES.lastOpen = now;
    const nav = findNav();
    SES.openFails++;
    if (!nav) { if (!SES.navWarned) { SES.navWarned = true; addLog('AVISO', 'não achei o item "Mercado" do menu para abrir sozinho: abra o mercado uma vez à mão'); } return false; }
    addLog('ABRINDO', 'cliquei em "' + flat(nav.textContent) + '" no menu');
    nav.click();
    const p = await waitFor(() => { TX = new Map(); return ensurePanel(findRows().map(analyze)); }, 2500);
    if (p) { SES.openFails = 0; SES.navWarned = false; }
    return !!p;
  }
  function setSearchText(panel) { // opcional: digita o nome da regra no campo de busca do mercado (uma regra por vez)
    const inp = findInput(panel);
    if (!inp) return;
    const terms = []; let blank = false;
    S.rules.filter((r) => r.on).forEach((r) => { const t = E.terms(r.names); if (t.length) terms.push(...t); else blank = true; });
    if (blank) terms.push('');
    if (!terms.length) return;
    const v = terms[SES.nameIdx++ % terms.length];
    const setter = Object.getOwnPropertyDescriptor(inp.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype, 'value').set;
    setter.call(inp, v);
    inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true }));
  }
  function maybeSearch(panel) {
    if (!S.cfg.autoSearch || !panel) return;
    const now = Date.now();
    if (now - SES.lastSearch < Math.max(3, S.cfg.searchSec) * 1000) return;
    SES.lastSearch = now;
    if (S.cfg.searchByName) setSearchText(panel);
    const f = findSearchBtn(panel);
    if (f.n === 1) { f.btn.click(); SES.searches++; setTimeout(quick, 700); }
    else if (!SES.searchWarned) { SES.searchWarned = true; addLog('AVISO', f.n ? 'há mais de um botão de pesquisar/atualizar no mercado: não cliquei em nenhum' : 'não achei o botão de pesquisar/atualizar do mercado'); }
  }
  // reage na hora quando a lista do mercado muda (não espera o próximo ciclo)
  function quick() { // nunca descarta o aviso de mudança: se for cedo demais (ou ocupado), reprograma
    if (!SES.armed) return;
    const wait = SES.looping || SES.busy ? 300 : Math.max(0, 300 - (Date.now() - SES.lastLoopAt));
    if (wait > 0) { clearTimeout(SES.qt); SES.qt = setTimeout(quick, wait + 20); return; }
    loop();
  }
  function observe(panel) {
    if (!panel || SES.obsEl === panel) return;
    if (SES.obs) SES.obs.disconnect();
    SES.obsEl = panel;
    SES.obs = new MutationObserver(() => { clearTimeout(SES.qt); SES.qt = setTimeout(quick, 150); });
    SES.obs.observe(panel, { childList: true, subtree: true });
  }
  async function loop() {
    if (SES.busy || SES.looping) return;
    SES.looping = true; SES.lastLoopAt = Date.now(); SES.loops++;
    try {
      TX = new Map(); rollDay();
      let rows = [];
      try { rows = findRows().map(analyze); } catch (e) { if (!SES.errShown) { SES.errShown = true; addLog('ERRO', 'leitura do mercado: ' + e.message); } }
      SES.rows = rows;
      for (const r of rows) if (r.eligible && isNew(SES.seen, r)) { addLog('ENCONTRADO', desc(r.L) + ' (regra "' + r.rule.label + '")'); beep(); }
      if (SES.armed) {
        const cand = rows.filter((r) => r.eligible && !coolHit(r)).sort((a, b) => a.L.price - b.L.price)[0];
        if (cand) await attempt(cand); // comprar vem antes de pesquisar de novo
        const open = await maybeOpen(rows);
        if (open) { maybeSearch(SES.panel); observe(SES.panel); }
      }
    } finally { SES.looping = false; }
    renderLive(); if (S.ui.tab === 'market' || S.ui.tab === 'log') renderTab();
  }

  // ---------- diagnóstico ----------
  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; }
  }
  async function diag() {
    const s = JSON.stringify({ v: VERSION, cfg: S.cfg, rules: S.rules.map((r) => ({ label: r.label, names: r.names, kind: r.kind, currency: r.currency, min: r.min, max: r.max })), day: S.day,
      rows: SES.rows.slice(0, 8).map((r) => ({ text: flat(tx(r.el)).slice(0, 160), parsed: { name: r.L.name, kind: r.L.kind, level: r.L.level, power: r.L.power, iv: r.L.iv, shiny: r.L.shiny, price: r.L.price, currency: r.L.currency, seller: r.L.seller }, status: r.why })),
      nav: { panel: SES.panel ? flat(tx(SES.panel)).slice(0, 100) : null, searchBtn: SES.panel ? (() => { const f = findSearchBtn(SES.panel); return f.n === 1 ? label(f.btn) : f.n + ' candidatos'; })() : null, input: SES.panel ? !!findInput(SES.panel) : null, navItem: (() => { const n = findNav(); return n ? flat(n.textContent) : null; })(), searches: SES.searches, openFails: SES.openFails },
      log: S.log.slice(-12) }, null, 1);
    diagText = '';
    if (await copyText(s)) msg = 'Diagnóstico copiado: cole na conversa com o Claude.'; else diagText = s;
    renderLive(); renderTab();
  }

  // ---------- HUD ----------
  const host = document.createElement('div');
  host.id = 'pmab-host';
  const root = host.attachShadow({ mode: 'open' });
  root.innerHTML = '<style>' +
    ':host{all:initial}*{box-sizing:border-box}#p{position:fixed;right:12px;bottom:12px;z-index:2147483645;width:380px;max-height:86vh;overflow:auto;background:#101a17;color:#e8f3ee;font:12px/1.45 system-ui;border:1px solid #2b4a3f;border-radius:12px;box-shadow:0 6px 24px #000a}' +
    '.hd{display:flex;align-items:center;gap:6px;padding:7px 10px;background:linear-gradient(90deg,#34d399,#10b981);color:#04281c;border-radius:11px 11px 0 0;cursor:pointer}.hd b{font-size:13px;color:#04281c}.hd .ver{opacity:.7;font-size:10px}.hd .car{margin-left:auto}' +
    '.bd{padding:8px 10px}.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0}label.c{display:flex;gap:6px;align-items:flex-start;margin:3px 0}' +
    'button{background:#183229;color:#e8f3ee;border:1px solid #2f6b55;border-radius:6px;padding:3px 8px;font:11px system-ui;cursor:pointer}button:hover{background:#21443a}' +
    'button.big{font-size:13px;font-weight:700;padding:6px 14px}button.go{background:#0f5c3f;border-color:#34d399}button.stop{background:#7a1f1f;border-color:#ff6b6b}' +
    '.badge{display:inline-block;border-radius:999px;padding:1px 9px;font-weight:700;font-size:11px}.b0{background:#26352f;color:#b6c9c0}.b1{background:#3b3a14;color:#ffe27a}.b2{background:#0f5c3f;color:#9bf0c8}' +
    '.card{background:#17261f;border:1px solid #2b4a3f;border-radius:8px;padding:6px 8px;margin:6px 0}.card.ruleon{border-color:#34d399}.dim{color:#8fa89d}.warn{color:#ffd78a}.bad{color:#ff8a8a}.ok{color:#7be495}b{color:#fff}' +
    '.tabs{display:flex;gap:4px;margin:8px 0 4px}.tabs button.on{background:#0f5c3f;border-color:#34d399}' +
    '.f{display:flex;flex-direction:column;gap:1px;margin:3px 0;flex:1 1 45%}.f span{color:#8fa89d;font-size:10px;text-transform:uppercase;letter-spacing:.03em}' +
    'input[type=text],input[type=number],select{width:100%;background:#0c1512;color:#e8f3ee;border:1px solid #2f6b55;border-radius:5px;padding:3px 5px;font:12px system-ui}' +
    '.grid{display:flex;flex-wrap:wrap;gap:6px}table{width:100%;border-collapse:collapse;margin:4px 0;font-size:11px}td,th{padding:2px 3px;text-align:left;vertical-align:top}th{color:#8fa89d;font-weight:500}' +
    '.lg{font-size:11px;border-bottom:1px solid #1e332a;padding:2px 0}.k{font-weight:700}.k-COMPRA{color:#7be495}.k-ENCONTRADO{color:#9bd8ff}.k-CANCELADO,.k-ERRO{color:#ff8a8a}.k-SIMULAÇÃO{color:#ffe27a}.k-AVISO{color:#ffd78a}' +
    '</style><div id="p"><div class="hd" data-a="collapse"><b>Market Auto-Buy</b><span class="ver">v' + VERSION + '</span><span class="car" id="car">▼</span></div><div class="bd" id="bd"><div id="ctl"></div><div id="live"></div><div id="tabs"></div><div id="tab"></div></div></div>';
  const $r = (id) => root.getElementById(id);

  function renderCtl() {
    const c = S.cfg;
    $r('ctl').innerHTML = '<div class="row"><button class="big ' + (SES.armed ? 'stop' : 'go') + '" data-a="arm">' + (SES.armed ? '⏹ PARAR' : '▶ ARMAR') + '</button>' +
      '<span class="badge ' + (!SES.armed ? 'b0' : c.sim ? 'b1' : 'b2') + '">' + (!SES.armed ? 'PARADO · só monitorando' : c.sim ? 'SIMULANDO · pesquisa, mas não compra' : 'COMPRANDO DE VERDADE') + '</span></div>' +
      '<label class="c"><input type="checkbox" data-f="cfg.sim" ' + (c.sim ? 'checked' : '') + '><span><b>Modo simulação</b> (recomendado até você ver que as linhas foram lidas certo): só avisa o que compraria.</span></label>' +
      (c.sim ? '' : '<label class="c warn"><input type="checkbox" data-a="ack" ' + (SES.ack ? 'checked' : '') + '><span>Entendo que compras reais gastam ouro/gemas (gemas valem dinheiro) e que não dá para desfazer.</span></label>');
  }
  function renderLive() {
    const rows = SES.rows, ok = rows.filter((r) => r.eligible).length, c = S.cfg, d = S.day;
    const warns = [];
    const navTxt = SES.panel && SES.panel.isConnected && vis(SES.panel) ? 'mercado aberto ✓' : SES.armed && c.autoOpen ? 'abrindo o mercado…' : 'mercado fechado';
    if (!(c.capGems > 0) && !(c.capGold > 0)) warns.push('Os limites diários de gemas e de ouro estão em 0, então nada será comprado. Defina na aba Config.');
    if (!S.rules.some((r) => r.on && r.max > 0)) warns.push('Nenhuma regra ligada tem preço máximo, então nenhuma compra pode acontecer.');
    if (!rows.length) warns.push(SES.armed && c.autoOpen ? 'Ainda sem listagens na tela: estou tentando abrir o mercado e pesquisar sozinho.' : 'Não vejo listagens com botão "Comprar". Clique em ARMAR (ele abre o mercado e pesquisa sozinho) ou abra a tela do mercado.');
    if (c.autoSearch && c.searchSec < 5) warns.push('Pesquisar com menos de 5 s de intervalo pode ser tratado como excesso de requisições pelo servidor do jogo.');
    $r('live').innerHTML = '<div class="card"><div>Linhas lidas: <b>' + rows.length + '</b> · aptas a comprar: <b class="' + (ok ? 'ok' : '') + '">' + ok + '</b></div>' +
      '<div class="dim">' + navTxt + (c.autoSearch ? ' · pesquisas feitas: ' + SES.searches + (SES.lastSearch ? ' · última há ' + Math.round((Date.now() - SES.lastSearch) / 1000) + ' s' : '') : '') + '</div>' +
      '<div class="dim">Hoje: gemas ' + fmt(d.gems) + ' / ' + fmt(c.capGems) + ' · ouro ' + fmt(d.gold) + ' / ' + fmt(c.capGold) + ' · compras ' + d.buys + ' / ' + (c.capBuysDay || '∞') + '</div></div>' +
      warns.map((w) => '<div class="warn">⚠ ' + esc(w) + '</div>').join('') + (msg ? '<div class="ok">' + esc(msg) + '</div>' : '') + (!storageOk ? '<div class="bad">Não consegui salvar as configurações neste navegador.</div>' : '');
    $r('car').textContent = S.ui.collapsed ? '▲' : '▼';
    $r('bd').style.display = S.ui.collapsed ? 'none' : '';
  }
  const fld = (ri, f, label, type, ph) => '<label class="f"><span>' + label + '</span><input data-r="' + ri + '" data-f="' + f + '" data-t="' + type + '" type="' + (type === 'num' ? 'number' : 'text') + '" ' + (type === 'num' ? 'min="0" step="any"' : '') + ' value="' + esc(type === 'num' && !S.rules[ri][f] ? '' : S.rules[ri][f]) + '" placeholder="' + esc(ph || '') + '"></label>';
  const sel = (ri, f, label, opts, rr) => '<label class="f"><span>' + label + '</span><select data-r="' + ri + '" data-f="' + f + '" ' + (rr ? 'data-rr="1"' : '') + '>' + opts.map((o) => '<option value="' + o[0] + '"' + (S.rules[ri][f] === o[0] ? ' selected' : '') + '>' + o[1] + '</option>').join('') + '</select></label>';
  function ruleCard(r, i) {
    const sum = (r.kind === 'pokemon' ? 'Pokémon' : r.kind === 'item' ? 'Item' : 'Qualquer') + ' · ' + (r.names ? esc(r.names) : 'qualquer nome') + ' · ' + (r.min ? fmt(r.min) + '–' : 'até ') + (r.max ? fmt(r.max) : '⚠ sem máximo') + ' ' + cname(r.currency);
    let h = '<div class="card ' + (r.on ? 'ruleon' : '') + '"><div class="row"><label class="c" style="margin:0"><input type="checkbox" data-r="' + i + '" data-f="on" ' + (r.on ? 'checked' : '') + '><b>' + esc(r.label) + '</b></label><button data-a="open:' + i + '">' + (r.open ? 'Fechar' : 'Editar') + '</button><button data-a="del:' + i + '">Excluir</button></div><div class="dim">' + sum + (r.bought ? ' · comprou ' + r.bought : '') + '</div>';
    if (r.open) {
      h += '<div class="grid">' + fld(i, 'label', 'Nome da regra', 'str') + sel(i, 'kind', 'O que comprar', [['any', 'Qualquer coisa'], ['pokemon', 'Só pokémon'], ['item', 'Só item']], true) + '</div>' +
        fld(i, 'names', 'Nome(s) (separe por vírgula; vazio = qualquer)', 'str', 'ex.: charizard, garchomp') +
        '<div class="grid">' + sel(i, 'currency', 'Pagar em', [['gems', 'Gemas'], ['gold', 'Ouro']]) + fld(i, 'min', 'Preço mínimo', 'num', '0') + fld(i, 'max', 'Preço MÁXIMO (obrigatório)', 'num', 'ex.: 1500') + '</div>';
      if (r.kind !== 'item') h += '<div class="dim" style="margin-top:4px">Filtros de pokémon (0 = sem filtro; se o dado não aparece na linha, não compra):</div><div class="grid">' + fld(i, 'lvMin', 'Nível mín.', 'num') + fld(i, 'lvMax', 'Nível máx.', 'num') + fld(i, 'powerMin', 'Poder mín. (1–5)', 'num') + fld(i, 'ivMin', 'IV mín.', 'num') + fld(i, 'qualMin', 'Qualidade mín.', 'num') + sel(i, 'shiny', 'Shiny', [['any', 'Tanto faz'], ['only', 'Só shiny'], ['no', 'Sem shiny']]) + '</div>';
      h += '<div class="grid">' + fld(i, 'must', 'Texto obrigatório (vírgula)', 'str', 'ex.: perfeito') + fld(i, 'forbid', 'Texto proibido (vírgula)', 'str') + fld(i, 'maxBuys', 'Máx. de compras desta regra (0 = sem limite)', 'num') + '</div>';
    }
    return h + '</div>';
  }
  function renderTab() {
    const tab = S.ui.tab, c = S.cfg;
    $r('tabs').innerHTML = '<div class="tabs">' + [['rules', 'Regras'], ['config', 'Config'], ['market', 'Mercado'], ['log', 'Registro']].map((t) => '<button data-a="tab:' + t[0] + '" class="' + (tab === t[0] ? 'on' : '') + '">' + t[1] + '</button>').join('') + '</div>';
    let h = '';
    if (tab === 'rules') h = S.rules.map(ruleCard).join('') + '<button data-a="add">+ Nova regra</button><div class="dim">As regras são testadas de cima para baixo. Entre as linhas aptas, a mais barata é comprada primeiro.</div>';
    else if (tab === 'config') {
      const cf = (f, label, ph) => '<label class="f"><span>' + label + '</span><input data-f="cfg.' + f + '" data-t="num" type="number" min="0" step="any" value="' + (c[f] || '') + '" placeholder="' + (ph || '0') + '"></label>';
      h = '<div class="card"><b>Limites de segurança (por dia)</b><div class="dim">Com 0, o script NÃO compra naquela moeda.</div><div class="grid">' + cf('capGems', 'Máx. de gemas por dia') + cf('capGold', 'Máx. de ouro por dia') + cf('capBuysDay', 'Máx. de compras por dia', '5') + '</div></div>' +
        '<div class="card"><b>Abrir e pesquisar sozinho (só com o script ARMADO)</b>' +
        '<label class="c"><input type="checkbox" data-f="cfg.autoOpen" ' + (c.autoOpen ? 'checked' : '') + '><span>Abrir o mercado sozinho (clica no item "Mercado" do menu quando ele não está na tela)</span></label>' +
        '<label class="c"><input type="checkbox" data-f="cfg.autoSearch" ' + (c.autoSearch ? 'checked' : '') + '><span>Pesquisar sozinho o tempo todo (clica em "Buscar/Atualizar" do mercado)</span></label>' +
        (c.autoSearch ? '<div class="grid">' + cf('searchSec', 'A cada quantos segundos (mín. 3; recomendo 5 ou mais)', '5') + '</div>' +
        '<label class="c"><input type="checkbox" data-f="cfg.searchByName" ' + (c.searchByName ? 'checked' : '') + '><span>Digitar o nome das regras no campo de busca, uma regra por vez (útil se a lista do mercado é grande ou tem páginas)</span></label>' : '') +
        '<div class="dim">Isso só navega e pesquisa: comprar continua dependendo de armar, da simulação e dos limites.</div></div>' +
        '<div class="card"><b>Ritmo das compras</b><div class="grid">' + cf('minGapSec', 'Segundos entre compras', '4') + cf('maxPerMin', 'Máx. de compras por minuto', '3') + '</div></div>' +
        '<div class="card"><b>Confirmação e avisos</b><label class="c"><input type="checkbox" data-f="cfg.autoConfirm" ' + (c.autoConfirm ? 'checked' : '') + '><span>Confirmar sozinho o diálogo de compra do jogo (desligado: você clica em confirmar). Só confirma se o diálogo trouxer o mesmo preço, moeda e nome.</span></label>' +
        '<label class="c"><input type="checkbox" data-f="cfg.beep" ' + (c.beep ? 'checked' : '') + '><span>Tocar um bip quando achar algo</span></label></div>' +
        '<div class="card"><b>Não comprar de</b><label class="f"><span>Vendedores/textos bloqueados (vírgula)</span><input data-f="cfg.blocked" data-t="str" type="text" value="' + esc(c.blocked) + '" placeholder="ex.: minhaconta2, amigo"></label><div class="dim">Ponha aqui as suas outras contas: os termos do jogo proíbem combinar resultados do mercado para transferir valor entre contas.</div></div>' +
        '<div><button data-a="resetday">Zerar contadores de hoje</button></div>';
    } else if (tab === 'market') {
      h = SES.rows.length ? '<table><tr><th>Item / pokémon</th><th>Preço</th><th>Situação</th></tr>' + SES.rows.slice(0, 14).map((r) => '<tr><td>' + esc(r.L.name || '?') + (r.L.kind === 'pokemon' ? '<div class="dim">Nv ' + r.L.level + (Number.isFinite(r.L.power) ? ' P' + r.L.power : '') + (Number.isFinite(r.L.iv) ? ' IV ' + r.L.iv : '') + (r.L.shiny ? ' ✨' : '') + '</div>' : '') + '</td><td>' + (Number.isFinite(r.L.price) ? fmt(r.L.price) + ' ' + cname(r.L.currency) : '–') + '</td><td class="' + (r.eligible ? 'ok' : 'dim') + '">' + esc(r.why) + '</td></tr>').join('') + '</table>' : '<div class="dim">Nenhuma linha com botão "Comprar" na tela.</div>';
      h += '<div><button data-a="diag">Diagnóstico</button></div>';
    } else {
      h = (S.log.length ? S.log.slice(-40).reverse().map((l) => '<div class="lg"><span class="k k-' + esc(l.kind) + '">' + esc(l.kind) + '</span> <span class="dim">' + new Date(l.t).toLocaleTimeString() + '</span> ' + esc(l.text) + '</div>').join('') : '<div class="dim">Sem eventos ainda.</div>') + '<div style="margin-top:6px"><button data-a="clearlog">Limpar registro</button></div>';
    }
    if (diagText) h += '<textarea data-a="selall" readonly rows="6" style="width:100%;font:10px monospace;background:#0c1512;color:#e8f3ee">' + esc(diagText) + '</textarea><div class="dim">Não consegui copiar sozinho: clique, Ctrl+A, Ctrl+C e cole na conversa.</div><button data-a="closediag">Fechar</button>';
    $r('tab').innerHTML = h;
  }
  const setPath = (o, path, v) => { const ks = path.split('.'); let t = o; ks.slice(0, -1).forEach((k) => { t = t[k]; }); t[ks[ks.length - 1]] = v; };
  function apply(t) {
    const f = t.dataset.f; if (!f) return;
    let v = t.type === 'checkbox' ? t.checked : t.value;
    if (t.dataset.t === 'num') v = Math.max(0, parseFloat(String(v).replace(',', '.')) || 0);
    if (t.dataset.r !== undefined) S.rules[+t.dataset.r][f] = v; else setPath(S, f, v);
    if (f === 'cfg.sim') { SES.ack = false; if (v) msg = ''; }
    if (f === 'cfg.autoSearch' || f === 'cfg.autoOpen' || f === 'cfg.searchByName') { SES.searchWarned = false; SES.navWarned = false; SES.openFails = 0; }
    save();
    if (f === 'cfg.sim') renderCtl();
    if (t.dataset.rr || f === 'cfg.autoSearch' || f === 'on') renderTab();
    renderLive();
  }
  root.addEventListener('input', (e) => { if (e.target.type !== 'checkbox' && e.target.tagName !== 'SELECT') apply(e.target); });
  root.addEventListener('change', (e) => {
    const t = e.target;
    if (t.dataset && t.dataset.a === 'ack') { SES.ack = t.checked; renderCtl(); return; }
    apply(t);
  });
  ['keydown', 'keyup', 'keypress'].forEach((k) => root.addEventListener(k, (e) => e.stopPropagation())); // o jogo não pode ler o que você digita aqui
  root.addEventListener('click', (e) => {
    const a = e.target.closest('[data-a]');
    if (!a) return;
    const k = a.dataset.a;
    if (k === 'collapse') { S.ui.collapsed = !S.ui.collapsed; save(); renderLive(); }
    else if (k === 'arm') {
      if (SES.armed) { SES.armed = false; addLog('PARADO', 'você parou'); }
      else if (!S.cfg.sim && !SES.ack) { msg = 'Para comprar de verdade, marque o aviso de que você entende que gasta ouro/gemas.'; }
      else { SES.armed = true; SES.lastBuy = Date.now(); msg = ''; addLog('ARMADO', S.cfg.sim ? 'modo simulação' : 'COMPRAS REAIS'); }
      renderCtl(); renderLive();
    }
    else if (k.startsWith('tab:')) { S.ui.tab = k.slice(4); save(); renderTab(); }
    else if (k === 'add') { S.rules.push(newRule()); save(); renderTab(); renderLive(); }
    else if (k.startsWith('del:')) { S.rules.splice(+k.slice(4), 1); save(); renderTab(); renderLive(); }
    else if (k.startsWith('open:')) { const r = S.rules[+k.slice(5)]; r.open = !r.open; save(); renderTab(); }
    else if (k === 'resetday') { S.day = { date: today(), gems: 0, gold: 0, buys: 0 }; S.rules.forEach((r) => { r.bought = 0; }); save(); renderLive(); renderTab(); }
    else if (k === 'diag') diag();
    else if (k === 'selall') a.select();
    else if (k === 'closediag') { diagText = ''; renderTab(); }
    else if (k === 'clearlog') { S.log = []; save(); renderTab(); }
  });

  const saved = load();
  if (saved && saved.cfg) { S = Object.assign(S, saved); S.cfg = Object.assign({}, DEFAULT_CFG, saved.cfg); delete S.cfg.refresh; delete S.cfg.refreshSec; S.v = VERSION; S.ui = Object.assign({ tab: 'rules', collapsed: false }, saved.ui); }
  S.cfg.sim = true; // depois de recarregar, volta sempre ao modo simulação (e parado)
  rollDay();
  document.documentElement.appendChild(host);
  try { if (localStorage.getItem('pmab_debug')) window.__pmab = { loop, quick, SES, get S() { return S; }, stopTimer: () => clearInterval(timerId) }; } catch (e) {}
  renderCtl(); renderLive(); renderTab();
  const timerId = setInterval(loop, 1500);
  loop();
})();