Damien's racing guide, live on Torn's racing pages: track-to-car advisor, racing-point waste guard, per-car build checklist and plan dashboard. Runs in Chrome (Tampermonkey/Violentmonkey) and Torn PDA.
// ==UserScript==
// @name Torn Racing Advisor
// @namespace https://burmese.dev
// @version 1.6.0
// @description Damien's racing guide, live on Torn's racing pages: track-to-car advisor, racing-point waste guard, per-car build checklist and plan dashboard. Runs in Chrome (Tampermonkey/Violentmonkey) and Torn PDA.
// @author JHector
// @match https://www.torn.com/*
// @match https://torn.com/*
// @run-at document-idle
// @grant none
// @license MIT
// @noframes
// ==/UserScript==
/*
* Source guide:
* Damiens Racing Guides - https://www.torn.com/forums.php#/p=threads&f=61&t=16414760
* plus Rogue [1873490]'s correction post in the same thread.
*
* The single most valuable rule in that thread is Rogue's: "maxing out" a car must
* exclude every upgrade whose only effect is safety. Safety shortens hospital time
* after a crash; it does not improve race times. Buying them all costs 45 racing
* points per car (42 across the Safety category + 3 for Uprated Gaskets).
*
* This script never clicks anything for you. It decorates and advises only.
*/
(function () {
'use strict';
// ───────────────────────────────────────────────────────────── constants ──
const VERSION = '1.6.0';
const NS = 'tra:';
const API_BASE = 'https://api.torn.com/v2';
const CLASS_ORDER = ['E', 'D', 'C', 'B', 'A'];
// Every stat that actually affects race time. Safety is deliberately absent.
const RACE_STATS = ['top_speed', 'acceleration', 'braking', 'handling', 'dirt', 'tarmac'];
const STAT_LABELS = {
'Top Speed': 'top_speed',
Acceleration: 'acceleration',
Braking: 'braking',
Handling: 'handling',
Safety: 'safety',
Dirt: 'dirt',
Tarmac: 'tarmac',
};
/*
* Track shorthands used in car nicknames. This is track identity, not guide
* opinion, so it is shared across profiles. Keep the aliases unambiguous:
* matching prefers the longest alias, but overlapping short forms are still a
* source of silent misassignment, so short forms that could collide are omitted.
* "Com" (Commerce) and "Con" (Convict) sit on opposite surfaces - be careful here.
*/
const TRACK_ALIAS = {
Underdog: ['ud'],
Commerce: ['com'],
Industrial: ['ind'],
Vector: ['vec'],
Sewage: ['sew'],
Meltdown: ['mel'],
Uptown: ['ut'],
Withdrawal: ['with'],
Speedway: ['spd'],
Parkland: ['park'],
'Two Islands': ['2is'],
Hammerhead: ['ham'],
Docks: ['dock'],
Mudpit: ['mud'],
'Stone Park': ['stone'],
Convict: ['con'],
};
/*
* Shorthand used when suggesting car names, matching the convention already on
* this account ("UD,Com,Ind,Vec,Sew,Mel"). These must round-trip through
* trackNameOf, or a car named from a suggestion would not be understood back.
*/
const TRACK_SHORT = {
Underdog: 'UD',
Commerce: 'Com',
Industrial: 'Ind',
Vector: 'Vec',
Sewage: 'Sew',
Meltdown: 'Mel',
Uptown: 'UT',
Withdrawal: 'With',
Speedway: 'Spd',
Parkland: 'Park',
'Two Islands': '2Is',
Hammerhead: 'Ham',
Docks: 'Docks',
Mudpit: 'Mud',
'Stone Park': 'Stone',
Convict: 'Con',
};
/*
* Two published guides, both encodable, and they disagree.
*
* Torn's own /racing/tracks endpoint returns no surface field, so the surface
* classification has to live here either way.
*
* `build` groups tracks that share one physical car. Damien runs 7 cars; Sky runs
* 11, splitting what Damien treats as a single tarmac NSX into a Stage 2 turbo car
* and a Stage 3 turbo car, because turbo choice is a top-speed / acceleration
* trade that suits different track layouts.
*/
const PROFILES = {
damien: {
label: 'Damien',
cars: 7,
thread: 'https://www.torn.com/forums.php#/p=threads&f=61&t=16414760',
note: "Damien's Racing Guides, 107 likes. Seven cars. Plus Rogue's correction on safety upgrades, which this script applies to both profiles.",
tracks: [
{ track: 'Underdog', surface: 'tarmac', car: 'Edomondo NSX', ratio: 'short', build: 'nsx-tarmac' },
{ track: 'Commerce', surface: 'tarmac', car: 'Edomondo NSX', ratio: 'short', build: 'nsx-tarmac' },
{ track: 'Industrial', surface: 'tarmac', car: 'Edomondo NSX', ratio: 'short', build: 'nsx-tarmac' },
{ track: 'Vector', surface: 'tarmac', car: 'Edomondo NSX', ratio: 'short', build: 'nsx-tarmac' },
{ track: 'Sewage', surface: 'tarmac', car: 'Edomondo NSX', ratio: 'short', build: 'nsx-tarmac' },
{ track: 'Meltdown', surface: 'tarmac', car: 'Edomondo NSX', ratio: 'short', build: 'nsx-tarmac' },
{ track: 'Uptown', surface: 'tarmac', car: 'Veloria LFA', ratio: 'long', build: 'veloria' },
{ track: 'Withdrawal', surface: 'tarmac', car: 'Veloria LFA', ratio: 'long', build: 'veloria' },
{ track: 'Speedway', surface: 'tarmac', car: 'Veloria LFA', ratio: 'long', build: 'veloria' },
{ track: 'Parkland', surface: 'dirt', car: 'Edomondo NSX', ratio: 'short', build: 'nsx-dirt' },
{ track: 'Two Islands', surface: 'dirt', car: 'Edomondo NSX', ratio: 'short', build: 'nsx-dirt' },
{ track: 'Hammerhead', surface: 'dirt', car: 'Edomondo NSX', ratio: 'short', build: 'nsx-dirt' },
{ track: 'Docks', surface: 'tarmac', car: 'Volt GT', ratio: 'long', build: 'volt' },
{ track: 'Mudpit', surface: 'dirt', car: 'Colina Tanprice', ratio: 'short', build: 'colina' },
{ track: 'Stone Park', surface: 'dirt', car: 'Echo R8', ratio: 'short', build: 'echo' },
{ track: 'Convict', surface: 'tarmac', car: 'Mercia SLR', ratio: 'long', build: 'mercia' },
],
order: [
{ build: 'nsx-tarmac', note: 'Step 1 - max the tarmac NSX, run it on all non-dirt tracks' },
{ build: 'colina', note: 'Step 2 - finish the Colina, run it on all dirt tracks' },
{ build: 'veloria', note: 'Step 3 - max the Veloria for its three maps' },
{ build: 'nsx-dirt', note: 'Step 4 - max the dirt NSX' },
{ build: 'volt', note: 'Step 5 - work around maxing the rest' },
{ build: 'mercia', note: 'Step 5 - work around maxing the rest' },
{ build: 'echo', note: 'Step 5 - work around maxing the rest' },
],
},
sky: {
label: 'Sky',
cars: 11,
thread: 'https://www.torn.com/forums.php#/p=threads&f=61&t=16411662',
note: 'Sky [380980], Committee, 406 likes. Distilled from RD_\'s data-driven guides. Eleven cars, and it specifies turbo stage per track. Sky calls it descriptive, not prescriptive.',
tracks: [
{ track: 'Convict', surface: 'tarmac', car: 'Mercia SLR', ratio: 'long', turbo: '3', build: 'mercia' },
{ track: 'Docks', surface: 'tarmac', car: 'Volt GT', ratio: 'long', turbo: '3', build: 'volt' },
{ track: 'Uptown', surface: 'tarmac', car: 'Lambrini Torobravo', ratio: 'long', turbo: '3', build: 'lambrini' },
{ track: 'Speedway', surface: 'tarmac', car: 'Veloria LFA', ratio: 'long', turbo: '3', build: 'veloria' },
{ track: 'Withdrawal', surface: 'tarmac', car: 'Veloria LFA', ratio: 'long', turbo: '3', build: 'veloria' },
{ track: 'Commerce', surface: 'tarmac', car: 'Edomondo NSX', ratio: 'short', turbo: '2', build: 'nsx-tarmac-s2' },
{ track: 'Underdog', surface: 'tarmac', car: 'Edomondo NSX', ratio: 'short', turbo: '2', build: 'nsx-tarmac-s2' },
{ track: 'Sewage', surface: 'tarmac', car: 'Edomondo NSX', ratio: 'short', turbo: '2', build: 'nsx-tarmac-s2' },
{ track: 'Industrial', surface: 'tarmac', car: 'Edomondo NSX', ratio: 'short', turbo: '3', build: 'nsx-tarmac-s3' },
{ track: 'Meltdown', surface: 'tarmac', car: 'Edomondo NSX', ratio: 'short', turbo: '3', build: 'nsx-tarmac-s3' },
{ track: 'Vector', surface: 'tarmac', car: 'Edomondo NSX', ratio: 'short', turbo: '3', build: 'nsx-tarmac-s3' },
{ track: 'Hammerhead', surface: 'dirt', car: 'Edomondo NSX', ratio: 'short', turbo: '2', build: 'nsx-dirt-s2' },
{ track: 'Parkland', surface: 'dirt', car: 'Edomondo NSX', ratio: 'short', turbo: '3', build: 'nsx-dirt-s3' },
{ track: 'Two Islands', surface: 'dirt', car: 'Edomondo NSX', ratio: 'long', turbo: '3', build: 'nsx-dirt-long' },
{ track: 'Mudpit', surface: 'dirt', car: 'Colina Tanprice', ratio: 'long', turbo: '3', build: 'colina' },
{ track: 'Stone Park', surface: 'dirt', car: 'Echo R8', ratio: 'short', turbo: '3', build: 'echo' },
],
order: [
{ build: 'nsx-tarmac-s3', note: 'Start with the tarmac NSX' },
{ build: 'nsx-dirt-s3', note: 'Then the dirt NSX' },
{ build: 'veloria', note: 'Then the Veloria' },
{ build: 'colina', note: 'Mudpit specialist' },
{ build: 'nsx-tarmac-s2', note: 'Second tarmac NSX, Stage 2 turbo' },
{ build: 'nsx-dirt-s2', note: 'Hammerhead, Stage 2 turbo' },
{ build: 'nsx-dirt-long', note: 'Two Islands, long ratio' },
{ build: 'echo', note: 'Stone Park' },
{ build: 'volt', note: 'Docks' },
{ build: 'mercia', note: 'Convict' },
{ build: 'lambrini', note: 'Uptown' },
],
},
};
const profile = () => PROFILES[settings.profile] || PROFILES.damien;
const guideList = () => profile().tracks;
const guideOrder = () => profile().order;
/*
* Where the two guides actually disagree. Only fields both guides state are
* compared, so Damien's silence on turbo is not counted as a conflict.
*/
function disagreement(trackName) {
const a = PROFILES.damien.tracks.find((t) => t.track === trackName);
const b = PROFILES.sky.tracks.find((t) => t.track === trackName);
if (!a || !b) return null;
const diffs = [];
['car', 'surface', 'ratio', 'turbo'].forEach(function (f) {
if (a[f] == null || b[f] == null) return;
if (a[f] !== b[f]) diffs.push(f);
});
return diffs.length ? { diffs: diffs, damien: a, sky: b } : null;
}
/*
* Subcategories where the guide overrides "pick the biggest numbers".
* Verified against the live parts page on 2026-08-27:
*
* Paddle Shift Gearbox (Long) 10rp TS+8 Acc+6 Dirt-4 Tarmac+4
* Rally Gearbox (Long) 10rp TS+8 Acc+6 Dirt+4 Tarmac-4
* Track Tires 6rp Acc+6 Brk+6 Han+6 Dirt-2 Tarmac+2
* Rally Tires 6rp Acc+6 Brk+6 Han+6 Dirt+2 Tarmac-2
* Adjustable Coilover Susp. 8rp Han+10 Dirt-6 Tarmac+6
* Group N Rally Suspension 8rp Han+10 Dirt+6 Tarmac-6
*
* The surface-weighted score would already pick most of these correctly. The
* gearbox ratio is the exception: short and long trade acceleration against top
* speed at identical cost, so it can only be resolved from the track, not the stats.
*/
const SUB_RULES = {
Gearbox: { tarmac: /paddle\s*shift\s*gearbox/i, dirt: /rally\s*gearbox/i, ratioed: true },
Tyres: { tarmac: /^track\s*tire?s?$/i, dirt: /^rally\s*tire?s?$/i },
Springs: { tarmac: /adjustable\s*coil-?over/i, dirt: /group\s*n\s*rally/i },
};
/*
* Turbo is not a surface choice, it is a top-speed / acceleration trade:
* Stage Two Turbo kit 12rp TS+10 Acc+10
* Stage Three Turbo Kit 18rp TS+14 Acc+8
* A plain stat sum always prefers Stage 3 (22 vs 20), but Sky's data says Stage 2
* wins on Commerce, Underdog, Sewage and Hammerhead. Only a profile that states a
* stage can override the sum - otherwise the solver keeps its own preference.
*/
const TURBO_RE = { 2: /stage\s*two\s*turbo/i, 3: /stage\s*three\s*turbo/i };
// Panel text size in px. Everything else in the stylesheet is relative to it, so
// this one number scales the whole panel. What counts as readable depends on the
// display, so it is a setting rather than a guess.
const FONT_MIN = 11;
const FONT_MAX = 20;
const FONT_DEFAULT = 14;
const DEFAULTS = {
key: '',
fontSize: FONT_DEFAULT,
profile: 'damien',
tab: 'plan',
flagWaste: true,
collapseWaste: false,
badgeRows: true,
panelOpen: true,
showOtherCars: false,
};
// ──────────────────────────────────────────────────────────────── storage ──
const store = {
get(k, fallback) {
try {
const raw = localStorage.getItem(NS + k);
return raw === null ? fallback : JSON.parse(raw);
} catch (e) {
return fallback;
}
},
set(k, v) {
try {
localStorage.setItem(NS + k, JSON.stringify(v));
} catch (e) {
/* quota or private mode - settings just do not persist */
}
},
del(k) {
try {
localStorage.removeItem(NS + k);
} catch (e) {
/* ignore */
}
},
cacheGet(k, maxAgeMs) {
const box = store.get('cache:' + k, null);
if (!box || typeof box.t !== 'number') return null;
if (Date.now() - box.t > maxAgeMs) return null;
return box.v;
},
cacheSet(k, v) {
store.set('cache:' + k, { t: Date.now(), v: v });
},
};
const settings = Object.assign({}, DEFAULTS, store.get('settings', {}));
// The key lives under its own storage entry so "forget key" cannot be undone by
// a stale settings blob written from another tab.
settings.key = store.get('key', '');
function saveSettings() {
const { key } = settings;
const rest = Object.assign({}, settings);
delete rest.key;
store.set('settings', rest);
if (key) store.set('key', key);
else store.del('key');
}
// ──────────────────────────────────────────────────────────────────── api ──
const api = (function () {
let chain = Promise.resolve();
let lastError = null;
// Torn allows 100 requests/minute. Nothing here is chatty, but serialising with a
// small gap keeps a burst of tab switches from tripping the limit.
function throttled(fn) {
const run = chain.then(fn, fn);
chain = run.then(
() => new Promise((r) => setTimeout(r, 350)),
() => new Promise((r) => setTimeout(r, 350))
);
return run;
}
function call(path) {
if (!settings.key) return Promise.reject(new Error('no-key'));
return throttled(function () {
return fetch(API_BASE + path, {
method: 'GET',
// The key goes in a header, never in the query string, so it does not end
// up in browser history, referrers or any proxy log.
headers: { Authorization: 'ApiKey ' + settings.key, Accept: 'application/json' },
credentials: 'omit',
cache: 'no-store',
})
.then(function (res) {
if (!res.ok) throw new Error('http-' + res.status);
return res.json();
})
.then(function (json) {
if (json && json.error) {
throw new Error('torn-' + json.error.code + ': ' + json.error.error);
}
lastError = null;
return json;
})
.catch(function (err) {
lastError = err;
throw err;
});
});
}
function cached(path, storeKey, maxAge) {
const hit = store.cacheGet(storeKey, maxAge);
if (hit) return Promise.resolve(hit);
return call(path).then(function (json) {
store.cacheSet(storeKey, json);
return json;
});
}
return {
// Static reference data. Torn changes these roughly never.
upgrades: () => cached('/racing/carupgrades', 'carupgrades', 7 * 864e5).then((d) => d.carupgrades || []),
cars: () => cached('/racing/cars', 'cars', 7 * 864e5).then((d) => d.cars || []),
tracks: () => cached('/racing/tracks', 'tracks', 7 * 864e5).then((d) => d.tracks || []),
// Live data.
/*
* /user/enlistedcars returns historical rows too, flagged is_removed. Keeping
* them puts cars you no longer own in the dashboard - and duplicates, because a
* car re-enlisted after removal appears twice.
*/
enlisted: () =>
cached('/user/enlistedcars', 'enlistedcars', 60e3).then((d) =>
(d.enlistedcars || []).filter((c) => !c.is_removed)
),
races: () => cached('/user/races?limit=100', 'races', 5 * 60e3).then((d) => d.races || []),
lastError: () => lastError,
clearCache() {
['carupgrades', 'cars', 'tracks', 'enlistedcars', 'races'].forEach((k) => store.del('cache:' + k));
},
};
})();
// ───────────────────────────────────────────────────────────────── guide ──
const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
/*
* Every verb Torn puts in front of a car name in the enlisted list, across all the
* screens that render it. Missing one silently corrupts the car name.
*/
const ACTION_RE =
/^\s*(Remove from Enlisted|Currently in race|Use this car|Change this Car|Change car|Change Car|Select|Enlist)\s*/i;
// Longest alias first, so "spd" wins over any shorter prefix that could also match.
const ALIASES = Object.keys(TRACK_ALIAS)
.flatMap((track) => [norm(track)].concat(TRACK_ALIAS[track].map(norm)).map((a) => ({ a, track })))
.sort((x, y) => y.a.length - x.a.length);
// Resolves any spelling to a canonical track name. Profile-independent.
function trackNameOf(name) {
const n = norm(name);
if (!n) return null;
const exact = Object.keys(TRACK_ALIAS).find((t) => norm(t) === n);
if (exact) return exact;
const hit = ALIASES.find((e) => e.a === n || n.startsWith(e.a) || e.a.startsWith(n));
return hit ? hit.track : null;
}
// The active profile's entry for a track. Identity is stable within a profile,
// because profile track arrays are static, so callers can compare by reference.
function trackByName(name) {
const t = trackNameOf(name);
if (!t) return null;
return guideList().find((g) => g.track === t) || null;
}
/*
* Car nicknames on this account carry the track assignment, e.g.
* "Edomondo NSX (Class A) - UD,Com,Ind,Vec,Sew,Mel"
* Returns the active profile's entries that nickname claims.
*/
function tracksFromNickname(nickname) {
if (!nickname) return [];
const seen = new Set();
return nickname
.split(/[,;/|]+/)
.map((t) => trackByName(t.trim()))
.filter(function (g) {
if (!g || seen.has(g.track)) return false;
seen.add(g.track);
return true;
});
}
// A car covering both surfaces cannot have a correct tyre/gearbox/suspension pick.
function assignmentFor(tracks) {
if (!tracks.length) {
return { surface: null, ratio: null, turbo: null, builds: [], mixed: false, tracks: [] };
}
const surfaces = new Set(tracks.map((t) => t.surface));
const ratios = new Set(tracks.map((t) => t.ratio));
const turbos = new Set(tracks.map((t) => t.turbo).filter(Boolean));
const builds = Array.from(new Set(tracks.map((t) => t.build).filter(Boolean)));
return {
turbo: turbos.size === 1 ? Array.from(turbos)[0] : null,
// Kept as a list so the UI can show *why* an assignment is unbuildable rather
// than flagging it amber with no visible reason.
turbos: Array.from(turbos).sort(),
builds: builds,
surface: surfaces.size === 1 ? tracks[0].surface : 'mixed',
ratio: ratios.size === 1 ? tracks[0].ratio : null,
// More than one build means the nickname spans cars the guide keeps separate,
// so no single set of parts satisfies every track on it.
mixed: surfaces.size > 1 || ratios.size > 1 || turbos.size > 1,
tracks: tracks,
};
}
// ──────────────────────────────────────────────────────────── upgrade math ──
function effOf(u) {
return (u && u.effects) || {};
}
/*
* Rogue's rule, computed rather than hardcoded: an upgrade is a racing-point waste
* only if it raises safety and moves nothing else. Parts like Stage Three Remap
* (+14% top speed, +8% acceleration, +2% safety) are NOT waste and must not be
* flagged - that distinction is the whole point.
*/
function isWaste(u) {
const e = effOf(u);
if (!(e.safety > 0)) return false;
return RACE_STATS.every((k) => !(e[k] || 0));
}
// A part with no effect at all is dead weight too, but for a different reason.
function isInert(u) {
const e = effOf(u);
return RACE_STATS.every((k) => !(e[k] || 0)) && !(e.safety > 0);
}
function score(u, surface) {
const e = effOf(u);
let s = (e.top_speed || 0) + (e.acceleration || 0) + (e.braking || 0) + (e.handling || 0);
if (surface === 'dirt') s += e.dirt || 0;
else if (surface === 'tarmac') s += e.tarmac || 0;
// Mixed or unknown surface: judge on the worse of the two, so a car that has to
// run both does not get a part that is strong on one and a penalty on the other.
// Counting neither would let Track Tires (-2 dirt) tie with the neutral tyre.
else s += Math.min(e.dirt || 0, e.tarmac || 0);
return s;
}
/*
* The target build for one car: the best legal part in every subcategory, minus
* everything that is safety-only, with the guide's surface and ratio overrides
* applied where the raw numbers cannot decide.
*
* A car's class does NOT bound the parts it can hold. The Colina Tanprice is
* Class B and carries Class A parts - Stage Three Turbo Kit, Group N Rally
* Suspension, 6 Pot Uprated Brakes, Ultra-Lightweight Alloys. Since car class only
* ever rises with upgrades, it must have bought those while Class B, so
* `class_required` is not a gate on the car's class and filtering by it was wrong.
*
* That filter dropped exactly the parts the car was carrying, leaving worse parts
* as the "best" pick, and the checklist proposed eight downgrades in a row.
*
* `ownedIds` is therefore used for the second guard instead: a slot is never
* reported as needing an upgrade unless the proposal actually beats what is
* fitted.
*/
function targetBuild(carClass, assignment, catalogue, ownedIds) {
const surface = assignment.surface;
const ratio = assignment.ratio;
const owned = new Set(ownedIds || []);
const bySub = new Map();
catalogue.forEach(function (u) {
if (isWaste(u) || isInert(u)) return;
const sub = u.subcategory || u.category;
if (!bySub.has(sub)) bySub.set(sub, []);
bySub.get(sub).push(u);
});
const picks = [];
bySub.forEach(function (list, sub) {
let cands = list;
const rule = SUB_RULES[sub];
if (rule && (surface === 'tarmac' || surface === 'dirt')) {
const byName = list.filter((u) => rule[surface].test(u.name));
if (byName.length) {
cands = byName;
if (rule.ratioed && ratio) {
const byRatio = byName.filter((u) => new RegExp(ratio, 'i').test(u.name));
if (byRatio.length) cands = byRatio;
}
}
}
// A profile that names a turbo stage overrides the stat sum, which would
// otherwise always take Stage 3.
if (sub === 'Turbo' && assignment.turbo && TURBO_RE[assignment.turbo]) {
const byStage = list.filter((u) => TURBO_RE[assignment.turbo].test(u.name));
if (byStage.length) cands = byStage;
}
cands = cands.slice().sort(function (a, b) {
const d = score(b, surface) - score(a, surface);
if (d) return d;
return (a.cost.points || 0) - (b.cost.points || 0);
});
// The best thing already fitted in this slot, whatever tier it is.
const fitted = list
.filter((u) => owned.has(u.id))
.sort((a, b) => score(b, surface) - score(a, surface))[0] || null;
const pick = cands[0];
/*
* Never propose a swap that does not strictly improve the car. Without this
* the checklist recommended Stage Three Turbo -> Stage Two, 6 Pot -> 4 Pot,
* and Group N Rally -> Coil-over on a dirt car: eight downgrades in a row,
* every one of them costing racing points to make the car slower.
*/
const satisfied = !!fitted && (!pick || score(fitted, surface) >= score(pick, surface));
picks.push({
sub: sub,
pick: satisfied ? fitted : pick,
fitted: fitted,
satisfied: satisfied,
gain: satisfied ? 0 : score(pick, surface) - (fitted ? score(fitted, surface) : 0),
alternatives: cands.slice(1, 3),
});
});
return picks;
}
/*
* A car with no nickname still has an obvious job if the guide names exactly one
* build for its model - a Mercia SLR is for Convict, an Echo R8 for Stone Park.
* Without this those cars read "unassigned" with no surface, so the solver could
* not apply the tyre / gearbox / suspension rules and the checklist was useless
* for precisely the two cars still to be built.
*
* Ambiguous models get nothing: the Edomondo NSX has a tarmac build and a dirt
* build under Damien, and five under Sky, so guessing would be worse than silence.
*/
function inferredTracks(car) {
if (tracksFromNickname(car.car_name).length) return null;
const model = norm(car.car_item_name);
const entries = guideList().filter((g) => norm(g.car) === model);
if (!entries.length) return null;
const builds = {};
entries.forEach((g) => {
builds[g.build] = 1;
});
return Object.keys(builds).length === 1 ? entries : null;
}
function planFor(car, catalogue) {
let tracks = tracksFromNickname(car.car_name);
let inferred = false;
if (!tracks.length) {
const guess = inferredTracks(car);
if (guess) {
tracks = guess;
inferred = true;
}
}
const assignment = assignmentFor(tracks);
const build = targetBuild(car.class, assignment, catalogue, car.parts);
const installed = new Set(car.parts || []);
const byId = new Map(catalogue.map((u) => [u.id, u]));
const missing = [];
const done = [];
build.forEach(function (slot) {
const u = slot.pick;
if (!u) return;
// Satisfied slots are done even when the fitted part is not the catalogue's
// top pick - it already matches or beats anything worth buying.
if (slot.satisfied || installed.has(u.id)) {
done.push(u);
return;
}
missing.push({
upgrade: u,
sub: slot.sub,
// The category is the tile you actually click on the parts page.
cat: u.category || null,
replaces: slot.fitted || null,
// The improvement over what is fitted, not the part's absolute stat sum -
// an absolute figure made downgrades look like gains.
gain: slot.gain,
cost: u.cost.points || 0,
cash: u.cost.cash || 0,
});
});
// Best value first: race-stat points bought per racing point spent.
missing.sort(function (a, b) {
const va = a.cost ? a.gain / a.cost : a.gain;
const vb = b.cost ? b.gain / b.cost : b.gain;
return vb - va || a.cost - b.cost;
});
const wasteHeld = (car.parts || []).map((id) => byId.get(id)).filter((u) => u && isWaste(u));
/*
* Parts fitted that actively penalise the surface this car races on - a rally
* gearbox on a tarmac car, or track tyres on a dirt one. Worse than a missing
* upgrade, because the points are spent AND the car is slower for it.
*/
const wrongSurface =
assignment.surface === 'tarmac' || assignment.surface === 'dirt'
? (car.parts || [])
.map((id) => byId.get(id))
.filter(function (u) {
if (!u) return false;
const e = effOf(u);
return assignment.surface === 'tarmac' ? (e.tarmac || 0) < 0 : (e.dirt || 0) < 0;
})
: [];
return {
wrongSurface: wrongSurface,
car: car,
tracks: tracks,
// True when the tracks came from the guide rather than the car's nickname.
inferred: inferred,
assignment: assignment,
done: done,
missing: missing,
wasteHeld: wasteHeld,
wastePoints: wasteHeld.reduce((a, u) => a + (u.cost.points || 0), 0),
rpToFinish: missing.reduce((a, m) => a + m.cost, 0),
cashToFinish: missing.reduce((a, m) => a + m.cash, 0),
targetSlots: build.length,
completion: build.length ? Math.round((done.length / build.length) * 100) : 0,
};
}
// ──────────────────────────────────────────────────────────── DOM helpers ──
const $ = (sel, root) => (root || document).querySelector(sel);
const $$ = (sel, root) => Array.prototype.slice.call((root || document).querySelectorAll(sel));
/*
* Direct-child lookup by class. Deliberately not `:scope > .x` - `:scope` is
* unevenly supported in the webviews this has to run in, and a descendant query
* would find our own marker inside a nested row.
*/
function childByClass(node, cls) {
if (!node || !node.children) return null;
for (let i = 0; i < node.children.length; i++) {
const c = node.children[i];
if (c.classList && c.classList.contains(cls)) return c;
}
return null;
}
function dropNode(n) {
if (n && n.parentElement) n.parentElement.removeChild(n);
}
function el(tag, attrs, children) {
const n = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach(function (k) {
if (k === 'class') n.className = attrs[k];
else if (k === 'text') n.textContent = attrs[k];
else if (k.slice(0, 2) === 'on') n.addEventListener(k.slice(2), attrs[k]);
else n.setAttribute(k, attrs[k]);
});
}
(children || []).forEach(function (c) {
if (c) n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
return n;
}
const fmt = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
function isRacingPage() {
return (
/\/(page|loader)\.php/.test(location.pathname) &&
/sid=racing/.test(location.search + location.hash)
);
}
function racingRoot() {
return $('.content-wrapper') || $('#mainContainer') || document.body;
}
// Parse the effect rows Torn renders as text, e.g. "+2% Safety". Used in DOM-only
// mode and to locate nodes when the API catalogue is available.
function effectsFromNode(node) {
const out = {};
const text = node.innerText || '';
const re = /([+-]\d+)%\s*(Top Speed|Acceleration|Braking|Handling|Safety|Dirt|Tarmac)/g;
let m;
while ((m = re.exec(text))) {
const key = STAT_LABELS[m[2]];
if (key) out[key] = parseInt(m[1], 10);
}
return out;
}
function partNameFromNode(node) {
const t = $('div.title', node);
if (t) return t.textContent.trim();
const first = (node.innerText || '').split('\n').map((s) => s.trim()).filter(Boolean)[0];
return first || '';
}
function pointsFromNode(node) {
const m = (node.innerText || '').replace(/\s+/g, ' ').match(/(\d+)\s+racing points?/i);
return m ? parseInt(m[1], 10) : 0;
}
/*
* The balance is only printed on the Parts & Modifications tab, so on every other
* tab it has to come from the last time we saw it. Returned with a staleness flag
* rather than silently - a wrong RP figure would quietly break the spend plan.
*/
function rpInfo() {
const m = (racingRoot().innerText || '').match(/You have ([\d,]+) racing points? available/i);
if (m) {
const v = parseInt(m[1].replace(/,/g, ''), 10);
store.set('rp', { v: v, t: Date.now() });
return { value: v, stale: false };
}
const c = store.get('rp', null);
if (c && typeof c.v === 'number') return { value: c.v, stale: true, seen: c.t };
return { value: null, stale: false };
}
const rpAvailable = () => rpInfo().value;
function selectedCarName() {
const m = (racingRoot().innerText || '').match(/part you wish to add to your ([^.]+)\./i);
return m ? m[1].trim() : null;
}
function currentRaceTrack() {
const m = (racingRoot().innerText || '').match(/^\s*([A-Za-z' ]+?)\s+-\s+\d+\s+laps?\s+-/m);
return m ? m[1].trim() : null;
}
/*
* Torn prints the car you are actually racing with in a "Current car" box:
* Current car / Veloria LFA / UT,With,Spd / Leave this race / Change Car
* The second line is the nickname unless the car has none, in which case the
* action links come next.
*/
function currentRaceCar() {
const lines = (racingRoot().innerText || '').split('\n').map((s) => s.trim());
const i = lines.findIndex((l) => /^Current car$/i.test(l));
if (i < 0) return null;
const rest = lines.slice(i + 1).filter(Boolean);
if (!rest.length) return null;
const name = rest[0];
const second = rest[1] || '';
const nickname = /leave this race|change car|top speed/i.test(second) ? null : second;
return { name: name, nickname: nickname };
}
// A race can run well over an hour; this is only a backstop on a stale cache.
const RACE_MEMORY_MS = 3 * 3600e3;
/*
* Are we still in a race, even with no header on screen? Torn keeps saying so on
* the surfaces that replace the race view.
*/
function looksInRace() {
return /Currently in race|Leave this race/i.test(racingRoot().innerText || '');
}
/*
* What the panel should be shouting about: the race you are in right now.
*
* Torn REMOVES the "<Track> - N laps - ..." header while the Change Car picker is
* open - which is precisely when knowing the track matters most, because that is
* the screen where you pick the car. So the race is remembered, and the memory is
* used only while the page still shows evidence of being in a race, so a finished
* race cannot leave a stale banner behind.
*/
function currentRace() {
/*
* "<Track> - N laps - <time>" is also the shape of every row in the events list,
* so the header alone would make an upcoming race look like one you are in. Only
* trust it when the page also shows in-race controls.
*/
if (!looksInRace()) return null;
const trackName = currentRaceTrack();
const car = currentRaceCar();
if (trackName) {
store.set('race', { track: trackName, car: car, t: Date.now() });
return { trackName: trackName, guide: trackByName(trackName), car: car, stale: false };
}
if (!looksInRace()) return null;
const c = store.get('race', null);
if (!c || !c.track || Date.now() - c.t > RACE_MEMORY_MS) return null;
return {
trackName: c.track,
guide: trackByName(c.track),
// The picker names the in-race car on its own row when the header is gone.
car: car || inRaceCarFromList() || c.car,
stale: true,
};
}
/*
* Joining an official race takes you straight to a car picker, and the race you
* are joining is not "the race you are in" - there is no header naming it, and
* nothing on the picker says which track you just clicked.
*
* Rather than guess at the events-list markup (which differs per event type and
* cannot be inspected while already racing), the track is captured from the click
* itself: whatever you clicked, the nearest enclosing element that names a track
* is the race you are joining. That works for any layout Torn uses.
*/
const JOIN_MEMORY_MS = 5 * 60e3;
function rememberJoinTarget(ev) {
try {
let node = ev.target;
for (let i = 0; node && i < 6; i++) {
if (node.closest && node.closest('#tra-panel')) return;
const txt = (node.innerText || '').trim();
if (txt && txt.length < 200) {
const line = txt.split('\n')[0].trim();
const g = trackNameOf(line.replace(/\s*-.*$/, ''));
if (g) {
store.set('join', { track: g, t: Date.now() });
return;
}
}
node = node.parentElement;
}
} catch (e) {
/* never let a click handler break the page */
}
}
function pendingJoinTrack() {
const j = store.get('join', null);
if (!j || !j.track || Date.now() - j.t > JOIN_MEMORY_MS) return null;
return j.track;
}
/*
* The track a car choice should be judged against: the race in progress, or the
* one you are in the middle of joining.
*/
function targetTrack() {
const now = currentRace();
if (now && now.guide) return { guide: now.guide, joining: false, car: now.car };
const pending = pendingJoinTrack();
if (!pending) return null;
const g = trackByName(pending);
return g ? { guide: g, joining: true, car: null } : null;
}
// The row the enlisted-car list marks "Currently in race".
function inRaceCarFromList() {
const m = (racingRoot().innerText || '').match(
/Currently in race\s*\|?\s*([^\n(]+?)\s*\(Class [EDCBA]\)(?:\s*-\s*([^\n]+))?/
);
if (!m) return null;
return { name: m[1].trim(), nickname: (m[2] || '').trim() || null };
}
// Enlisted cars straight from the page, so the dashboard still works with no key.
function enlistedFromDom() {
const rows = [];
const re = /^(.+?)\s+\(Class ([EDCBA])\)(?:\s*-\s*(.+))?$/;
$$('#racingMainContainer li, .content-wrapper li, .content-wrapper div').forEach(function (node) {
if (node.getAttribute('data-tra-scanned')) return;
const lines = (node.innerText || '').split('\n').map((s) => s.trim()).filter(Boolean);
if (!lines.length || lines.length > 24) return;
const head = lines.find((l) => re.test(l));
if (!head) return;
// Only the node that also carries the stat bullets is the car row itself.
if (!/Points spent:/.test(node.innerText) || !/Upgrades:/.test(node.innerText)) return;
const m = head.match(re);
const num = (label) => {
const mm = node.innerText.match(new RegExp(label + ':\\s*\\$?([\\d,]+)'));
return mm ? parseInt(mm[1].replace(/,/g, ''), 10) : null;
};
/*
* The title line carries the row's action link first, e.g.
* "Remove from Enlisted | Edomondo NSX (Class A) - Park,2Is,Ham,Con"
* "Currently in race | Veloria LFA (Class A) - UT,With,Spd"
* so everything up to the last pipe, and any leading action wording, goes.
*/
const rawName = m[1].split('|').pop().replace(ACTION_RE, '').trim();
const entry = {
car_item_name: rawName,
inRace: /Currently in race/i.test(m[1]),
class: m[2],
car_name: (m[3] || '').trim() || null,
points_spent: num('Points spent'),
upgrades: num('Upgrades'),
races_won: num('Races won'),
races_entered: num('Races entered'),
node: node,
fromDom: true,
};
if (!rows.some((r) => r.car_item_name === entry.car_item_name && r.car_name === entry.car_name)) {
rows.push(entry);
}
});
return rows;
}
/*
* Torn marks a part already fitted to the *selected* car with `bought`, on the same
* li that carries `unlock`. Verified on this account: a car reading
* "Upgrades: 46 / Points spent: 269" has exactly 46 li.bought summing to 269
* racing points. So installed parts are readable without an API key - but only
* for the one car whose parts page is open.
*/
function partsFromDom() {
return $$('ul.pm-items > li.unlock')
.map(function (li) {
return {
name: partNameFromNode(li),
points: pointsFromNode(li),
effects: effectsFromNode(li),
bought: li.classList.contains('bought'),
node: li,
};
})
.filter((r) => r.name);
}
const referenceKey = (surface) => 'ref:' + surface;
/*
* A finished car is the most reliable statement of what "maxed" means - better
* than any rule, because it is what the guide actually produced. Remember the
* biggest build seen per surface so the checklist still works before a key is
* entered, and so an API-derived plan can be sanity-checked against reality.
*/
function captureReference() {
const carName = selectedCarName();
if (!carName) return;
const owned = partsFromDom().filter((r) => r.bought);
if (owned.length < 20) return; // a part-built car is not a reference
const car =
selectedEnlistedCar() ||
enlistedFromDom().find((c) => norm(c.car_item_name) === norm(carName));
const surface = assignmentFor(car ? tracksFromNickname(car.car_name) : []).surface;
if (surface !== 'tarmac' && surface !== 'dirt') return;
const prev = store.get(referenceKey(surface), null);
if (prev && prev.parts.length >= owned.length) return;
store.set(referenceKey(surface), {
car: carName,
captured: Date.now(),
points: owned.reduce((a, r) => a + r.points, 0),
parts: owned.map((r) => ({ name: r.name, points: r.points })),
});
}
function isWasteEffects(e) {
return e.safety > 0 && RACE_STATS.every((k) => !(e[k] || 0));
}
// ─────────────────────────────────────────────────────────────────── style ──
/*
* Everything is scoped under .tra-panel and sets its own font-size. Torn's own
* stylesheet targets bare headings with a heavier selector than a single class,
* which is why section titles rendered enormous at first - so this markup uses no
* h1-h6 or p elements at all, only divs with tra- classes.
*/
const CSS = `
/*
* Torn's own stylesheet targets bare td / div / span, and a direct rule always beats
* inheritance - so every element inside the panel must have its colour, size and
* font set EXPLICITLY. Relying on inheritance from .tra-panel is what made the panel
* render as near-black text on near-black background on the live page while looking
* fine in isolation.
*
* !important is used for the same reason: this is an overlay on a page whose
* stylesheet we do not control and cannot out-specify reliably.
*
* Sizes stay relative to the panel's own font-size, which the size control writes
* inline, so one setting scales everything coherently.
*/
.tra-panel,.tra-panel div,.tra-panel span,.tra-panel table,.tra-panel tbody,
.tra-panel thead,.tra-panel tr,.tra-panel td,.tra-panel th,.tra-panel label,
.tra-panel b,.tra-panel strong,.tra-panel em,.tra-panel small{
color:#e8e8e8 !important;
font-family:Arial,Helvetica,sans-serif !important;
line-height:1.5 !important;
text-shadow:none !important;
float:none !important;
}
.tra-panel td,.tra-panel th,.tra-panel div,.tra-panel span,.tra-panel label{
font-size:1em !important;
}
.tra-panel{margin:8px 0;border:1px solid #3a3a3a;border-radius:5px;background:#1a1a1a !important;
font-size:14px !important;overflow:hidden;text-align:left !important}
.tra-panel *{box-sizing:border-box}
.tra-head{display:flex;align-items:center;gap:8px;padding:9px 11px;
background:linear-gradient(#303030,#222) !important;border-bottom:1px solid #111;
cursor:pointer;user-select:none;flex-wrap:wrap}
.tra-panel .tra-title{margin:0;font-size:1.05em !important;font-weight:bold;color:#ffffff !important;flex:1}
.tra-panel .tra-caret{color:#b0b0b0 !important;font-size:.8em !important}
.tra-panel .tra-chip{padding:2px 8px;border-radius:10px;font-size:.78em !important;font-weight:bold;
background:#454545 !important;color:#f0f0f0 !important;white-space:nowrap}
.tra-panel .tra-chip.ok{background:#2c6b39 !important;color:#d6ffdb !important}
.tra-panel .tra-chip.warn{background:#6b5325 !important;color:#ffe6b0 !important}
.tra-panel .tra-chip.bad{background:#7c2c2c !important;color:#ffd0d0 !important}
.tra-body{padding:12px;display:block}
.tra-panel.tra-collapsed .tra-body{display:none}
.tra-sec{margin:0 0 18px}
.tra-sec:last-child{margin-bottom:0}
.tra-panel .tra-h{margin:0 0 7px;padding:0 0 4px;font-size:.95em !important;font-weight:bold;
color:#9ed0ff !important;text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid #383838}
.tra-panel .tra-p{margin:0 0 6px;padding:0;font-size:.92em !important}
.tra-panel .tra-table{width:100%;border-collapse:collapse;font-size:.95em !important;background:transparent !important}
.tra-panel .tra-table th,.tra-panel .tra-table td{padding:7px 8px;text-align:left !important;
vertical-align:middle;background:transparent}
.tra-panel .tra-table th{color:#c0c0c0 !important;font-weight:bold;font-size:.78em !important;
text-transform:uppercase;letter-spacing:.8px;border-bottom:2px solid #4a4a4a}
.tra-panel .tra-table td{border-bottom:1px solid #303030}
.tra-panel .tra-table tr:nth-child(even) td{background:#242424}
.tra-panel .tra-table tr:hover td{background:#2e2e2e}
.tra-panel .tra-table td.num{text-align:right !important;font-variant-numeric:tabular-nums;white-space:nowrap}
/* semantic colours - after the reset, and more specific than it */
.tra-panel .tra-muted,.tra-panel td.tra-muted,.tra-panel .tra-muted span{color:#b0b0b0 !important}
.tra-panel .tra-good,.tra-panel td.tra-good{color:#8ff09a !important}
.tra-panel .tra-warnc,.tra-panel td.tra-warnc{color:#ffcf70 !important}
.tra-panel .tra-badc,.tra-panel td.tra-badc{color:#ffa0a0 !important}
.tra-panel .tra-dim{opacity:.65}
.tra-panel .tra-note{margin:8px 0 0;padding:8px 11px;background:#2f2819 !important;
border-left:4px solid #e8b757;border-radius:0 3px 3px 0}
.tra-panel .tra-note,.tra-panel .tra-note div{color:#f2e3b8 !important;font-size:.92em !important}
.tra-panel .tra-err{margin:8px 0 0;padding:8px 11px;background:#361c1c !important;
border-left:4px solid #ef8b8b;border-radius:0 3px 3px 0}
.tra-panel .tra-err,.tra-panel .tra-err div{color:#ffd2d2 !important;font-size:.92em !important}
.tra-panel .tra-btn{padding:5px 11px;border:1px solid #555;border-radius:3px;
background:#303030 !important;color:#f0f0f0 !important;cursor:pointer;
font-size:.9em !important;font-family:Arial,Helvetica,sans-serif !important}
.tra-panel .tra-btn:hover{background:#3c3c3c !important;border-color:#666}
.tra-panel .tra-btn.on{background:#2c6b39 !important;border-color:#48b05e;color:#dcffe0 !important}
.tra-panel .tra-input{padding:5px 8px;border:1px solid #555;border-radius:3px;
background:#0e0e0e !important;color:#f0f0f0 !important;font-size:.9em !important;min-width:200px}
.tra-row{display:flex;flex-wrap:wrap;gap:9px;align-items:center;margin:7px 0}
.tra-panel label{display:flex;align-items:center;gap:6px;font-size:.92em !important;cursor:pointer}
.tra-settings{border-top:1px solid #3a3a3a;padding-top:10px;margin-top:10px}
.tra-sizebtn{min-width:32px;text-align:center !important}
/* tab strip */
.tra-tabs{display:flex;gap:4px;flex-wrap:wrap;margin:0 0 14px;border-bottom:2px solid #3a3a3a;padding-bottom:0}
.tra-panel .tra-tab{padding:7px 14px;border:1px solid transparent;border-bottom:none;border-radius:4px 4px 0 0;
background:#242424 !important;color:#c0c0c0 !important;cursor:pointer;font-size:.92em !important;
font-weight:bold;font-family:Arial,Helvetica,sans-serif !important;margin-bottom:-2px}
.tra-panel .tra-tab:hover{background:#303030 !important;color:#f0f0f0 !important}
.tra-panel .tra-tab.on{background:#1a1a1a !important;color:#9ed0ff !important;
border-color:#3a3a3a;border-bottom:2px solid #1a1a1a}
/* recommended car in the enlisted-car / change-car list */
li.tra-pick-row,div.tra-pick-row{box-shadow:inset 0 0 0 2px #48b05e !important;border-radius:3px}
li.tra-pick-no,div.tra-pick-no{box-shadow:inset 0 0 0 2px #8b4a4a !important;border-radius:3px}
.tra-pick{padding:4px 9px;font:bold 11px/1.5 Arial,Helvetica,sans-serif !important;letter-spacing:.3px;
border-radius:3px 3px 0 0}
.tra-pick.ok{background:#2c6b39 !important;color:#e2ffe6 !important}
.tra-pick.bad{background:#6b2c2c !important;color:#ffe0e0 !important}
.tra-pick.warn{background:#6b5325 !important;color:#ffe6b0 !important}
/* the race you are in right now */
.tra-panel .tra-now{margin:0 0 16px;padding:11px 13px;border-radius:4px;
border-left:5px solid #48b05e;background:#1b3020 !important}
.tra-panel .tra-now.warn{border-left-color:#e8b757;background:#302818 !important}
.tra-panel .tra-now.bad{border-left-color:#ef8b8b;background:#301b1b !important}
.tra-now-top{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
.tra-panel .tra-now-label{font-size:.75em !important;letter-spacing:1.4px;color:#c0c0c0 !important;
text-transform:uppercase;font-weight:bold}
.tra-panel .tra-now-track{font-size:1.5em !important;font-weight:bold;color:#ffffff !important;line-height:1.2 !important}
.tra-panel .tra-now-meta{font-size:.92em !important;color:#c8c8c8 !important}
.tra-panel .tra-now-verdict{margin-top:6px;font-size:.95em !important}
/* current-track row: one bar on the left edge, not one per cell */
.tra-panel .tra-table tr.tra-nowrow td{background:#1b3020}
.tra-panel .tra-table tr.tra-nowrow:hover td{background:#223d28}
.tra-panel .tra-table tr.tra-nowrow td:first-child{box-shadow:inset 4px 0 0 #48b05e;
font-weight:bold;color:#ffffff !important}
.tra-panel .tra-conflict{display:inline-block;margin-left:6px;padding:1px 6px;border-radius:3px;
background:#4a3d66 !important;color:#e2d0ff !important;font-size:.75em !important;font-weight:bold;cursor:help}
.tra-panel .tra-badge{display:inline-block;margin-left:6px;padding:2px 7px;border-radius:9px;
font-size:.8em !important;font-weight:bold;line-height:1.5 !important;vertical-align:middle;white-space:nowrap}
.tra-panel .tra-badge.ok{background:#2c6b39 !important;color:#d6ffdb !important}
.tra-panel .tra-badge.warn{background:#6b5325 !important;color:#ffe6b0 !important}
.tra-panel .tra-badge.bad{background:#7c2c2c !important;color:#ffd0d0 !important}
/* inline decorations on Torn's own parts list - outside .tra-panel by necessity */
li.tra-waste{position:relative;opacity:.55;filter:grayscale(.6)}
li.tra-waste div.title{text-decoration:line-through}
li.tra-waste::after{content:'RP WASTE';position:absolute;top:2px;right:2px;z-index:5;
background:#8b1d1d;color:#fff;font:bold 10px/1.5 Arial,sans-serif;padding:1px 6px;border-radius:2px}
li.tra-waste-collapsed{display:none !important}
li.tra-target{box-shadow:inset 0 0 0 2px #2f7d3a}
li.tra-target::before{content:'GUIDE PICK';position:absolute;top:2px;left:2px;z-index:5;
background:#2f7d3a;color:#fff;font:bold 10px/1.5 Arial,sans-serif;padding:1px 6px;border-radius:2px}
li.tra-wrongsurface{box-shadow:inset 0 0 0 2px #b02b2b}
.tra-cat-banner{margin:4px 0 0;padding:4px 6px;background:#2a1616;color:#ffb3b3 !important;
border-radius:3px;font-size:11px;text-align:center;font-weight:bold}
@media (max-width:520px){
.tra-body{padding:9px 7px}
.tra-panel .tra-table th,.tra-panel .tra-table td{padding:6px 4px}
.tra-panel .tra-input{min-width:130px;flex:1}
.tra-hide-narrow{display:none}
}
`;
function injectStyle() {
if ($('#tra-style')) return;
const s = el('style', { id: 'tra-style' });
s.textContent = CSS;
document.head.appendChild(s);
}
// Written inline so it beats the stylesheet without needing !important.
function applyFontSize() {
const p = $('#tra-panel');
if (p && p.style) p.style.fontSize = (settings.fontSize || FONT_DEFAULT) + 'px';
}
// ───────────────────────────────────────────────────────────── state cache ──
const state = {
catalogue: null,
enlisted: null,
races: null,
loading: false,
error: null,
};
function loadData(force) {
if (state.loading) return Promise.resolve();
if (!settings.key) {
state.error = 'no-key';
return Promise.resolve();
}
if (state.catalogue && state.enlisted && !force) return Promise.resolve();
state.loading = true;
if (force) api.clearCache();
return Promise.all([api.upgrades(), api.enlisted()])
.then(function (res) {
state.catalogue = res[0];
state.enlisted = res[1];
state.error = null;
})
.catch(function (err) {
state.error = err.message || String(err);
})
.then(function () {
// Race history is a nice-to-have; a failure here must not blank the panel.
return api.races().then(
function (r) {
state.races = r;
},
function () {
state.races = null;
}
);
})
.then(function () {
state.loading = false;
render();
// The in-race badge is computed from car data, and the first decorate() runs
// before any of it has arrived. Without this it stays stuck on its initial
// "no car" verdict for the rest of the session.
decorate();
});
}
// ────────────────────────────────────────────────────── inline decorations ──
/*
* Parts & Modifications: strike out safety-only parts and mark the guide's pick.
* Torn renders every category into ul.pm-items up front and only refreshes a list
* when its category is opened, so this runs on each mutation and stays idempotent.
*/
function decorateParts() {
if (!settings.flagWaste) return;
const items = $$('ul.pm-items > li');
if (!items.length) return;
const carName = selectedCarName();
let plan = null;
if (carName && state.enlisted && state.catalogue) {
const car = selectedEnlistedCar();
if (car) plan = planFor(car, state.catalogue);
}
const targetNames = plan ? new Set(plan.missing.map((m) => norm(m.upgrade.name))) : null;
// Fitted parts that penalise this car's surface, outlined red where they sit.
const wrongNames = plan ? new Set(plan.wrongSurface.map((u) => norm(u.name))) : null;
items.forEach(function (li) {
if (li.classList.contains('m-hide')) return;
const name = partNameFromNode(li);
if (!name) return;
// Prefer the API's numeric effects; fall back to the rendered text.
let eff = null;
if (state.catalogue) {
const hit = state.catalogue.find((u) => norm(u.name) === norm(name));
if (hit) eff = hit.effects;
}
if (!eff) eff = effectsFromNode(li);
const waste = isWasteEffects(eff);
li.classList.toggle('tra-waste', !!waste);
li.classList.toggle('tra-waste-collapsed', !!waste && settings.collapseWaste);
li.classList.toggle('tra-target', !!(targetNames && targetNames.has(norm(name))));
li.classList.toggle('tra-wrongsurface', !!(wrongNames && wrongNames.has(norm(name))));
});
}
/*
* Banner on the Safety *category tile*: the 42 points it holds buy no race time.
*
* "Safety" is also the label of every car's safety stat bar, inside `.properties`,
* and matching those stamped the banner under all eight enlisted cars. The tile is
* the one that is not a stat bar and not a parts-list header.
*/
function decorateCategories() {
$$('.content-wrapper div.title').forEach(function (t) {
if (t.textContent.trim() !== 'Safety') return;
const tile = t.closest('li') || t.parentElement;
if (!tile) return;
const existing = childByClass(tile, 'tra-cat-banner');
const isStatBar = !!t.closest('.properties');
const isPartsHeader = !!tile.querySelector('ul.pm-items');
if (!settings.flagWaste || isStatBar || isPartsHeader) {
dropNode(existing);
return;
}
if (existing) return;
tile.appendChild(el('div', { class: 'tra-cat-banner', text: 'No race-time benefit' }));
});
}
/*
* The enlisted-car list. Torn renders it on several screens and labels the action
* differently on each - "Select" on Parts & Modifications, "Change car" in the
* picker during a race, "Remove from Enlisted" on Your Cars, "Currently in race"
* on the active one. Matching on the verb meant the picker was skipped entirely,
* so rows are identified by structure instead: a class, points spent, upgrades.
*/
function enlistedRows() {
const matches = $$('.content-wrapper li, .content-wrapper div').filter(function (n) {
const txt = n.innerText || '';
if (!/\(Class [EDCBA]\)/.test(txt)) return false;
if (!/Points spent:/.test(txt) || !/Upgrades:/.test(txt)) return false;
return txt.split('\n').length <= 30;
});
// Keep the innermost match; wrappers around a single row match identically.
return matches.filter((n) => !matches.some((o) => o !== n && n.contains(o)));
}
function carNameFromRow(text) {
const m = text.match(/^(.+?)\s+\(Class ([EDCBA])\)(?:\s*-\s*([^\n]+))?/m);
if (!m) return null;
return {
name: m[1].split('|').pop().replace(ACTION_RE, '').trim(),
cls: m[2],
nickname: (m[3] || '').trim(),
inRace: /Currently in race/i.test(m[1]),
};
}
/*
* When a race is in progress, mark which of these cars the guide wants for that
* track. This list is where the choice is actually made, so the answer belongs
* here rather than in a panel further up the page.
*/
/*
* Exactly one row should be marked "use this". Two cars can share the guide's
* model - this account runs two Edomondo NSXs, one tarmac and one dirt - and
* marking on model alone lit up both, which is no answer at all.
*
* Preference order, strongest evidence first:
* 1. its nickname claims this track
* 2. its build matches the track's surface and ratio
* 3. it is not built for the opposite surface
*/
function pickRowFor(g, rows) {
const sameModel = rows.filter((r) => r.car && norm(r.car.name) === norm(g.car));
if (sameModel.length <= 1) return sameModel[0] || null;
const claiming = sameModel.filter((r) =>
tracksFromNickname(r.car.nickname).some((t) => t.track === g.track)
);
if (claiming.length) return claiming[0];
const scored = sameModel
.map(function (r) {
const asg = assignmentFor(tracksFromNickname(r.car.nickname));
let s = 0;
if (asg.surface === g.surface) s += 2;
else if (asg.surface && asg.surface !== 'mixed') s -= 2;
if (asg.ratio === g.ratio) s += 1;
return { row: r, s: s };
})
.sort((a, b) => b.s - a.s);
// A dead tie is not a recommendation; say so rather than pick arbitrarily.
if (scored.length > 1 && scored[0].s === scored[1].s) return null;
return scored[0].row;
}
function decorateCarList() {
const target = targetTrack();
const g = target && target.guide;
const nowCar = target && target.car;
const rows = enlistedRows().map(function (row) {
dropNode(childByClass(row, 'tra-pick'));
row.classList.remove('tra-pick-row', 'tra-pick-no');
return { node: row, car: carNameFromRow(row.innerText || '') };
});
if (!g) return;
const chosen = pickRowFor(g, rows);
rows.forEach(function (r) {
const car = r.car;
if (!car) return;
const claims = tracksFromNickname(car.nickname).some((t) => t.track === g.track);
const racing =
car.inRace ||
(nowCar &&
norm(nowCar.name) === norm(car.name) &&
(!nowCar.nickname || norm(nowCar.nickname) === norm(car.nickname)));
const verb = target.joining ? 'JOIN WITH ' : racing ? 'CORRECT CAR for ' : 'USE THIS for ';
let mark = null;
if (chosen && r === chosen) {
mark = {
cls: 'ok',
text:
verb + g.track + ' — ' + g.ratio + ' ratio, ' + g.surface +
(g.turbo ? ', stage ' + g.turbo + ' turbo' : '') +
(claims ? '' : ' (its nickname does not claim this track)'),
};
} else if (!chosen && norm(car.name) === norm(g.car)) {
// Several cars of the guide's model and nothing to separate them.
mark = {
cls: 'warn',
text:
'One of your ' + g.car + 's is right for ' + g.track +
', but their names do not say which. Name them after their tracks.',
};
} else if (racing) {
mark = { cls: 'bad', text: 'Racing this on ' + g.track + ', but the guide wants ' + g.car };
} else if (claims) {
mark = { cls: 'bad', text: 'Claims ' + g.track + ', but the guide wants ' + g.car };
}
if (!mark) return;
r.node.classList.add(mark.cls === 'ok' ? 'tra-pick-row' : 'tra-pick-no');
r.node.insertBefore(el('div', { class: 'tra-pick ' + mark.cls, text: mark.text }), r.node.firstChild);
});
}
/*
* Badge the in-race header, which is the one track mention with a reliable shape.
* The badge is *updated in place* rather than added once: the first decorate() runs
* before the API data arrives, so a one-shot badge would freeze on the verdict it
* could reach with no car data at all - reporting "no car" for a track that is in
* fact correctly covered.
*/
function decorateRaceHeader() {
const track = currentRaceTrack();
if (!track) return;
const g = trackByName(track);
if (!g) return;
const existing = $('.tra-badge[data-tra-race]');
if (!settings.badgeRows) {
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
return;
}
const verdict = verdictForTrack(g, currentRaceCar());
if (existing) {
existing.className = 'tra-badge ' + verdict.level;
existing.textContent = verdict.short;
existing.setAttribute('title', verdict.detail);
return;
}
const nodes = $$('.content-wrapper div, .content-wrapper li, .content-wrapper span').filter(function (n) {
if (n.children.length > 3) return false;
return new RegExp('^\\s*' + track + '\\s+-\\s+\\d+\\s+laps?').test(n.innerText || '');
});
if (!nodes.length) return;
nodes[nodes.length - 1].appendChild(
el('span', {
class: 'tra-badge ' + verdict.level,
'data-tra-race': '1',
text: verdict.short,
title: verdict.detail,
})
);
}
function matchEnlisted(name) {
if (!state.enlisted) return null;
const n = norm(name);
return (
state.enlisted.find((c) => norm(c.car_item_name + (c.car_name || '')) === n) ||
state.enlisted.find((c) => norm(c.car_item_name) === n) ||
state.enlisted.find((c) => n.indexOf(norm(c.car_item_name)) === 0) ||
null
);
}
/*
* Which enlisted car the open Parts & Modifications page belongs to.
*
* Torn's header names the model only - "the part you wish to add to your Edomondo
* NSX" - with no nickname. This account runs TWO Edomondo NSXs, one dirt and one
* tarmac, so matching on the model alone silently returned whichever came first in
* the API list. The checklist could then report a finished car while the plan was
* talking about the other one.
*
* The parts marked `bought` on the page belong, unambiguously, to the car actually
* open - so the candidate whose fitted set overlaps them most is the right one.
* Points spent cannot break the tie here: both NSXs sit on exactly 269.
*/
function selectedEnlistedCar() {
const name = selectedCarName();
if (!name || !state.enlisted) return null;
const n = norm(name);
const cands = state.enlisted.filter(
(c) => norm(c.car_item_name) === n || n.indexOf(norm(c.car_item_name)) === 0
);
if (!cands.length) return matchEnlisted(name);
if (cands.length === 1) return cands[0];
const bought = new Set(partsFromDom().filter((r) => r.bought).map((r) => norm(r.name)));
if (!bought.size || !state.catalogue) return cands[0];
const byId = new Map(state.catalogue.map((u) => [u.id, u]));
let best = cands[0];
let bestScore = -1;
cands.forEach(function (c) {
const names = (c.parts || []).map((id) => byId.get(id)).filter(Boolean).map((u) => norm(u.name));
let hits = 0;
names.forEach((x) => {
if (bought.has(x)) hits++;
});
// Jaccard, so a car with more parts cannot win on volume alone.
const union = names.length + bought.size - hits;
const s = union ? hits / union : 0;
if (s > bestScore) {
bestScore = s;
best = c;
}
});
return best;
}
const spec = (g) =>
g.car + ', ' + g.ratio + ' ratio, ' + g.surface + (g.turbo ? ', stage ' + g.turbo + ' turbo' : '');
/*
* Which car covers this track, and is it right? `inRaceCar`, when given, is the car
* actually in the race right now, which beats the nickname as evidence.
*/
function verdictForTrack(g, inRaceCar) {
const cars = state.enlisted || enlistedFromDom();
let car = null;
if (inRaceCar) {
car = cars.find(
(c) =>
norm(c.car_item_name) === norm(inRaceCar.name) &&
(!inRaceCar.nickname || norm(c.car_name || '') === norm(inRaceCar.nickname))
) || cars.find((c) => norm(c.car_item_name) === norm(inRaceCar.name));
// Racing a car we cannot find in the list is still worth judging by name.
if (!car) car = { car_item_name: inRaceCar.name, car_name: inRaceCar.nickname, class: null };
}
if (!car) {
car = cars.filter((c) => tracksFromNickname(c.car_name).indexOf(g) !== -1)[0] || null;
}
if (!car) {
return {
level: 'bad',
short: 'no car',
car: null,
detail: g.track + ': guide says ' + spec(g) + '. No enlisted car claims this track.',
};
}
const right = norm(car.car_item_name) === norm(g.car);
const plan = state.catalogue && car.parts ? planFor(car, state.catalogue) : null;
const complete = plan ? plan.missing.length === 0 : car.upgrades >= 46;
/*
* A car that is not the guide's pick is not automatically a mistake. If the car
* the guide wants is sitting in the garage unbuilt, what you have here is a
* deliberate stand-in and the fix is to build that car, not to swap this one.
* Calling both "wrong car" hid which of the two situations you were in.
*/
if (!right) {
const intended = cars.find((c) => norm(c.car_item_name) === norm(g.car));
if (!intended) {
return {
level: 'bad',
short: 'no ' + g.car,
car: car,
detail:
g.track + ': covered by ' + car.car_item_name + '. The guide wants ' + spec(g) +
', and you have no ' + g.car + ' enlisted.',
};
}
const ip = state.catalogue && intended.parts ? planFor(intended, state.catalogue) : null;
const intendedBuilt = ip ? ip.missing.length === 0 : intended.upgrades >= 46;
if (!intendedBuilt) {
return {
level: 'warn',
short: 'stand-in',
car: car,
detail:
g.track + ': ' + car.car_item_name + ' is standing in. The guide wants ' + g.car +
', which is ' + (ip ? ip.completion + '% built' : 'not built') +
(ip && ip.rpToFinish ? ' (' + fmt(ip.rpToFinish) + ' RP to finish)' : '') +
'. Build it and reassign.',
};
}
return {
level: 'bad',
short: 'wrong car',
car: car,
detail:
g.track + ': running ' + car.car_item_name + ', but your ' + g.car +
' is built and ready. Guide says ' + spec(g) + '.',
};
}
// Right car, wrong parts - the Volt GT case. Worse than under-built, because the
// points are spent and the fitted parts actively penalise this surface.
if (plan && plan.wrongSurface.length) {
return {
level: 'bad',
short: 'wrong parts',
car: car,
detail:
g.track + ' is ' + g.surface + ', but ' + car.car_item_name + ' has ' +
plan.wrongSurface.map((u) => u.name).join(', ') + ' fitted.',
};
}
if (car.class && car.class !== 'A' && CLASS_ORDER.indexOf(car.class) < CLASS_ORDER.indexOf('A')) {
return {
level: 'warn',
short: 'class ' + car.class,
car: car,
detail: g.track + ': ' + car.car_item_name + ' matches the guide but is only class ' + car.class + '.',
};
}
if (!complete) {
return {
level: 'warn',
short: 'under-built',
car: car,
detail:
g.track + ': ' + car.car_item_name + ' is the right car but is not finished' +
(plan ? ' (' + fmt(plan.rpToFinish) + ' RP to go)' : '') + '.',
};
}
return {
level: 'ok',
short: g.ratio + (g.turbo ? '/s' + g.turbo : ''),
car: car,
detail: g.track + ': ' + car.car_item_name + ', ' + spec(g) + '. Built.',
};
}
// ─────────────────────────────────────────────────────────────────── panel ──
/*
* Racing renders as
* .racing-main-wrap > .header-wrap (banner + tab strip)
* > .delimiter-999
* > div (the active tab's content)
* so the panel goes after the delimiter: under the tabs, above the content.
*/
function panelHost() {
const wrap = $('.racing-main-wrap');
if (wrap) {
const delim = $('.delimiter-999', wrap) || $('.header-wrap', wrap);
if (delim) return { parent: wrap, before: delim.nextSibling };
return { parent: wrap, before: wrap.firstChild };
}
const root = racingRoot();
return { parent: root, before: root.firstChild };
}
function ensurePanel() {
let p = $('#tra-panel');
if (p && p.isConnected) return p;
p = el('div', { class: 'tra-panel', id: 'tra-panel' });
const head = el('div', { class: 'tra-head' }, [
el('div', { class: 'tra-title', text: 'Racing Advisor' }),
el('span', { class: 'tra-chip', id: 'tra-chip-profile', text: '-' }),
el('span', { class: 'tra-chip', id: 'tra-chip-rp', text: '- RP' }),
el('span', { class: 'tra-chip', id: 'tra-chip-mode', text: 'DOM' }),
el('span', { class: 'tra-caret', id: 'tra-caret', text: '▾' }),
]);
head.addEventListener('click', function (ev) {
if (ev.target.closest('.tra-body')) return;
settings.panelOpen = !settings.panelOpen;
saveSettings();
p.classList.toggle('tra-collapsed', !settings.panelOpen);
const caret = $('#tra-caret', p);
if (caret) caret.textContent = settings.panelOpen ? '▾' : '▸';
});
p.appendChild(head);
p.appendChild(el('div', { class: 'tra-body', id: 'tra-body' }));
p.classList.toggle('tra-collapsed', !settings.panelOpen);
const host = panelHost();
host.parent.insertBefore(p, host.before || null);
applyFontSize();
return p;
}
/*
* The race you are in right now, at the top of the panel and impossible to miss.
* This is the only thing on screen that is time-critical: everything else can be
* read at leisure, this one is about the lap being driven.
*/
function sectionNow() {
const now = currentRace();
if (!now) return null;
if (!now.guide) {
return el('div', { class: 'tra-now warn' }, [
el('div', { class: 'tra-now-top' }, [
el('div', { class: 'tra-now-label', text: 'Racing now' }),
el('div', { class: 'tra-now-track', text: now.trackName }),
]),
el('div', { class: 'tra-now-verdict tra-muted', text: 'Not a track this guide covers.' }),
]);
}
const g = now.guide;
const v = verdictForTrack(g, now.car);
const meta = g.surface + ' · ' + g.ratio + ' ratio' + (g.turbo ? ' · stage ' + g.turbo + ' turbo' : '');
const top = el('div', { class: 'tra-now-top' }, [
el('div', { class: 'tra-now-label', text: 'Racing now' }),
el('div', { class: 'tra-now-track', text: g.track }),
el('div', { class: 'tra-now-meta', text: meta }),
el('span', { class: 'tra-badge ' + v.level, text: v.short }),
]);
const wantLine = el('div', { class: 'tra-now-verdict' }, [
el('span', { class: 'tra-muted', text: 'Guide wants ' }),
el('span', { text: g.car }),
]);
if (now.car) {
wantLine.appendChild(el('span', { class: 'tra-muted', text: ' · you brought ' }));
wantLine.appendChild(
el('span', {
class: norm(now.car.name) === norm(g.car) ? 'tra-good' : 'tra-badc',
text: now.car.name + (now.car.nickname ? ' (' + now.car.nickname + ')' : ''),
})
);
}
const box = el('div', { class: 'tra-now ' + (v.level === 'ok' ? '' : v.level) }, [top, wantLine]);
if (v.level !== 'ok') {
box.appendChild(el('div', { class: 'tra-now-verdict tra-warnc', text: v.detail }));
}
const d = disagreement(g.track);
if (d) {
const other = settings.profile === 'sky' ? d.damien : d.sky;
const otherName = settings.profile === 'sky' ? 'Damien' : 'Sky';
box.appendChild(
el('div', { class: 'tra-now-verdict tra-muted' }, [
otherName + ' disagrees on ' + d.diffs.join(' and ') + ': ' + spec(other) + '.',
])
);
}
return box;
}
function sectionDashboard() {
const sec = el('div', { class: 'tra-sec' }, [el('div', { class: 'tra-h', text: 'Plan' })]);
const cars = state.enlisted || enlistedFromDom();
if (!cars.length) {
sec.appendChild(
el('div', {
class: 'tra-p tra-muted',
text: 'No enlisted cars found on this page. Open "Your Cars", or add an API key for a full read.',
})
);
return sec;
}
const now = currentRace();
const nowCar = now && now.car ? now.car : null;
const plans = cars.map(function (car) {
// An unnamed car whose model the guide covers unambiguously still has a job.
const named = tracksFromNickname(car.car_name);
const guess = named.length ? null : inferredTracks(car);
const tracks = named.length ? named : guess || [];
return {
car: car,
tracks: tracks,
inferred: !!guess,
asg: assignmentFor(tracks),
plan: state.catalogue && car.parts ? planFor(car, state.catalogue) : null,
isNow:
!!nowCar &&
norm(car.car_item_name) === norm(nowCar.name) &&
(!nowCar.nickname || norm(car.car_name || '') === norm(nowCar.nickname)),
};
});
/*
* Ordering, most useful first: the car you are racing right now, then cars the
* guide actually has an opinion about, then everything else. Within the guide
* group, unfinished cars come before finished ones - those are the ones with a
* decision attached.
*/
const guideCars = new Set(guideList().map((g) => norm(g.car)));
const rank = (p) => {
if (p.isNow) return 0;
if (!guideCars.has(norm(p.car.car_item_name))) return 3;
return p.tracks.length ? 1 : 2;
};
plans.sort(function (a, b) {
const d = rank(a) - rank(b);
if (d) return d;
const ap = a.plan ? a.plan.rpToFinish : 0;
const bp = b.plan ? b.plan.rpToFinish : 0;
return bp - ap;
});
const inGuide = plans.filter((p) => rank(p) < 3);
const others = plans.filter((p) => rank(p) === 3);
const table = el('table', { class: 'tra-table' });
table.appendChild(
el('tr', {}, [
el('th', { text: 'Car' }),
el('th', { text: 'Tracks' }),
el('th', { class: 'tra-hide-narrow', text: 'Setup' }),
el('th', { class: 'num', text: 'Built' }),
el('th', { class: 'num', text: 'RP left' }),
])
);
function carRow(p, dim) {
const plan = p.plan;
const car = p.car;
const built = plan ? plan.completion + '%' : car.upgrades != null ? car.upgrades + ' upg' : '?';
let builtClass = 'num';
if (plan) {
if (plan.wrongSurface.length) builtClass += ' tra-badc';
else if (plan.missing.length === 0) builtClass += ' tra-good';
else if (plan.completion < 50) builtClass += ' tra-warnc';
}
const nameCell = el('td', {}, [
el('span', {
class: car.class && car.class !== 'A' ? 'tra-warnc' : '',
text: car.car_item_name + (car.class ? ' (' + car.class + ')' : ''),
}),
]);
if (p.isNow) nameCell.appendChild(el('span', { class: 'tra-badge ok', text: 'racing' }));
if (plan && plan.wrongSurface.length) {
nameCell.appendChild(
el('span', {
class: 'tra-badge bad',
text: 'wrong parts',
title: plan.wrongSurface.map((u) => u.name).join(', '),
})
);
}
// Spell out every axis, including the one that conflicts, so an amber row
// always shows its own reason: "tarmac/short/s2+s3" rather than "tarmac/short".
const turbos = p.asg.turbos || [];
const turboPart = p.asg.turbo
? '/s' + p.asg.turbo
: turbos.length > 1
? '/s' + turbos.join('+s')
: '';
const setup = !p.asg.surface
? '-'
: p.asg.surface === 'mixed'
? // Surfaces already conflict; naming the ratio too just reads "mixed/mixed".
'mixed surface'
: p.asg.surface + '/' + (p.asg.ratio || 'mixed ratio') + turboPart;
const row = el('tr', { class: (p.isNow ? 'tra-nowrow' : '') + (dim ? ' tra-dim' : '') }, [
nameCell,
el('td', { class: 'tra-muted' }, [
el('span', {
text: p.tracks.length ? p.tracks.map((t) => t.track).join(', ') : car.car_name || 'unassigned',
}),
// Say when the tracks are the guide's suggestion rather than your nickname.
p.inferred
? el('span', {
class: 'tra-conflict',
text: 'per guide',
title:
car.car_item_name + ' has no nickname. The guide gives this model exactly one job, ' +
'so that is what is planned for it. Name the car to make it explicit.',
})
: null,
]),
el('td', {
class: 'tra-hide-narrow ' + (p.asg.mixed ? 'tra-warnc' : 'tra-muted'),
text: setup,
}),
el('td', { class: builtClass, text: built }),
el('td', { class: 'num tra-muted', text: plan ? fmt(plan.rpToFinish) : '?' }),
]);
return row;
}
inGuide.forEach((p) => table.appendChild(carRow(p, false)));
sec.appendChild(table);
// Cars the guide has no opinion on are noise by default, but hiding them without
// saying so would misreport how many cars are enlisted.
if (others.length) {
const toggle = el('button', {
class: 'tra-btn' + (settings.showOtherCars ? ' on' : ''),
text:
(settings.showOtherCars ? 'Hide' : 'Show') + ' ' + others.length + ' car' +
(others.length === 1 ? '' : 's') + ' not in this guide',
onclick: function () {
settings.showOtherCars = !settings.showOtherCars;
saveSettings();
render();
},
});
sec.appendChild(el('div', { class: 'tra-row' }, [toggle]));
if (settings.showOtherCars) {
const t3 = el('table', { class: 'tra-table' });
others.forEach((p) => t3.appendChild(carRow(p, true)));
sec.appendChild(t3);
}
}
// Cars carrying tracks of both surfaces cannot have a correct tyre/gearbox pick.
const mixed = plans.filter((p) => p.asg.mixed);
if (mixed.length) {
sec.appendChild(
el('div', { class: 'tra-note' }, [
el('div', {
text:
'Mixed assignment: ' +
mixed.map((p) => p.car.car_item_name + ' (' + p.tracks.map((t) => t.track).join(', ') + ')').join('; ') +
'. Tyres, gearbox and suspension cannot be right for both surfaces on one car.',
}),
])
);
}
// Tracks with nobody assigned to them.
const claimed = new Set();
plans.forEach((p) => p.tracks.forEach((t) => claimed.add(t.track)));
const uncovered = guideList().filter((g) => !claimed.has(g.track));
if (uncovered.length) {
sec.appendChild(
el('div', { class: 'tra-note' }, [
el('div', {
text:
'Unclaimed tracks: ' +
uncovered.map((g) => g.track + ' -> ' + g.car + ' (' + g.ratio + ')').join('; '),
}),
])
);
}
// Spend order: guide build order first, then value.
if (state.catalogue) {
const budget = rpAvailable();
/*
* Queued by the guide's own build order, matched on build id rather than car
* name - Sky runs several different Edomondo NSX builds, so the name alone is
* ambiguous. Cars the guide says nothing about are left out entirely: they were
* dominating this list with "not in the guide build order" rows.
*/
const queue = [];
guideOrder().forEach(function (step) {
plans.forEach(function (p) {
if (!p.plan || !p.plan.missing.length) return;
if (p.asg.builds.indexOf(step.build) === -1) return;
if (queue.some((q) => q.p === p)) return;
queue.push({ p: p, step: step });
});
});
if (queue.length) {
const spend = el('div', { class: 'tra-sec' }, [el('div', { class: 'tra-h', text: 'Spend order' })]);
const t2 = el('table', { class: 'tra-table' });
t2.appendChild(
el('tr', {}, [
el('th', { text: 'Next' }),
el('th', { text: 'Why' }),
el('th', { class: 'num', text: 'RP' }),
el('th', { class: 'num', text: 'Running' }),
])
);
let running = 0;
queue.slice(0, 6).forEach(function (q) {
running += q.p.plan.rpToFinish;
const affordable = budget == null || running <= budget;
t2.appendChild(
el('tr', {}, [
el('td', {
class: affordable ? 'tra-good' : 'tra-muted',
text: q.p.car.car_item_name + (q.p.car.car_name ? ' - ' + q.p.car.car_name : ''),
}),
el('td', { class: 'tra-muted', text: q.step.note }),
el('td', { class: 'num', text: fmt(q.p.plan.rpToFinish) }),
el('td', { class: 'num' + (affordable ? '' : ' tra-badc'), text: fmt(running) }),
])
);
});
spend.appendChild(t2);
if (budget != null) {
spend.appendChild(
el('div', {
class: 'tra-p tra-muted',
text: 'Budget ' + fmt(budget) + ' RP. Rows past your budget are dimmed.',
})
);
}
sec.appendChild(spend);
}
}
// Points already sunk into safety-only parts, per car.
if (state.catalogue) {
const wasted = plans.filter((p) => p.plan && p.plan.wastePoints > 0);
if (wasted.length) {
const total = wasted.reduce((a, p) => a + p.plan.wastePoints, 0);
sec.appendChild(
el('div', { class: 'tra-note' }, [
el('div', {
text:
'Already spent on safety-only parts: ' + fmt(total) + ' RP (' +
wasted.map((p) => p.car.car_item_name + ' ' + p.plan.wastePoints).join(', ') +
'). Not recoverable - shown so the pattern stops.',
}),
])
);
}
}
return sec;
}
function sectionTrackAdvisor() {
const sec = el('div', { class: 'tra-sec' }, [el('div', { class: 'tra-h', text: 'Track to car' })]);
const now = currentRace();
const nowTrack = now && now.guide ? now.guide.track : null;
const table = el('table', { class: 'tra-table' });
table.appendChild(
el('tr', {}, [
el('th', { text: 'Track' }),
el('th', { class: 'tra-hide-narrow', text: 'Surface' }),
el('th', { text: 'Guide car' }),
el('th', { text: 'Setup' }),
el('th', { text: 'You' }),
])
);
// Current track first, then grouped by the car that should run it, so the table
// reads as "these tracks share this build" rather than an arbitrary list.
const rows = guideList().slice().sort(function (a, b) {
if (a.track === nowTrack) return -1;
if (b.track === nowTrack) return 1;
if (a.build !== b.build) return a.build < b.build ? -1 : 1;
return a.track < b.track ? -1 : 1;
});
rows.forEach(function (g) {
const isNow = g.track === nowTrack;
const v = verdictForTrack(g, isNow ? now.car : null);
const trackCell = el('td', { text: g.track });
const d = disagreement(g.track);
if (d) {
const other = settings.profile === 'sky' ? d.damien : d.sky;
const otherName = settings.profile === 'sky' ? 'Damien' : 'Sky';
// Naming the dissenting guide reads better than a bare symbol - "≠" in a
// small purple chip was indistinguishable from a "#" at panel sizes.
trackCell.appendChild(
el('span', {
class: 'tra-conflict',
text: otherName + ' differs',
title: otherName + ' says ' + spec(other) + ' (differs on ' + d.diffs.join(', ') + ')',
})
);
}
table.appendChild(
el('tr', { class: isNow ? 'tra-nowrow' : '' }, [
trackCell,
el('td', { class: 'tra-hide-narrow tra-muted', text: g.surface }),
el('td', { class: 'tra-muted', text: g.car }),
el('td', { class: 'tra-muted', text: g.ratio + (g.turbo ? ' · s' + g.turbo : '') }),
el('td', {}, [el('span', { class: 'tra-badge ' + v.level, text: v.short, title: v.detail })]),
])
);
});
sec.appendChild(table);
sec.appendChild(
el('div', {
class: 'tra-p tra-muted',
text: 'A purple tag marks a track where the other guide disagrees. Hover it for its opinion.',
})
);
return sec;
}
function sectionChecklist() {
const carName = selectedCarName();
if (!carName) return null;
const sec = el('div', { class: 'tra-sec' }, [
el('div', { class: 'tra-h', id: 'tra-checklist-head', text: 'Build checklist - ' + carName }),
]);
// Filled in below once the car is identified: with two cars of the same model,
// the model name alone does not say which one this is.
const setHeading = function (label) {
const h = $('#tra-checklist-head', sec);
if (h) h.textContent = 'Build checklist - ' + label;
};
// Whatever else happens, points already sunk into safety-only parts on THIS car
// are readable straight off the page.
const domParts = partsFromDom();
const heldWaste = domParts.filter((r) => r.bought && isWasteEffects(r.effects));
if (heldWaste.length) {
sec.appendChild(
el('div', { class: 'tra-note' }, [
el('div', {
text:
'Fitted but safety-only: ' +
heldWaste.map((r) => r.name + ' (' + r.points + ' RP)').join(', ') +
' = ' + heldWaste.reduce((a, r) => a + r.points, 0) +
' RP that bought no race time. Not refundable; skip these on the next car.',
}),
])
);
}
if (!state.catalogue || !state.enlisted) {
sec.appendChild(checklistFromReference(carName, domParts));
return sec;
}
const car = selectedEnlistedCar();
if (!car) {
sec.appendChild(el('div', { class: 'tra-p tra-muted', text: 'Could not match this car against /user/enlistedcars.' }));
return sec;
}
if (car.car_name) setHeading(car.car_item_name + ' - ' + car.car_name);
/*
* Two enlisted cars of the same model are indistinguishable from Torn's header,
* so say out loud which one this is and how it was worked out.
*/
const sameModel = state.enlisted.filter((c) => norm(c.car_item_name) === norm(car.car_item_name));
if (sameModel.length > 1) {
sec.appendChild(
el('div', { class: 'tra-p tra-muted' }, [
'You have ' + sameModel.length + ' cars called ' + car.car_item_name +
'. Torn does not say which one this page is for, so it was identified from the parts fitted to it: ' +
(car.car_name || 'the unnamed one') + '.',
])
);
}
const plan = planFor(car, state.catalogue);
if (!plan.tracks.length) {
sec.appendChild(
el('div', { class: 'tra-note' }, [
el('div', {
text:
'No track assignment on this car. Name it after its tracks (e.g. "UD,Com,Ind") and the ' +
'tyre, gearbox and suspension picks below become surface-correct.',
}),
])
);
}
sec.appendChild(
el('div', { class: 'tra-p tra-muted' }, [
plan.completion + '% built, ' + plan.done.length + '/' + plan.targetSlots + ' slots. ' +
fmt(plan.rpToFinish) + ' RP and $' + fmt(plan.cashToFinish) + ' to finish' +
(plan.assignment.surface ? ' as a ' + plan.assignment.surface + ' car.' : '.'),
])
);
if (plan.wrongSurface.length) {
sec.appendChild(
el('div', { class: 'tra-err' }, [
el('div', {
text:
'Built for the wrong surface. This car races ' + plan.assignment.surface +
' (' + plan.tracks.map((t) => t.track).join(', ') + ') but has ' +
plan.wrongSurface.map((u) => u.name).join(', ') +
' fitted, which costs it ' + plan.assignment.surface + ' grip. Replace with the picks below.',
}),
])
);
}
if (!plan.missing.length) {
sec.appendChild(el('div', { class: 'tra-p tra-good', text: 'Finished. Every non-safety slot is filled.' }));
return sec;
}
const table = el('table', { class: 'tra-table' });
table.appendChild(
el('tr', {}, [
el('th', { text: 'Part' }),
el('th', { text: 'Where' }),
el('th', { class: 'num', text: 'Gain' }),
el('th', { class: 'num', text: 'RP' }),
el('th', { class: 'num', text: 'Gain/RP' }),
])
);
plan.missing.forEach(function (m) {
/*
* Name the clickable category, not just the subcategory. "Air Filter" is a
* subcategory of Exhaust and there is no tile with that name, so the slot
* alone left you hunting for a category that does not exist.
*/
const where = m.cat && norm(m.cat) !== norm(m.sub) ? m.cat + ' › ' + m.sub : m.sub;
table.appendChild(
el('tr', {}, [
el('td', {}, [
el('span', { text: m.upgrade.name }),
m.replaces ? el('span', { class: 'tra-muted', text: ' (replaces ' + m.replaces.name + ')' }) : null,
]),
el('td', { class: 'tra-muted', text: where }),
el('td', { class: 'num', text: '+' + m.gain }),
el('td', { class: 'num', text: fmt(m.cost) }),
el('td', { class: 'num', text: m.cost ? (m.gain / m.cost).toFixed(2) : '-' }),
])
);
});
sec.appendChild(table);
return sec;
}
/*
* Keyless fallback. The target set cannot be derived from the page - Torn's part
* classes are coarser than its real slots (engine.enginecleaning covers two
* independent slots, suspension.brace covers three), so "one per class" would be
* wrong. Diffing against a finished car of the same surface avoids the problem
* entirely: it compares against a build that demonstrably works.
*/
function checklistFromReference(carName, domParts) {
const wrap = el('div', {});
const car =
selectedEnlistedCar() ||
enlistedFromDom().find((c) => norm(c.car_item_name) === norm(carName));
const surface = assignmentFor(car ? tracksFromNickname(car.car_name) : []).surface;
const owned = domParts.filter((r) => r.bought);
const ownedPoints = owned.reduce((a, r) => a + r.points, 0);
wrap.appendChild(
el('div', { class: 'tra-p tra-muted', text: owned.length + ' parts fitted, ' + fmt(ownedPoints) + ' RP spent.' })
);
if (surface !== 'tarmac' && surface !== 'dirt') {
wrap.appendChild(
el('div', { class: 'tra-p tra-muted', text: 'Name this car after its tracks to compare it against a finished car of the same surface.' })
);
return wrap;
}
const ref = store.get(referenceKey(surface), null);
if (!ref) {
wrap.appendChild(
el('div', { class: 'tra-p tra-muted' }, [
'No finished ' + surface + ' car recorded yet. Open Parts & Modifications on a maxed ' +
surface + ' car once and it becomes the reference, or add an API key for the full solver.',
])
);
return wrap;
}
const have = new Set(owned.map((r) => norm(r.name)));
const missing = ref.parts.filter((p) => !have.has(norm(p.name)));
wrap.appendChild(
el('div', { class: 'tra-p tra-muted' }, [
'Compared against ' + ref.car + ' (' + ref.parts.length + ' parts, ' + fmt(ref.points) + ' RP).',
])
);
if (!missing.length) {
wrap.appendChild(el('div', { class: 'tra-p tra-good', text: 'Matches the reference build.' }));
return wrap;
}
const table = el('table', { class: 'tra-table' });
table.appendChild(
el('tr', {}, [el('th', { text: 'Missing vs reference' }), el('th', { class: 'num', text: 'RP' })])
);
missing.forEach(function (p) {
table.appendChild(
el('tr', {}, [el('td', { text: p.name }), el('td', { class: 'num', text: fmt(p.points) })])
);
});
table.appendChild(
el('tr', {}, [
el('td', { class: 'tra-warnc', text: 'Total' }),
el('td', {
class: 'num tra-warnc',
text: fmt(missing.reduce((a, p) => a + p.points, 0)),
}),
])
);
wrap.appendChild(table);
// The reference may itself contain the gasket mistake - say so rather than
// recommending it onward.
const refWaste = missing.filter(function (p) {
const hit = domParts.find((r) => norm(r.name) === norm(p.name));
return hit && isWasteEffects(hit.effects);
});
if (refWaste.length) {
wrap.appendChild(
el('div', { class: 'tra-note' }, [
el('div', {
text:
'The reference car itself carries ' +
refWaste.map((p) => p.name).join(', ') +
', which is safety-only. Do not copy that across.',
}),
])
);
}
return wrap;
}
// The Build tab only has content on a car's parts page; say so rather than blank.
/*
* A name for every car, derived from the build the guide gives it. Names are the
* only thing tying a car to a track - the panel reads them, and so do you when the
* picker offers two identical models - so getting them right fixes several
* problems at once.
*
* Assignment is greedy: for each build the guide defines, take the enlisted car of
* that model that best matches it already, so existing names mostly stay put and
* the two unnamed cars pick up the jobs nobody else is doing.
*/
function nameSuggestions() {
const cars = (state.enlisted || enlistedFromDom()).slice();
const builds = new Map();
guideList().forEach(function (g) {
if (!builds.has(g.build)) builds.set(g.build, []);
builds.get(g.build).push(g);
});
const taken = new Set();
const out = [];
builds.forEach(function (tracks, build) {
const model = tracks[0].car;
const label = tracks.map((t) => TRACK_SHORT[t.track] || t.track).join(',');
const cands = cars.filter((c) => !taken.has(c) && norm(c.car_item_name) === norm(model));
if (!cands.length) {
out.push({ car: null, model: model, build: build, suggested: label, missing: true });
return;
}
// Prefer the car whose current name already covers these tracks.
cands.sort(function (a, b) {
const at = tracksFromNickname(a.car_name).filter((t) => tracks.indexOf(t) !== -1).length;
const bt = tracksFromNickname(b.car_name).filter((t) => tracks.indexOf(t) !== -1).length;
if (at !== bt) return bt - at;
// Then whichever is further along, so the built car keeps the built job.
return (b.points_spent || 0) - (a.points_spent || 0);
});
const car = cands[0];
taken.add(car);
out.push({
car: car,
model: model,
build: build,
suggested: label,
current: car.car_name || null,
changed: norm(car.car_name || '') !== norm(label),
});
});
// Anything left over has no job under this guide.
cars.forEach(function (c) {
if (!taken.has(c)) out.push({ car: c, model: c.car_item_name, suggested: null, spare: true });
});
return out;
}
function sectionNames() {
const sec = el('div', { class: 'tra-sec' }, [el('div', { class: 'tra-h', text: 'Suggested car names' })]);
const rows = nameSuggestions();
sec.appendChild(
el('div', {
class: 'tra-p tra-muted',
text:
'Names are what tie a car to a track - the panel reads them, and they are how you tell two ' +
'identical models apart in the car picker. Rename in Torn under Your Cars.',
})
);
const table = el('table', { class: 'tra-table' });
table.appendChild(
el('tr', {}, [
el('th', { text: 'Car' }),
el('th', { text: 'Now' }),
el('th', { text: 'Suggested' }),
])
);
rows.forEach(function (r) {
if (r.missing) {
table.appendChild(
el('tr', {}, [
el('td', { class: 'tra-muted', text: r.model }),
el('td', { class: 'tra-badc', text: 'not enlisted' }),
el('td', { class: 'tra-muted', text: r.suggested }),
])
);
return;
}
if (r.spare) {
table.appendChild(
el('tr', { class: 'tra-dim' }, [
el('td', { text: r.car.car_item_name }),
el('td', { class: 'tra-muted', text: r.car.car_name || '—' }),
el('td', { class: 'tra-muted', text: 'no job in this guide' }),
])
);
return;
}
table.appendChild(
el('tr', {}, [
el('td', { text: r.car.car_item_name }),
el('td', { class: 'tra-muted', text: r.current || '—' }),
el('td', { class: r.changed ? 'tra-warnc' : 'tra-good', text: r.suggested }),
])
);
});
sec.appendChild(table);
const changes = rows.filter((r) => r.car && r.changed);
sec.appendChild(
el('div', {
class: 'tra-p ' + (changes.length ? 'tra-warnc' : 'tra-good'),
text: changes.length
? changes.length + ' car' + (changes.length === 1 ? '' : 's') + ' to rename.'
: 'Every car is already named after its build.',
})
);
return sec;
}
function sectionChecklistPlaceholder() {
return el('div', { class: 'tra-sec' }, [
el('div', { class: 'tra-h', text: 'Build checklist' }),
el('div', {
class: 'tra-p tra-muted',
text: 'Open Parts & Modifications and pick a car. This tab then lists what that car is missing, cheapest gain first.',
}),
]);
}
function sectionSettings() {
const sec = el('div', { class: 'tra-sec tra-settings' }, [el('div', { class: 'tra-h', text: 'Settings' })]);
const sizeLabel = el('span', { class: 'tra-muted', text: settings.fontSize + 'px' });
const setSize = function (px) {
settings.fontSize = Math.max(FONT_MIN, Math.min(FONT_MAX, px));
saveSettings();
applyFontSize();
sizeLabel.textContent = settings.fontSize + 'px';
};
sec.appendChild(
el('div', { class: 'tra-row' }, [
el('span', { class: 'tra-muted', text: 'Text size:' }),
el('button', {
class: 'tra-btn tra-sizebtn',
text: 'A-',
title: 'Smaller',
onclick: () => setSize(settings.fontSize - 1),
}),
el('button', {
class: 'tra-btn tra-sizebtn',
text: 'A+',
title: 'Larger',
onclick: () => setSize(settings.fontSize + 1),
}),
sizeLabel,
el('button', {
class: 'tra-btn',
text: 'Reset',
onclick: () => setSize(FONT_DEFAULT),
}),
])
);
// Guide profile. The two published guides disagree on three tracks, so which one
// is active changes what every other section says.
const picker = el('div', { class: 'tra-row' }, [el('span', { class: 'tra-muted', text: 'Guide:' })]);
Object.keys(PROFILES).forEach(function (k) {
const p = PROFILES[k];
picker.appendChild(
el('button', {
class: 'tra-btn' + (settings.profile === k ? ' on' : ''),
text: p.label + ' (' + p.cars + ' cars)',
title: p.note,
onclick: function () {
settings.profile = k;
saveSettings();
render();
decorate();
},
})
);
});
sec.appendChild(picker);
sec.appendChild(el('div', { class: 'tra-p tra-muted', text: profile().note }));
const input = el('input', {
class: 'tra-input',
type: 'password',
autocomplete: 'off',
placeholder: settings.key ? 'key stored - paste to replace' : 'Torn API key (Limited)',
id: 'tra-key',
});
const save = el('button', {
class: 'tra-btn',
text: 'Save key',
onclick: function () {
const v = input.value.trim();
if (!v) return;
settings.key = v;
saveSettings();
input.value = '';
input.placeholder = 'key stored - paste to replace';
api.clearCache();
state.catalogue = state.enlisted = null;
loadData(true);
},
});
const forget = el('button', {
class: 'tra-btn',
text: 'Forget key',
onclick: function () {
settings.key = '';
saveSettings();
api.clearCache();
state.catalogue = state.enlisted = state.races = null;
state.error = 'no-key';
render();
},
});
const refresh = el('button', {
class: 'tra-btn',
text: 'Refresh',
onclick: function () {
loadData(true);
},
});
sec.appendChild(el('div', { class: 'tra-row' }, [input, save, forget, refresh]));
[
['flagWaste', 'Flag safety-only parts'],
['collapseWaste', 'Hide them entirely'],
['badgeRows', 'Badge the race header'],
].forEach(function (pair) {
const cb = el('input', { type: 'checkbox' });
cb.checked = !!settings[pair[0]];
cb.addEventListener('change', function () {
settings[pair[0]] = cb.checked;
saveSettings();
if (!settings.flagWaste) {
$$('li.tra-waste').forEach((li) => li.classList.remove('tra-waste', 'tra-waste-collapsed'));
}
decorateParts();
render();
});
const lab = el('label', { class: 'tra-row' }, [cb, el('span', { text: ' ' + pair[1] })]);
sec.appendChild(lab);
});
sec.appendChild(
el('div', { class: 'tra-p tra-muted' }, [
'The key is stored in this browser\'s localStorage for torn.com and is sent only to api.torn.com, ' +
'in an Authorization header. Any other userscript on torn.com can read localStorage, so use a ' +
'Limited key, not a Full one. v' + VERSION,
])
);
return sec;
}
// ────────────────────────────────────────────────────────────────── render ──
let rendering = false;
function render() {
if (rendering) return;
rendering = true;
try {
injectStyle();
const panel = ensurePanel();
const body = $('#tra-body', panel);
if (!body) return;
body.textContent = '';
const rp = rpInfo();
const chipRp = $('#tra-chip-rp', panel);
if (chipRp) {
chipRp.textContent = rp.value == null ? '- RP' : fmt(rp.value) + ' RP';
chipRp.className = 'tra-chip' + (rp.stale ? ' warn' : '');
chipRp.setAttribute(
'title',
rp.value == null
? 'Open Parts & Modifications once so the balance can be read.'
: rp.stale
? 'Last seen on the Parts & Modifications tab; Torn only prints it there.'
: 'Racing points available'
);
}
const chipProfile = $('#tra-chip-profile', panel);
if (chipProfile) {
chipProfile.textContent = profile().label;
chipProfile.setAttribute('title', profile().note);
}
const chipMode = $('#tra-chip-mode', panel);
if (chipMode) {
if (state.loading) {
chipMode.textContent = 'loading';
chipMode.className = 'tra-chip';
} else if (state.catalogue && state.enlisted) {
chipMode.textContent = 'API';
chipMode.className = 'tra-chip ok';
} else if (settings.key) {
chipMode.textContent = 'API error';
chipMode.className = 'tra-chip bad';
} else {
chipMode.textContent = 'no key';
chipMode.className = 'tra-chip warn';
}
}
// The race in progress goes above everything, including warnings: it is the
// only thing here that is about the lap being driven right now.
const nowBox = sectionNow();
if (nowBox) body.appendChild(nowBox);
if (state.error && state.error !== 'no-key') {
body.appendChild(
el('div', { class: 'tra-err' }, [
el('div', { text: 'Torn API: ' + state.error + '. Falling back to what the page shows.' }),
])
);
}
if (!settings.key) {
body.appendChild(
el('div', { class: 'tra-note' }, [
el('div', {
text:
'No API key. The waste guard, the track table and the current-race banner all work from ' +
'the page alone. The full build checklist needs a key: Torn marks fitted parts in the DOM, ' +
'but only for the one car whose parts page is open, and its part classes are too coarse to ' +
'group slots correctly.',
}),
])
);
}
/*
* Tabs, because the flat stack ran to several screens and buried everything
* below the first one. The race banner stays outside them - it is the one
* thing that should never need a click to see.
*/
const checklist = sectionChecklist();
const TABS = [
{ id: 'plan', label: 'Plan', build: sectionDashboard },
{ id: 'tracks', label: 'Tracks', build: sectionTrackAdvisor },
{ id: 'names', label: 'Names', build: sectionNames },
{ id: 'build', label: 'Build', build: () => checklist || sectionChecklistPlaceholder() },
{ id: 'settings', label: 'Settings', build: sectionSettings },
];
if (!TABS.some((t) => t.id === settings.tab)) settings.tab = 'plan';
const strip = el('div', { class: 'tra-tabs' });
TABS.forEach(function (t) {
strip.appendChild(
el('button', {
class: 'tra-tab' + (settings.tab === t.id ? ' on' : ''),
text: t.label + (t.id === 'build' && checklist ? ' •' : ''),
onclick: function () {
settings.tab = t.id;
saveSettings();
render();
},
})
);
});
body.appendChild(strip);
const active = TABS.find((t) => t.id === settings.tab);
const pane = active.build();
if (pane) body.appendChild(pane);
} catch (err) {
// A broken panel must never take the racing page down with it.
if (window.console) console.warn('[TRA] render failed', err);
} finally {
rendering = false;
}
}
function decorate() {
try {
decorateParts();
decorateCategories();
decorateCarList();
decorateRaceHeader();
captureReference();
} catch (err) {
if (window.console) console.warn('[TRA] decorate failed', err);
}
}
// ──────────────────────────────────────────────────────────────────── boot ──
/*
* Everything the panel's content depends on that can change without a navigation.
* Torn swaps tabs in place, so the panel has to notice - re-rendering only when
* the panel went missing left the racing-point chip reading "- RP" forever,
* because the balance is printed on one tab and the panel was built on another.
*/
function contextSignature() {
const now = currentRace();
return [
settings.tab,
settings.profile,
selectedCarName() || '',
now ? now.trackName : '',
now && now.car ? now.car.name : '',
rpInfo().value,
state.enlisted ? state.enlisted.length : -1,
state.catalogue ? state.catalogue.length : -1,
state.error || '',
].join('|');
}
let lastSignature = null;
let timer = null;
function schedule() {
clearTimeout(timer);
timer = setTimeout(function () {
if (!isRacingPage()) return;
decorate();
const missing = !$('#tra-panel') || !$('#tra-panel').isConnected;
const sig = contextSignature();
if (missing || sig !== lastSignature) {
lastSignature = sig;
render();
}
}, 250);
}
let observer = null;
function watchClicks() {
const root = racingRoot();
if (!root || root.getAttribute('data-tra-clicks')) return;
root.setAttribute('data-tra-clicks', '1');
root.addEventListener('click', rememberJoinTarget, true);
}
function watch() {
const root = racingRoot();
if (!root) return;
if (observer) observer.disconnect();
observer = new MutationObserver(function (muts) {
// Ignore our own writes so the observer cannot feed itself.
const ours = muts.every(function (m) {
const t = m.target;
return t && t.closest && t.closest('#tra-panel');
});
if (ours) return;
schedule();
});
observer.observe(root, { childList: true, subtree: true });
}
function start() {
if (!isRacingPage()) return;
injectStyle();
render();
decorate();
watch();
watchClicks();
loadData(false);
}
// Torn swaps racing tabs without navigating, and PDA restores pages from cache.
let lastHref = location.href;
setInterval(function () {
if (location.href === lastHref) return;
lastHref = location.href;
if (isRacingPage()) {
setTimeout(start, 400);
} else if (observer) {
observer.disconnect();
observer = null;
}
}, 700);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
// Small diagnostics hook: TRA.debug() in the console.
window.TRA = {
version: VERSION,
state: state,
settings: settings,
guide: guideList(),
plan: function (name) {
const c = matchEnlisted(name);
return c && state.catalogue ? planFor(c, state.catalogue) : null;
},
// Pure helpers, exposed so the rule set can be exercised outside a browser.
internals: {
trackByName: trackByName,
tracksFromNickname: tracksFromNickname,
assignmentFor: assignmentFor,
isWaste: isWaste,
isInert: isInert,
score: score,
targetBuild: targetBuild,
planFor: planFor,
render: render,
decorate: decorate,
enlistedFromDom: enlistedFromDom,
partsFromDom: partsFromDom,
selectedCarName: selectedCarName,
rpAvailable: rpAvailable,
rpInfo: rpInfo,
profiles: PROFILES,
guideList: guideList,
disagreement: disagreement,
trackNameOf: trackNameOf,
trackByName: trackByName,
currentRace: currentRace,
currentRaceCar: currentRaceCar,
currentRaceTrack: currentRaceTrack,
inRaceCarFromList: inRaceCarFromList,
enlistedRows: enlistedRows,
contextSignature: contextSignature,
selectedEnlistedCar: selectedEnlistedCar,
matchEnlisted: matchEnlisted,
nameSuggestions: nameSuggestions,
inferredTracks: inferredTracks,
rememberJoinTarget: rememberJoinTarget,
targetTrack: targetTrack,
pendingJoinTrack: pendingJoinTrack,
decorateCarList: decorateCarList,
decorateCategories: decorateCategories,
verdictForTrack: verdictForTrack,
},
debug: function () {
return {
version: VERSION,
racingPage: isRacingPage(),
hasKey: !!settings.key,
catalogue: state.catalogue ? state.catalogue.length : 0,
enlisted: state.enlisted ? state.enlisted.length : 0,
domCars: enlistedFromDom().length,
rp: rpAvailable(),
selectedCar: selectedCarName(),
currentTrack: currentRaceTrack(),
error: state.error,
};
},
};
})();