Compare commits
21 Commits
ef90a6b779
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e44bf2b3c9 | |||
| b626fb7d41 | |||
| c6de810417 | |||
| ca83996c6e | |||
| a1e79ac801 | |||
| 7fddd84b6a | |||
| 2ed25c14de | |||
| 95c655c24c | |||
| d494093139 | |||
| 429a5d1b14 | |||
| ac1c04ecad | |||
| 8d89e40b91 | |||
| 540416949b | |||
| 6dd5d2e3f2 | |||
| 76e3ba2488 | |||
| b03cc80665 | |||
| 501c6746eb | |||
| 578736a492 | |||
| a061410f16 | |||
| bb33bcbb61 | |||
| e200faf8c4 |
@@ -10,3 +10,4 @@ pnpm-debug.log*
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
.DS_Store
|
||||
gym-rendered.html
|
||||
|
||||
+49
-81
@@ -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;
|
||||
}
|
||||
|
||||
+39
-19
@@ -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;
|
||||
|
||||
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;
|
||||
if (delta > 0) onTrain({ attr: parsed.attr, delta, ts: Date.now() });
|
||||
};
|
||||
} 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 };
|
||||
}
|
||||
|
||||
+51
-17
@@ -4,20 +4,54 @@ import { currentAttribute } from './dom.js';
|
||||
import { startRequestInterceptor } from './interceptor.js';
|
||||
|
||||
function findAnchorElement() {
|
||||
// Torn's training form is the element containing the Train button.
|
||||
// Selector is best-effort; the Dialog will fall back if missing.
|
||||
const btn = document.querySelector('button[name="train"], a[href*="train"]');
|
||||
if (!btn) return null;
|
||||
return btn.closest('form') || btn.parentElement;
|
||||
// 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[href*="train"]',
|
||||
'button[name="train"]',
|
||||
'a[href*="train"]',
|
||||
];
|
||||
for (const sel of candidates) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function start() {
|
||||
try {
|
||||
const store = new Store({
|
||||
storage: localStorage,
|
||||
onWarn: (m) => console.warn(m),
|
||||
});
|
||||
const prefs = store.getPrefs();
|
||||
|
||||
// State that applyMode() and render() may touch on first call.
|
||||
// Declared up-front to avoid TDZ ReferenceError if applyMode()'s
|
||||
// anchor-miss branch fires before the natural declaration point.
|
||||
let lastSnapshot = null;
|
||||
let lastAttr = null;
|
||||
let lastDelta = 0;
|
||||
let anchorError = null;
|
||||
|
||||
// One-time migration: dialog now defaults to bottom-left, so reset any
|
||||
// previously-saved position from the bottom-right era.
|
||||
if (prefs.pos && (prefs.pos.x !== 0 || prefs.pos.y !== 0)) {
|
||||
console.info('[tat] resetting dialog position to new bottom-left default');
|
||||
prefs.pos = { x: 0, y: 0 };
|
||||
store.setPos(prefs.pos);
|
||||
}
|
||||
|
||||
const dialog = new Dialog({
|
||||
onTargetChange: (v) => {
|
||||
const attr = currentAttribute()?.attr;
|
||||
@@ -37,10 +71,6 @@ function start() {
|
||||
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) {
|
||||
@@ -56,6 +86,7 @@ function start() {
|
||||
perTrain: lastDelta,
|
||||
summary,
|
||||
warn: store._saveDisabled ? 'saving disabled this session' : null,
|
||||
anchorError: anchorError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,17 +99,17 @@ 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 UI on this page.";
|
||||
render();
|
||||
return;
|
||||
}
|
||||
anchorError = null;
|
||||
dialog.setMode('free');
|
||||
}
|
||||
|
||||
@@ -110,6 +141,9 @@ function start() {
|
||||
},
|
||||
onParseFail: (url) => console.warn('[tat] could not parse train response from', url),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[tat] failed to start:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (location.hash === '#tat-test') {
|
||||
|
||||
@@ -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;
|
||||
@@ -30,6 +41,7 @@ const STYLE = `
|
||||
}
|
||||
.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; }
|
||||
`;
|
||||
@@ -99,7 +111,7 @@ export class Dialog {
|
||||
|
||||
if (initialMode === 'free') {
|
||||
root.style.bottom = '20px';
|
||||
root.style.right = '20px';
|
||||
root.style.left = '20px';
|
||||
if (initialPos.x || initialPos.y) {
|
||||
root.style.transform = `translate(${initialPos.x}px, ${initialPos.y}px)`;
|
||||
}
|
||||
@@ -116,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.right = '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%)';
|
||||
@@ -134,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;
|
||||
@@ -148,6 +181,9 @@ export class Dialog {
|
||||
const self = this;
|
||||
this.root.addEventListener('mousedown', (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 };
|
||||
@@ -224,6 +260,7 @@ export class Dialog {
|
||||
<div class="tat-row"><span>ETA</span><span>${est.days > 0 ? `~ ${fmtFull(est.days)} days (${fmtDate(est.eta)})` : '—'}</span></div>
|
||||
<div class="tat-modes">${modes}</div>
|
||||
${warn ? `<div class="tat-warn">⚠ ${esc(warn)}</div>` : ''}
|
||||
${state.anchorError ? `<div class="tat-anchor-err">⚠ ${esc(state.anchorError)}</div>` : ''}
|
||||
`;
|
||||
|
||||
this.root.querySelector('.tat-close').onclick = () => this.onClose && this.onClose();
|
||||
|
||||
+175
-82
@@ -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;
|
||||
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;
|
||||
if (delta > 0) opts.onTrain({ attr: parsed.attr || opts.currentAttr, delta: delta, ts: Date.now() });
|
||||
} 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; }
|
||||
@@ -263,6 +289,7 @@
|
||||
.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; }
|
||||
`;
|
||||
@@ -314,7 +341,7 @@
|
||||
this.mode = opts.initialMode || 'free';
|
||||
if (this.mode === 'free') {
|
||||
root.style.bottom = '20px';
|
||||
root.style.right = '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)';
|
||||
}
|
||||
@@ -325,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.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%)'; }
|
||||
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;
|
||||
@@ -343,6 +397,9 @@
|
||||
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 };
|
||||
@@ -399,7 +456,8 @@
|
||||
+ '<div class="tat-row"><span>Trains to go</span><span>≈ ' + tatFmtFull(est.trainsToGo) + '</span></div>'
|
||||
+ '<div class="tat-row"><span>ETA</span><span>' + (est.days > 0 ? '~ ' + tatFmtFull(est.days) + ' days (' + tatFmtDate(est.eta) + ')' : '—') + '</span></div>'
|
||||
+ '<div class="tat-modes">' + modes + '</div>'
|
||||
+ (s.warn ? '<div class="tat-warn">⚠ ' + tatEsc(s.warn) + '</div>' : '');
|
||||
+ (s.warn ? '<div class="tat-warn">⚠ ' + tatEsc(s.warn) + '</div>' : '')
|
||||
+ (s.anchorError ? '<div class="tat-anchor-err">⚠ ' + tatEsc(s.anchorError) + '</div>' : '');
|
||||
this.root.querySelector('.tat-close').onclick = function () { self.onClose && self.onClose(); };
|
||||
this.root.querySelector('[data-role="target"]').onchange = function (e) { self.onTargetChange && self.onTargetChange(e.target.value); };
|
||||
this.root.querySelector('[data-role="milestone"]').onchange = function (e) { const v = e.target.value; if (v !== '') self.onTargetChange && self.onTargetChange(Number(v)); };
|
||||
@@ -409,16 +467,52 @@
|
||||
|
||||
// ===== 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;
|
||||
// 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[href*="train"]',
|
||||
'button[name="train"]',
|
||||
'a[href*="train"]',
|
||||
];
|
||||
for (const sel of candidates) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (window.__tat_started) return; window.__tat_started = true;
|
||||
try {
|
||||
const store = new Store({ storage: localStorage, onWarn: function (m) { console.warn(m); } });
|
||||
const prefs = store.getPrefs();
|
||||
|
||||
// State that applyMode() and render() may touch on first call.
|
||||
// Declared up-front to avoid TDZ ReferenceError if applyMode()'s
|
||||
// anchor-miss branch fires before the natural declaration point.
|
||||
let lastSnapshot = null;
|
||||
let lastAttr = null;
|
||||
let lastDelta = 0;
|
||||
let anchorError = null;
|
||||
|
||||
// One-time migration: dialog now defaults to bottom-left, so reset any
|
||||
// previously-saved position from the bottom-right era.
|
||||
if (prefs.pos && (prefs.pos.x !== 0 || prefs.pos.y !== 0)) {
|
||||
console.info('[tat] resetting dialog position to new bottom-left default');
|
||||
prefs.pos = { x: 0, y: 0 };
|
||||
store.setPos(prefs.pos);
|
||||
}
|
||||
|
||||
const dialog = new Dialog({
|
||||
onTargetChange: function (v) {
|
||||
const a = currentAttribute(); if (!a) return; store.setTarget(a.attr, v); render();
|
||||
@@ -431,10 +525,6 @@
|
||||
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." };
|
||||
@@ -444,6 +534,7 @@
|
||||
attr: a.attr, gym: a.gym, current: a.current,
|
||||
target: store.getTarget(a.attr), perTrain: lastDelta, summary: summary,
|
||||
warn: store._saveDisabled ? 'saving disabled this session' : null,
|
||||
anchorError: anchorError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -453,18 +544,17 @@
|
||||
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 UI on this page.";
|
||||
render();
|
||||
return;
|
||||
}
|
||||
anchorError = null;
|
||||
dialog.setMode('free');
|
||||
}
|
||||
|
||||
@@ -488,6 +578,9 @@
|
||||
onTrain: function (e) { store.recordTrain(e.attr, e.delta, e.ts); lastDelta = e.delta; render(); },
|
||||
onParseFail: function (url) { console.warn('[tat] could not parse train response from', url); },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[tat] failed to start:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== self-test (only when location.hash === '#tat-test') =====
|
||||
|
||||
Reference in New Issue
Block a user