// ==UserScript== // @name Torn Attribute Training Tracker // @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, latestDelta = 0, latestTs = -Infinity; for (const e of entries) { if (e.ts >= todayCutoff) trainsToday++; if (e.ts >= weekCutoff) trainsWeek++; if (e.ts > latestTs) { latestTs = e.ts; latestDelta = e.delta; } } const sevenDayAvgPerDay = trainsWeek / 7; const perDay = latestDelta > 0 ? Math.floor(sevenDayAvgPerDay * latestDelta) : 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']; const TAT_KNOWN_GYMS = [ 'Total Bastion', 'Frontline Fitness', 'Premier Fitness', 'Average Joes', "Woody's Workout Club", "Baldr's Gym", 'Sportscience Laboratory', 'Chrome Gym', "Mr. Miyagi's", 'Power House', 'Gym 300', 'Gym 400', 'Gym 500', 'Gym 600', 'Elite Gym', "David's Gym", ]; 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: tatEsc(gym) }; } function tatFindActiveAttributeLi() { // Priority 1: the
  • 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
  • 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
  • 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() { // Gym names live in aria-labels of '; 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 ''; }, this).join(''); const milestoneOptions = TAT_MILESTONES.map(function (m) { const sel = m.value === s.target ? 'selected' : ''; return ''; }).join(''); this.root.innerHTML = '' + '
    ⚙ Attribute Tracker
    ' + '
    Attribute' + tatEsc(s.attr) + ' · ' + tatEsc(s.gym) + '
    ' + '
    Current' + tatFmtFull(s.current) + '
    ' + '
    Target
    ' + '
    ' + '
    Per train' + (s.perTrain ? '+ ' + tatFmtFull(s.perTrain) : '—') + '
    ' + '
    Trains today' + tatFmtFull(s.summary && s.summary.trainsToday || 0) + '
    ' + '
    7-day avg' + (s.summary ? s.summary.sevenDayAvgPerDay.toFixed(1) : '0.0') + ' / day
    ' + '
    Per day' + (s.summary && s.summary.perDay > 0 ? '+ ' + tatFmtFull(s.summary.perDay) : '—') + '
    ' + '
    ' + '
    Remaining' + tatFmtFull(est.remaining) + '
    ' + '
    Trains to go≈ ' + tatFmtFull(est.trainsToGo) + '
    ' + '
    ETA' + (est.days > 0 ? '~ ' + tatFmtFull(est.days) + ' days (' + tatFmtDate(est.eta) + ')' : '—') + '
    ' + '
    ' + modes + '
    ' + (s.warn ? '
    ⚠ ' + tatEsc(s.warn) + '
    ' : '') + (s.anchorError ? '
    ⚠ ' + tatEsc(s.anchorError) + '
    ' : ''); 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() { // Try several selectors in priority order. Torn's gym page structure // varies; we cast a wide net and return the first match. const candidates = [ 'form[action*="train"]', 'form.train-form', 'form[class*="train"]', '[class*="train-button"]', 'button[class*="train"]', 'a[class*="train"]', 'button[name="train"]', 'a[href*="train"]', ]; for (const sel of candidates) { const el = document.querySelector(sel); if (el) { // Prefer the form ancestor if the match is a button/link, since we // want to anchor above the whole form, not just the button. return el.closest('form') || el; } } // Last-ditch: any element inside the gym panel that looks like the // training form. const panel = document.querySelector('.gym, #gym, [class*="gym-"], [class*="Gym"]'); if (panel) { const form = panel.querySelector('form'); if (form) return form; } 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) { const rect = el.getBoundingClientRect(); dialog.setMode('anchored', { canAnchor: true }); dialog._positionAnchored(rect); if (typeof ResizeObserver !== 'undefined') { const ro = new ResizeObserver(function () { if (prefs.mode === 'anchored') dialog._positionAnchored(el.getBoundingClientRect()); }); ro.observe(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 form on this page."; render(); return; } anchorError = null; dialog.setMode('free'); } render(); 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(); } })();