ef90a6b779
- 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.
175 lines
8.5 KiB
Markdown
175 lines
8.5 KiB
Markdown
# 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.
|