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
Desktop.ini
.DS_Store
gym-rendered.html
+49 -81
View File
@@ -3,104 +3,72 @@
* { attr: 'strength'|'speed'|..., current: number, gym: string }
* 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
* and will need adjustment if Torn changes the markup.
* Torn's gym page is a React app using CSS modules with hash suffixes
* (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() {
// The attribute name is shown in the gym page header.
// Torn displays it as a capitalized word (e.g. "Strength") near the
// top of the gym form.
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; }
}
const li = findActiveAttributeLi();
if (!li) return null;
const attr = extractAttrFromLi(li);
if (!attr) return null;
// 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);
const current = extractValueFromLi(li);
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';
return { attr, current, gym };
}
function findValueNear(el) {
// Look at the element itself, then up to a few ancestors, then their descendants.
// Prefer the largest formatted number within ~2 parent levels.
const scope = [];
let cur = el;
for (let depth = 0; depth < 3 && cur; depth++) {
scope.push(cur);
cur = cur.parentElement;
function findActiveAttributeLi() {
// 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 (extractAttrFromLi(li)) return li;
}
let best = null;
let 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; }
// 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 KNOWN_ATTRS) {
if (text.includes(attr)) {
const li = document.querySelector('ul[class*="properties"] > li[class^="' + attr + '___"]');
if (li) return li;
}
}
}
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() {
// Fallback only used when no element is found near the attribute.
// Walk all elements; pick the largest formatted number on the page.
const candidates = document.querySelectorAll('*');
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; }
function extractAttrFromLi(li) {
const cls = li.className || '';
for (const attr of KNOWN_ATTRS) {
if (cls.split(/\s+/).some((c) => c.startsWith(attr + '___'))) return attr;
}
return best;
return null;
}
function extractValueFromLi(li) {
const valueSpan = li.querySelector('[class^="propertyValue"]');
if (!valueSpan) return null;
return parseNumber(valueSpan.textContent);
}
function findGymName() {
// Look for a known set of Torn gym name fragments. Adjust as needed.
// Prefer an element that looks like the gym panel so we don't match
// against global widgets (news, sidebar, ads).
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;
}
}
// 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;
}
+40 -20
View File
@@ -5,15 +5,16 @@
* compared against the previous one, and `onTrain({attr, delta, ts})`
* is invoked.
*
* `parseTrainResponse(text, url)` is intentionally permissive and
* returns `{ newValue, attr } | null`. The default implementation
* tries JSON first, then a regex fallback.
* `parseTrainResponse(text, url)` returns:
* { delta, attr } — when a "You gained X.XX <attr>" message is found
* { newValue, attr } — when a propertyValue span or JSON newValue is found
* null — when nothing usable is found
*/
export function startRequestInterceptor({ prevValue, currentAttr, onTrain, onParseFail }) {
let lastValue = prevValue;
let warnedFor = null;
const handle = (text, url) => {
function handle(text, url) {
const parsed = parseTrainResponse(text, url, currentAttr);
if (!parsed) {
if (warnedFor !== url) {
@@ -22,11 +23,22 @@ export function startRequestInterceptor({ prevValue, currentAttr, onTrain, onPar
}
return;
}
if (parsed.attr && currentAttr && parsed.attr !== currentAttr) return;
const delta = parsed.newValue - lastValue;
lastValue = parsed.newValue;
if (delta > 0) onTrain({ attr: parsed.attr, delta, ts: Date.now() });
};
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 || currentAttr;
if (!attr) return;
onTrain({ attr, delta, ts: Date.now() });
}
wrapXhr(handle);
wrapFetch(handle);
@@ -37,23 +49,31 @@ export function startRequestInterceptor({ prevValue, currentAttr, onTrain, onPar
}
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 {
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) {
return { newValue: Number(j.newValue), attr: String(j.attr) };
}
} catch { /* not JSON */ }
// Regex fallback: scan text for a number formatted like an attribute.
// If we find one and the caller passed a fallbackAttr, use it; otherwise
// the caller can choose to ignore the result.
const m = text.match(/(\d{1,3}(?:,\d{3})+|\d{4,})/);
if (m) {
const newValue = parseInt(m[1].replace(/,/g, ''), 10);
// 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, attr: fallbackAttr || null };
}
+11 -24
View File
@@ -4,32 +4,26 @@ import { currentAttribute } from './dom.js';
import { startRequestInterceptor } from './interceptor.js';
function findAnchorElement() {
// Try several selectors in priority order. Torn's gym page structure
// varies; we cast a wide net and return the first match.
// Return the element to insert the dialog BEFORE in the DOM.
// The user wants the dialog between the notification wrapper and the
// gym content wrapper; we insert before gymContentWrapper.
const candidates = [
'[class*="gymContentWrapper"]', // 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.train-form',
'form[class*="train"]',
'[class*="train-button"]',
'button[class*="train"]',
'a[class*="train"]',
'a[href*="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;
if (el) return el;
}
return null;
}
@@ -105,20 +99,13 @@ function start() {
if (prefs.mode === 'anchored') {
const el = findAnchorElement();
if (el) {
const rect = el.getBoundingClientRect();
dialog.setMode('anchored', { canAnchor: true });
dialog._positionAnchored(rect);
// observe
const ro = new ResizeObserver(() => {
if (prefs.mode === 'anchored') dialog._positionAnchored(el.getBoundingClientRect());
});
ro.observe(el);
dialog.setMode('anchored', { canAnchor: true, insertBefore: el });
anchorError = null;
return;
}
// Anchor selector missed — don't snap to default, just keep current
// position and show a note.
anchorError = "Couldn't find the training form on this page.";
anchorError = "Couldn't find the training UI on this page.";
render();
return;
}
+33 -1
View File
@@ -8,6 +8,17 @@ const STYLE = `
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;
@@ -117,17 +128,37 @@ export class Dialog {
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) {
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 {
// 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%)';
@@ -135,6 +166,7 @@ export class Dialog {
}
_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;
+130 -91
View File
@@ -122,76 +122,73 @@
}
// ===== dom.js (embedded) =====
const TAT_KNOWN_ATTRS = ['strength', 'defense', 'speed', 'dexterity', 'endurance', 'intelligence'];
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; }
}
const li = tatFindActiveAttributeLi();
if (!li) return null;
const attr = tatExtractAttrFromLi(li);
if (!attr) return null;
let valEl = findValueNear(attrEl);
if (!valEl) valEl = findValueElement();
if (!valEl) return null;
const current = parseNumber(valEl.textContent);
const current = tatExtractValueFromLi(li);
if (current == null) return null;
const gym = findGymName() || 'Unknown gym';
const gym = tatFindGymName() || '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;
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;
}
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; }
// 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;
}
}
}
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; }
// 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 best;
return null;
}
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; }
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 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;
const cleaned = text.replace(/,/g, '').trim();
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;
@@ -204,25 +201,52 @@
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() });
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 {}
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 };
// 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;
}
@@ -252,6 +276,8 @@
// ===== 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; }
@@ -326,12 +352,39 @@
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 = '';
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%)'; }
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;
@@ -414,32 +467,26 @@
// ===== 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.
// Return the element to insert the dialog BEFORE in the DOM.
// The user wants the dialog between the notification wrapper and the
// gym content wrapper; we insert before gymContentWrapper.
const candidates = [
'[class*="gymContentWrapper"]',
'[class*="gymContent"]',
'ul[class*="properties"]',
// Legacy fallbacks (kept in case Torn ever wraps the list in a form):
'form[action*="train"]',
'form.train-form',
'form[class*="train"]',
'[class*="train-button"]',
'button[class*="train"]',
'a[class*="train"]',
'a[href*="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;
if (el) return el;
}
return null;
}
@@ -497,21 +544,13 @@
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);
}
dialog.setMode('anchored', { canAnchor: true, insertBefore: el });
anchorError = null;
return;
}
// Anchor selector missed — don't snap to default, just keep current
// position and show a note.
anchorError = "Couldn't find the training form on this page.";
anchorError = "Couldn't find the training UI on this page.";
render();
return;
}