feat: bundle torn-attribute-tracker.user.js (Tat-style Tampermonkey script)
This commit is contained in:
@@ -0,0 +1,538 @@
|
|||||||
|
// ==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 ? 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) =====
|
||||||
|
function currentAttribute() {
|
||||||
|
const KNOWN = ['strength', 'defense', 'speed', 'dexterity', 'endurance', 'intelligence'];
|
||||||
|
const ATTR_RE = new RegExp('\\b(' + KNOWN.join('|') + ')\\b');
|
||||||
|
const headers = document.querySelectorAll('h1, h2, h3, h4, .title, .gym-title, [class*="gym"]');
|
||||||
|
let attr = null, attrEl = null;
|
||||||
|
for (const el of headers) {
|
||||||
|
const t = (el.textContent || '').trim().toLowerCase();
|
||||||
|
const m = t.match(ATTR_RE);
|
||||||
|
if (m) { attr = m[1]; attrEl = el; break; }
|
||||||
|
}
|
||||||
|
if (!attr) return null;
|
||||||
|
let valEl = findValueNear(attrEl);
|
||||||
|
if (!valEl) valEl = findValueElement();
|
||||||
|
if (!valEl) return null;
|
||||||
|
const current = parseNumber(valEl.textContent);
|
||||||
|
if (current == null) return null;
|
||||||
|
const gym = findGymName() || 'Unknown gym';
|
||||||
|
return { attr: attr, current: current, gym: gym };
|
||||||
|
}
|
||||||
|
function findValueNear(el) {
|
||||||
|
const scope = [];
|
||||||
|
let cur = el;
|
||||||
|
for (let depth = 0; depth < 3 && cur; depth++) {
|
||||||
|
scope.push(cur);
|
||||||
|
cur = cur.parentElement;
|
||||||
|
}
|
||||||
|
let best = null, bestN = -Infinity;
|
||||||
|
for (const root of scope) {
|
||||||
|
const candidates = root.querySelectorAll('*');
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (c.children.length > 0) continue;
|
||||||
|
const t = (c.textContent || '').trim();
|
||||||
|
if (!/^[\d,]+(\.\d+)?$/.test(t)) continue;
|
||||||
|
const n = parseNumber(t);
|
||||||
|
if (n == null || n < 1) continue;
|
||||||
|
if (n > bestN) { best = c; bestN = n; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
function findValueElement() {
|
||||||
|
const candidates = document.querySelectorAll('*');
|
||||||
|
let best = null, bestN = -Infinity;
|
||||||
|
for (const el of candidates) {
|
||||||
|
if (el.children.length > 0) continue;
|
||||||
|
const t = (el.textContent || '').trim();
|
||||||
|
if (!/^[\d,]+(\.\d+)?$/.test(t)) continue;
|
||||||
|
const n = parseNumber(t);
|
||||||
|
if (n == null || n < 1) continue;
|
||||||
|
if (n > bestN) { best = el; bestN = n; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
function findGymName() {
|
||||||
|
const panel = document.querySelector('.gym, #gym, [class*="gym-"], [class*="Gym"]');
|
||||||
|
const roots = panel ? [panel, document.body] : [document.body];
|
||||||
|
const known = ['Total Bastion', 'Frontline Fitness', 'Gym 300', 'Gym 500', "Baldr's Gym", 'Sportscience Laboratory', 'Premier Fitness', 'Chrome Gym', "Mr. Miyagi's", 'Power House'];
|
||||||
|
for (const root of roots) {
|
||||||
|
const all = root.querySelectorAll('h1, h2, h3, h4, p, span, div, li');
|
||||||
|
for (const el of all) {
|
||||||
|
if (el.children.length > 0) continue;
|
||||||
|
const t = (el.textContent || '').trim();
|
||||||
|
for (const name of known) { if (t.includes(name)) return name; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function parseNumber(text) {
|
||||||
|
if (!text) return null;
|
||||||
|
const cleaned = 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; }
|
||||||
|
if (parsed.attr && opts.currentAttr && parsed.attr !== opts.currentAttr) return;
|
||||||
|
const delta = parsed.newValue - lastValue;
|
||||||
|
lastValue = parsed.newValue;
|
||||||
|
if (delta > 0) opts.onTrain({ attr: parsed.attr || opts.currentAttr, delta: delta, ts: Date.now() });
|
||||||
|
}
|
||||||
|
wrapXhr(handle); wrapFetch(handle);
|
||||||
|
return { updatePrevValue: function (v) { lastValue = v; } };
|
||||||
|
}
|
||||||
|
function parseTrainResponse(text, url, fallbackAttr) {
|
||||||
|
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 {}
|
||||||
|
const m = text.match(/(\d{1,3}(?:,\d{3})+|\d{4,})/);
|
||||||
|
if (m) {
|
||||||
|
const newValue = parseInt(m[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-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, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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.right = '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.right = '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;
|
||||||
|
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;
|
||||||
|
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>' : '');
|
||||||
|
const self = this;
|
||||||
|
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() {
|
||||||
|
const btn = document.querySelector('button[name="train"], a[href*="train"]');
|
||||||
|
if (!btn) return null;
|
||||||
|
return btn.closest('form') || btn.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (window.__tat_started) return; window.__tat_started = true;
|
||||||
|
const store = new Store({ storage: localStorage, onWarn: function (m) { console.warn(m); } });
|
||||||
|
const prefs = store.getPrefs();
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
let lastSnapshot = null;
|
||||||
|
let lastAttr = null;
|
||||||
|
let lastDelta = 0;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user