Compare commits

...

31 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
Claude 76e3ba2488 fix(bundle): mirror source fixes in embedded userscript
Mirrors the three src/ changes into the embedded copy in
torn-attribute-tracker.user.js:

1. Hoist the four let bindings (lastSnapshot, lastAttr, lastDelta,
   anchorError) to the top of start(), before dialog.mount() and
   applyMode(), so the anchor-miss branch of applyMode() can write
   anchorError without tripping the TDZ.

2. Default the floating dialog to bottom-left (left: 20px, bottom: 20px)
   in both Dialog.mount() and Dialog.setMode()'s 'free' branch.

3. One-time migration: if prefs.pos has any non-zero coordinate on load
   (a residue of the bottom-right era), reset it to {x: 0, y: 0} and log
   to the console. Stored position from any subsequent drag is preserved.

4. Wrap the start() body in try/catch and log failures to the console,
   so an unexpected error (e.g. TornTools conflict, future regressions)
   does not prevent the dialog from appearing.

All four changes are byte-equivalent to the corresponding src/ changes;
the build-time drift tests in tests/build.test.js still pass.
2026-06-01 22:24:57 -05:00
Claude b03cc80665 fix(ui): default floating dialog position to bottom-left
When the dialog first appears (mount) and when it switches to 'free' mode
(setMode), pin it to the bottom-left corner of the viewport (left: 20px,
bottom: 20px) rather than the previous bottom-right default. The header
drag handler still uses left/top for the new position, so this change
flows through cleanly on subsequent drags.

Note: existing users with a saved pos.x/pos.y in localStorage will still
see the dialog at the old transform-offset position until pos is reset
in a follow-up migration (see next commit).
2026-06-01 22:24:01 -05:00
Claude 501c6746eb fix(main): hoist let bindings to top of start() to fix TDZ on anchorError
The applyMode() function reads/writes anchorError, lastSnapshot, and other
let-bound state. Function declarations are hoisted, so applyMode() can fire
from inside the dialog.mount() / applyMode() call sequence at the top of
start() — but the let bindings themselves are not initialized until
execution reaches their declaration line, which came later.

When prefs.mode === 'anchored' and findAnchorElement() returns null, the
new 'anchor missed' branch writes to anchorError and calls render(). Both
access anchorError before its let binding is initialized, throwing
ReferenceError: Cannot access 'anchorError' before initialization.

Move all four let declarations (lastSnapshot, lastAttr, lastDelta,
anchorError) to the top of start(), before dialog.mount() and applyMode().
Function declarations are unaffected — they are hoisted regardless.
2026-06-01 22:22:29 -05:00
dev 578736a492 fix(test): adjust comment formatting for clarity in computeEstimate tests 2026-06-01 21:42:39 -05:00
Claude a061410f16 fix(bundle): mirror source fixes in embedded userscript
Mirror the three source-level fixes in the embedded copies inside
torn-attribute-tracker.user.js:

1. _wireHeaderDrag: add the .tat-header closest() guard so the bundle
   no longer steals focus from inputs/selects.
2. findAnchorElement: replace the narrow 'button[name="train"]' query
   with the priority-ordered candidate list and the gym-panel last-
   ditch fallback.
3. Inline anchor-error UX: add the anchorError state, include it in
   the snapshot, surface it via applyMode, render the note with the
   .tat-anchor-err class, and add the corresponding CSS rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:47:39 -05:00
Claude bb33bcbb61 fix(main): broaden anchor selector and show inline "can't anchor" note
findAnchorElement used a narrow selector ('button[name="train"],
a[href*="train"]') that often missed Torn's actual gym page DOM.
When it missed, applyMode fell through to dialog.setMode('free'),
snapping the dialog to the default bottom-right position — which the
user perceived as a 'bounce' when clicking 'Above training UI'.

Widen the selector to a priority-ordered candidate list and prefer the
form ancestor of any matched element. As a last-ditch, look for a form
inside the gym panel. This covers more of Torn's gym-page variations.

When the anchor selector still misses, do NOT snap to the default free
position. Instead, keep the dialog where it is, set anchorError on the
state, and let the dialog render an inline note so the user gets
visible feedback explaining what happened.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:45:40 -05:00
Claude e200faf8c4 fix(ui): scope mousedown handler to .tat-header to restore input/select focus
The dialog's mousedown listener was attached to the whole root and
unconditionally called preventDefault(), which blocked the target
element from receiving focus. As a result, the custom target <input>
and the milestone <select> could never be focused.

Only initiate drag (and only preventDefault) when the mousedown is on
the .tat-header bar. This lets the user click into inputs, selects, and
buttons inside the dialog body, while still allowing the dialog to be
dragged from the title bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:42:16 -05:00
dev ef90a6b779 Add design document for Torn Attribute Training Tracker
- Introduced a new userscript for torn.com to assist players in planning attribute training.
- Document outlines the purpose, scope, architecture, dialog content, placement modes, error handling, testing, and file layout.
- Details on data handling, UI interactions, and user preferences included.
2026-06-01 18:16:02 -05:00
dev ecea14b051 test(build): extend behavioral drift checks to all four pure functions 2026-06-01 17:59:36 -05:00
dev e21582b7c4 fix(bundle): strict 30-day boundary in pruneHistory to match src/pure.js 2026-06-01 17:59:06 -05:00
dev adc8a5dec9 docs: README with install, usage, and self-test instructions 2026-06-01 17:40:18 -05:00
dev a5f402132e test(build): catch behavioral drift in bundle summary via eval-extracted comparison 2026-06-01 17:37:46 -05:00
dev 62fe86a71f test(build): verify userscript bundle embeds pure functions and self-test 2026-06-01 17:31:24 -05:00
dev 75f10126d2 fix(bundle): floor perDay in summary and hoist self alias in render 2026-06-01 17:28:41 -05:00
dev 7c2533d188 feat: bundle torn-attribute-tracker.user.js (Tat-style Tampermonkey script) 2026-06-01 17:16:54 -05:00
dev 36a6b4c52d fix(main): keep prefs.mode in sync and rAF-coalesce mutation observer 2026-06-01 17:09:38 -05:00
dev 12fc79022f feat(main): wire Store + Dialog + DataLayer on /gym.php 2026-06-01 17:03:03 -05:00
10 changed files with 3465 additions and 106 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
+43 -2
View File
@@ -1,5 +1,46 @@
# Torn Attribute Training Tracker # Torn Attribute Training Tracker
A userscript for [torn.com](https://www.torn.com) that shows a floating dialog on the gym page with your attribute, target, and ETA. A userscript for [torn.com](https://www.torn.com) that shows a floating dialog on the gym page with your current attribute, target, rate of gain, and an ETA to the target.
_(installation and usage coming once tasks complete)_ ## Install
1. Install [Tampermonkey](https://www.tampermonkey.net/) (or Violentmonkey / Greasemonkey).
2. Open `torn-attribute-tracker.user.js` in your editor, copy its contents.
3. In Tampermonkey, click the dashboard → **+** (Create new script) → paste → save.
4. Visit `https://www.torn.com/gym.php`. The dialog appears in the bottom-right.
## Use
- Type a target value in the **Target** field, or pick a milestone from the dropdown.
- The dialog updates live as you train.
- Drag the header to reposition. Click **Above training UI** to anchor above the gym form. Click **Float free** to drag again.
- The **✕** closes the dialog for the current page session; it returns on next visit.
Targets, dialog position, and the 30-day train history are stored in `localStorage`.
## Self-test
Load the script with `#tat-test` in the URL:
```
https://www.torn.com/gym.php#tat-test
```
Open the browser console; you'll see `[tat] self-test results:` followed by `OK …` / `FAIL …` lines.
## Tests
```
npm install # no deps; node:test ships with Node 18+
npm test
```
## Files
- `torn-attribute-tracker.user.js` — the script you install in Tampermonkey.
- `src/pure.js`, `src/store.js`, `src/dom.js`, `src/interceptor.js`, `src/ui.js`, `src/main.js` — source split for testability; the user-facing file is the bundle in step 11.
- `tests/` — Node test runner (`node --test`).
## Notes
The DOM scraper and request interceptor are best-effort matches for the current Torn gym page. If Torn updates the markup, you may need to adjust the selectors in `src/dom.js` and re-bundle by copying the new source into the embedded section in `torn-attribute-tracker.user.js`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
# Torn Attribute Training Tracker — Design
**Date:** 2026-06-01
**Status:** Approved (brainstorming)
## Purpose
A userscript for `torn.com` that helps players plan attribute training. Given a target attribute value, it shows a floating dialog (optionally anchored above the training interface) with the attribute being trained, the current value, the target, the increase per train and per day, and an estimate of how long it will take to reach the target.
The dialog updates live as the user trains. It also tracks a rolling 30-day history of trains per attribute and shows today's count plus a 7-day average rate, so the estimate is grounded in the user's actual history rather than a guess.
## Scope
**In scope (v1):**
- Single Torn gym page (`/gym.php`).
- Per-attribute target, persisted across sessions.
- Free-floating and "anchored above training UI" placement modes, user-selectable.
- DOM scrape for current attribute value and gym name.
- XHR/fetch interception to detect trains and capture per-train deltas.
- localStorage persistence of targets, 30-day history, and UI prefs.
- Torn-matched visual style.
**Out of scope (v1):**
- Other Torn pages (chains, items, etc.).
- The official Torn API.
- Cross-device sync.
- Multi-user support.
- Notifications (browser/system).
- Historical analytics beyond the 30-day window.
## Architecture
One file: `torn-attribute-tracker.user.js`. Three logical layers separated by header comments, plus a small `main` glue block.
```
// ===== DataLayer =====
// ===== Store =====
// ===== UI =====
// ===== main =====
```
### DataLayer — talks to Torn
- `currentAttribute()` — reads the gym page DOM. Returns `{ attr, current, gym }` or `null` if the selectors miss.
- `lastDelta()` — exposes the most recent train delta so the dialog can show "+N per train" without re-deriving it.
- `startRequestInterceptor()` — wraps `XMLHttpRequest` and `fetch` to watch for the request Torn sends when the user clicks Train. Parses the new value from the response, computes `delta = newValue oldValue`, emits `train:recorded` with `{attr, delta, ts}`.
The request interceptor is the authoritative "user clicked Train" signal. We do not infer trains from DOM changes alone, because Torn re-renders the page in ways that look like value changes but are not trains.
### Store — talks to localStorage
Three keys, all JSON:
- `tat.targets``{ [attr]: targetValue }`
- `tat.history``{ [attr]: [{ ts, delta }] }` rolling 30 days
- `tat.prefs``{ version: 1, mode: 'free' | 'anchored', pos: {x, y} }`
Public methods:
- `load()` / `save()` — read/write the three keys, with try/catch around `JSON.parse` and `setItem`. Bad JSON is wiped with a console warning; full storage is tolerated by falling back to in-memory state for the session.
- `recordTrain(attr, delta)` — appends a new entry to `history[attr]`, prunes anything older than 30 days, persists synchronously.
- `summary(attr)` — returns `{ trainsToday, sevenDayAvgPerDay, perDay }`:
- `trainsToday` = entries in `history[attr]` with `ts` within the last 24 hours
- `sevenDayAvgPerDay` = total trains in the last 7 days ÷ 7
- `perDay` = `sevenDayAvgPerDay × lastDelta`
- `getTarget(attr)` / `setTarget(attr, value)` — read/write `targets[attr]`. `setTarget` requires a positive integer (Torn attributes are integers); coerces user-typed strings like `"25,000,000"` (strips commas) or `"25M"` (expands to `25_000_000`); reverts to the previous value silently on invalid input.
### UI — talks to the user
- `Dialog` — a single `<div>` injected at `document.body` with Torn-matched styling (dark background, Tahoma/Verdana, rounded corners, subtle shadow so it reads as a script widget). The header bar is the drag handle; an `✕` button closes the dialog for the current page session.
- `render(state)` — pure function. Rewrites the dialog's inner HTML from `{ attr, gym, current, target, perTrain, perDay, trainsToday, sevenDayAvg, remaining, eta }`. Called on every state change.
- `computeEstimate(current, target, perTrain, perDay)` — pure function.
- `remaining = target current`
- `trainsToGo = ceil(remaining / perTrain)`
- `days = ceil(remaining / perDay)`
- `eta = today + days`, formatted
- `wireEvents(dialog, store, dataLayer)` — sets up: target input change, milestone dropdown, mode toggle, drag handle mousedown/move/up. Re-renders after every interaction.
### main — glues them
```
store = Store.load()
dataLayer = new DataLayer()
dataLayer.on('train:recorded', ({attr, delta}) => {
store.recordTrain(attr, delta)
dialog.render(snapshot())
})
dialog = new Dialog()
dialog.mount({ dataLayer, store })
dialog.render(snapshot())
```
## Dialog content
```
⚙ Attribute Tracker drag · ✕
Attribute Strength · at Total Bastion
Current 14,328,501
Target [ 25,000,000 ] [Custom ▾]
Per train + 247
Trains today 14
7-day avg 18.3 / day
Per day + 4,520
Remaining 10,671,499
Trains to go ≈ 43,205
ETA ~ 2,362 days (Wed 17 Nov 2032)
[ Float free ] [ Above training UI ]
```
The bottom toggle switches placement mode and persists immediately. The active mode is visually distinct.
## Placement modes
Both modes share the same `Dialog` instance; only the position is computed differently.
**Free-floating (default).**
- On first mount, position at `bottom: 20px; right: 20px`.
- The header bar is the drag handle. `mousedown` records the cursor-to-dialog offset; `mousemove` on `document` updates `transform: translate(x, y)`; `mouseup` persists `{x, y}` to `prefs.pos` and removes the listeners.
- Default size ~360px wide. No resize handle (matches Torn's own panels).
**Anchored above the training UI.**
- Look up a stable Torn selector for the training form (the element containing the Train button). Position the dialog immediately above it with the same horizontal alignment.
- If the dialog would overflow the viewport, fall back to `top: 20px; left: 50%; transform: translateX(-50%)`.
- Re-anchor on `ResizeObserver` for the training form so the dialog follows layout changes.
- Drag is disabled in this mode. The mode toggle is the only way to move it.
- If the training form selector misses, show an inline "can't anchor here" note and fall back to free-floating automatically.
The `✕` close button hides the dialog for the current page session only; it reappears on next page load. We deliberately don't add a "remember as closed" state.
## Error handling
| Failure | Detection | User-visible behavior |
|---|---|---|
| Gym page layout changed; selectors miss | `currentAttribute()` returns `null` | Dialog shows "Couldn't read attribute — Torn may have updated the page" with a "Reload" button. No infinite retries. |
| Train request shape changed; interceptor can't parse the response | Response parse fails | `train:recorded` isn't emitted. Dialog still works, just doesn't update after trains. One-time `console.warn` names the request. |
| localStorage quota exceeded | `setItem` throws | Falls back to in-memory state for the session. Dialog shows a "⚠ saving disabled this session" line. |
| Stored JSON corrupted (manual edit, partial write) | try/catch in `Store.load()` | Wipe the bad key, start fresh, log a warning. |
| User changes attribute mid-session | `currentAttribute()` returns a different `attr` key | Dialog updates its header; per-attribute state is independent. |
| Torn is down / request errors out | Network interceptor sees a non-2xx response | Don't record a train. No state change. |
We never `throw` to the user. Every failure path produces a working degraded state or a clear inline message.
## Testing
Userscripts are hard to unit-test in isolation. Two accommodations:
- Pure functions (`computeEstimate`, `summary`, `recordTrain`'s pruning) are exported via `window.TAT` so they can be exercised manually from the browser console.
- A `__test__` block guarded by `location.hash === '#tat-test'` runs assertions on synthetic history data and prints `OK` / `FAIL: …` lines to the console.
The README documents how to load the script, set a target, and verify the dialog updates after a train.
## File layout
```
torn-attribute-tracker/
torn-attribute-tracker.user.js # the script
README.md # install + usage
docs/
superpowers/
specs/
2026-06-01-torn-attribute-training-tracker-design.md # this file
```
## Open questions
None at design time. All scope questions resolved during brainstorming.
+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;
} }
+39 -19
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;
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; 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); 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 };
} }
+154
View File
@@ -0,0 +1,154 @@
import { Store } from './store.js';
import { Dialog } from './ui.js';
import { currentAttribute } from './dom.js';
import { startRequestInterceptor } from './interceptor.js';
function findAnchorElement() {
// 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;
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();
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) {
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');
}
// 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),
});
} catch (e) {
console.error('[tat] failed to start:', e);
}
}
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();
}
+39 -2
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;
@@ -30,6 +41,7 @@ const STYLE = `
} }
.tat-modes button.active { background: #444; border-color: #888; } .tat-modes button.active { background: #444; border-color: #888; }
.tat-warn { color: #c90; margin-top: 6px; font-size: 11px; } .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 { padding: 8px 0; color: #f88; }
.tat-error button { margin-left: 8px; } .tat-error button { margin-left: 8px; }
`; `;
@@ -99,7 +111,7 @@ export class Dialog {
if (initialMode === 'free') { if (initialMode === 'free') {
root.style.bottom = '20px'; root.style.bottom = '20px';
root.style.right = '20px'; root.style.left = '20px';
if (initialPos.x || initialPos.y) { if (initialPos.x || initialPos.y) {
root.style.transform = `translate(${initialPos.x}px, ${initialPos.y}px)`; root.style.transform = `translate(${initialPos.x}px, ${initialPos.y}px)`;
} }
@@ -116,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.right = '20px'; this.root.style.left = '20px';
} else if (anchorInfo && anchorInfo.canAnchor) { } 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); 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%)';
@@ -134,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;
@@ -148,6 +181,9 @@ export class Dialog {
const self = this; const self = this;
this.root.addEventListener('mousedown', (e) => { this.root.addEventListener('mousedown', (e) => {
if (self.mode !== 'free') return; 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; if (e.target.classList.contains('tat-close')) return;
const rect = self.root.getBoundingClientRect(); const rect = self.root.getBoundingClientRect();
self.dragState = { dx: e.clientX - rect.left, dy: e.clientY - rect.top }; 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-row"><span>ETA</span><span>${est.days > 0 ? `~ ${fmtFull(est.days)} days (${fmtDate(est.eta)})` : '—'}</span></div>
<div class="tat-modes">${modes}</div> <div class="tat-modes">${modes}</div>
${warn ? `<div class="tat-warn">⚠ ${esc(warn)}</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(); this.root.querySelector('.tat-close').onclick = () => this.onClose && this.onClose();
+194
View File
@@ -0,0 +1,194 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, '..');
test('userscript embeds a copy of src/pure.js that is present and parseable', () => {
const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8');
// The bundle must reference all four pure function names.
for (const name of ['parseTarget', 'computeEstimate', 'pruneHistory', 'summary']) {
assert.ok(bundle.includes('function ' + name), 'missing ' + name + ' in bundle');
}
// The bundle must include the Tampermonkey header.
assert.ok(bundle.includes('// ==UserScript=='), 'missing Tampermonkey header');
// The bundle must include the @match directive.
assert.ok(bundle.includes('@match'), 'missing @match');
assert.ok(bundle.includes('torn.com/gym.php'), 'missing torn.com/gym.php match');
});
test('userscript self-test block is wired to #tat-test', () => {
const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8');
assert.ok(bundle.includes("location.hash === '#tat-test'"), 'self-test guard missing');
assert.ok(bundle.includes('runSelfTest'), 'runSelfTest function missing');
});
test('bundle summary behavior matches src/pure.js (catches Math.floor-style regressions)', () => {
// Read both the source and the bundle.
const src = readFileSync(join(root, 'src/pure.js'), 'utf8');
const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8');
// Extract the body of `summary` from src/pure.js between `function summary(` and the next `}`.
// Use a simple state machine: find the function header, then count braces.
function extractBody(source, fnName) {
const start = source.indexOf('function ' + fnName + '(');
if (start === -1) throw new Error('could not find ' + fnName + ' in source');
// Find the opening brace of the function body.
let i = source.indexOf('{', start);
if (i === -1) throw new Error('no opening brace for ' + fnName);
let depth = 1;
i++;
while (i < source.length && depth > 0) {
const c = source[i];
if (c === '{') depth++;
else if (c === '}') depth--;
i++;
}
return source.slice(start, i);
}
// Eval-extract the `summary` function from both sources. The src/pure.js
// version references the module-level `MS_PER_DAY` constant, so inject
// it into the eval scope; the bundle inlines the literal value, so the
// injected binding is a harmless no-op there.
const srcSummary = eval('(function() { const MS_PER_DAY = 86_400_000; ' + extractBody(src, 'summary') + ' ; return summary; })()');
const bundleSummary = eval('(function() { const MS_PER_DAY = 86_400_000; ' + extractBody(bundle, 'summary') + ' ; return summary; })()');
// Run both with a known input that exercises perDay flooring.
const NOW = 1_700_000_000_000;
const entries = [
{ ts: NOW - 1000, delta: 247 },
{ ts: NOW - 2000, delta: 247 },
];
const srcResult = srcSummary(entries, NOW);
const bundleResult = bundleSummary(entries, NOW);
// The bundle MUST match the source exactly.
assert.deepEqual(bundleResult, srcResult, 'bundle summary differs from src/pure.js summary');
// And specifically: perDay must be the floored integer 70, not the float 70.57.
assert.equal(bundleResult.perDay, 70, 'perDay should be floored to 70');
});
test('bundle parseTarget behavior matches src/pure.js', () => {
const src = readFileSync(join(root, 'src/pure.js'), 'utf8');
const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8');
function extractBody(source, fnName) {
const start = source.indexOf('function ' + fnName + '(');
if (start === -1) throw new Error('could not find ' + fnName + ' in source');
let i = source.indexOf('{', start);
if (i === -1) throw new Error('no opening brace for ' + fnName);
let depth = 1;
i++;
while (i < source.length && depth > 0) {
const c = source[i];
if (c === '{') depth++;
else if (c === '}') depth--;
i++;
}
return source.slice(start, i);
}
// Both src and bundle reference module-scope `SUFFIXES`, so inject it for both.
const srcFn = eval('(function() { const SUFFIXES = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }; ' + extractBody(src, 'parseTarget') + ' ; return parseTarget; })()');
const bundleFn = eval('(function() { const SUFFIXES = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }; ' + extractBody(bundle, 'parseTarget') + ' ; return parseTarget; })()');
for (const input of [25, 25000000, '25M', '1.5B', '25,000,000', 'abc', null, undefined, 0, -1]) {
assert.equal(bundleFn(input), srcFn(input), 'parseTarget drift on input: ' + JSON.stringify(input));
}
});
test('bundle computeEstimate behavior matches src/pure.js', () => {
const src = readFileSync(join(root, 'src/pure.js'), 'utf8');
const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8');
function extractBody(source, fnName) {
const start = source.indexOf('function ' + fnName + '(');
if (start === -1) throw new Error('could not find ' + fnName + ' in source');
let i = source.indexOf('{', start);
if (i === -1) throw new Error('no opening brace for ' + fnName);
let depth = 1;
i++;
while (i < source.length && depth > 0) {
const c = source[i];
if (c === '{') depth++;
else if (c === '}') depth--;
i++;
}
return source.slice(start, i);
}
// The bundle inlines the 86_400_000 constant; src/pure.js uses MS_PER_DAY.
// Both versions call `Date.now()` inside the function for the `eta` Date, so
// we strip `eta` from the comparison (it would differ by sub-millisecond
// jitter between the two evals) and only compare the numeric fields.
const srcFn = eval('(function() { const MS_PER_DAY = 86_400_000; ' + extractBody(src, 'computeEstimate') + ' ; return computeEstimate; })()');
const bundleFn = eval('(function() { ' + extractBody(bundle, 'computeEstimate') + ' ; return computeEstimate; })()');
function stripEta(r) {
return { remaining: r.remaining, trainsToGo: r.trainsToGo, days: r.days, etaDays: r.days };
}
for (const args of [
[14_328_501, 25_000_000, 247, 4520],
[25_000_000, 25_000_000, 247, 4520],
[30_000_000, 25_000_000, 247, 4520],
[100, 200, 0, 50],
[100, 200, 50, 0],
]) {
const [c, t, pt, pd] = args;
const srcResult = srcFn(c, t, pt, pd);
const bundleResult = bundleFn(c, t, pt, pd);
// Both eta fields should be null together, or both should be Date objects.
assert.equal(bundleResult.eta === null, srcResult.eta === null, 'eta nullity drift on args: ' + JSON.stringify(args));
assert.deepEqual(stripEta(bundleResult), stripEta(srcResult), 'computeEstimate drift on args: ' + JSON.stringify(args));
}
});
test('bundle pruneHistory behavior matches src/pure.js (catches strict-boundary regressions)', () => {
const src = readFileSync(join(root, 'src/pure.js'), 'utf8');
const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8');
function extractBody(source, fnName) {
const start = source.indexOf('function ' + fnName + '(');
if (start === -1) throw new Error('could not find ' + fnName + ' in source');
let i = source.indexOf('{', start);
if (i === -1) throw new Error('no opening brace for ' + fnName);
let depth = 1;
i++;
while (i < source.length && depth > 0) {
const c = source[i];
if (c === '{') depth++;
else if (c === '}') depth--;
i++;
}
return source.slice(start, i);
}
// The bundle inlines 30 * 86_400_000; src uses THIRTY_DAYS_MS = 30 * MS_PER_DAY.
// Both must be visible to the extracted function body.
const srcFn = eval('(function() { const MS_PER_DAY = 86_400_000; const THIRTY_DAYS_MS = 30 * MS_PER_DAY; ' + extractBody(src, 'pruneHistory') + ' ; return pruneHistory; })()');
const bundleFn = eval('(function() { const THIRTY_DAYS_MS = 30 * 86_400_000; ' + extractBody(bundle, 'pruneHistory') + ' ; return pruneHistory; })()');
const NOW = 1_700_000_000_000;
const DAY = 86_400_000;
// Critical case: entry at exactly 30 days. Source drops it (strict >), bundle should too.
const boundaryEntries = [
{ ts: NOW, delta: 1 },
{ ts: NOW - 1 * DAY, delta: 1 },
{ ts: NOW - 29 * DAY, delta: 1 },
{ ts: NOW - 30 * DAY, delta: 1 }, // exactly 30 days — should be DROPPED
{ ts: NOW - 31 * DAY, delta: 1 },
];
const srcResult = srcFn(boundaryEntries, NOW);
const bundleResult = bundleFn(boundaryEntries, NOW);
assert.deepEqual(bundleResult, srcResult, 'pruneHistory drift on 30-day boundary');
assert.equal(srcResult.length, 3, 'source should keep 3 entries (drop the 30-day one)');
assert.equal(bundleResult.length, 3, 'bundle should keep 3 entries (drop the 30-day one)');
});
+631
View File
@@ -0,0 +1,631 @@
// ==UserScript==
// @name Torn Attribute Training Tracker
// @namespace https://github.com/local/torn-attribute-tracker
// @version 0.1.0
// @description Floating dialog showing attribute target, rate, and ETA on the Torn gym page.
// @match https://www.torn.com/gym.php*
// @grant none
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
// ===== pure.js (embedded) =====
const SUFFIXES = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 };
function parseTarget(input) {
if (input === null || input === undefined || input === '') return null;
if (typeof input === 'number') {
if (!Number.isFinite(input) || input <= 0) return null;
return Math.floor(input);
}
if (typeof input !== 'string') return null;
const cleaned = input.replace(/,/g, '').trim().toLowerCase();
if (cleaned === '') return null;
if (!/^\d+(\.\d+)?[kmbt]?$/.test(cleaned)) return null;
const match = cleaned.match(/^(\d+(?:\.\d+)?)([kmbt])?$/);
if (!match) return null;
const num = parseFloat(match[1]);
if (!Number.isFinite(num) || num <= 0) return null;
const suffix = match[2];
const multiplier = suffix ? SUFFIXES[suffix] : 1;
return Math.floor(num * multiplier);
}
const THIRTY_DAYS_MS = 30 * 86_400_000;
function pruneHistory(entries, now) {
const cutoff = (now || Date.now()) - THIRTY_DAYS_MS;
return entries.filter((e) => e.ts > cutoff);
}
function computeEstimate(current, target, perTrain, perDay) {
const remaining = Math.max(0, target - current);
if (remaining === 0) return { remaining: 0, trainsToGo: 0, days: 0, eta: null };
const trainsToGo = perTrain > 0 ? Math.ceil(remaining / perTrain) : 0;
const days = perDay > 0 ? Math.ceil(remaining / perDay) : 0;
const eta = days > 0 ? new Date(Date.now() + days * 86_400_000) : null;
return { remaining, trainsToGo, days, eta };
}
function summary(entries, now) {
const t = now || Date.now();
if (entries.length === 0) return { trainsToday: 0, sevenDayAvgPerDay: 0, perDay: 0 };
const ONE_DAY = 86_400_000, SEVEN_DAYS = 7 * ONE_DAY;
const todayCutoff = t - ONE_DAY, weekCutoff = t - SEVEN_DAYS;
let trainsToday = 0, trainsWeek = 0, latestDelta = 0, latestTs = -Infinity;
for (const e of entries) {
if (e.ts >= todayCutoff) trainsToday++;
if (e.ts >= weekCutoff) trainsWeek++;
if (e.ts > latestTs) { latestTs = e.ts; latestDelta = e.delta; }
}
const sevenDayAvgPerDay = trainsWeek / 7;
const perDay = latestDelta > 0 ? Math.floor(sevenDayAvgPerDay * latestDelta) : 0;
return { trainsToday, sevenDayAvgPerDay, perDay };
}
// ===== store.js (embedded) =====
const KEY_TARGETS = 'tat.targets';
const KEY_HISTORY = 'tat.history';
const KEY_PREFS = 'tat.prefs';
const DEFAULT_PREFS = { version: 1, mode: 'free', pos: { x: 0, y: 0 } };
class Store {
constructor(opts) {
opts = opts || {};
const storage = opts.storage || localStorage;
const onWarn = opts.onWarn || function (m) { console.warn(m); };
if (!storage) throw new Error('Store requires storage');
this.storage = storage;
this.onWarn = onWarn;
this._saveDisabled = false;
this.targets = this._loadJson(KEY_TARGETS, {});
this.history = this._loadJson(KEY_HISTORY, {});
this.prefs = this._mergePrefs(this._loadJson(KEY_PREFS, null));
}
_loadJson(key, fallback) {
let raw;
try { raw = this.storage.getItem(key); } catch { return fallback; }
if (raw == null) return fallback;
try { return JSON.parse(raw); }
catch { this.onWarn('[tat] discarding corrupted ' + key); try { this.storage.removeItem(key); } catch {} return fallback; }
}
_saveJson(key, value) {
if (this._saveDisabled) return false;
try { this.storage.setItem(key, JSON.stringify(value)); return true; }
catch (e) { this.onWarn('[tat] failed to persist ' + key + ': ' + e.message + '; further saves disabled for this session'); this._saveDisabled = true; return false; }
}
_mergePrefs(loaded) {
if (!loaded || loaded.version !== 1) return Object.assign({}, DEFAULT_PREFS);
return Object.assign({}, DEFAULT_PREFS, loaded);
}
getTarget(attr) { const v = this.targets[attr]; return typeof v === 'number' && v > 0 ? v : null; }
setTarget(attr, value) {
const parsed = parseTarget(value);
if (parsed === null) return false;
this.targets[attr] = parsed;
return this._saveJson(KEY_TARGETS, this.targets);
}
getSummary(attr, now) { return summary(this.history[attr] || [], now); }
recordTrain(attr, delta, ts) {
ts = ts || Date.now();
if (typeof delta !== 'number' || !Number.isFinite(delta) || delta <= 0) return false;
const list = this.history[attr] || [];
list.push({ ts: ts, delta: delta });
this.history[attr] = pruneHistory(list, ts);
return this._saveJson(KEY_HISTORY, this.history);
}
getPrefs() { return Object.assign({}, this.prefs); }
setMode(mode) { if (mode !== 'free' && mode !== 'anchored') return false; this.prefs.mode = mode; return this._saveJson(KEY_PREFS, this.prefs); }
setPos(pos) { if (!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number') return false; this.prefs.pos = { x: pos.x, y: pos.y }; return this._saveJson(KEY_PREFS, this.prefs); }
}
// ===== dom.js (embedded) =====
const TAT_KNOWN_ATTRS = ['strength', 'defense', 'speed', 'dexterity', 'endurance', 'intelligence'];
function currentAttribute() {
const li = tatFindActiveAttributeLi();
if (!li) return null;
const attr = tatExtractAttrFromLi(li);
if (!attr) return null;
const current = tatExtractValueFromLi(li);
if (current == null) return null;
const gym = tatFindGymName() || 'Unknown gym';
return { attr: attr, current: current, gym: gym };
}
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;
}
// 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;
}
}
}
// 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 null;
}
function tatExtractAttrFromLi(li) {
const cls = li.className || '';
const parts = cls.split(/\s+/);
for (const attr of TAT_KNOWN_ATTRS) {
const prefix = attr + '___';
for (const c of parts) {
if (c.indexOf(prefix) === 0) return attr;
}
}
return null;
}
function tatExtractValueFromLi(li) {
const valueSpan = li.querySelector('[class^="propertyValue"]');
if (!valueSpan) return null;
return tatParseNumber(valueSpan.textContent);
}
function tatFindGymName() {
// Find the currently selected gym button. It has the "active" class.
const activeBtn = document.querySelector('button[class*="gymButton"][class*="active"]');
if (activeBtn) {
const label = activeBtn.getAttribute('aria-label') || '';
// aria-label format: "<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 = String(text).replace(/,/g, '').trim();
if (!/^\d+(\.\d+)?$/.test(cleaned)) return null;
const n = parseFloat(cleaned);
return Number.isFinite(n) ? Math.floor(n) : null;
}
// ===== interceptor.js (embedded) =====
function startRequestInterceptor(opts) {
let lastValue = opts.prevValue;
let warnedFor = null;
function handle(text, url) {
const parsed = parseTrainResponse(text, url, opts.currentAttr);
if (!parsed) { if (warnedFor !== url) { warnedFor = url; opts.onParseFail && opts.onParseFail(url); } return; }
let delta;
if (typeof parsed.delta === 'number' && parsed.delta > 0) {
delta = parsed.delta;
} else if (typeof parsed.newValue === 'number' && parsed.newValue > 0) {
delta = parsed.newValue - lastValue;
lastValue = parsed.newValue;
} else {
return;
}
if (delta <= 0) return;
const attr = parsed.attr || opts.currentAttr;
if (!attr) return;
opts.onTrain({ attr: attr, delta: delta, ts: Date.now() });
}
wrapXhr(handle); wrapFetch(handle);
return { updatePrevValue: function (v) { lastValue = v; } };
}
function parseTrainResponse(text, url, fallbackAttr) {
// Strategy 1: look for the "gained" message in the response.
// Format: "You gained <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 {}
// Strategy 3: regex fallback (last resort). Don't use the first number
// blindly; look specifically for the propertyValue span content, which
// is the authoritative source.
const propertyValueMatch = text.match(/class="propertyValue[^"]*"[^>]*>([\d,]+(?:\.\d+)?)</);
if (propertyValueMatch) {
const newValue = parseInt(propertyValueMatch[1].replace(/,/g, ''), 10);
if (Number.isFinite(newValue) && newValue > 0) {
return { newValue: newValue, attr: fallbackAttr || null };
}
}
return null;
}
function wrapXhr(handle) {
if (XMLHttpRequest.prototype.send.__tatWrapped) return;
const origOpen = XMLHttpRequest.prototype.open;
const origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) { this.__tatUrl = String(url); return origOpen.apply(this, arguments); };
XMLHttpRequest.prototype.send = function () {
this.addEventListener('load', function () { try { handle(this.responseText, this.__tatUrl); } catch {} });
return origSend.apply(this, arguments);
};
XMLHttpRequest.prototype.send.__tatWrapped = true;
}
function wrapFetch(handle) {
const origFetch = window.fetch;
if (origFetch.__tatWrapped) return;
window.fetch = async function () {
const url = typeof arguments[0] === 'string' ? arguments[0] : (arguments[0] && arguments[0].url) || '';
const res = await origFetch.apply(this, arguments);
try { const text = await res.clone().text(); handle(text, String(url)); } catch {}
return res;
};
window.fetch.__tatWrapped = true;
}
// ===== ui.js (embedded) =====
const TAT_STYLE = `
.tat-root { position: fixed; z-index: 99999; min-width: 320px; max-width: 420px; background: #2b2b2b; color: #ddd; border: 1px solid #444; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); font: 13px/1.4 Tahoma, Verdana, sans-serif; padding: 12px 14px; }
.tat-root.tat-anchored { position: static; margin: 0 0 12px 0; max-width: none; box-shadow: none; border-radius: 0; border: 1px solid #444; border-top: 2px solid #c00; padding: 16px 20px; }
.tat-root.tat-anchored .tat-header { cursor: default; }
.tat-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 8px; margin-bottom: 10px; border-bottom: 1px solid #444; cursor: move; user-select: none; }
.tat-header strong { color: #fff; }
.tat-close { cursor: pointer; opacity: 0.6; padding: 0 4px; }
.tat-close:hover { opacity: 1; }
.tat-row { display: flex; justify-content: space-between; padding: 2px 0; }
.tat-row.tat-target input, .tat-row.tat-target select { background: #1a1a1a; color: #ddd; border: 1px solid #555; padding: 2px 4px; font: inherit; font-size: 12px; }
.tat-hr { border: none; border-top: 1px solid #444; margin: 8px 0; }
.tat-modes { display: flex; gap: 6px; margin-top: 12px; }
.tat-modes button { flex: 1; padding: 4px; background: #2b2b2b; color: #ddd; border: 1px solid #555; font: inherit; font-size: 11px; cursor: pointer; }
.tat-modes button.active { background: #444; border-color: #888; }
.tat-warn { color: #c90; margin-top: 6px; font-size: 11px; }
.tat-anchor-err { color: #c90; margin-top: 6px; font-size: 11px; }
.tat-error { padding: 8px 0; color: #f88; }
.tat-error button { margin-left: 8px; }
`;
const TAT_MILESTONES = [
{ label: 'Custom', value: null },
{ label: '1M', value: 1_000_000 }, { label: '5M', value: 5_000_000 },
{ label: '10M', value: 10_000_000 }, { label: '25M', value: 25_000_000 },
{ label: '50M', value: 50_000_000 }, { label: '100M', value: 100_000_000 },
{ label: '250M', value: 250_000_000 }, { label: '500M', value: 500_000_000 },
{ label: '1B', value: 1_000_000_000 },
];
function tatFmt(n) {
if (n == null) return '—';
if (n >= 1e9) return (n / 1e9).toFixed(2).replace(/\.?0+$/, '') + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.?0+$/, '') + 'K';
return String(n);
}
function tatFmtFull(n) { if (n == null) return '—'; return Math.round(n).toLocaleString('en-US'); }
function tatFmtDate(d) { if (!d) return '—'; return d.toLocaleDateString('en-US', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' }); }
function tatEsc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
class Dialog {
constructor(opts) {
opts = opts || {};
this.onTargetChange = opts.onTargetChange;
this.onModeChange = opts.onModeChange;
this.onPosChange = opts.onPosChange;
this.onClose = opts.onClose;
this.root = null; this.dragState = null; this.mode = 'free';
}
mount(opts) {
opts = opts || {};
if (this.root) return;
if (typeof document === 'undefined') return;
if (!document.getElementById('tat-style')) {
const s = document.createElement('style'); s.id = 'tat-style'; s.textContent = TAT_STYLE; document.head.appendChild(s);
}
const root = document.createElement('div');
root.className = 'tat-root';
root.dataset.tat = '1';
document.body.appendChild(root);
this.root = root;
this.mode = opts.initialMode || 'free';
if (this.mode === 'free') {
root.style.bottom = '20px';
root.style.left = '20px';
if (opts.initialPos && (opts.initialPos.x || opts.initialPos.y)) {
root.style.transform = 'translate(' + opts.initialPos.x + 'px, ' + opts.initialPos.y + 'px)';
}
}
this._wireHeaderDrag();
}
destroy() { if (this.root && this.root.parentNode) this.root.parentNode.removeChild(this.root); this.root = null; }
setMode(mode, anchorInfo) {
this.mode = mode;
if (!this.root) return;
// Clear all position styles
this.root.style.transform = ''; this.root.style.top = ''; this.root.style.bottom = ''; this.root.style.left = ''; this.root.style.right = '';
this.root.classList.remove('tat-anchored');
if (mode === 'free') {
// Floating mode: ensure dialog is in body and position at bottom-left.
if (this.root.parentNode !== document.body) {
document.body.appendChild(this.root);
}
this.root.style.bottom = '20px';
this.root.style.left = '20px';
} else if (anchorInfo && anchorInfo.canAnchor) {
if (anchorInfo.insertBefore) {
// Docked mode: insert the dialog into the page flow before the
// given element, and add the tat-anchored class to switch to
// static positioning.
anchorInfo.insertBefore.parentNode.insertBefore(this.root, anchorInfo.insertBefore);
this.root.classList.add('tat-anchored');
} else if (anchorInfo.rect) {
// Fallback: position fixed above the rect (old behavior, used when
// no insertion point is available but a rect was given).
this._positionAnchored(anchorInfo.rect);
}
// If neither insertBefore nor rect, leave the dialog where it is
// (the caller will show an anchorError note).
} else {
// Top-center fallback (used when mode is anchored but no anchor info).
this.root.style.top = '20px';
this.root.style.left = '50%';
this.root.style.transform = 'translateX(-50%)';
}
}
_positionAnchored(rect) {
if (!rect) return; // defensive: setMode may be called without a rect
const dialogRect = this.root.getBoundingClientRect();
let top = rect.top - dialogRect.height - 8;
if (top < 8) top = 20;
let left = rect.left + (rect.width - dialogRect.width) / 2;
if (left < 8) left = 8;
if (left + dialogRect.width > window.innerWidth - 8) left = window.innerWidth - dialogRect.width - 8;
this.root.style.top = top + 'px'; this.root.style.left = left + 'px';
}
_wireHeaderDrag() {
const self = this;
this.root.addEventListener('mousedown', function (e) {
if (self.mode !== 'free') return;
// Only initiate drag from the header bar. This prevents stealing focus
// from inputs, selects, and buttons inside the dialog body.
if (!e.target.closest('.tat-header')) return;
if (e.target.classList.contains('tat-close')) return;
const rect = self.root.getBoundingClientRect();
self.dragState = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
e.preventDefault();
const onMove = function (ev) {
if (!self.dragState) return;
const x = ev.clientX - self.dragState.dx, y = ev.clientY - self.dragState.dy;
self.root.style.left = x + 'px'; self.root.style.top = y + 'px';
self.root.style.right = 'auto'; self.root.style.bottom = 'auto';
};
const onUp = function () {
if (!self.dragState) return;
const r = self.root.getBoundingClientRect();
self.dragState = null;
self.onPosChange && self.onPosChange({ x: r.left, y: r.top });
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
render(state) {
if (!this.root) return;
const s = state;
const self = this;
if (s.error) {
this.root.innerHTML = '<div class="tat-header"><strong>⚙ Attribute Tracker</strong><span class="tat-close">✕</span></div><div class="tat-error">' + tatEsc(s.error) + '<button data-action="reload">Reload</button></div>';
this.root.querySelector('[data-action="reload"]').onclick = function () { location.reload(); };
this.root.querySelector('.tat-close').onclick = function () { self.onClose && self.onClose(); };
return;
}
const est = computeEstimate(s.current || 0, s.target || 0, s.perTrain || 0, (s.summary && s.summary.perDay) || 0);
const modes = ['free', 'anchored'].map(function (m) {
return '<button data-mode="' + m + '" class="' + (this.mode === m ? 'active' : '') + '">' + (m === 'free' ? 'Float free' : 'Above training UI') + '</button>';
}, this).join('');
const milestoneOptions = TAT_MILESTONES.map(function (m) {
const sel = m.value === s.target ? 'selected' : '';
return '<option value="' + (m.value == null ? '' : m.value) + '" ' + sel + '>' + m.label + '</option>';
}).join('');
this.root.innerHTML = ''
+ '<div class="tat-header"><strong>⚙ Attribute Tracker</strong><span class="tat-close" title="Hide for this session">✕</span></div>'
+ '<div class="tat-row"><span>Attribute</span><span><strong>' + tatEsc(s.attr) + '</strong> · <em>' + tatEsc(s.gym) + '</em></span></div>'
+ '<div class="tat-row"><span>Current</span><span>' + tatFmtFull(s.current) + '</span></div>'
+ '<div class="tat-row tat-target"><span>Target</span><span><input data-role="target" value="' + (s.target || '') + '" placeholder="e.g. 25M" style="width:110px"><select data-role="milestone">' + milestoneOptions + '</select></span></div>'
+ '<hr class="tat-hr">'
+ '<div class="tat-row"><span>Per train</span><span>' + (s.perTrain ? '+ ' + tatFmtFull(s.perTrain) : '—') + '</span></div>'
+ '<div class="tat-row"><span>Trains today</span><span>' + tatFmtFull(s.summary && s.summary.trainsToday || 0) + '</span></div>'
+ '<div class="tat-row"><span>7-day avg</span><span>' + (s.summary ? s.summary.sevenDayAvgPerDay.toFixed(1) : '0.0') + ' / day</span></div>'
+ '<div class="tat-row"><span>Per day</span><span>' + (s.summary && s.summary.perDay > 0 ? '+ ' + tatFmtFull(s.summary.perDay) : '—') + '</span></div>'
+ '<hr class="tat-hr">'
+ '<div class="tat-row"><span>Remaining</span><span>' + tatFmtFull(est.remaining) + '</span></div>'
+ '<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.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)); };
this.root.querySelectorAll('[data-mode]').forEach(function (btn) { btn.onclick = function () { self.onModeChange && self.onModeChange(btn.dataset.mode); }; });
}
}
// ===== main.js (embedded) =====
function findAnchorElement() {
// 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();
},
onModeChange: function (m) { store.setMode(m); prefs.mode = m; applyMode(); },
onPosChange: function (pos) { store.setPos(pos); },
onClose: function () { dialog.destroy(); },
});
dialog.mount({ initialMode: prefs.mode, initialPos: prefs.pos });
applyMode();
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: 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) {
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');
}
render();
let pending = false;
const observer = new MutationObserver(function () {
if (pending) return;
pending = true;
requestAnimationFrame(function () {
pending = false;
const a = currentAttribute();
if (a && (a.attr !== lastAttr || a.current !== (lastSnapshot && lastSnapshot.current))) render();
});
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
const prev = (currentAttribute() && currentAttribute().current) || 0;
startRequestInterceptor({
prevValue: prev, currentAttr: lastAttr,
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') =====
function runSelfTest() {
const results = [];
function t(name, fn) {
try { fn(); results.push('OK ' + name); }
catch (e) { results.push('FAIL ' + name + ': ' + e.message); }
}
t('parseTarget integer', function () { if (parseTarget(25) !== 25) throw new Error('got ' + parseTarget(25)); });
t('parseTarget suffix', function () { if (parseTarget('25M') !== 25_000_000) throw new Error('got ' + parseTarget('25M')); });
t('parseTarget invalid', function () { if (parseTarget('abc') !== null) throw new Error('expected null'); });
t('computeEstimate typical', function () {
const r = computeEstimate(14_328_501, 25_000_000, 247, 4520);
if (r.remaining !== 10_671_499) throw new Error('remaining');
if (r.trainsToGo !== 43_205) throw new Error('trainsToGo');
if (r.days !== 2_361) throw new Error('days'); // 10_671_499 / 4_520 = 2360.95... → ceil = 2361
});
t('computeEstimate reached', function () {
const r = computeEstimate(25_000_000, 25_000_000, 247, 4520);
if (r.eta !== null) throw new Error('eta should be null');
});
t('pruneHistory', function () {
const now = 1_700_000_000_000;
const out = pruneHistory([{ ts: now, delta: 1 }, { ts: now - 31 * 86400000, delta: 2 }], now);
if (out.length !== 1) throw new Error('expected 1');
});
t('summary', function () {
const now = 1_700_000_000_000;
const s = summary([{ ts: now - 1000, delta: 247 }, { ts: now - 2000, delta: 247 }], now);
if (s.trainsToday !== 2) throw new Error('trainsToday');
if (Math.abs(s.perDay - 70) > 0.01) throw new Error('perDay, got ' + s.perDay);
});
console.log('[tat] self-test results:\n' + results.join('\n'));
}
// ===== exports for tests / console =====
window.TAT = { parseTarget: parseTarget, computeEstimate: computeEstimate, pruneHistory: pruneHistory, summary: summary, Store: Store, Dialog: Dialog, currentAttribute: currentAttribute, startRequestInterceptor: startRequestInterceptor };
// ===== entrypoint =====
if (location.hash === '#tat-test') {
runSelfTest();
} else if (/\/gym\.php(\?|$)/.test(location.pathname + location.search)) {
start();
}
})();