Torn Attribute Training Tracker

Floating dialog showing attribute target, rate, and ETA on the Torn gym page.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         Torn Attribute Training Tracker
// @license      Unlicense
// @namespace    https://github.com/local/torn-attribute-tracker
// @version      0.1.0
// @description  Floating dialog showing attribute target, rate, and ETA on the Torn gym page.
// @match        https://www.torn.com/gym.php*
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  // ===== pure.js (embedded) =====
  const SUFFIXES = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 };

  function parseTarget(input) {
    if (input === null || input === undefined || input === '') return null;
    if (typeof input === 'number') {
      if (!Number.isFinite(input) || input <= 0) return null;
      return Math.floor(input);
    }
    if (typeof input !== 'string') return null;
    const cleaned = input.replace(/,/g, '').trim().toLowerCase();
    if (cleaned === '') return null;
    if (!/^\d+(\.\d+)?[kmbt]?$/.test(cleaned)) return null;
    const match = cleaned.match(/^(\d+(?:\.\d+)?)([kmbt])?$/);
    if (!match) return null;
    const num = parseFloat(match[1]);
    if (!Number.isFinite(num) || num <= 0) return null;
    const suffix = match[2];
    const multiplier = suffix ? SUFFIXES[suffix] : 1;
    return Math.floor(num * multiplier);
  }

  const THIRTY_DAYS_MS = 30 * 86_400_000;

  function pruneHistory(entries, now) {
    const cutoff = (now || Date.now()) - THIRTY_DAYS_MS;
    return entries.filter((e) => e.ts > cutoff);
  }

  function computeEstimate(current, target, perTrain, perDay) {
    const remaining = Math.max(0, target - current);
    if (remaining === 0) return { remaining: 0, trainsToGo: 0, days: 0, eta: null };
    const trainsToGo = perTrain > 0 ? Math.ceil(remaining / perTrain) : 0;
    const days = perDay > 0 ? Math.ceil(remaining / perDay) : 0;
    const eta = days > 0 ? new Date(Date.now() + days * 86_400_000) : null;
    return { remaining, trainsToGo, days, eta };
  }

  function summary(entries, now) {
    const t = now || Date.now();
    if (entries.length === 0) return { trainsToday: 0, sevenDayAvgPerDay: 0, perDay: 0 };
    const ONE_DAY = 86_400_000, SEVEN_DAYS = 7 * ONE_DAY;
    const todayCutoff = t - ONE_DAY, weekCutoff = t - SEVEN_DAYS;
    let trainsToday = 0, trainsWeek = 0, totalDeltaWeek = 0;
    for (const e of entries) {
      if (e.ts >= todayCutoff) trainsToday++;
      if (e.ts >= weekCutoff) { trainsWeek++; totalDeltaWeek += e.delta; }
    }
    const sevenDayAvgPerDay = trainsWeek / 7;
    const perDay = trainsWeek > 0 ? Math.floor(totalDeltaWeek / 7) : 0;
    return { trainsToday, sevenDayAvgPerDay, perDay };
  }

  // ===== store.js (embedded) =====
  const KEY_TARGETS = 'tat.targets';
  const KEY_HISTORY = 'tat.history';
  const KEY_PREFS = 'tat.prefs';
  const DEFAULT_PREFS = { version: 1, mode: 'free', pos: { x: 0, y: 0 } };

  class Store {
    constructor(opts) {
      opts = opts || {};
      const storage = opts.storage || localStorage;
      const onWarn = opts.onWarn || function (m) { console.warn(m); };
      if (!storage) throw new Error('Store requires storage');
      this.storage = storage;
      this.onWarn = onWarn;
      this._saveDisabled = false;
      this.targets = this._loadJson(KEY_TARGETS, {});
      this.history = this._loadJson(KEY_HISTORY, {});
      this.prefs = this._mergePrefs(this._loadJson(KEY_PREFS, null));
    }
    _loadJson(key, fallback) {
      let raw;
      try { raw = this.storage.getItem(key); } catch { return fallback; }
      if (raw == null) return fallback;
      try { return JSON.parse(raw); }
      catch { this.onWarn('[tat] discarding corrupted ' + key); try { this.storage.removeItem(key); } catch {} return fallback; }
    }
    _saveJson(key, value) {
      if (this._saveDisabled) return false;
      try { this.storage.setItem(key, JSON.stringify(value)); return true; }
      catch (e) { this.onWarn('[tat] failed to persist ' + key + ': ' + e.message + '; further saves disabled for this session'); this._saveDisabled = true; return false; }
    }
    _mergePrefs(loaded) {
      if (!loaded || loaded.version !== 1) return Object.assign({}, DEFAULT_PREFS);
      return Object.assign({}, DEFAULT_PREFS, loaded);
    }
    getTarget(attr) { const v = this.targets[attr]; return typeof v === 'number' && v > 0 ? v : null; }
    setTarget(attr, value) {
      const parsed = parseTarget(value);
      if (parsed === null) return false;
      this.targets[attr] = parsed;
      return this._saveJson(KEY_TARGETS, this.targets);
    }
    getSummary(attr, now) { return summary(this.history[attr] || [], now); }
    recordTrain(attr, delta, ts) {
      ts = ts || Date.now();
      if (typeof delta !== 'number' || !Number.isFinite(delta) || delta <= 0) return false;
      const list = this.history[attr] || [];
      list.push({ ts: ts, delta: delta });
      this.history[attr] = pruneHistory(list, ts);
      return this._saveJson(KEY_HISTORY, this.history);
    }
    getPrefs() { return Object.assign({}, this.prefs); }
    setMode(mode) { if (mode !== 'free' && mode !== 'anchored') return false; this.prefs.mode = mode; return this._saveJson(KEY_PREFS, this.prefs); }
    setPos(pos) { if (!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number') return false; this.prefs.pos = { x: pos.x, y: pos.y }; return this._saveJson(KEY_PREFS, this.prefs); }
  }

  // ===== dom.js (embedded) =====
  const TAT_KNOWN_ATTRS = ['strength', 'defense', 'speed', 'dexterity', 'endurance', 'intelligence'];
  function currentAttribute() {
    const li = tatFindActiveAttributeLi();
    if (!li) return null;
    const attr = tatExtractAttrFromLi(li);
    if (!attr) return null;
    const current = tatExtractValueFromLi(li);
    if (current == null) return null;
    const gym = tatFindGymName() || 'Unknown gym';
    return { attr: attr, current: current, gym: gym };
  }
  function tatFindActiveAttributeLi() {
    // Priority 1: the <li> with the "success" class (just trained).
    const lis = document.querySelectorAll('ul[class*="properties"] > li[class*="success"]');
    for (const li of lis) {
      if (tatExtractAttrFromLi(li)) return li;
    }
    // Priority 2: the <li> corresponding to the .gained message's attribute.
    const gained = document.querySelector('[class*="gained"]');
    if (gained) {
      const text = (gained.textContent || '').toLowerCase();
      for (const attr of TAT_KNOWN_ATTRS) {
        if (text.indexOf(attr) !== -1) {
          const li = document.querySelector('ul[class*="properties"] > li[class^="' + attr + '___"]');
          if (li) return li;
        }
      }
    }
    // Priority 3: the first <li> in the properties list.
    const all = document.querySelectorAll('ul[class*="properties"] > li');
    for (const li of all) {
      if (tatExtractAttrFromLi(li)) return li;
    }
    return null;
  }
  function tatExtractAttrFromLi(li) {
    const cls = li.className || '';
    const parts = cls.split(/\s+/);
    for (const attr of TAT_KNOWN_ATTRS) {
      const prefix = attr + '___';
      for (const c of parts) {
        if (c.indexOf(prefix) === 0) return attr;
      }
    }
    return null;
  }
  function tatExtractValueFromLi(li) {
    const valueSpan = li.querySelector('[class^="propertyValue"]');
    if (!valueSpan) return null;
    return tatParseNumber(valueSpan.textContent);
  }
  function tatFindGymName() {
    // Find the currently selected gym button. It has the "active" class.
    const activeBtn = document.querySelector('button[class*="gymButton"][class*="active"]');
    if (activeBtn) {
      const label = activeBtn.getAttribute('aria-label') || '';
      // aria-label format: "<Gym Name>. Membership cost - $X. Energy usage - N per train."
      // The gym name is everything before the first ". ".
      const dot = label.indexOf('. ');
      if (dot !== -1) return label.slice(0, dot);
      return label; // no period, return whole label as fallback
    }
    return null;
  }
  function tatParseNumber(text) {
    if (!text) return null;
    const cleaned = String(text).replace(/,/g, '').trim();
    if (!/^\d+(\.\d+)?$/.test(cleaned)) return null;
    const n = parseFloat(cleaned);
    return Number.isFinite(n) ? Math.floor(n) : null;
  }

  // ===== interceptor.js (embedded) =====
  function startRequestInterceptor(opts) {
    let lastValue = opts.prevValue;
    let warnedFor = null;
    function handle(text, url) {
      const parsed = parseTrainResponse(text, url, opts.currentAttr);
      if (!parsed) { if (warnedFor !== url) { warnedFor = url; opts.onParseFail && opts.onParseFail(url); } return; }
      let delta;
      if (typeof parsed.delta === 'number' && parsed.delta > 0) {
        delta = parsed.delta;
      } else if (typeof parsed.newValue === 'number' && parsed.newValue > 0) {
        delta = parsed.newValue - lastValue;
        lastValue = parsed.newValue;
      } else {
        return;
      }
      if (delta <= 0) return;
      const attr = parsed.attr || opts.currentAttr;
      if (!attr) return;
      opts.onTrain({ attr: attr, delta: delta, ts: Date.now() });
    }
    wrapXhr(handle); wrapFetch(handle);
    return { updatePrevValue: function (v) { lastValue = v; } };
  }
  function parseTrainResponse(text, url, fallbackAttr) {
    // Strategy 1: look for the "gained" message in the response.
    // Format: "You gained <number> <attr>" (e.g. "You gained 10,885.76 dexterity").
    // Torn sometimes prefixes with other text (e.g. "You gained 10,885.76 dexterity"),
    // so we match the number-and-attribute-name pattern directly.
    const gainedMatch = text.match(/[Yy]ou\s+gained\s+([\d,]+(?:\.\d+)?)\s+(strength|defense|speed|dexterity|endurance|intelligence)\b/i);
    if (gainedMatch) {
      const delta = parseFloat(gainedMatch[1].replace(/,/g, ''));
      const attr = gainedMatch[2].toLowerCase();
      if (Number.isFinite(delta) && delta >= 0) {
        return { delta: delta, attr: attr };
      }
    }
    // Strategy 2: JSON with newValue + attr.
    try {
      const j = JSON.parse(text);
      if (j && typeof j === 'object' && 'newValue' in j && 'attr' in j) {
        return { newValue: Number(j.newValue), attr: String(j.attr) };
      }
    } catch {}
    // Strategy 3: regex fallback (last resort). Don't use the first number
    // blindly; look specifically for the propertyValue span content, which
    // is the authoritative source.
    const propertyValueMatch = text.match(/class="propertyValue[^"]*"[^>]*>([\d,]+(?:\.\d+)?)</);
    if (propertyValueMatch) {
      const newValue = parseInt(propertyValueMatch[1].replace(/,/g, ''), 10);
      if (Number.isFinite(newValue) && newValue > 0) {
        return { newValue: newValue, attr: fallbackAttr || null };
      }
    }
    return null;
  }
  function wrapXhr(handle) {
    if (XMLHttpRequest.prototype.send.__tatWrapped) return;
    const origOpen = XMLHttpRequest.prototype.open;
    const origSend = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.open = function (method, url) { this.__tatUrl = String(url); return origOpen.apply(this, arguments); };
    XMLHttpRequest.prototype.send = function () {
      this.addEventListener('load', function () { try { handle(this.responseText, this.__tatUrl); } catch {} });
      return origSend.apply(this, arguments);
    };
    XMLHttpRequest.prototype.send.__tatWrapped = true;
  }
  function wrapFetch(handle) {
    const origFetch = window.fetch;
    if (origFetch.__tatWrapped) return;
    window.fetch = async function () {
      const url = typeof arguments[0] === 'string' ? arguments[0] : (arguments[0] && arguments[0].url) || '';
      const res = await origFetch.apply(this, arguments);
      try { const text = await res.clone().text(); handle(text, String(url)); } catch {}
      return res;
    };
    window.fetch.__tatWrapped = true;
  }

  // ===== ui.js (embedded) =====
  const TAT_STYLE = `
  .tat-root { position: fixed; z-index: 99999; min-width: 320px; max-width: 420px; background: #2b2b2b; color: #ddd; border: 1px solid #444; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); font: 13px/1.4 Tahoma, Verdana, sans-serif; padding: 12px 14px; }
  .tat-root.tat-anchored { position: static; margin: 0 0 12px 0; max-width: none; box-shadow: none; border-radius: 0; border: 1px solid #444; border-top: 2px solid #c00; padding: 16px 20px; }
  .tat-root.tat-anchored .tat-header { cursor: default; }
  .tat-root.tat-free { bottom: 20px !important; left: 20px !important; }
  .tat-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 8px; margin-bottom: 10px; border-bottom: 1px solid #444; cursor: move; user-select: none; }
  .tat-header strong { color: #fff; }
  .tat-close { cursor: pointer; opacity: 0.6; padding: 0 4px; }
  .tat-close:hover { opacity: 1; }
  .tat-row { display: flex; justify-content: space-between; padding: 2px 0; }
  .tat-row.tat-target input, .tat-row.tat-target select { background: #1a1a1a; color: #ddd; border: 1px solid #555; padding: 2px 4px; font: inherit; font-size: 12px; }
  .tat-hr { border: none; border-top: 1px solid #444; margin: 8px 0; }
  .tat-modes { display: flex; gap: 6px; margin-top: 12px; }
  .tat-modes button { flex: 1; padding: 4px; background: #2b2b2b; color: #ddd; border: 1px solid #555; font: inherit; font-size: 11px; cursor: pointer; }
  .tat-modes button.active { background: #444; border-color: #888; }
  .tat-warn { color: #c90; margin-top: 6px; font-size: 11px; }
  .tat-anchor-err { color: #c90; margin-top: 6px; font-size: 11px; }
  .tat-error { padding: 8px 0; color: #f88; }
  .tat-error button { margin-left: 8px; }
  `;

  const TAT_MILESTONES = [
    { label: 'Custom', value: null },
    { label: '1M', value: 1_000_000 }, { label: '5M', value: 5_000_000 },
    { label: '10M', value: 10_000_000 }, { label: '25M', value: 25_000_000 },
    { label: '50M', value: 50_000_000 }, { label: '100M', value: 100_000_000 },
    { label: '250M', value: 250_000_000 }, { label: '500M', value: 500_000_000 },
    { label: '1B', value: 1_000_000_000 },
  ];

  function tatFmt(n) {
    if (n == null) return '—';
    if (n >= 1e9) return (n / 1e9).toFixed(2).replace(/\.?0+$/, '') + 'B';
    if (n >= 1e6) return (n / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M';
    if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.?0+$/, '') + 'K';
    return String(n);
  }
  function tatFmtFull(n) { if (n == null) return '—'; return Math.round(n).toLocaleString('en-US'); }
  function tatFmtDate(d) { if (!d) return '—'; return d.toLocaleDateString('en-US', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' }); }
  function tatEsc(s) {
    if (s == null) return '';
    return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
  }

  // Read TornTools' transform on <body>. TornTools applies a CSS transform
  // to <body> to make room for its sidebar, which would otherwise push our
  // position:fixed dialog off-screen. Returns the translation values from
  // the matrix, or (0, 0) if there's no transform.
  function getBodyTransformOffset() {
    var t = getComputedStyle(document.body).transform;
    if (!t || t === 'none') return { x: 0, y: 0 };
    var m2 = t.match(/matrix\(([^)]+)\)/);
    if (m2) {
      var parts = m2[1].split(',').map(parseFloat);
      return { x: parts[4] || 0, y: parts[5] || 0 };
    }
    var m3 = t.match(/matrix3d\(([^)]+)\)/);
    if (m3) {
      var parts3 = m3[1].split(',').map(parseFloat);
      return { x: parts3[12] || 0, y: parts3[13] || 0 };
    }
    return { x: 0, y: 0 };
  }
  // Apply a counter-transform to the dialog to cancel TornTools' body
  // transform. If there's no body transform, clears any existing counter.
  function applyBodyTransformCompensation(dialog) {
    var off = getBodyTransformOffset();
    if (off.x === 0 && off.y === 0) {
      dialog.style.transform = '';
    } else {
      dialog.style.transform = 'translate(' + (-off.x) + 'px, ' + (-off.y) + 'px)';
    }
  }

  class Dialog {
    constructor(opts) {
      opts = opts || {};
      this.onTargetChange = opts.onTargetChange;
      this.onModeChange = opts.onModeChange;
      this.onPosChange = opts.onPosChange;
      this.onClose = opts.onClose;
      this.root = null; this.dragState = null; this.mode = 'free';
    }
    mount(opts) {
      opts = opts || {};
      if (this.root) return;
      if (typeof document === 'undefined') return;
      if (!document.getElementById('tat-style')) {
        const s = document.createElement('style'); s.id = 'tat-style'; s.textContent = TAT_STYLE; document.head.appendChild(s);
      }
      const root = document.createElement('div');
      root.className = 'tat-root';
      root.dataset.tat = '1';
      // Mount on <body>. TornTools applies a CSS transform to <body> to make
      // room for its left sidebar; we compensate for that transform via
      // applyBodyTransformCompensation() so the dialog still appears in the
      // viewport. (Earlier versions mounted on documentElement to escape the
      // transform, but that put our dialog in a place TornTools didn't expect
      // and triggered their own internal TypeError / SyntaxError.)
      document.body.appendChild(root);
      this.root = root;
      this.mode = opts.initialMode || 'free';
      if (this.mode === 'free') {
        root.style.bottom = '20px';
        root.style.left = '20px';
        root.classList.add('tat-free');
        if (opts.initialPos && (opts.initialPos.x || opts.initialPos.y)) {
          root.style.transform = 'translate(' + opts.initialPos.x + 'px, ' + opts.initialPos.y + 'px)';
        }
      }
      this._wireHeaderDrag();
      // Compensate for TornTools' body transform (or any other ancestor
      // transform that would push position:fixed off-screen).
      applyBodyTransformCompensation(root);
      // Watch for body transform changes (e.g., TornTools sidebar toggle)
      // and re-apply the counter-transform.
      if (typeof MutationObserver !== 'undefined') {
        const self = this;
        this._bodyObserver = new MutationObserver(function () {
          if (self.root) applyBodyTransformCompensation(self.root);
        });
        this._bodyObserver.observe(document.body, { attributes: true, attributeFilter: ['style'] });
      }
      // Delayed defense: TornTools may set its inline styles after our
      // initial mount. Re-add the tat-free class (with !important rules)
      // and re-apply the counter-transform after 1s to make sure we win.
      if (this.mode === 'free') {
        const self = this;
        setTimeout(function () {
          if (self.root && self.mode === 'free') {
            self.root.classList.add('tat-free');
            applyBodyTransformCompensation(self.root);
          }
        }, 1000);
      }
    }
    destroy() {
      if (this._bodyObserver) { this._bodyObserver.disconnect(); this._bodyObserver = null; }
      if (this.root && this.root.parentNode) this.root.parentNode.removeChild(this.root);
      this.root = null;
    }
    setMode(mode, anchorInfo) {
      this.mode = mode;
      if (!this.root) return;
      // Clear all position styles
      this.root.style.transform = ''; this.root.style.top = ''; this.root.style.bottom = ''; this.root.style.left = ''; this.root.style.right = '';
      this.root.classList.remove('tat-anchored');
      if (mode === 'free') {
        // Floating mode: ensure dialog is on <body> and position at bottom-left.
        // See mount() for why we mount on <body> and compensate for its transform.
        if (this.root.parentNode !== document.body) {
          document.body.appendChild(this.root);
        }
        this.root.style.bottom = '20px';
        this.root.style.left = '20px';
        this.root.classList.add('tat-free');
        this.root.classList.remove('tat-anchored');
        // Re-apply TornTools body-transform compensation in case the body's
        // transform changed while the dialog was in a different mode.
        applyBodyTransformCompensation(this.root);
      } else {
        // Any non-free mode: clear the !important free-positioning class
        // so the dialog can be positioned by other means (anchored or fallback).
        this.root.classList.remove('tat-free');
        if (anchorInfo && anchorInfo.canAnchor) {
          if (anchorInfo.insertBefore) {
            // Docked mode: insert the dialog into the page flow before the
            // given element, and add the tat-anchored class to switch to
            // static positioning.
            anchorInfo.insertBefore.parentNode.insertBefore(this.root, anchorInfo.insertBefore);
            this.root.classList.add('tat-anchored');
          } else if (anchorInfo.rect) {
            // Fallback: position fixed above the rect (old behavior, used when
            // no insertion point is available but a rect was given).
            this._positionAnchored(anchorInfo.rect);
          }
          // If neither insertBefore nor rect, leave the dialog where it is
          // (the caller will show an anchorError note).
        } else {
          // Top-center fallback (used when mode is anchored but no anchor info).
          this.root.style.top = '20px';
          this.root.style.left = '50%';
          this.root.style.transform = 'translateX(-50%)';
        }
      }
    }
    _positionAnchored(rect) {
      if (!rect) return;  // defensive: setMode may be called without a rect
      const dialogRect = this.root.getBoundingClientRect();
      let top = rect.top - dialogRect.height - 8;
      if (top < 8) top = 20;
      let left = rect.left + (rect.width - dialogRect.width) / 2;
      if (left < 8) left = 8;
      if (left + dialogRect.width > window.innerWidth - 8) left = window.innerWidth - dialogRect.width - 8;
      this.root.style.top = top + 'px'; this.root.style.left = left + 'px';
    }
    _wireHeaderDrag() {
      const self = this;
      this.root.addEventListener('mousedown', function (e) {
        if (self.mode !== 'free') return;
        // Only initiate drag from the header bar. This prevents stealing focus
        // from inputs, selects, and buttons inside the dialog body.
        if (!e.target.closest('.tat-header')) return;
        if (e.target.classList.contains('tat-close')) return;
        const rect = self.root.getBoundingClientRect();
        self.dragState = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
        e.preventDefault();
        // Remove tat-free class during drag so our !important rule doesn't
        // fight the drag's inline top/left/bottom='auto' positioning.
        self.root.classList.remove('tat-free');

        const onMove = function (ev) {
          if (!self.dragState) return;
          const x = ev.clientX - self.dragState.dx, y = ev.clientY - self.dragState.dy;
          self.root.style.left = x + 'px'; self.root.style.top = y + 'px';
          self.root.style.right = 'auto'; self.root.style.bottom = 'auto';
        };
        const onUp = function () {
          if (!self.dragState) return;
          const r = self.root.getBoundingClientRect();
          self.dragState = null;
          self.onPosChange && self.onPosChange({ x: r.left, y: r.top });
          // Re-add the class if still in free mode, so TornTools can't
          // push the dialog off-screen again.
          if (self.root && self.mode === 'free') {
            self.root.classList.add('tat-free');
          }
          document.removeEventListener('mousemove', onMove);
          document.removeEventListener('mouseup', onUp);
        };
        document.addEventListener('mousemove', onMove);
        document.addEventListener('mouseup', onUp);
      });
    }
    render(state) {
      if (!this.root) return;
      const s = state;
      const self = this;
      if (s.error) {
        this.root.innerHTML = '<div class="tat-header"><strong>⚙ Attribute Tracker</strong><span class="tat-close">✕</span></div><div class="tat-error">' + tatEsc(s.error) + '<button data-action="reload">Reload</button></div>';
        this.root.querySelector('[data-action="reload"]').onclick = function () { location.reload(); };
        this.root.querySelector('.tat-close').onclick = function () { self.onClose && self.onClose(); };
        return;
      }
      const est = computeEstimate(s.current || 0, s.target || 0, s.perTrain || 0, (s.summary && s.summary.perDay) || 0);
      const modes = ['free', 'anchored'].map(function (m) {
        return '<button data-mode="' + m + '" class="' + (this.mode === m ? 'active' : '') + '">' + (m === 'free' ? 'Float free' : 'Above training UI') + '</button>';
      }, this).join('');
      const milestoneOptions = TAT_MILESTONES.map(function (m) {
        const sel = m.value === s.target ? 'selected' : '';
        return '<option value="' + (m.value == null ? '' : m.value) + '" ' + sel + '>' + m.label + '</option>';
      }).join('');
      this.root.innerHTML = ''
        + '<div class="tat-header"><strong>⚙ Attribute Tracker</strong><span class="tat-close" title="Hide for this session">✕</span></div>'
        + '<div class="tat-row"><span>Attribute</span><span><strong>' + tatEsc(s.attr) + '</strong> · <em>' + tatEsc(s.gym) + '</em></span></div>'
        + '<div class="tat-row"><span>Current</span><span>' + tatFmtFull(s.current) + '</span></div>'
        + '<div class="tat-row tat-target"><span>Target</span><span><input data-role="target" value="' + (s.target || '') + '" placeholder="e.g. 25M" style="width:110px"><select data-role="milestone">' + milestoneOptions + '</select></span></div>'
        + '<hr class="tat-hr">'
        + '<div class="tat-row"><span>Per train</span><span>' + (s.perTrain ? '+ ' + tatFmtFull(s.perTrain) : '—') + '</span></div>'
        + '<div class="tat-row"><span>Trains today</span><span>' + tatFmtFull(s.summary && s.summary.trainsToday || 0) + '</span></div>'
        + '<div class="tat-row"><span>7-day avg</span><span>' + (s.summary ? s.summary.sevenDayAvgPerDay.toFixed(1) : '0.0') + ' / day</span></div>'
        + '<div class="tat-row"><span>Per day</span><span>' + (s.summary && s.summary.perDay > 0 ? '+ ' + tatFmtFull(s.summary.perDay) : '—') + '</span></div>'
        + '<hr class="tat-hr">'
        + '<div class="tat-row"><span>Remaining</span><span>' + tatFmtFull(est.remaining) + '</span></div>'
        + '<div class="tat-row"><span>Trains to go</span><span>≈ ' + tatFmtFull(est.trainsToGo) + '</span></div>'
        + '<div class="tat-row"><span>ETA</span><span>' + (est.days > 0 ? '~ ' + tatFmtFull(est.days) + ' days (' + tatFmtDate(est.eta) + ')' : '—') + '</span></div>'
        + '<div class="tat-modes">' + modes + '</div>'
        + (s.warn ? '<div class="tat-warn">⚠ ' + tatEsc(s.warn) + '</div>' : '')
        + (s.anchorError ? '<div class="tat-anchor-err">⚠ ' + tatEsc(s.anchorError) + '</div>' : '');
      this.root.querySelector('.tat-close').onclick = function () { self.onClose && self.onClose(); };
      this.root.querySelector('[data-role="target"]').onchange = function (e) { self.onTargetChange && self.onTargetChange(e.target.value); };
      this.root.querySelector('[data-role="milestone"]').onchange = function (e) { const v = e.target.value; if (v !== '') self.onTargetChange && self.onTargetChange(Number(v)); };
      this.root.querySelectorAll('[data-mode]').forEach(function (btn) { btn.onclick = function () { self.onModeChange && self.onModeChange(btn.dataset.mode); }; });
    }
  }

  // ===== main.js (embedded) =====
  function findAnchorElement() {
    // Return the element to insert the dialog BEFORE in the DOM.
    // The user wants the dialog between the notification wrapper and the
    // gym content wrapper; we insert before gymContentWrapper.
    const candidates = [
      '[class*="gymContentWrapper"]',
      '[class*="gymContent"]',
      'ul[class*="properties"]',
      // Legacy fallbacks (kept in case Torn ever wraps the list in a form):
      'form[action*="train"]',
      'form.train-form',
      'form[class*="train"]',
      '[class*="train-button"]',
      'button[class*="train"]',
      'a[href*="train"]',
      'button[name="train"]',
      'a[href*="train"]',
    ];
    for (const sel of candidates) {
      const el = document.querySelector(sel);
      if (el) return el;
    }
    return null;
  }

  function start() {
    if (window.__tat_started) return; window.__tat_started = true;
    try {
      const store = new Store({ storage: localStorage, onWarn: function (m) { console.warn(m); } });
      const prefs = store.getPrefs();

      // State that applyMode() and render() may touch on first call.
      // Declared up-front to avoid TDZ ReferenceError if applyMode()'s
      // anchor-miss branch fires before the natural declaration point.
      let lastSnapshot = null;
      let lastAttr = null;
      let lastDelta = 0;
      let anchorError = null;

      // One-time migration: dialog now defaults to bottom-left, so reset any
      // previously-saved position from the bottom-right era.
      if (prefs.pos && (prefs.pos.x !== 0 || prefs.pos.y !== 0)) {
        console.info('[tat] resetting dialog position to new bottom-left default');
        prefs.pos = { x: 0, y: 0 };
        store.setPos(prefs.pos);
      }

      const dialog = new Dialog({
        onTargetChange: function (v) {
          const a = currentAttribute(); if (!a) return; store.setTarget(a.attr, v); render();
        },
        onModeChange: function (m) { store.setMode(m); prefs.mode = m; applyMode(); },
        onPosChange: function (pos) { store.setPos(pos); },
        onClose: function () { dialog.destroy(); },
      });

      dialog.mount({ initialMode: prefs.mode, initialPos: prefs.pos });
      applyMode();

      function snapshot() {
        const a = currentAttribute();
        if (!a) return { error: "Couldn't read attribute — Torn may have updated the page." };
        lastAttr = a.attr;
        const summary = store.getSummary(a.attr);
        return {
          attr: a.attr, gym: a.gym, current: a.current,
          target: store.getTarget(a.attr), perTrain: lastDelta, summary: summary,
          warn: store._saveDisabled ? 'saving disabled this session' : null,
          anchorError: anchorError,
        };
      }

      function render() { lastSnapshot = snapshot(); dialog.render(lastSnapshot); }

      function applyMode() {
        if (prefs.mode === 'anchored') {
          const el = findAnchorElement();
          if (el) {
            dialog.setMode('anchored', { canAnchor: true, insertBefore: el });
            anchorError = null;
            return;
          }
          // Anchor selector missed — don't snap to default, just keep current
          // position and show a note.
          anchorError = "Couldn't find the training UI on this page.";
          render();
          return;
        }
        anchorError = null;
        dialog.setMode('free');
      }

      render();

      // Retry anchor mode when the DOM changes. Helpful when TornTools
      // lazy-loads the gym content after our initial applyMode() runs.
      const anchorObserver = new MutationObserver(function () {
        if (prefs.mode === 'anchored' && anchorError) {
          const el = findAnchorElement();
          if (el) applyMode();
        }
      });
      anchorObserver.observe(document.body, { childList: true, subtree: true });

      let pending = false;
      const observer = new MutationObserver(function () {
        if (pending) return;
        pending = true;
        requestAnimationFrame(function () {
          pending = false;
          const a = currentAttribute();
          if (a && (a.attr !== lastAttr || a.current !== (lastSnapshot && lastSnapshot.current))) render();
        });
      });
      observer.observe(document.body, { childList: true, subtree: true, characterData: true });

      const prev = (currentAttribute() && currentAttribute().current) || 0;
      startRequestInterceptor({
        prevValue: prev, currentAttr: lastAttr,
        onTrain: function (e) { store.recordTrain(e.attr, e.delta, e.ts); lastDelta = e.delta; render(); },
        onParseFail: function (url) { console.warn('[tat] could not parse train response from', url); },
      });
    } catch (e) {
      console.error('[tat] failed to start:', e);
    }
  }

  // ===== self-test (only when location.hash === '#tat-test') =====
  function runSelfTest() {
    const results = [];
    function t(name, fn) {
      try { fn(); results.push('OK   ' + name); }
      catch (e) { results.push('FAIL ' + name + ': ' + e.message); }
    }

    t('parseTarget integer', function () { if (parseTarget(25) !== 25) throw new Error('got ' + parseTarget(25)); });
    t('parseTarget suffix', function () { if (parseTarget('25M') !== 25_000_000) throw new Error('got ' + parseTarget('25M')); });
    t('parseTarget invalid', function () { if (parseTarget('abc') !== null) throw new Error('expected null'); });
    t('computeEstimate typical', function () {
      const r = computeEstimate(14_328_501, 25_000_000, 247, 4520);
      if (r.remaining !== 10_671_499) throw new Error('remaining');
      if (r.trainsToGo !== 43_205) throw new Error('trainsToGo');
      if (r.days !== 2_361) throw new Error('days'); // 10_671_499 / 4_520 = 2360.95... → ceil = 2361
    });
    t('computeEstimate reached', function () {
      const r = computeEstimate(25_000_000, 25_000_000, 247, 4520);
      if (r.eta !== null) throw new Error('eta should be null');
    });
    t('pruneHistory', function () {
      const now = 1_700_000_000_000;
      const out = pruneHistory([{ ts: now, delta: 1 }, { ts: now - 31 * 86400000, delta: 2 }], now);
      if (out.length !== 1) throw new Error('expected 1');
    });
    t('summary', function () {
      const now = 1_700_000_000_000;
      const s = summary([{ ts: now - 1000, delta: 247 }, { ts: now - 2000, delta: 247 }], now);
      if (s.trainsToday !== 2) throw new Error('trainsToday');
      if (Math.abs(s.perDay - 70) > 0.01) throw new Error('perDay, got ' + s.perDay);
    });

    console.log('[tat] self-test results:\n' + results.join('\n'));
  }

  // ===== exports for tests / console =====
  window.TAT = { parseTarget: parseTarget, computeEstimate: computeEstimate, pruneHistory: pruneHistory, summary: summary, Store: Store, Dialog: Dialog, currentAttribute: currentAttribute, startRequestInterceptor: startRequestInterceptor };

  // ===== entrypoint =====
  if (location.hash === '#tat-test') {
    runSelfTest();
  } else if (/\/gym\.php(\?|$)/.test(location.pathname + location.search)) {
    start();
  }
})();