import { Store } from './store.js'; import { Dialog } from './ui.js'; 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. const candidates = [ 'form[action*="train"]', 'form.train-form', 'form[class*="train"]', '[class*="train-button"]', 'button[class*="train"]', 'a[class*="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; } return null; } function start() { const store = new Store({ storage: localStorage, onWarn: (m) => console.warn(m), }); const prefs = store.getPrefs(); const dialog = new Dialog({ onTargetChange: (v) => { const attr = currentAttribute()?.attr; if (!attr) return; store.setTarget(attr, v); render(); }, onModeChange: (m) => { store.setMode(m); prefs.mode = m; applyMode(); }, onPosChange: (pos) => store.setPos(pos), onClose: () => dialog.destroy(), }); dialog.mount({ initialMode: prefs.mode, initialPos: prefs.pos }); applyMode(); let lastSnapshot = null; let lastAttr = null; let lastDelta = 0; let anchorError = null; function snapshot() { const a = currentAttribute(); if (!a) { return { error: "Couldn't read attribute — Torn may have updated the page." }; } lastAttr = a.attr; const summary = store.getSummary(a.attr); return { attr: a.attr, gym: a.gym, current: a.current, target: store.getTarget(a.attr), perTrain: lastDelta, summary, warn: store._saveDisabled ? 'saving disabled this session' : null, anchorError: anchorError, }; } function render() { lastSnapshot = snapshot(); dialog.render(lastSnapshot); } function applyMode() { if (prefs.mode === 'anchored') { const el = findAnchorElement(); if (el) { const rect = el.getBoundingClientRect(); dialog.setMode('anchored', { canAnchor: true }); dialog._positionAnchored(rect); // observe const ro = new ResizeObserver(() => { if (prefs.mode === 'anchored') dialog._positionAnchored(el.getBoundingClientRect()); }); ro.observe(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."; render(); return; } anchorError = null; dialog.setMode('free'); } // initial paint render(); // watch DOM for attribute changes let pending = false; const observer = new MutationObserver(() => { if (pending) return; pending = true; requestAnimationFrame(() => { pending = false; const a = currentAttribute(); if (a && (a.attr !== lastAttr || a.current !== lastSnapshot?.current)) render(); }); }); observer.observe(document.body, { childList: true, subtree: true, characterData: true }); // intercept train requests const prev = currentAttribute()?.current ?? 0; startRequestInterceptor({ prevValue: prev, currentAttr: lastAttr, onTrain: ({ attr, delta, ts }) => { store.recordTrain(attr, delta, ts); lastDelta = delta; render(); }, onParseFail: (url) => console.warn('[tat] could not parse train response from', url), }); } if (location.hash === '#tat-test') { // Self-test path: the in-browser tests live in the bundled userscript. // The bundle runs the test block; nothing to do here. } else if (/\/gym\.php(\?|$)/.test(location.pathname + location.search)) { start(); }