- 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.
8.5 KiB
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 }ornullif the selectors miss.lastDelta()— exposes the most recent train delta so the dialog can show "+N per train" without re-deriving it.startRequestInterceptor()— wrapsXMLHttpRequestandfetchto watch for the request Torn sends when the user clicks Train. Parses the new value from the response, computesdelta = newValue − oldValue, emitstrain:recordedwith{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 daystat.prefs—{ version: 1, mode: 'free' | 'anchored', pos: {x, y} }
Public methods:
load()/save()— read/write the three keys, with try/catch aroundJSON.parseandsetItem. 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 tohistory[attr], prunes anything older than 30 days, persists synchronously.summary(attr)— returns{ trainsToday, sevenDayAvgPerDay, perDay }:trainsToday= entries inhistory[attr]withtswithin the last 24 hourssevenDayAvgPerDay= total trains in the last 7 days ÷ 7perDay=sevenDayAvgPerDay × lastDelta
getTarget(attr)/setTarget(attr, value)— read/writetargets[attr].setTargetrequires a positive integer (Torn attributes are integers); coerces user-typed strings like"25,000,000"(strips commas) or"25M"(expands to25_000_000); reverts to the previous value silently on invalid input.
UI — talks to the user
Dialog— a single<div>injected atdocument.bodywith 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 − currenttrainsToGo = 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.
mousedownrecords the cursor-to-dialog offset;mousemoveondocumentupdatestransform: translate(x, y);mouseuppersists{x, y}toprefs.posand 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
ResizeObserverfor 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 viawindow.TATso they can be exercised manually from the browser console. - A
__test__block guarded bylocation.hash === '#tat-test'runs assertions on synthetic history data and printsOK/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.