Files
Torn-Training-Tracker/torn-attribute-tracker.user.js
T
dev b626fb7d41 fix(bundle): stop pre-escaping gym name in embedded dom.js (apostrophe fix)
The embedded currentAttribute() in the bundle was returning gym:
tatEsc(gym), but the render template in Dialog.render also escapes with
tatEsc(s.gym). The double-escape turned 'George's' into 'George's',
which the browser decoded to the visible text 'George's'.

The src/dom.js source does NOT pre-escape (returns the raw gym string and
lets the render template handle escaping once). This commit restores the
mirror in the embedded dom.js so the bundle matches the source.

No changes to src/dom.js (it was already correct).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:00:40 -05:00

632 lines
30 KiB
JavaScript

// ==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'];
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-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;');
}
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';
document.body.appendChild(root);
this.root = root;
this.mode = opts.initialMode || 'free';
if (this.mode === 'free') {
root.style.bottom = '20px';
root.style.left = '20px';
if (opts.initialPos && (opts.initialPos.x || opts.initialPos.y)) {
root.style.transform = 'translate(' + opts.initialPos.x + 'px, ' + opts.initialPos.y + 'px)';
}
}
this._wireHeaderDrag();
}
destroy() { 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 in body and position at bottom-left.
if (this.root.parentNode !== document.body) {
document.body.appendChild(this.root);
}
this.root.style.bottom = '20px';
this.root.style.left = '20px';
} else 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();
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 });
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();
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();
}
})();