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+)?));
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, '&').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.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 = '