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.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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