From eb03d54b3d5869e1b6f5ab1d7616945fef105c26 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Wed, 8 Jul 2026 07:53:15 -0500 Subject: [PATCH] Initial commit --- .vscode/settings.json | 16 + .../torn-training-tracker.js | 725 ++++++++++++++++++ 2 files changed, 741 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 Torn Training Tracker/torn-training-tracker.js diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..24bdbe9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "workbench.colorCustomizations": { + "tree.indentGuidesStroke": "#3d92ec", + "activityBar.background": "#594905", + "titleBar.activeBackground": "#7D6607", + "titleBar.activeForeground": "#FEFBEC", + "titleBar.inactiveBackground": "#594905", + "titleBar.inactiveForeground": "#FEFBEC", + "statusBar.background": "#594905", + "statusBar.foreground": "#FEFBEC", + "statusBar.debuggingBackground": "#594905", + "statusBar.debuggingForeground": "#FEFBEC", + "statusBar.noFolderBackground": "#594905", + "statusBar.noFolderForeground": "#FEFBEC" + } +} diff --git a/Torn Training Tracker/torn-training-tracker.js b/Torn Training Tracker/torn-training-tracker.js new file mode 100644 index 0000000..cd23e54 --- /dev/null +++ b/Torn Training Tracker/torn-training-tracker.js @@ -0,0 +1,725 @@ +// ==UserScript== +// @name Torn 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, 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
  • 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() { + // 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: ". 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 " (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+)?) 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, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + // Read TornTools' transform on . TornTools applies a CSS transform + // to 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 . TornTools applies a CSS transform to 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 and position at bottom-left. + // See mount() for why we mount on 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 = '
    ⚙ Attribute Tracker
    ' + tatEsc(s.error) + '
    '; + 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() { + // 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(); + } +})();