Bazaar Mate

Your own bazaar helper

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Bazaar Mate
// @namespace    onebazaar.zero.nao
// @version      4.0.0
// @description  Your own bazaar helper
// @author       GFOUR
// @match        https://www.torn.com/bazaar.php*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const NS = 'OBM4';
    const WATCH_URL_PART = '/bazaar.php?sid=bazaarData&step=getBazaarItems';

    const DEFAULTS = {
        sort: 'price-asc',
        maxPrice: Infinity,
        feePercent: 5,
        showNegatives: false,
        notifications: false,
        search: '',
        minimized: false,
        showLog: false,
        pos: {
            top: 64,
            left: null,
            right: 16
        },
        favorites: [],
        favoriteCaps: {},
        favoriteNames: {},
    };

    const SEL = {
        container: '#obm',
        header: '#obmHdr',
        list: '#obmList',
        log: '#obmLog',
        search: '#obmSearch',
        maxPrice: '#obmMax',
        cashBtn: '#obmCashBtn',
        fab: '#obmFab',
        counters: {
            items: '#obmItems',
            dollars: '#obmDollars',
            cash: '#obmCash',
            favs: '#obmFavs',
        },
        settingsBtn: '#obmSettingsBtn',
        minimizeBtn: '#obmMinimize',
        closeBtn: '#obmClose',
        settings: {
            overlay: '#obmSettingsPane',
            sort: '#obmSort',
            fee: '#obmFee',
            showNeg: '#obmShowNeg',
            notif: '#obmNotif',
            logToggle: '#obmLogToggle',
            close: '#obmSettingsBack',
            favCaps: '#obmFavCaps',
        },
    };
    const ICONS = {
  drag: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="currentColor" aria-hidden="true">
    <circle cx="7" cy="8" r="1.2"/><circle cx="12" cy="8" r="1.2"/><circle cx="17" cy="8" r="1.2"/>
    <circle cx="7" cy="15" r="1.2"/><circle cx="12" cy="15" r="1.2"/><circle cx="17" cy="15" r="1.2"/>
  </svg>`,
  scan: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <circle cx="12" cy="12" r="7"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2"/>
  </svg>`,
  settings: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <path d="M4 6h16M4 12h16M4 18h16"/><circle cx="8" cy="6" r="2"/><circle cx="16" cy="12" r="2"/><circle cx="10" cy="18" r="2"/>
  </svg>`,
  minimize: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <path d="M5 12h14"/>
  </svg>`,
  restore: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <rect x="6" y="6" width="12" height="12" rx="2"/>
  </svg>`,
  close: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <path d="M5 5l14 14M19 5l-14 14"/>
  </svg>`,
  cash: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <rect x="3.5" y="7" width="17" height="10" rx="2"/><circle cx="12" cy="12" r="3"/>
  </svg>`,
  back: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <path d="M15 18l-6-6 6-6"/>
  </svg>`,
  star: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
  </svg>`,
  starFilled: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="ico" fill="currentColor" aria-hidden="true">
    <path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/>
  </svg>`,
};

    let settings = loadAll();
    let items = new Map();
    let bought = new Set();
    let lastClick = 0;
    let latestSeller = null;
    let updateScheduled = false;
    let currentCash = 0;
    let hookedFetch = false;
    let hookedXHR = false;
    let cashObs = null;
    let cashIntervalId = 0;
    let scanHintDone = false;

    function stopScanHint() {
        scanHintDone = true;
        $('#obmScanBtn').removeClass('blink');
    }
    // Stop scan as soon as we see any item above the max price (for current seller).
    function shouldStopScanByMax() {
        if (!isFinite(settings.maxPrice) || !latestSeller) return false;
        const cutoff = settings.maxPrice;
        for (const it of items.values()) {
            if (it.sellerUserId === latestSeller && it.price > cutoff) {
                return true;
            }
        }
        return false;
    }

    function getSortIndicatorText() {
        // Prefer the classes you referenced; fall back to a generic search
        const el =
              document.querySelector("div.loadingSame___XWj3U .loaderText___d8TAE") ||
              document.querySelector("[class*='loaderText']");
        return (el && el.textContent ? el.textContent.trim() : '');
    }

    function getCostSortState() {
        const txt = (getSortIndicatorText() || '').toLowerCase();
        if (!txt) return 'unknown';
        const isCost = /(cost|price)/.test(txt);
        if (isCost && /ascending/.test(txt)) return 'cost-asc';
        if (isCost && /descending/.test(txt)) return 'cost-desc';
        return 'unknown';
    }

    function isCostAscending() {
        return getCostSortState() === 'cost-asc';
    }

    function findCostSortButton() {
        // Try to find the "Cost/Price" sort button (best-effort)
        return (
            document.querySelector("button[title*='Cost' i]") ||
            document.querySelector("button[title*='Price' i]") ||
            Array.from(document.querySelectorAll('button, [role="button"]'))
            .find(el => /cost|price/i.test(el.textContent || '')) ||
            document.querySelector('button.item___UN3Mg:nth-child(5)') // your original fallback
        );
    }


    function k(key) {
        return `${NS}:${key}`;
    }

    function load(key, def = DEFAULTS[key]) {
        try {
            const raw = localStorage.getItem(k(key));
            if (raw == null) return def;
            if (raw === 'Infinity') return Infinity;
            return JSON.parse(raw);
        } catch {
            return def;
        }
    }

    function save(key, val) {
        try {
            localStorage.setItem(k(key), val === Infinity ? 'Infinity' : JSON.stringify(val));
        } catch {}
    }

    function loadAll() {
        const s = {};
        for (const key of Object.keys(DEFAULTS)) s[key] = load(key);
        return s;
    }

    function formatNum(n) {
        try {
            return Math.floor(Number(n)).toLocaleString();
        } catch {
            return String(Math.floor(n));
        }
    }

    function parseMoney(str) {
        if (typeof str === 'number') return str;
        const x = parseInt(String(str || '').replace(/[^\d-]/g, ''), 10);
        return isNaN(x) ? 0 : x;
    }

    function escapeHtml(s) {
        return String(s ?? '')
            .replaceAll('&', '&amp;').replaceAll('<', '&lt;')
            .replaceAll('>', '&gt;').replaceAll('"', '&quot;')
            .replaceAll("'", '&#39;');
    }

    function getRFC() {
        return (window.$ && $.cookie && $.cookie('rfc_v')) ||
            document.cookie.split('; ').find(r => r.startsWith('rfc_v='))?.split('=')[1] || '';
    }

    function notify(title, body) {
        try {
            if (!settings.notifications) return;
            if (!('Notification' in window)) return;
            if (Notification.permission !== 'granted') return;
            const n = new Notification(title, {
                body
            });
            n.onclick = () => window.focus();
        } catch {}
    }

    function profitPerUnit(price, mv, feePercent) {
        return (mv * (1 - feePercent / 100)) - price;
    }

    function isFavItem(itemId) {
        const arr = settings.favorites || [];
        return arr.includes(Number(itemId));
    }
    function setFavorite(itemId, on) {
        itemId = Number(itemId);
        let arr = (settings.favorites || []).map(Number).filter(n => !isNaN(n));
        const i = arr.indexOf(itemId);
        if (on && i === -1) arr.push(itemId);
        if (!on && i !== -1) arr.splice(i, 1);
        settings.favorites = arr;
        save('favorites', arr);
    }
    function toggleFavorite(itemId) {
        const nowOn = !isFavItem(itemId);
        setFavorite(itemId, nowOn);
        return nowOn;
    }
function getFavMax(itemId) {
  const caps = settings.favoriteCaps || {};
  const v = caps[itemId];
  return (v == null || !(v >= 0)) ? Infinity : Number(v);
}
    function favCapProfit(it) {
  const cap = getFavMax(it.itemId);
  // If no cap set (Infinity), use -Infinity so it sorts last among favorites
  return isFinite(cap) ? (cap - it.price) : Number.NEGATIVE_INFINITY;
}
function setFavMax(itemId, val) {
  const caps = settings.favoriteCaps || {};
  if (val == null || !isFinite(val) || val <= 0) {
    delete caps[itemId]; // Infinity when cleared
  } else {
    caps[itemId] = Math.floor(Number(val));
  }
  settings.favoriteCaps = caps;
  save('favoriteCaps', caps);
  scheduleUI();
}
function formatCapInputLive(el, itemId) {
  const raw = el.value;
  const caret = el.selectionStart ?? raw.length;
  const digitsBefore = getDigitsBeforeCaret(raw, caret);
  const digitsOnly = raw.replace(/[^\d]/g, '');
  if (!digitsOnly.length) {
    el.value = '';
    setFavMax(itemId, Infinity);
    return;
  }
  const n = parseInt(digitsOnly, 10);
  const formatted = formatMoneyInline(n);
  el.value = formatted;
  const newPos = caretPosForDigits(formatted, digitsBefore);
  try { el.setSelectionRange(newPos, newPos); } catch {}
  setFavMax(itemId, n);
}

function setFavName(itemId, name) {
  if (!name) return;
  const m = settings.favoriteNames || {};
  if (m[itemId] !== name) {
    m[itemId] = name;
    settings.favoriteNames = m;
    save('favoriteNames', m);
  }
}
function getFavName(itemId) {
  return (settings.favoriteNames && settings.favoriteNames[itemId]) ||
         (() => {
           for (const it of items.values()) if (it.itemId === itemId) return it.name;
           return 0;
         })();
}

function isFavActiveForPin(item) {
  return isFavItem(item.itemId) && item.price <= getFavMax(item.itemId);
}

    function renderFavCapsList() {
  const $wrap = $(SEL.settings.favCaps);
  if (!$wrap.length) return;

  const favIds = (settings.favorites || []).map(Number).filter(n => !isNaN(n));
  if (!favIds.length) {
    $wrap.html('<div class="muted">No favorites yet. Star items to manage caps here.</div>');
    return;
  }

  const rows = favIds.map(id => {
    const name = escapeHtml(getFavName(id));
    const cap = getFavMax(id);
    const val = isFinite(cap) ? formatMoneyInline(cap) : '';
      if (name == 0) return;
      return `
  <div class="caprow" data-itemid="${id}">
    <div class="iname" title="${name}">${name}</div>
    <input class="input capinput" data-itemid="${id}" placeholder="∞" value="${val}" title="Max price">
    <button class="btn-icon removefav" data-itemid="${id}" title="Remove">${ICONS.close}</button>
  </div>
`;

  }).join('');

  $wrap.html(rows);
}
    function buildUI() {
  $(SEL.container).remove();
  $('#obmStyles').remove();
  $(SEL.fab).remove();

  const top = settings.pos?.top ?? DEFAULTS.pos.top;
  const right = settings.pos?.right ?? DEFAULTS.pos.right;
  const left = settings.pos?.left;

  const fabPos = `${left != null ? `left:${left}px;` : ''}${right != null ? `right:${right}px;` : ''}top:${top}px;`;

  const styles = `
    <style id="obmStyles">
      #obm, #obm * { box-sizing: border-box; }
      ${SEL.container}{
        position: fixed;
        ${left != null ? `left:${left}px;` : ''}
        ${right != null ? `right:${right}px;` : ''}
        top:${top}px;
        width:340px; height:60vh; max-height:80vh; min-width:300px;
        display:flex; flex-direction:column; overflow:hidden;
        background:#1f1f1f; border:1px solid #3a3a3a; border-radius:10px; color:#eaeaea;
        font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;
        box-shadow:0 10px 26px rgba(0,0,0,0.35); z-index:9999;
      }
      ${SEL.header}{
        display:flex; align-items:center; gap:6px; padding:6px 8px;
        background:#2b2b2b; border-bottom:1px solid #3a3a3a; user-select:none;
      }
      #obmDrag{ display:flex; align-items:center; gap:8px; font-weight:600; color:#ddd; cursor:move; -webkit-app-region:drag; }
      #obmHdr .spacer{flex:1;}

      /* Views */
      #obmMain{display:flex; flex-direction:column; min-height:0; flex:1;}
      #obmSettingsPane{display:none; flex:1; overflow:auto; padding:8px; background:#1b1b1b;}
      .mode-settings #obmMain{display:none;}
      .mode-settings #obmSettingsPane{display:flex; flex-direction:column;}

      /* Controls, list, status */
      #obmCtrls{ display:flex; align-items:center; gap:6px; padding:6px 8px; background:#262626; border-bottom:1px solid #333; }
      ${SEL.list}{flex:1; overflow:auto; padding:8px; background:#1b1b1b;}
      #obmStatus{ display:flex; align-items:center; justify-content:space-between; gap:8px; padding:6px 8px; background:#202020; border-top:1px solid #333; }

      .chip{font-size:11px; padding:2px 6px; border-radius:999px; background:#444; color:#fff; font-weight:600;}
      #obmDollars{background:#a53939;}
      #obmCash{background:#385a38;}
      #obmFavs{background:#5a43a5;}
      .input{ background:#2a2a2a; border:1px solid #444; color:#fff; border-radius:6px; padding:5px 7px; font-size:12px; outline:none; }
      .input::placeholder{color:#aaa;}
      .btn{ background:#2a2a2a; border:1px solid #444; color:#ddd; border-radius:6px; padding:5px 7px; font-size:12px; cursor:pointer; }
      .btn:hover{background:#383838;}
      .btn-icon{width:28px; height:28px; display:flex; align-items:center; justify-content:center; padding:0;}

      /* Items */
      .it{ background:#2a2a2a; border:1px solid #3a3a3a; border-radius:8px; padding:8px; margin:0 0 6px; color:#eee; cursor:pointer; position:relative; }
      .it:hover{background:#313131; border-color:#4a4a4a;}
      .it.dollar{border-left:4px solid #ff4747; background:#332626;}
      .it.fav{border-left:4px solid #8f6bff; background:linear-gradient(0deg, #2b2640, #2a2a2a);}
      .it .top{display:flex; justify-content:space-between; gap:8px; align-items:center;}
      .it .name{font-weight:700; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
      .tag{font-size:10px; padding:0 6px; border-radius:999px; background:#3a3a3a; color:#ccc; border:1px solid #4a4a4a;}
      .tag.price{background:#3a3a3a;}
      .tag.profit{background:#214a2a; color:#9ae39a; border-color:#2f6b3b;}
      .una{opacity:0.55;}
      .act{display:flex; gap:6px; align-items:center; margin-top:6px;}
      .act .qty{width:66px; text-align:right;}
      .act .hint{margin-left:auto; font-size:10px; color:#aaa; pointer-events:none;}
      .lack{ margin-top:4px; font-size:11px; color:#f15a5a; }
      .it.inflight{opacity:0.6; pointer-events:none;}

      /* Fav button */
      .favtoggle { border:none; background:transparent; color:#b9b1e6; padding:0; width:24px; height:24px; display:flex; align-items:center; justify-content:center; cursor:pointer; }
      .favtoggle:hover{ color:#e6d8ff; }
      .favtoggle.on{ color:#ffd666; }

      /* In-panel settings */
      #obmSettingsPane .panel{ background:#222; border:1px solid #3a3a3a; color:#eee; border-radius:10px; width:100%; max-width:360px; margin:0 auto; padding:10px; }
      #obmSettingsPane .h{display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;}
      #obmSettingsPane .row{display:flex; gap:8px; align-items:center; margin:6px 0;}
      #obmSettingsPane label{font-size:12px; color:#bbb; min-width:90px;}

      /* Log */
      #obmLog{display:none; height:80px; overflow:auto; padding:6px 8px; background:#1a1a1a; border-top:1px solid #333;}
      #obmLog .count { color:#9ae39a; font-weight:700; }
      #obmLog .total { color:#f6d98a; font-weight:700; }
      .show-log #obmLog{display:block;}
      .loge{margin:2px 0; font-size:11px;}
      .ok{color:#7ad07a;} .err{color:#f15a5a;} .info{color:#8ab4f8;}

      .mini #obmCtrls, .mini #obmLog, .mini #obmStatus{display:none;}
      .mini #obmSettingsPane{display:none;}

      .ico { width:16px; height:16px; }
      .btn-icon svg { width:16px; height:16px; }
      #obmDrag .ico { margin-right:6px; }

      /* Floating mini icon */
      #obmFab{
        position:fixed; ${fabPos}
        width:44px; height:44px; border-radius:50%;
        background:#2b2b2b; border:1px solid #3a3a3a; color:#ddd;
        display:flex; align-items:center; justify-content:center;
        box-shadow:0 10px 26px rgba(0,0,0,0.35); z-index:9999; cursor:pointer;
      }
      #obmFab:hover{ background:#343434; }
      #obmFab .ico{ width:18px; height:18px; }
      #obmSettingsPane .favcaps .caprow{display:flex; align-items:center; gap:8px; margin:4px 0;}
#obmSettingsPane .favcaps .iname{flex:1; font-size:12px; color:#ddd; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
#obmSettingsPane .favcaps .capinput{width:120px;}
#obmSettingsPane .favcaps .muted{font-size:12px; color:#888; padding:6px 0;}
#obmSettingsPane .favcaps .removefav {
  width: 24px;
  height: 24px;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
/* Make item cards unselectable */
#obmList .it {
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
  -webkit-touch-callout: none; /* iOS long-press menu */
}

/* Re-enable selection where you need it (inputs/textareas) */
#obmList .it input,
#obmList .it textarea {
  -webkit-user-select: text;
  -moz-user-select: text;
  -ms-user-select: text;
  user-select: text;
}
    </style>
  `;

  const html = `
    <div id="obm" class="${settings.minimized ? '' : ''} ${settings.showLog ? 'show-log' : ''}">
      <div id="obmHdr" title="Bar">
        <div id="obmDrag" title="Drag">${ICONS.drag} One Bazaar</div>
        <div class="spacer"></div>
        <button id="obmScanBtn" class="btn btn-icon" title="Scan">${ICONS.scan}</button>
        <button id="obmSettingsBtn" class="btn btn-icon" title="Settings">${ICONS.settings}</button>
        <button id="obmMinimize" class="btn btn-icon" title="Minimize">${ICONS.minimize}</button>
        <button id="obmClose" class="btn btn-icon" title="Close">${ICONS.close}</button>
      </div>

      <div id="obmMain">
        <div id="obmCtrls" title="Filters">
          <input id="obmSearch" class="input" placeholder="Search" title="Search" value="${escapeHtml(settings.search || '')}" style="flex:1;">
          <input id="obmMax" class="input" type="text" inputmode="numeric" placeholder="Max $" title="Max price" style="width:110px;">
          <button id="obmCashBtn" class="btn btn-icon" title="Cash">${ICONS.cash}</button>
        </div>
        <div id="obmList" title="Items"></div>
        <div id="obmStatus" title="Status">
          <div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap;">
            <span id="obmItems" class="chip" title="Items">0</span>
            <span id="obmDollars" class="chip" title="$1">0</span>
            <span id="obmFavs" class="chip" title="Favorites">0</span>
          </div>
          <span id="obmCash" class="chip" title="Cash">$0</span>
        </div>
        <div id="obmLog" title="Log"><div class="loge info">Ready — listening for bazaar updates</div></div>
      </div>

      <div id="obmSettingsPane" title="Settings">
        <div class="panel">
          <div class="h">
            <div>Settings</div>
            <button id="obmSettingsBack" class="btn btn-icon" title="Back">${ICONS.back}</button>
          </div>
          <div class="row">
            <label>Sort</label>
            <select id="obmSort" class="input" style="flex:1;" title="Sort">
              <option value="price-asc">Price ↑</option>
              <option value="price-desc">Price ↓</option>
              <option value="profit-desc">Profit/u ↓</option>
              <option value="totalprofit-desc">Total profit ↓</option>
              <option value="gain-desc">Gain % ↓</option>
              <option value="name-asc">Name ↑</option>
            </select>
          </div>
          <div class="row">
            <label>Fee %</label>
            <input id="obmFee" class="input" type="number" min="0" max="20" step="0.5" style="width:80px;" title="Fee %">
          </div>
          <div class="row">
            <label>Negatives</label>
            <input id="obmShowNeg" type="checkbox" title="Negatives">
          </div>
          <div class="row">
            <label>Notify</label>
            <input id="obmNotif" type="checkbox" title="Notify">
          </div>
          <div class="row">
            <label>Log</label>
            <input id="obmLogToggle" type="checkbox" title="Log">
          </div>
          <div class="row">
  <label>Fav caps</label>
  <div style="flex:1; font-size:12px; color:#888;">Only pin/highlight favorites priced at or under their cap.</div>
</div>
<div id="obmFavCaps" class="favcaps"></div>
        </div>
      </div>
    </div>

    <button id="obmFab" title="Open One Bazaar" style="display:${settings.minimized ? 'flex' : 'none'};">
      ${ICONS.restore}
    </button>
  `;

  $('head').append(styles);
  $('body').append(html);

  $(SEL.settings.sort).val(settings.sort);
  $(SEL.settings.fee).val(settings.feePercent);
  $(SEL.settings.showNeg).prop('checked', !!settings.showNegatives);
  $(SEL.settings.notif).prop('checked', !!settings.notifications);
  $(SEL.settings.logToggle).prop('checked', !!settings.showLog);


  setMaxInputFromSettings();

  // Drag full panel
  let dragging = false;
  let offset = { x: 0, y: 0 };
  $('#obmDrag').on('mousedown', (e) => {
    dragging = true;
    const r = $(SEL.container)[0].getBoundingClientRect();
    offset.x = e.clientX - r.left;
    offset.y = e.clientY - r.top;
  });
  $(document).off('mousemove.obm mouseup.obm')
    .on('mousemove.obm', (e) => {
      if (!dragging) return;
      $(SEL.container).css({ left: e.clientX - offset.x, right: '', top: e.clientY - offset.y });
    })
    .on('mouseup.obm', () => {
      if (!dragging) return;
      dragging = false;
      const r = $(SEL.container)[0].getBoundingClientRect();
      settings.pos.left = r.left;
      settings.pos.top = r.top;
      settings.pos.right = null;
      save('pos', settings.pos);
      // also move fab accordingly
      $(SEL.fab).css({ left: r.left, right: '', top: r.top });
    });

  // Drag FAB
  let fabDragging = false;
  let fabOffset = { x: 0, y: 0 };
  $(SEL.fab).on('mousedown', (e) => {
    fabDragging = true;
    const r = $(SEL.fab)[0].getBoundingClientRect();
    fabOffset.x = e.clientX - r.left;
    fabOffset.y = e.clientY - r.top;
    e.preventDefault();
  });
  $(document)
    .on('mousemove.obmFab', (e) => {
      if (!fabDragging) return;
      $(SEL.fab).css({ left: e.clientX - fabOffset.x, right: '', top: e.clientY - fabOffset.y });
    })
    .on('mouseup.obmFab', () => {
      if (!fabDragging) return;
      fabDragging = false;
      const r = $(SEL.fab)[0].getBoundingClientRect();
      settings.pos.left = r.left;
      settings.pos.top = r.top;
      settings.pos.right = null;
      save('pos', settings.pos);
    });

  // Filters and UI controls
  $(SEL.search).on('input', (e) => { settings.search = e.target.value || ''; save('search', settings.search); scheduleUI(); });
  $(SEL.maxPrice).on('input', (e) => { formatMaxInputLive(e.target); });
  $(SEL.maxPrice).on('focus', (e) => { setTimeout(() => { try { e.target.select(); } catch {} }, 0); });
  $(SEL.cashBtn).on('click', () => { settings.maxPrice = currentCash || 0; save('maxPrice', settings.maxPrice); setMaxInputFromSettings(); scheduleUI(); });
  $('#obmScanBtn').on('click', async () => { stopScanHint?.(); await runScan(); });

  $(SEL.settingsBtn).on('click', () => showSettings());
  $(SEL.settings.close).on('click', () => showSettings(false));

  // Minimize to FAB
  $(SEL.minimizeBtn).off('click').on('click', () => {
    $(SEL.container).hide();
    $(SEL.fab).show();
    settings.minimized = true;
    save('minimized', true);
  });
  // Restore from FAB
  $(SEL.fab).on('click', (e) => {
    // ignore if we just dragged (mouse moved > few px handled inherently by mousedown/mouseup)
    $(SEL.fab).hide();
    $(SEL.container).show();
    settings.minimized = false;
    save('minimized', false);
  });

  $(SEL.closeBtn).on('click', () => $(SEL.container).hide());
        $(SEL.list).on('selectstart', '.it', function (e) {
  if (!$(e.target).is('input,textarea')) e.preventDefault();
});

  $(SEL.settings.sort).on('change', (e) => { settings.sort = e.target.value; save('sort', settings.sort); scheduleUI(); });
  $(SEL.settings.fee).on('input', (e) => { settings.feePercent = Math.max(0, Math.min(20, Number(e.target.value) || 0)); save('feePercent', settings.feePercent); recomputeProfits(); scheduleUI(); });
  $(SEL.settings.showNeg).on('change', (e) => { settings.showNegatives = !!e.target.checked; save('showNegatives', settings.showNegatives); scheduleUI(); });
  $(SEL.settings.notif).on('change', async (e) => {
    if (e.target.checked) {
      if ('Notification' in window) {
        const perm = await Notification.requestPermission().catch(() => 'denied');
        settings.notifications = perm === 'granted';
      } else settings.notifications = false;
    } else settings.notifications = false;
    save('notifications', settings.notifications);
    log(`Notifications ${settings.notifications ? 'enabled' : 'disabled'}`, 'info');
  });
  $(SEL.settings.logToggle).on('change', (e) => {
    settings.showLog = !!e.target.checked;
    save('showLog', settings.showLog);
    $(SEL.container).toggleClass('show-log', settings.showLog);
  });

        renderFavCapsList();

$(SEL.settings.overlay) // '#obmSettingsPane'
  .off('input.obmCap click.obmCap')
  .on('input.obmCap', '.capinput', function () {
    const itemId = Number(this.dataset.itemid);
    if (!itemId) return;
    formatCapInputLive(this, itemId);
  })
  .on('click', '.removefav', function () {
    const id = Number($(this).data('itemid'));
    setFavorite(id, false); // remove from favorites
    scheduleUI(); // re-render UI
});

  // Buy handlers
  $(SEL.list).on('click', '.it', async function (e) {
    if ($(e.target).closest('.buyqty, .qty, .favtoggle').length) return;
    const id = Number(this.getAttribute('data-id'));
    const data = items.get(id);
    if (!data) return;
    const enoughForFull = (data.price * data.amount) <= currentCash;
    if (!enoughForFull) return;
    const t = Date.now();
    if (t - lastClick < 800) return;
    lastClick = t;
    $(this).addClass('inflight');
    await buyItem(data, data.amount);
  });
  $(SEL.list).on('click', '.buyqty', async function (e) {
    e.preventDefault(); e.stopPropagation();
    const $it = $(this).closest('.it');
    const id = Number($it.attr('data-id'));
    const data = items.get(id);
    if (!data) return;
    const qty = Math.max(1, Math.min(data.amount, parseMoney($it.find('.qty').val() || 0)));
    const cost = qty * data.price;
    if (cost > currentCash) return;
    const t = Date.now();
    if (t - lastClick < 800) return;
    lastClick = t;
    $it.addClass('inflight');
    await buyItem(data, qty);
  });
  $(SEL.list).on('input', '.qty', function () {
    const $it = $(this).closest('.it');
    const id = Number($it.attr('data-id'));
    const data = items.get(id);
    if (!data) return;
    let qty = parseMoney($(this).val() || 0);
    qty = Math.max(1, Math.min(data.amount, qty));
    const cost = qty * data.price;
    $it.find('.buyqty').prop('disabled', cost > currentCash || qty < 1);
  });

  // Favorite toggle
  $(SEL.list).on('click', '.favtoggle', function (e) {
  e.preventDefault(); e.stopPropagation();
  const $it = $(this).closest('.it');
  const id = Number($it.attr('data-id'));
  const data = items.get(id);
  if (!data) return;

  const nowOn = toggleFavorite(data.itemId);
  if (nowOn) setFavName(data.itemId, data.name);
  log(`${nowOn ? '⭐ Added to' : '☆ Removed from'} favorites: ${data.name}`, 'info');

  // If settings is open, refresh the list
  if ($(SEL.container).hasClass('mode-settings')) renderFavCapsList();

  scheduleUI();
});

  if (typeof scanHintDone !== 'undefined' && !scanHintDone) $('#obmScanBtn').addClass('blink');
  log('Settings now open inline. Click ⚙️ to toggle.', 'info');

  // Panel / FAB initial visibility
  if (settings.minimized) {
    $(SEL.container).hide();
    $(SEL.fab).show();
  } else {
    $(SEL.container).show();
    $(SEL.fab).hide();
  }
}

    function scheduleUI() {
        if (updateScheduled) return;
        updateScheduled = true;
        requestAnimationFrame(() => {
            updateUI();
            updateScheduled = false;
        });
    }

    function formatMoneyInline(n) {
        try {
            return Number(n).toLocaleString();
        } catch {
            return String(n || '');
        }
    }

    function getDigitsBeforeCaret(str, caret) {
        return (str.slice(0, caret).match(/\d/g) || []).length;
    }

    function caretPosForDigits(formatted, targetDigits) {
        let pos = 0,
            seen = 0;
        while (pos < formatted.length && seen < targetDigits) {
            if (/\d/.test(formatted[pos])) seen++;
            pos++;
        }
        return pos;
    }

    function formatMaxInputLive(el) {
        const raw = el.value;
        const caret = el.selectionStart ?? raw.length;
        const digitsBefore = getDigitsBeforeCaret(raw, caret);
        const digitsOnly = raw.replace(/[^\d]/g, '');
        if (!digitsOnly.length) {
            el.value = '';
            settings.maxPrice = Infinity;
            save('maxPrice', settings.maxPrice);
            scheduleUI();
            return;
        }
        const n = parseInt(digitsOnly, 10);
        const formatted = formatMoneyInline(n);
        el.value = formatted;
        const newPos = caretPosForDigits(formatted, digitsBefore);
        try {
            el.setSelectionRange(newPos, newPos);
        } catch {}
        settings.maxPrice = n;
        save('maxPrice', n);
        scheduleUI();
    }

    function setMaxInputFromSettings() {
        const $el = $(SEL.maxPrice);
        const v = isFinite(settings.maxPrice) ? settings.maxPrice : '';
        $el.val(v === '' ? '' : formatMoneyInline(v));
    }

    const sleep = (ms) => new Promise(r => setTimeout(r, ms));

    function scrollEl() {
        return document.scrollingElement || document.documentElement || document.body;
    }

    async function scrollToBottomLoad(maxLoops = 80, stopWhen) {
        const s = scrollEl();
        let lastH = -1, stable = 0;
        for (let i = 0; i < maxLoops; i++) {
            if (typeof stopWhen === 'function' && stopWhen()) break;

            window.scrollTo(0, s.scrollHeight);
            await sleep(200);

            if (typeof stopWhen === 'function' && stopWhen()) break;

            const h = s.scrollHeight;
            if (h === lastH) {
                if (++stable >= 3) break;
            } else {
                stable = 0;
                lastH = h;
            }
        }
    }

    async function scrollToTopSmooth() {
        const s = scrollEl();
        for (let i = 0; i < 40 && s.scrollTop > 0; i++) {
            window.scrollBy(0, -Math.max(200, window.innerHeight * 0.8));
            await sleep(30);
        }
        window.scrollTo(0, 0);
    }

    async function runScan() {
        try {
            stopScanHint();

            const wasAsc = isCostAscending();
            let clicked = false;

            if (!wasAsc) {
                const btn = findCostSortButton();
                if (btn) {
                    // One click max (compliant)
                    btn.click();
                    clicked = true;
                    await sleep(250);
                }
            }

            const asc = isCostAscending();
            if (asc) {
                log(`Scanning (Cost ↑ ${clicked ? 'after 1 click' : 'detected'})`, 'info');
            } else {
                log(`Scanning (Cost ↑ not detected${clicked ? ' after 1 click' : ''}). Early-stop disabled.`, 'info');
            }

            const hasMax = isFinite(settings.maxPrice);
            const canEarlyStop = asc && hasMax;

            if (hasMax) {
                log(`Max price: $${formatNum(settings.maxPrice)}${canEarlyStop ? ' — early-stop enabled' : ''}`, 'info');
            }

            await scrollToBottomLoad(80, () => canEarlyStop && shouldStopScanByMax());
            await sleep(250);
            await scrollToTopSmooth();

            if (canEarlyStop && shouldStopScanByMax()) {
                log(`Scan stopped early — reached first item above $${formatNum(settings.maxPrice)}.`, 'ok');
            } else {
                log('Scan complete', 'ok');
            }
        } catch (e) {
            log(`Scan error: ${e.message}`, 'err');
        }
    }

    function sortItems(arr) {
        const fns = {
            'price-asc': (a, b) => a.price - b.price,
            'price-desc': (a, b) => b.price - a.price,
            'profit-desc': (a, b) => b.profit - a.profit,
            'totalprofit-desc': (a, b) => b.totalProfit - a.totalProfit,
            'gain-desc': (a, b) => b.gainPct - a.gainPct,
            'name-asc': (a, b) => a.name.localeCompare(b.name),
        };
        const cmp = fns[settings.sort] || fns['price-asc'];
        arr.sort((a, b) => {
            if (a.price === 1 && b.price !== 1) return -1;
            if (b.price === 1 && a.price !== 1) return 1;
            return cmp(a, b);
        });
        return arr;
    }

    function updateUI() {
  $(SEL.counters.cash).text(`$${formatNum(currentCash)}`);

  let arr = Array.from(items.values());

  // Filters
  if (settings.search) {
    const s = settings.search.toLowerCase();
    arr = arr.filter(x => x.name && x.name.toLowerCase().includes(s));
  }
  if (!settings.showNegatives) arr = arr.filter(x => x.profit >= 0 && x.gainPct >= 0);
  if (isFinite(settings.maxPrice)) arr = arr.filter(x => x.price <= settings.maxPrice);

  // Grouping: $1 first, then favorites (only if price <= cap), then the rest
const dollars = arr.filter(x => x.price === 1);
const favPinned = arr.filter(x => x.price !== 1 && isFavActiveForPin(x));
const rest = arr.filter(x => x.price !== 1 && !isFavActiveForPin(x));

// Sort $1 and rest with your chosen sort, favorites by cap-profit desc
sortItems(dollars);
favPinned.sort((a, b) => {
  const db = favCapProfit(b);
  const da = favCapProfit(a);
  if (db !== da) return db - da;                // cap diff (desc)
  // tie-breakers to keep things stable
  if (a.price !== b.price) return a.price - b.price;
  return a.name.localeCompare(b.name);
});
sortItems(rest);

// Final order
arr = [...dollars, ...favPinned, ...rest];

  $(SEL.counters.items).text(arr.length);
$(SEL.counters.dollars).text(dollars.length);
const totalFavIds = (settings.favorites || []).length;
$(SEL.counters.favs).text(favPinned.length).attr('title', `Favorites pinned: ${favPinned.length}/${totalFavIds}`);

  const html = arr.map(x => {
    const fullCost = x.price * x.amount;
    const affordableFull = fullCost <= currentCash;
    const maxQtyAffordable = Math.min(x.amount, Math.floor((currentCash || 0) / x.price));
    const favOn = isFavItem(x.itemId);
const cap = getFavMax(x.itemId);
const favPinnedNow = favOn && x.price <= cap;
const capProfitVal = isFinite(cap) ? (cap - x.price) : null;

const profitTag = (favOn && isFinite(cap) && capProfitVal > 0)
  ? `<span class="tag profit" title="Profit vs cap">+$${formatNum(capProfitVal)}</span>`
  : (x.profit > 0
     ? `<span class="tag profit" title="Profit/u (MV-based)">+$${formatNum(x.profit)}</span>`
     : ``);
      return `
  <div class="it ${x.price === 1 ? 'dollar' : ''} ${favPinnedNow ? 'fav' : ''} ${affordableFull ? '' : 'una'}"
       data-id="${x.id}"
       title="${escapeHtml(x.name)}">
    <div class="top">
      <div class="name" title="Name">${escapeHtml(x.name)}</div>
      <div class="row" style="display:flex; gap:6px; align-items:center;">
        <button class="favtoggle ${favOn ? 'on' : ''}" title="${favOn ? 'Unfavorite' : 'Favorite'}">
          ${favOn ? ICONS.starFilled : ICONS.star}
        </button>
        <span class="tag" title="Qty">${x.amount}×</span>
        <span class="tag price" title="Price">$${formatNum(x.price)}</span>
        ${profitTag}
      </div>
    </div>
        <div class="act" title="Buy qty">
          <input class="input qty" type="number" min="1" max="${x.amount}"
                 value="${Math.min( Math.max(1, maxQtyAffordable || 1), x.amount)}"
                 title="Qty (max ${x.amount})">
          <button class="btn buyqty" ${maxQtyAffordable < 1 ? 'disabled' : ''} title="Buy">Buy</button>
          ${affordableFull ? `<span class="hint" title="Buy all">Click card to buy all</span>` : ``}
        </div>
        ${affordableFull ? `` : `<div class="lack" title="Need cash">Not enough cash</div>`}
      </div>
    `;
  }).join('');

  $(SEL.list).html(html);
}

    function recomputeProfits() {
        for (const it of items.values()) {
            it.profit = profitPerUnit(it.price, it.marketValue, settings.feePercent);
            it.totalProfit = it.profit * it.amount;
            it.gainPct = it.price > 0 ? (it.profit / it.price) * 100 : 0;
        }
    }

    function log(msg, level = 'ok', opts = {}) {
        const time = new Date().toLocaleTimeString([], {
            hour12: false
        });
        const cls = level === 'err' ? 'err' : level === 'info' ? 'info' : 'ok';
        const asHtml = !!opts.html;
        const content = asHtml ? sanitizeLogHTML(String(msg)) : escapeHtml(String(msg));
        $(SEL.log).prepend(`<div class="loge ${cls}">[${time}] ${content}</div>`);
        const kids = $(SEL.log).children();
        if (kids.length > 60) kids.slice(60).remove();
    }

    function sanitizeLogHTML(s) {
        try {
            const tpl = document.createElement('template');
            tpl.innerHTML = String(s);

            function clean(node) {
                const kids = Array.from(node.childNodes);
                for (const n of kids) {
                    if (n.nodeType === Node.TEXT_NODE) continue;
                    if (n.nodeType === Node.ELEMENT_NODE) {
                        const el = n;
                        const tag = el.tagName;
                        const spanAllowed = tag === 'SPAN' && (el.classList.contains('count') || el.classList.contains('total'));
                        const allowBr = tag === 'BR';
                        const allowEm = tag === 'B' || tag === 'STRONG' || tag === 'EM' || tag === 'I' || tag === 'U' || tag === 'S';
                        if (spanAllowed || allowBr || allowEm) {
                            if (spanAllowed) {
                                const keep = ['count', 'total'].filter(c => el.classList.contains(c)).join(' ');
                                el.setAttribute('class', keep);
                                [...el.attributes].forEach(a => {
                                    if (a.name !== 'class') el.removeAttribute(a.name);
                                });
                            } else {
                                [...el.attributes].forEach(a => el.removeAttribute(a.name));
                            }
                            clean(el);
                        } else {
                            el.replaceWith(document.createTextNode(el.textContent || ''));
                        }
                    } else {
                        n.remove();
                    }
                }
            }
            clean(tpl.content);
            tpl.content.querySelectorAll('span.total').forEach(span => {
                const prev = span.previousSibling;
                if (prev && prev.nodeType === Node.TEXT_NODE) {
                    const m = /(.*?)(\s*)\$(\s*)$/.exec(prev.textContent || '');
                    if (m) {
                        prev.textContent = (m[1] || '') + (m[2] || '');
                        span.insertBefore(document.createTextNode('$'), span.firstChild);
                    }
                }
            });
            return tpl.innerHTML;
        } catch {
            return escapeHtml(String(s));
        }
    }

    function htmlToText(s) {
        const div = document.createElement('div');
        div.innerHTML = String(s || '');
        return (div.textContent || '').trim();
    }

    function showSettings(force) {
  const on = force === undefined ? !$(SEL.container).hasClass('mode-settings') : !!force;
  $(SEL.container).toggleClass('mode-settings', on);
  $(SEL.settingsBtn).attr('title', on ? 'Items' : 'Settings');
  if (on) renderFavCapsList();
}

    async function buyItem(it, qty) {
        try {
            qty = Math.max(1, Math.min(it.amount, Number(qty) || 1));
            const url = `https://www.torn.com/bazaar.php?sid=bazaarData&step=buyItem&rfcv=${getRFC()}`;
            const body = new URLSearchParams({
                userID: String(it.sellerUserId),
                id: String(it.id),
                itemid: String(it.itemId),
                amount: String(qty),
                price: String(it.price),
                beforeval: String(it.price * qty),
            });

            let data = null;
            try {
                const res = await fetch(url, {
                    method: 'POST',
                    credentials: 'include',
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
                        'X-Requested-With': 'XMLHttpRequest',
                    },
                    body: body.toString(),
                });
                data = await res.json();
            } catch {
                if (window.$ && $.post) {
                    const raw = await $.post(url, Object.fromEntries(body));
                    data = JSON.parse(raw);
                } else {
                    throw new Error('Could not post buy request');
                }
            }

            if (data?.success) {
                if (data?.text) log(data.text, 'ok', {
                    html: true
                });
                else log(`Bought ${it.name} x${qty} @ $${formatNum(it.price)}`, 'ok');
                if (qty >= it.amount) {
                    bought.add(it.id);
                    items.delete(it.id);
                } else {
                    it.amount -= qty;
                    it.totalProfit = it.profit * it.amount;
                }
                scheduleUI();
            } else {
                const text = data?.text ? htmlToText(data.text) : 'Buy failed';
                log(text, 'err');
                if (text.toLowerCase().includes('someone else') || text.toLowerCase().includes('no longer available')) {
                    items.delete(it.id);
                    scheduleUI();
                }
            }
        } catch (e) {
            log(`Error: ${e.message}`, 'err');
        } finally {
            $(`.it[data-id="${it.id}"]`).removeClass('inflight');
        }
    }

    function onBazaarData(payload) {
        try {
            latestSeller = payload?.ID ?? latestSeller;
            const list = Array.isArray(payload?.list) ? payload.list : [];
            let newDollar = 0;

            for (const x of list) {
                const id = Number(x?.bazaarID ?? x?.id);
                if (!id || bought.has(id)) continue;
                if (x?.isBlockedForBuying) continue;

                const price = parseMoney(x?.price);
                const amount = Number(x?.amount) || 1;
                const mv = Number(x?.averageprice ?? 0);
                const name = x?.name || 'Unknown';
                const sellerUserId = Number(payload?.ID) || latestSeller || 0;

                const ppu = profitPerUnit(price, mv, settings.feePercent);
                const item = {
                    id,
                    itemId: Number(x?.ID ?? 0),
                    sellerUserId,
                    price,
                    amount,
                    marketValue: mv,
                    profit: ppu,
                    totalProfit: ppu * amount,
                    gainPct: price > 0 ? (ppu / price) * 100 : 0,
                    name,
                };

                items.set(id, item);
                if (isFavItem(item.itemId)) setFavName(item.itemId, item.name);

                if (price === 1) {
                    newDollar++;
                    if (settings.notifications) notify('One Bazaar: $1 item', `${name} x${amount}`);
                }
            }

            if (newDollar > 0) log(`${newDollar} new $1 item(s)`, 'info');
            scheduleUI();
        } catch {}
    }

    function hookFetch() {
        if (hookedFetch || !window.fetch) return;
        hookedFetch = true;
        const orig = window.fetch;
        window.fetch = async function(...args) {
            const res = await orig.apply(this, args);
            try {
                const url = res?.url || (args[0] && args[0].url) || '';
                if (url.includes(WATCH_URL_PART)) {
                    const clone = res.clone();
                    const data = await clone.json();
                    onBazaarData(data);
                }
            } catch {}
            return res;
        };
    }

    function hookXHR() {
        if (hookedXHR) return;
        hookedXHR = true;
        const oOpen = XMLHttpRequest.prototype.open;
        const oSend = XMLHttpRequest.prototype.send;
        XMLHttpRequest.prototype.open = function(...args) {
            this._obm_url = args[1];
            return oOpen.apply(this, args);
        };
        XMLHttpRequest.prototype.send = function(...args) {
            this.addEventListener('readystatechange', function() {
                try {
                    if (this.readyState === 4) {
                        const url = this.responseURL || this._obm_url || '';
                        if (url.includes(WATCH_URL_PART)) {
                            try {
                                onBazaarData(JSON.parse(this.responseText));
                            } catch {}
                        }
                    }
                } catch {}
            });
            return oSend.apply(this, args);
        };
    }

    function updateCashFromDOM() {
        try {
            const moneyEls = $(".user-info .money, .money, [class*='money'], [data-money]");
            let best = 0;
            moneyEls.each(function() {
                const text = $(this).text() || $(this).attr('data-money') || '';
                const val = parseMoney(text);
                if (!isNaN(val) && val > best) best = val;
            });
            if (best !== currentCash) {
                currentCash = best;
                scheduleUI();
            }
        } catch {}
    }

    function initCashWatcher() {
        if (cashObs) return;
        updateCashFromDOM();
        cashObs = new MutationObserver(() => {
            if (updateScheduled) setTimeout(updateCashFromDOM, 150);
            else updateCashFromDOM();
        });
        cashObs.observe(document.body, {
            subtree: true,
            childList: true,
            characterData: true
        });
        cashIntervalId = window.setInterval(updateCashFromDOM, 3000);
    }

    function init() {
        if (!/bazaar\.php.*userId=/.test(location.href)) return;
        buildUI();
        hookFetch();
        hookXHR();
        initCashWatcher();
    }

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

    onReady(() => {
        init();
        window.addEventListener('popstate', () => setTimeout(init, 150));
        window.addEventListener('hashchange', () => setTimeout(init, 150));
    });
})();