Compare commits
10 Commits
8502c53663
...
ef90a6b779
| Author | SHA1 | Date | |
|---|---|---|---|
| ef90a6b779 | |||
| ecea14b051 | |||
| e21582b7c4 | |||
| adc8a5dec9 | |||
| a5f402132e | |||
| 62fe86a71f | |||
| 75f10126d2 | |||
| 7c2533d188 | |||
| 36a6b4c52d | |||
| 12fc79022f |
@@ -1,5 +1,46 @@
|
||||
# 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.
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { Store } from './store.js';
|
||||
import { Dialog } from './ui.js';
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -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)');
|
||||
});
|
||||
@@ -0,0 +1,538 @@
|
||||
// ==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) =====
|
||||
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; }
|
||||
}
|
||||
if (!attr) return null;
|
||||
let valEl = findValueNear(attrEl);
|
||||
if (!valEl) valEl = findValueElement();
|
||||
if (!valEl) return null;
|
||||
const current = parseNumber(valEl.textContent);
|
||||
if (current == null) return null;
|
||||
const gym = findGymName() || '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;
|
||||
}
|
||||
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; }
|
||||
}
|
||||
}
|
||||
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; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
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; }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function parseNumber(text) {
|
||||
if (!text) return null;
|
||||
const cleaned = 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; }
|
||||
if (parsed.attr && opts.currentAttr && parsed.attr !== opts.currentAttr) return;
|
||||
const delta = parsed.newValue - lastValue;
|
||||
lastValue = parsed.newValue;
|
||||
if (delta > 0) opts.onTrain({ attr: parsed.attr || opts.currentAttr, delta: delta, ts: Date.now() });
|
||||
}
|
||||
wrapXhr(handle); wrapFetch(handle);
|
||||
return { updatePrevValue: function (v) { lastValue = v; } };
|
||||
}
|
||||
function parseTrainResponse(text, url, fallbackAttr) {
|
||||
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 };
|
||||
}
|
||||
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-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-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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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.right = '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;
|
||||
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%)'; }
|
||||
}
|
||||
_positionAnchored(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;
|
||||
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>' : '');
|
||||
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() {
|
||||
const btn = document.querySelector('button[name="train"], a[href*="train"]');
|
||||
if (!btn) return null;
|
||||
return btn.closest('form') || btn.parentElement;
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (window.__tat_started) return; window.__tat_started = true;
|
||||
const store = new Store({ storage: localStorage, onWarn: function (m) { console.warn(m); } });
|
||||
const prefs = store.getPrefs();
|
||||
|
||||
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();
|
||||
|
||||
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." };
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(function () {
|
||||
if (prefs.mode === 'anchored') dialog._positionAnchored(el.getBoundingClientRect());
|
||||
});
|
||||
ro.observe(el);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
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); },
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user