Files
Torn-Training-Tracker/torn-attribute-tracker.user.js
T

624 lines
29 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'];
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 <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() {
// Gym names live in aria-labels of <button class="gymButton___HASH">.
const buttons = document.querySelectorAll('button[class*="gymButton"]');
for (const btn of buttons) {
const label = btn.getAttribute('aria-label') || '';
for (const name of TAT_KNOWN_GYMS) {
// aria-label format: "Gym Name. Membership cost - $X. ..."
if (label === name || label.indexOf(name + '.') === 0 || label.indexOf(name + ' ') === 0) {
return name;
}
}
}
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-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;
this.root.style.transform = ''; this.root.style.top = ''; this.root.style.bottom = ''; this.root.style.left = ''; this.root.style.right = '';
if (mode === 'free') { this.root.style.bottom = '20px'; this.root.style.left = '20px'; }
else if (anchorInfo && anchorInfo.canAnchor) { this._positionAnchored(anchorInfo.rect); }
else { this.root.style.top = '20px'; this.root.style.left = '50%'; this.root.style.transform = 'translateX(-50%)'; }
}
_positionAnchored(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() {
// 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();
}
})();