Torn Attribute Training Tracker

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey to install this script.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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