Torn Racing Advisor

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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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,
      };
    },
  };
})();