Compare commits

...

14 Commits

Author SHA1 Message Date
dev e44bf2b3c9 style(ui): make docked dialog look more like a Torn panel (full-width, accent border)
Previously the .tat-root.tat-anchored rule centered the dialog with a
720px max-width and no extra border, which made it look like a floating
widget squeezed into the page rather than an embedded panel. The
Torn gym page reference is a full-width panel with a thin dark border
and a red top accent line.

Changes:
- margin: 0 0 12px 0 (full-width, no centering)
- max-width: none (span the content area)
- border-radius: 0 (Torn panels are flat, not rounded)
- border: 1px solid #444 with border-top: 2px solid #c00 (red accent)
- padding: 16px 20px (more breathing room, matching Torn panels)
- .tat-root.tat-anchored .tat-header { cursor: default } (drag is
  disabled in anchored mode, so the move cursor was misleading)

Free-floating mode is unchanged: the .tat-root base rule keeps its
rounded shadowed look and .tat-header keeps cursor: move.

Mirrored into the embedded TAT_STYLE in the bundle to keep the source
and bundle in lockstep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:02:11 -05:00
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&amp;#39;s',
which the browser decoded to the visible text 'George&#39;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
dev c6de810417 feat(bundle): mirror source changes in embedded userscript 2026-06-07 12:17:29 -05:00
dev ca83996c6e feat(main): anchor dialog by inserting before gymContentWrapper (dock in page flow) 2026-06-07 12:15:09 -05:00
dev a1e79ac801 feat(ui): add tat-anchored CSS class and rework setMode to dock the dialog in the page flow 2026-06-07 12:14:05 -05:00
dev 7fddd84b6a fix(bundle): mirror source fixes in embedded userscript 2026-06-07 09:00:40 -05:00
dev 2ed25c14de fix(main): pass rect to setMode and remove redundant _positionAnchored call (anchor crash fix) 2026-06-07 09:00:04 -05:00
dev 95c655c24c fix(gitignore): add rendered gym.php source to ignore list 2026-06-07 08:49:41 -05:00
Claude Opus 4.8 d494093139 fix(bundle): mirror source fixes in embedded userscript 2026-06-06 09:15:13 -05:00
Claude Opus 4.8 429a5d1b14 fix(main): anchor above Torn's properties___HASH list instead of looking for a form 2026-06-06 09:14:05 -05:00
Claude Opus 4.8 ac1c04ecad fix(dom): find active gym button by active class instead of iterating all buttons 2026-06-06 09:13:22 -05:00
dev 8d89e40b91 fix(bundle): mirror source fixes in embedded userscript 2026-06-05 08:43:22 -05:00
dev 540416949b fix(interceptor): parse "gained" message for reliable per-train delta 2026-06-05 08:39:16 -05:00
dev 6dd5d2e3f2 fix(dom): rewrite scraper to use Torn's CSS module structure (li class prefix, propertyValue span) 2026-06-05 08:38:39 -05:00
6 changed files with 264 additions and 217 deletions
+1
View File
@@ -10,3 +10,4 @@ pnpm-debug.log*
Thumbs.db Thumbs.db
Desktop.ini Desktop.ini
.DS_Store .DS_Store
gym-rendered.html
+49 -81
View File
@@ -3,104 +3,72 @@
* { attr: 'strength'|'speed'|..., current: number, gym: string } * { attr: 'strength'|'speed'|..., current: number, gym: string }
* or `null` if the page doesn't look like a Torn gym page. * or `null` if the page doesn't look like a Torn gym page.
* *
* The selectors below are best-effort matches for torn.com/gym.php * Torn's gym page is a React app using CSS modules with hash suffixes
* and will need adjustment if Torn changes the markup. * (e.g. class="strength___iXqEf", class="propertyValue___IYxjf"). This
* scraper targets Torn's actual structure rather than guessing at selectors.
*/ */
const KNOWN_ATTRS = ['strength', 'defense', 'speed', 'dexterity', 'endurance', 'intelligence'];
export function currentAttribute() { export function currentAttribute() {
// The attribute name is shown in the gym page header. const li = findActiveAttributeLi();
// Torn displays it as a capitalized word (e.g. "Strength") near the if (!li) return null;
// top of the gym form. const attr = extractAttrFromLi(li);
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;
let 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; if (!attr) return null;
const current = extractValueFromLi(li);
// Current value: look for the prominent number on the page that is
// formatted like a Torn attribute (e.g. "14,328,501"). Search near
// the attribute element so we don't pick up unrelated global numbers.
let valEl = findValueNear(attrEl);
if (!valEl) valEl = findValueElement(); // fallback: whole-page scan
if (!valEl) return null;
const current = parseNumber(valEl.textContent);
if (current == null) return null; if (current == null) return null;
// Gym name: any element on the page containing the word "Gym" or
// "Bastion" / "Frontline" / etc. Torn's gym names vary.
const gym = findGymName() || 'Unknown gym'; const gym = findGymName() || 'Unknown gym';
return { attr, current, gym }; return { attr, current, gym };
} }
function findValueNear(el) { function findActiveAttributeLi() {
// Look at the element itself, then up to a few ancestors, then their descendants. // Priority 1: the <li> with the "success" class (just trained).
// Prefer the largest formatted number within ~2 parent levels. const lis = document.querySelectorAll('ul[class*="properties"] > li[class*="success"]');
const scope = []; for (const li of lis) {
let cur = el; if (extractAttrFromLi(li)) return li;
for (let depth = 0; depth < 3 && cur; depth++) {
scope.push(cur);
cur = cur.parentElement;
} }
let best = null; // Priority 2: the <li> corresponding to the .gained message's attribute.
let bestN = -Infinity; const gained = document.querySelector('[class*="gained"]');
for (const root of scope) { if (gained) {
const candidates = root.querySelectorAll('*'); const text = (gained.textContent || '').toLowerCase();
for (const c of candidates) { for (const attr of KNOWN_ATTRS) {
if (c.children.length > 0) continue; if (text.includes(attr)) {
const t = (c.textContent || '').trim(); const li = document.querySelector('ul[class*="properties"] > li[class^="' + attr + '___"]');
if (!/^[\d,]+(\.\d+)?$/.test(t)) continue; if (li) return li;
const n = parseNumber(t); }
if (n == null || n < 1) continue;
if (n > bestN) { best = c; bestN = n; }
} }
} }
return best; // Priority 3: the first <li> in the properties list.
const all = document.querySelectorAll('ul[class*="properties"] > li');
for (const li of all) {
if (extractAttrFromLi(li)) return li;
}
return null;
} }
function findValueElement() { function extractAttrFromLi(li) {
// Fallback only used when no element is found near the attribute. const cls = li.className || '';
// Walk all elements; pick the largest formatted number on the page. for (const attr of KNOWN_ATTRS) {
const candidates = document.querySelectorAll('*'); if (cls.split(/\s+/).some((c) => c.startsWith(attr + '___'))) return attr;
let best = null;
let 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; return null;
}
function extractValueFromLi(li) {
const valueSpan = li.querySelector('[class^="propertyValue"]');
if (!valueSpan) return null;
return parseNumber(valueSpan.textContent);
} }
function findGymName() { function findGymName() {
// Look for a known set of Torn gym name fragments. Adjust as needed. // Find the currently selected gym button. It has the "active" class.
// Prefer an element that looks like the gym panel so we don't match const activeBtn = document.querySelector('button[class*="gymButton"][class*="active"]');
// against global widgets (news, sidebar, ads). if (activeBtn) {
const panel = document.querySelector('.gym, #gym, [class*="gym-"], [class*="Gym"]'); const label = activeBtn.getAttribute('aria-label') || '';
const roots = panel ? [panel, document.body] : [document.body]; // aria-label format: "<Gym Name>. Membership cost - $X. Energy usage - N per train."
const known = [ // The gym name is everything before the first ". ".
'Total Bastion', 'Frontline Fitness', 'Gym 300', 'Gym 500', const dot = label.indexOf('. ');
'Baldr\'s Gym', 'Sportscience Laboratory', 'Premier Fitness', if (dot !== -1) return label.slice(0, dot);
'Chrome Gym', 'Mr. Miyagi\'s', 'Power House', return label; // no period, return whole label as fallback
];
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; return null;
} }
+40 -20
View File
@@ -5,15 +5,16 @@
* compared against the previous one, and `onTrain({attr, delta, ts})` * compared against the previous one, and `onTrain({attr, delta, ts})`
* is invoked. * is invoked.
* *
* `parseTrainResponse(text, url)` is intentionally permissive and * `parseTrainResponse(text, url)` returns:
* returns `{ newValue, attr } | null`. The default implementation * { delta, attr } — when a "You gained X.XX <attr>" message is found
* tries JSON first, then a regex fallback. * { newValue, attr } — when a propertyValue span or JSON newValue is found
* null — when nothing usable is found
*/ */
export function startRequestInterceptor({ prevValue, currentAttr, onTrain, onParseFail }) { export function startRequestInterceptor({ prevValue, currentAttr, onTrain, onParseFail }) {
let lastValue = prevValue; let lastValue = prevValue;
let warnedFor = null; let warnedFor = null;
const handle = (text, url) => { function handle(text, url) {
const parsed = parseTrainResponse(text, url, currentAttr); const parsed = parseTrainResponse(text, url, currentAttr);
if (!parsed) { if (!parsed) {
if (warnedFor !== url) { if (warnedFor !== url) {
@@ -22,11 +23,22 @@ export function startRequestInterceptor({ prevValue, currentAttr, onTrain, onPar
} }
return; return;
} }
if (parsed.attr && currentAttr && parsed.attr !== currentAttr) return;
const delta = parsed.newValue - lastValue; let delta;
lastValue = parsed.newValue; if (typeof parsed.delta === 'number' && parsed.delta > 0) {
if (delta > 0) onTrain({ attr: parsed.attr, delta, ts: Date.now() }); 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 || currentAttr;
if (!attr) return;
onTrain({ attr, delta, ts: Date.now() });
}
wrapXhr(handle); wrapXhr(handle);
wrapFetch(handle); wrapFetch(handle);
@@ -37,23 +49,31 @@ export function startRequestInterceptor({ prevValue, currentAttr, onTrain, onPar
} }
function parseTrainResponse(text, url, fallbackAttr) { function parseTrainResponse(text, url, fallbackAttr) {
// Try JSON // 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, attr };
}
}
// Strategy 2: JSON with newValue + attr.
try { try {
const j = JSON.parse(text); const j = JSON.parse(text);
// Torn historically returns an HTML fragment; if it's JSON, look
// for a known shape. This is a placeholder — adjust after manual
// verification.
if (j && typeof j === 'object' && 'newValue' in j && 'attr' in j) { if (j && typeof j === 'object' && 'newValue' in j && 'attr' in j) {
return { newValue: Number(j.newValue), attr: String(j.attr) }; return { newValue: Number(j.newValue), attr: String(j.attr) };
} }
} catch { /* not JSON */ } } catch { /* not JSON */ }
// Strategy 3: regex fallback (last resort). Don't use the first number
// Regex fallback: scan text for a number formatted like an attribute. // blindly; look specifically for the propertyValue span content, which
// If we find one and the caller passed a fallbackAttr, use it; otherwise // is the authoritative source.
// the caller can choose to ignore the result. const propertyValueMatch = text.match(/class="propertyValue[^"]*"[^>]*>([\d,]+(?:\.\d+)?)</);
const m = text.match(/(\d{1,3}(?:,\d{3})+|\d{4,})/); if (propertyValueMatch) {
if (m) { const newValue = parseInt(propertyValueMatch[1].replace(/,/g, ''), 10);
const newValue = parseInt(m[1].replace(/,/g, ''), 10);
if (Number.isFinite(newValue) && newValue > 0) { if (Number.isFinite(newValue) && newValue > 0) {
return { newValue, attr: fallbackAttr || null }; return { newValue, attr: fallbackAttr || null };
} }
+11 -24
View File
@@ -4,32 +4,26 @@ import { currentAttribute } from './dom.js';
import { startRequestInterceptor } from './interceptor.js'; import { startRequestInterceptor } from './interceptor.js';
function findAnchorElement() { function findAnchorElement() {
// Try several selectors in priority order. Torn's gym page structure // Return the element to insert the dialog BEFORE in the DOM.
// varies; we cast a wide net and return the first match. // The user wants the dialog between the notification wrapper and the
// gym content wrapper; we insert before gymContentWrapper.
const candidates = [ const candidates = [
'[class*="gymContentWrapper"]', // outer wrapper — best insertion point
'[class*="gymContent"]', // inner wrapper (fallback)
'ul[class*="properties"]', // the list itself (last resort)
// Legacy fallbacks (kept for robustness):
'form[action*="train"]', 'form[action*="train"]',
'form.train-form', 'form.train-form',
'form[class*="train"]', 'form[class*="train"]',
'[class*="train-button"]', '[class*="train-button"]',
'button[class*="train"]', 'button[class*="train"]',
'a[class*="train"]', 'a[href*="train"]',
'button[name="train"]', 'button[name="train"]',
'a[href*="train"]', 'a[href*="train"]',
]; ];
for (const sel of candidates) { for (const sel of candidates) {
const el = document.querySelector(sel); const el = document.querySelector(sel);
if (el) { if (el) return 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; return null;
} }
@@ -105,20 +99,13 @@ function start() {
if (prefs.mode === 'anchored') { if (prefs.mode === 'anchored') {
const el = findAnchorElement(); const el = findAnchorElement();
if (el) { if (el) {
const rect = el.getBoundingClientRect(); dialog.setMode('anchored', { canAnchor: true, insertBefore: el });
dialog.setMode('anchored', { canAnchor: true });
dialog._positionAnchored(rect);
// observe
const ro = new ResizeObserver(() => {
if (prefs.mode === 'anchored') dialog._positionAnchored(el.getBoundingClientRect());
});
ro.observe(el);
anchorError = null; anchorError = null;
return; return;
} }
// Anchor selector missed — don't snap to default, just keep current // Anchor selector missed — don't snap to default, just keep current
// position and show a note. // position and show a note.
anchorError = "Couldn't find the training form on this page."; anchorError = "Couldn't find the training UI on this page.";
render(); render();
return; return;
} }
+33 -1
View File
@@ -8,6 +8,17 @@ const STYLE = `
font: 13px/1.4 Tahoma, Verdana, sans-serif; font: 13px/1.4 Tahoma, Verdana, sans-serif;
padding: 12px 14px; 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 { .tat-header {
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
padding-bottom: 8px; margin-bottom: 10px; padding-bottom: 8px; margin-bottom: 10px;
@@ -117,17 +128,37 @@ export class Dialog {
setMode(mode, anchorInfo) { setMode(mode, anchorInfo) {
this.mode = mode; this.mode = mode;
if (!this.root) return; if (!this.root) return;
// Clear all position styles
this.root.style.transform = ''; this.root.style.transform = '';
this.root.style.top = ''; this.root.style.top = '';
this.root.style.bottom = ''; this.root.style.bottom = '';
this.root.style.left = ''; this.root.style.left = '';
this.root.style.right = ''; this.root.style.right = '';
this.root.classList.remove('tat-anchored');
if (mode === 'free') { 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.bottom = '20px';
this.root.style.left = '20px'; this.root.style.left = '20px';
} else if (anchorInfo && anchorInfo.canAnchor) { } else if (anchorInfo && anchorInfo.canAnchor) {
this._positionAnchored(anchorInfo.rect); 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 { } else {
// Top-center fallback (used when mode is anchored but no anchor info).
this.root.style.top = '20px'; this.root.style.top = '20px';
this.root.style.left = '50%'; this.root.style.left = '50%';
this.root.style.transform = 'translateX(-50%)'; this.root.style.transform = 'translateX(-50%)';
@@ -135,6 +166,7 @@ export class Dialog {
} }
_positionAnchored(rect) { _positionAnchored(rect) {
if (!rect) return; // defensive: setMode may be called without a rect
const dialogRect = this.root.getBoundingClientRect(); const dialogRect = this.root.getBoundingClientRect();
let top = rect.top - dialogRect.height - 8; let top = rect.top - dialogRect.height - 8;
if (top < 8) top = 20; if (top < 8) top = 20;
+130 -91
View File
@@ -122,76 +122,73 @@
} }
// ===== dom.js (embedded) ===== // ===== dom.js (embedded) =====
const TAT_KNOWN_ATTRS = ['strength', 'defense', 'speed', 'dexterity', 'endurance', 'intelligence'];
function currentAttribute() { function currentAttribute() {
const KNOWN = ['strength', 'defense', 'speed', 'dexterity', 'endurance', 'intelligence']; const li = tatFindActiveAttributeLi();
const ATTR_RE = new RegExp('\\b(' + KNOWN.join('|') + ')\\b'); if (!li) return null;
const headers = document.querySelectorAll('h1, h2, h3, h4, .title, .gym-title, [class*="gym"]'); const attr = tatExtractAttrFromLi(li);
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; if (!attr) return null;
let valEl = findValueNear(attrEl); const current = tatExtractValueFromLi(li);
if (!valEl) valEl = findValueElement();
if (!valEl) return null;
const current = parseNumber(valEl.textContent);
if (current == null) return null; if (current == null) return null;
const gym = findGymName() || 'Unknown gym'; const gym = tatFindGymName() || 'Unknown gym';
return { attr: attr, current: current, gym: gym }; return { attr: attr, current: current, gym: gym };
} }
function findValueNear(el) { function tatFindActiveAttributeLi() {
const scope = []; // Priority 1: the <li> with the "success" class (just trained).
let cur = el; const lis = document.querySelectorAll('ul[class*="properties"] > li[class*="success"]');
for (let depth = 0; depth < 3 && cur; depth++) { for (const li of lis) {
scope.push(cur); if (tatExtractAttrFromLi(li)) return li;
cur = cur.parentElement;
} }
let best = null, bestN = -Infinity; // Priority 2: the <li> corresponding to the .gained message's attribute.
for (const root of scope) { const gained = document.querySelector('[class*="gained"]');
const candidates = root.querySelectorAll('*'); if (gained) {
for (const c of candidates) { const text = (gained.textContent || '').toLowerCase();
if (c.children.length > 0) continue; for (const attr of TAT_KNOWN_ATTRS) {
const t = (c.textContent || '').trim(); if (text.indexOf(attr) !== -1) {
if (!/^[\d,]+(\.\d+)?$/.test(t)) continue; const li = document.querySelector('ul[class*="properties"] > li[class^="' + attr + '___"]');
const n = parseNumber(t); if (li) return li;
if (n == null || n < 1) continue; }
if (n > bestN) { best = c; bestN = n; }
} }
} }
return best; // Priority 3: the first <li> in the properties list.
} const all = document.querySelectorAll('ul[class*="properties"] > li');
function findValueElement() { for (const li of all) {
const candidates = document.querySelectorAll('*'); if (tatExtractAttrFromLi(li)) return li;
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; return null;
} }
function findGymName() { function tatExtractAttrFromLi(li) {
const panel = document.querySelector('.gym, #gym, [class*="gym-"], [class*="Gym"]'); const cls = li.className || '';
const roots = panel ? [panel, document.body] : [document.body]; const parts = cls.split(/\s+/);
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 attr of TAT_KNOWN_ATTRS) {
for (const root of roots) { const prefix = attr + '___';
const all = root.querySelectorAll('h1, h2, h3, h4, p, span, div, li'); for (const c of parts) {
for (const el of all) { if (c.indexOf(prefix) === 0) return attr;
if (el.children.length > 0) continue;
const t = (el.textContent || '').trim();
for (const name of known) { if (t.includes(name)) return name; }
} }
} }
return null; return null;
} }
function parseNumber(text) { 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; if (!text) return null;
const cleaned = text.replace(/,/g, '').trim(); const cleaned = String(text).replace(/,/g, '').trim();
if (!/^\d+(\.\d+)?$/.test(cleaned)) return null; if (!/^\d+(\.\d+)?$/.test(cleaned)) return null;
const n = parseFloat(cleaned); const n = parseFloat(cleaned);
return Number.isFinite(n) ? Math.floor(n) : null; return Number.isFinite(n) ? Math.floor(n) : null;
@@ -204,25 +201,52 @@
function handle(text, url) { function handle(text, url) {
const parsed = parseTrainResponse(text, url, opts.currentAttr); const parsed = parseTrainResponse(text, url, opts.currentAttr);
if (!parsed) { if (warnedFor !== url) { warnedFor = url; opts.onParseFail && opts.onParseFail(url); } return; } if (!parsed) { if (warnedFor !== url) { warnedFor = url; opts.onParseFail && opts.onParseFail(url); } return; }
if (parsed.attr && opts.currentAttr && parsed.attr !== opts.currentAttr) return; let delta;
const delta = parsed.newValue - lastValue; if (typeof parsed.delta === 'number' && parsed.delta > 0) {
lastValue = parsed.newValue; delta = parsed.delta;
if (delta > 0) opts.onTrain({ attr: parsed.attr || opts.currentAttr, delta: delta, ts: Date.now() }); } 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); wrapXhr(handle); wrapFetch(handle);
return { updatePrevValue: function (v) { lastValue = v; } }; return { updatePrevValue: function (v) { lastValue = v; } };
} }
function parseTrainResponse(text, url, fallbackAttr) { 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 { try {
const j = JSON.parse(text); const j = JSON.parse(text);
if (j && typeof j === 'object' && 'newValue' in j && 'attr' in j) { if (j && typeof j === 'object' && 'newValue' in j && 'attr' in j) {
return { newValue: Number(j.newValue), attr: String(j.attr) }; return { newValue: Number(j.newValue), attr: String(j.attr) };
} }
} catch {} } catch {}
const m = text.match(/(\d{1,3}(?:,\d{3})+|\d{4,})/); // Strategy 3: regex fallback (last resort). Don't use the first number
if (m) { // blindly; look specifically for the propertyValue span content, which
const newValue = parseInt(m[1].replace(/,/g, ''), 10); // is the authoritative source.
if (Number.isFinite(newValue) && newValue > 0) return { newValue: newValue, attr: fallbackAttr || null }; 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; return null;
} }
@@ -252,6 +276,8 @@
// ===== ui.js (embedded) ===== // ===== ui.js (embedded) =====
const TAT_STYLE = ` 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 { 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 { 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-header strong { color: #fff; }
.tat-close { cursor: pointer; opacity: 0.6; padding: 0 4px; } .tat-close { cursor: pointer; opacity: 0.6; padding: 0 4px; }
@@ -326,12 +352,39 @@
setMode(mode, anchorInfo) { setMode(mode, anchorInfo) {
this.mode = mode; this.mode = mode;
if (!this.root) return; 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.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'; } this.root.classList.remove('tat-anchored');
else if (anchorInfo && anchorInfo.canAnchor) { this._positionAnchored(anchorInfo.rect); } if (mode === 'free') {
else { this.root.style.top = '20px'; this.root.style.left = '50%'; this.root.style.transform = 'translateX(-50%)'; } // 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) { _positionAnchored(rect) {
if (!rect) return; // defensive: setMode may be called without a rect
const dialogRect = this.root.getBoundingClientRect(); const dialogRect = this.root.getBoundingClientRect();
let top = rect.top - dialogRect.height - 8; let top = rect.top - dialogRect.height - 8;
if (top < 8) top = 20; if (top < 8) top = 20;
@@ -414,32 +467,26 @@
// ===== main.js (embedded) ===== // ===== main.js (embedded) =====
function findAnchorElement() { function findAnchorElement() {
// Try several selectors in priority order. Torn's gym page structure // Return the element to insert the dialog BEFORE in the DOM.
// varies; we cast a wide net and return the first match. // The user wants the dialog between the notification wrapper and the
// gym content wrapper; we insert before gymContentWrapper.
const candidates = [ 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[action*="train"]',
'form.train-form', 'form.train-form',
'form[class*="train"]', 'form[class*="train"]',
'[class*="train-button"]', '[class*="train-button"]',
'button[class*="train"]', 'button[class*="train"]',
'a[class*="train"]', 'a[href*="train"]',
'button[name="train"]', 'button[name="train"]',
'a[href*="train"]', 'a[href*="train"]',
]; ];
for (const sel of candidates) { for (const sel of candidates) {
const el = document.querySelector(sel); const el = document.querySelector(sel);
if (el) { if (el) return 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; return null;
} }
@@ -497,21 +544,13 @@
if (prefs.mode === 'anchored') { if (prefs.mode === 'anchored') {
const el = findAnchorElement(); const el = findAnchorElement();
if (el) { if (el) {
const rect = el.getBoundingClientRect(); dialog.setMode('anchored', { canAnchor: true, insertBefore: el });
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; anchorError = null;
return; return;
} }
// Anchor selector missed — don't snap to default, just keep current // Anchor selector missed — don't snap to default, just keep current
// position and show a note. // position and show a note.
anchorError = "Couldn't find the training form on this page."; anchorError = "Couldn't find the training UI on this page.";
render(); render();
return; return;
} }