Race history, car suggestions, and upgrade helpers for Torn racing
// ==UserScript==
// @name Smart Racing
// @namespace smart.torn.tools
// @version 1.0.0
// @description Race history, car suggestions, and upgrade helpers for Torn racing
// @author Noobler
// @match https://www.torn.com/page.php?sid=racing*
// @match https://www.torn.com/loader.php?sid=racing*
// @icon https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// @run-at document-idle
// @license MIT
// ==/UserScript==
(() => {
// src/shared/theme.js
var tokens = {
accent: "#caa14a",
accentMuted: "rgba(202, 161, 74, 0.35)",
accentSubtle: "rgba(202, 161, 74, 0.12)",
accentText: "#f0e3c0",
panelBg: "linear-gradient(180deg, #23252b, #1b1d22)",
panelHeaderBg: "linear-gradient(180deg, #2c2f37, #23252b)",
panelBorder: "#34373f",
surface: "#15161a",
text: "#d7d9de",
textMuted: "#8a8d96",
textDim: "#71747d",
success: "#6fcf86",
successBg: "#16341f",
successBorder: "#2c6b3c",
warning: "#ffd966",
warningBg: "#332b13",
warningBorder: "#6b5a1f",
danger: "#ff6b6b",
dangerBg: "#341818",
dangerBorder: "#6b2c2c",
info: "#8dbdf0",
infoBg: "#15263d",
infoBorder: "#2c4f7b",
hover: "#d8b25c",
font: "'Trebuchet MS', Verdana, sans-serif",
radius: "8px",
shadow: "0 2px 10px rgba(0, 0, 0, 0.35)",
statDex: "#8a7ff0",
statDef: "#e07a4f",
statStr: "#3fae84",
statSpd: "#4a97e6"
};
var STYLE_ID = "hf-torn-theme";
function injectStyles(scopeClass = "hf-torn") {
if (document.getElementById(STYLE_ID)) {
return;
}
const css = `
.${scopeClass} {
--hf-accent: ${tokens.accent};
--hf-accent-muted: ${tokens.accentMuted};
--hf-accent-subtle: ${tokens.accentSubtle};
--hf-accent-text: ${tokens.accentText};
--hf-panel-bg: ${tokens.panelBg};
--hf-panel-header-bg: ${tokens.panelHeaderBg};
--hf-panel-border: ${tokens.panelBorder};
--hf-surface: ${tokens.surface};
--hf-text: ${tokens.text};
--hf-text-muted: ${tokens.textMuted};
--hf-text-dim: ${tokens.textDim};
--hf-success: ${tokens.success};
--hf-success-bg: ${tokens.successBg};
--hf-success-border: ${tokens.successBorder};
--hf-warning: ${tokens.warning};
--hf-warning-bg: ${tokens.warningBg};
--hf-warning-border: ${tokens.warningBorder};
--hf-danger: ${tokens.danger};
--hf-danger-bg: ${tokens.dangerBg};
--hf-danger-border: ${tokens.dangerBorder};
--hf-info: ${tokens.info};
--hf-info-bg: ${tokens.infoBg};
--hf-info-border: ${tokens.infoBorder};
--hf-hover: ${tokens.hover};
--hf-radius: ${tokens.radius};
--hf-shadow: ${tokens.shadow};
--hf-font: ${tokens.font};
--hf-stat-dex: ${tokens.statDex};
--hf-stat-def: ${tokens.statDef};
--hf-stat-str: ${tokens.statStr};
--hf-stat-spd: ${tokens.statSpd};
color: var(--hf-text);
font-family: var(--hf-font);
font-size: 12px;
line-height: 1.4;
box-sizing: border-box;
}
.${scopeClass} *, .${scopeClass} *::before, .${scopeClass} *::after {
box-sizing: border-box;
}
.${scopeClass} .hf-btn {
appearance: none;
border: 1px solid var(--hf-panel-border);
border-radius: 5px;
background: var(--hf-surface);
color: var(--hf-accent-text);
cursor: pointer;
padding: 6px 10px;
font: inherit;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.${scopeClass} .hf-btn:hover {
background: var(--hf-accent-subtle);
border-color: var(--hf-accent);
color: #fff;
}
.${scopeClass} .hf-btn:disabled {
opacity: 0.65;
cursor: wait;
pointer-events: none;
}
.${scopeClass} .hf-btn.is-active,
.${scopeClass} .hf-btn--primary {
background: var(--hf-accent);
border-color: var(--hf-accent);
color: #1b1d22;
font-weight: 700;
}
.${scopeClass} .hf-btn.is-active:hover,
.${scopeClass} .hf-btn--primary:hover {
background: var(--hf-hover);
border-color: var(--hf-hover);
}
.${scopeClass} .hf-muted {
color: var(--hf-text-muted);
}
.${scopeClass} .hf-dim {
color: var(--hf-text-dim);
}
.${scopeClass} .hf-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
background: var(--hf-accent);
color: #1b1d22;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.${scopeClass} .hf-input,
.${scopeClass} .hf-select {
width: 100%;
box-sizing: border-box;
padding: 6px 8px;
background: var(--hf-surface);
border: 1px solid var(--hf-panel-border);
color: var(--hf-accent-text);
border-radius: 5px;
font: inherit;
font-size: 12px;
}
.${scopeClass} .hf-label {
display: block;
font-size: 11px;
color: var(--hf-text-muted);
margin: 12px 0 4px;
}
.${scopeClass} .hf-table {
width: 100%;
border-collapse: collapse;
}
.${scopeClass} .hf-table th,
.${scopeClass} .hf-table td {
padding: 4px 6px;
text-align: left;
border-bottom: 1px solid var(--hf-panel-border);
color: var(--hf-text) !important;
background: transparent !important;
}
.${scopeClass} .hf-table th {
font-weight: bold;
color: var(--hf-accent-text) !important;
}
.${scopeClass} .hf-table tbody tr:hover td {
background: var(--hf-accent-subtle) !important;
}
.${scopeClass} .hf-table .hf-pos--1 {
color: #e8c547 !important;
font-weight: 700;
}
.${scopeClass} .hf-table .hf-pos--2 {
color: #c4c9d4 !important;
font-weight: 700;
}
.${scopeClass} .hf-table .hf-pos--3 {
color: #c98a5a !important;
font-weight: 700;
}
.${scopeClass} .hf-racing-log-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.${scopeClass} .hf-racing-log-hint {
font-size: 11px;
color: var(--hf-text-muted);
}
.${scopeClass} strong {
color: var(--hf-accent-text);
}
.${scopeClass} hr {
border: none;
border-top: 1px solid var(--hf-panel-border);
margin: 10px 0;
}
.${scopeClass} .hf-section-break {
box-sizing: border-box;
border-top: 1px solid var(--hf-panel-border);
margin-top: 10px;
padding-top: 12px;
}
.${scopeClass} .hf-guide-row {
margin: 0 0 4px;
}
.${scopeClass} .hf-guide-row:first-child {
margin-top: 0;
}
.${scopeClass} .hf-racing-car-row {
margin-bottom: 10px;
}
.${scopeClass} .hf-racing-live {
margin-bottom: 10px;
padding: 6px 8px;
border-radius: 5px;
background: var(--hf-accent-subtle);
border: 1px solid var(--hf-accent-muted);
color: var(--hf-accent-text);
font-size: 11px;
}
.${scopeClass} .hf-placement-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin: 4px 0 2px;
}
.${scopeClass} .hf-placement-chip {
display: inline-flex;
align-items: center;
gap: 4px;
width: auto;
max-width: none;
flex: 0 0 auto;
padding: 3px 7px 3px 5px;
border-radius: 5px;
border: 1px solid var(--hf-panel-border);
background: var(--hf-surface);
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.${scopeClass} .hf-placement-chip.is-zero {
opacity: 0.35;
}
.${scopeClass} .hf-placement-chip svg.hf-placement-icon {
width: 14px !important;
height: 14px !important;
min-width: 14px !important;
max-width: 14px !important;
min-height: 14px !important;
max-height: 14px !important;
flex: 0 0 14px !important;
display: block !important;
overflow: visible !important;
vertical-align: middle;
}
.${scopeClass} .hf-placement-chip--gold {
color: #e8c547;
border-color: rgba(232, 197, 71, 0.35);
background: rgba(232, 197, 71, 0.1);
}
.${scopeClass} .hf-placement-chip--silver {
color: #c4c9d4;
border-color: rgba(196, 201, 212, 0.35);
background: rgba(196, 201, 212, 0.08);
}
.${scopeClass} .hf-placement-chip--bronze {
color: #c98a5a;
border-color: rgba(201, 138, 90, 0.35);
background: rgba(201, 138, 90, 0.1);
}
.${scopeClass} .hf-placement-chip--out {
color: var(--hf-text-muted);
border-color: var(--hf-panel-border);
background: rgba(0, 0, 0, 0.15);
}
.${scopeClass}.hf-panel {
margin: 0 0 12px;
border-radius: var(--hf-radius);
background: var(--hf-panel-bg);
border: 1px solid var(--hf-panel-border);
box-shadow: var(--hf-shadow);
overflow: hidden;
}
.${scopeClass}.hf-panel--page {
width: 100%;
}
.${scopeClass}.hf-panel--fixed {
position: fixed;
z-index: 9999;
}
.${scopeClass} .hf-panel-header {
background: var(--hf-panel-header-bg);
border-bottom: 1px solid var(--hf-panel-border);
border-radius: var(--hf-radius) var(--hf-radius) 0 0;
}
.${scopeClass} .hf-panel-tabs {
border-bottom: 1px solid var(--hf-panel-border);
}
.${scopeClass} .hf-panel-body {
padding: 12px 13px;
}
.${scopeClass}.hf-panel--page .hf-panel-body {
max-height: min(70vh, 640px);
overflow-y: auto;
}
.${scopeClass}.hf-panel--page.hf-panel--grow .hf-panel-body {
max-height: none;
overflow: visible;
}
.${scopeClass}.hf-overlay,
.${scopeClass} .hf-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
}
.${scopeClass}.hf-modal,
.${scopeClass} .hf-modal {
background: #1f2127;
border: 1px solid var(--hf-panel-border);
border-radius: var(--hf-radius);
width: min(440px, 92vw);
max-height: 84vh;
overflow-y: auto;
padding: 18px;
color: var(--hf-text);
}
.${scopeClass}.hf-modal h2,
.${scopeClass} .hf-modal h2 {
margin: 0 0 14px;
font-size: 15px;
color: var(--hf-accent-text);
display: flex;
justify-content: space-between;
align-items: center;
}
.${scopeClass} .hf-mini {
font-size: 10px;
color: var(--hf-text-dim);
margin-top: 3px;
line-height: 1.4;
}
`;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = css;
document.head.appendChild(style);
}
var theme = {
tokens,
injectStyles
};
// src/shared/storage.js
var PREFIX = "smart.torn";
var LEGACY_PREFIX = "hf.torn";
function namespaced(key) {
return `${PREFIX}.${key}`;
}
function legacyNamespaced(key) {
return `${LEGACY_PREFIX}.${key}`;
}
function getLocal(key, fallback = null) {
try {
const raw = localStorage.getItem(namespaced(key));
if (raw !== null) {
return JSON.parse(raw);
}
const legacyRaw = localStorage.getItem(legacyNamespaced(key));
if (legacyRaw !== null) {
localStorage.setItem(namespaced(key), legacyRaw);
return JSON.parse(legacyRaw);
}
return fallback;
} catch {
return fallback;
}
}
function setLocal(key, value) {
localStorage.setItem(namespaced(key), JSON.stringify(value));
}
function removeLocal(key) {
localStorage.removeItem(namespaced(key));
localStorage.removeItem(legacyNamespaced(key));
}
var DB_NAME = "smart-torn";
var LEGACY_DB_NAME = "heartflower-torn";
var DB_VERSION = 1;
var dbPromise = null;
function openDbByName(name) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("races")) {
const store = db.createObjectStore("races", { keyPath: "id" });
store.createIndex("timestamp", "timestamp", { unique: false });
store.createIndex("track", "track", { unique: false });
store.createIndex("carKey", "carKey", { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("IndexedDB open failed"));
});
}
async function migrateLegacyDb(target) {
if (typeof indexedDB.databases !== "function") {
}
let legacy;
try {
legacy = await openDbByName(LEGACY_DB_NAME);
} catch {
return;
}
if (!legacy.objectStoreNames.contains("races") || !target.objectStoreNames.contains("races")) {
legacy.close();
return;
}
const rows = await new Promise((resolve, reject) => {
const tx = legacy.transaction("races", "readonly");
const request = tx.objectStore("races").getAll();
request.onsuccess = () => resolve(
/** @type {Record<string, unknown>[]} */
request.result ?? []
);
request.onerror = () => reject(request.error ?? new Error("Legacy IDB read failed"));
});
if (rows.length > 0) {
await new Promise((resolve, reject) => {
const tx = target.transaction("races", "readwrite");
const store = tx.objectStore("races");
for (const row of rows) {
store.put(row);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error ?? new Error("Legacy IDB migrate failed"));
});
}
legacy.close();
}
function openDb() {
if (dbPromise) {
return dbPromise;
}
dbPromise = (async () => {
const db = await openDbByName(DB_NAME);
const migratedKey = "idb.migratedFromHeartflower";
if (getLocal(migratedKey, false) !== true) {
try {
await migrateLegacyDb(db);
} catch {
}
setLocal(migratedKey, true);
}
return db;
})();
return dbPromise;
}
async function withStore(storeName, mode, run) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
const request = run(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
});
}
async function replaceAllRecords(storeName, records) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, "readwrite");
const store = tx.objectStore(storeName);
store.clear();
for (const record of records) {
store.put(record);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error ?? new Error("IndexedDB replaceAll failed"));
});
}
async function putRecord(storeName, record) {
await withStore(storeName, "readwrite", (store) => store.put(record));
}
async function deleteRecord(storeName, key) {
await withStore(storeName, "readwrite", (store) => store.delete(key));
}
async function getRecord(storeName, key) {
return withStore(storeName, "readonly", (store) => store.get(key));
}
async function getAllRecords(storeName, query, limit) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, "readonly");
const store = tx.objectStore(storeName);
const request = store.getAll(query, limit);
request.onsuccess = () => resolve(
/** @type {Record<string, unknown>[]} */
request.result ?? []
);
request.onerror = () => reject(request.error ?? new Error("IndexedDB getAll failed"));
});
}
async function getByIndex(storeName, indexName, key) {
return withStore(storeName, "readonly", (store) => store.index(indexName).getAll(key));
}
var storage = {
PREFIX,
LEGACY_PREFIX,
getLocal,
setLocal,
removeLocal,
putRecord,
deleteRecord,
getRecord,
getAllRecords,
getByIndex,
replaceAllRecords
};
// src/shared/dom.js
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitFor(selector, options = {}) {
const { timeout = 1e4, interval = 100, root = document } = options;
const start = Date.now();
while (Date.now() - start < timeout) {
const el = root.querySelector(selector);
if (el) {
return el;
}
await sleep(interval);
}
throw new Error(`waitFor timeout: ${selector}`);
}
async function waitForOptional(selector, options = {}) {
try {
return await waitFor(selector, options);
} catch {
return null;
}
}
function isHeaderBarNode(node) {
if (!(node instanceof HTMLElement)) {
return node.parentElement ? isHeaderBarNode(node.parentElement) : false;
}
return Boolean(node.closest(
'a[class*="bar-link"], [class*="bar-stats"], #header, header, [class*="topBar"], [class*="top-bar"], [class*="status-bar"]'
));
}
function isHeaderBarOnlyMutations(mutations) {
return mutations.length > 0 && mutations.every((mutation) => isHeaderBarNode(mutation.target));
}
function observeMutations(root, callback, options = {}) {
const { debounce = 50, ignoreHeaderBars = true } = options;
let timer = null;
const observer = new MutationObserver((mutations) => {
if (ignoreHeaderBars && isHeaderBarOnlyMutations(mutations)) {
return;
}
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(callback, debounce);
});
observer.observe(root, { childList: true, subtree: true });
callback();
return () => {
if (timer) {
clearTimeout(timer);
}
observer.disconnect();
};
}
function onAnchorClick(callback) {
const handler = (event) => {
const target = (
/** @type {Element} */
event.target
);
if (target.tagName === "A" || target.closest("a")) {
setTimeout(callback, 150);
}
};
document.body.addEventListener("click", handler);
return () => document.body.removeEventListener("click", handler);
}
function normalizeText(text) {
return text.replace(/\s+/g, " ").trim();
}
var dom = {
sleep,
waitFor,
waitForOptional,
observeMutations,
onAnchorClick,
normalizeText
};
// src/shared/mount.js
var MOUNT_PRESETS = {
racing: {
waitFor: ["#racingMainContainer"],
target: "#racingMainContainer",
position: "before"
},
gym: {
waitFor: ["#gymroot"],
target: "#gymroot",
position: "prepend"
},
gymBar: {
waitFor: ["#gymroot"],
target: "#gymroot",
position: "before"
},
stocksBar: {
waitFor: ["#stockmarketroot"],
target: "#stockmarketroot",
position: "before"
},
disposalBar: {
waitFor: ['[class*="disposal-root"]'],
target: '[class*="disposal-root"]',
position: "before"
},
contentTitle: {
waitFor: [".content-title", ".body > .content-title", "div.content-title"],
target: ".content-title",
position: "after"
}
};
function resolveMountConfig(mount2) {
if (!mount2) {
return MOUNT_PRESETS.contentTitle;
}
if (typeof mount2 === "string") {
if (mount2 === "page") {
return MOUNT_PRESETS.contentTitle;
}
return MOUNT_PRESETS[mount2] ?? MOUNT_PRESETS.contentTitle;
}
return mount2;
}
function findMountTarget(config) {
const pageReady = config.waitFor.some((selector) => document.querySelector(selector));
if (!pageReady) {
return null;
}
return document.querySelector(config.target);
}
function insertAt(target, element, position) {
switch (position) {
case "before":
target.insertAdjacentElement("beforebegin", element);
break;
case "after":
target.insertAdjacentElement("afterend", element);
break;
case "prepend":
target.prepend(element);
break;
case "append":
target.append(element);
break;
}
}
function isMountedAt(element, target, position) {
if (!element.isConnected) {
return false;
}
switch (position) {
case "before":
return element.nextElementSibling === target;
case "after":
return element.previousElementSibling === target;
case "prepend":
return target.firstElementChild === element;
case "append":
return target.lastElementChild === element;
default:
return false;
}
}
function mountElement(config, element) {
const target = findMountTarget(config);
if (!target) {
return false;
}
if (isMountedAt(element, target, config.position)) {
return true;
}
insertAt(target, element, config.position);
return true;
}
function getMountWatchRoot(config) {
const target = findMountTarget(config);
if (!target) {
return null;
}
if (config.position === "before" || config.position === "after") {
return target.parentElement;
}
return target;
}
function isOwnPanelMutation(mutations) {
return mutations.every((mutation) => {
if (mutation.target instanceof HTMLElement && mutation.target.closest(".hf-torn")) {
return true;
}
for (const node of mutation.addedNodes) {
if (node instanceof HTMLElement && (node.classList.contains("hf-torn") || node.closest(".hf-torn"))) {
continue;
}
if (node.nodeType === Node.TEXT_NODE && node.parentElement?.closest(".hf-torn")) {
continue;
}
return false;
}
for (const node of mutation.removedNodes) {
if (node instanceof HTMLElement && (node.classList.contains("hf-torn") || node.closest(".hf-torn"))) {
continue;
}
if (node.nodeType === Node.TEXT_NODE && node.parentElement?.closest(".hf-torn")) {
continue;
}
return false;
}
return true;
});
}
function watchMount(config, getElement) {
let stopped = false;
const start = Date.now();
const timeoutMs = 15e3;
let observer = null;
let debounceTimer = null;
let watchedRoot = null;
const disconnectObserver = () => {
observer?.disconnect();
observer = null;
watchedRoot = null;
};
const tryMount = () => {
if (stopped) {
return;
}
const element = getElement();
mountElement(config, element);
};
const scheduleTryMount = () => {
if (debounceTimer !== null) {
window.clearTimeout(debounceTimer);
}
debounceTimer = window.setTimeout(() => {
debounceTimer = null;
tryMount();
}, 80);
};
const attachObserver = () => {
const watchRoot = getMountWatchRoot(config);
if (!watchRoot || watchRoot === watchedRoot) {
return Boolean(watchRoot);
}
disconnectObserver();
watchedRoot = watchRoot;
observer = new MutationObserver((mutations) => {
if (isOwnPanelMutation(mutations)) {
return;
}
scheduleTryMount();
});
observer.observe(watchRoot, {
childList: true,
subtree: config.position === "prepend" || config.position === "append"
});
return true;
};
const poll = window.setInterval(() => {
tryMount();
attachObserver();
if (findMountTarget(config) || Date.now() - start > timeoutMs) {
window.clearInterval(poll);
}
}, 250);
tryMount();
attachObserver();
return () => {
stopped = true;
window.clearInterval(poll);
if (debounceTimer !== null) {
window.clearTimeout(debounceTimer);
}
disconnectObserver();
};
}
var mount = {
MOUNT_PRESETS,
resolveMountConfig,
findMountTarget,
insertAt,
isMountedAt,
mountElement,
watchMount
};
// src/shared/api.js
var API_BASE = "https://api.torn.com/v2";
var SETTINGS_KEY = "smart.torn.settings";
var LEGACY_SETTINGS_KEY = "hf.torn.settings";
function loadSettings() {
try {
let raw = GM_getValue(SETTINGS_KEY, null);
if (raw == null || raw === "" || raw === "{}") {
const legacy = GM_getValue(LEGACY_SETTINGS_KEY, null);
if (legacy != null && legacy !== "" && legacy !== "{}") {
GM_setValue(SETTINGS_KEY, typeof legacy === "string" ? legacy : JSON.stringify(legacy));
raw = legacy;
}
}
const parsed = typeof raw === "string" ? JSON.parse(raw || "{}") : raw ?? {};
return {
apiKey: String(parsed?.apiKey ?? "").trim()
};
} catch {
return { apiKey: "" };
}
}
function saveSettings(patch) {
const current = loadSettings();
const next = JSON.stringify({ ...current, ...patch });
GM_setValue(SETTINGS_KEY, next);
}
function parseTornError(json) {
if (!json || typeof json !== "object" || !("error" in json)) {
return null;
}
const err = (
/** @type {{ error?: unknown }} */
json.error
);
if (typeof err === "string") {
return new Error(err);
}
if (err && typeof err === "object") {
const detail = (
/** @type {{ error?: string, message?: string, code?: number }} */
err
);
return new Error(detail.error || detail.message || `Torn API error (${detail.code ?? "unknown"})`);
}
return new Error("Torn API error");
}
function tornGet(path, params, apiKey) {
const url = new URL(`${API_BASE}/${path.replace(/^\//, "")}`);
url.searchParams.set("key", apiKey);
for (const [key, value] of Object.entries(params ?? {})) {
if (value !== void 0 && value !== "") {
url.searchParams.set(key, String(value));
}
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url.toString(),
timeout: 2e4,
onload: (response) => {
try {
const json = JSON.parse(response.responseText || "{}");
const apiError = parseTornError(json);
if (apiError) {
reject(apiError);
return;
}
resolve(json);
} catch (error) {
reject(error instanceof Error ? error : new Error("Invalid API response"));
}
},
onerror: () => reject(new Error("Network error")),
ontimeout: () => reject(new Error("API request timed out"))
});
});
}
var api = {
loadSettings,
saveSettings,
parseTornError,
tornGet,
SETTINGS_KEY,
LEGACY_SETTINGS_KEY
};
// src/shared/ui.js
function panelCollapsedKey(panelId) {
return `panel.${panelId}.collapsed`;
}
function readPanelCollapsed(panelId, legacyKeys = []) {
const stored = getLocal(panelCollapsedKey(panelId), void 0);
if (stored === true || stored === "true") {
return true;
}
if (stored === false || stored === "false") {
return false;
}
for (const key of legacyKeys) {
const raw = localStorage.getItem(key);
if (raw === "true") {
setLocal(panelCollapsedKey(panelId), true);
return true;
}
if (raw === "false") {
setLocal(panelCollapsedKey(panelId), false);
return false;
}
}
return false;
}
var PANELS = /* @__PURE__ */ new Map();
function createPanel(options) {
const existing = PANELS.get(options.id);
if (existing?.element.isConnected) {
return existing;
}
if (existing) {
PANELS.delete(options.id);
}
injectStyles();
const mountOption = options.mount ?? "racing";
const isFixed = mountOption === "fixed";
const mountConfig = isFixed ? null : resolveMountConfig(mountOption);
const pageBody = options.pageBody ?? "cap";
const growPageBody = !isFixed && pageBody === "grow";
const root = document.createElement("section");
root.id = options.id;
root.className = isFixed ? "hf-torn hf-panel hf-panel--fixed" : `hf-torn hf-panel hf-panel--page${growPageBody ? " hf-panel--grow" : ""}`;
applyPanelLayout(root, isFixed, options.position ?? "bottom-right");
const header = document.createElement("div");
header.className = "hf-panel-header";
header.style.cssText = `
display: flex;
align-items: center;
gap: 10px;
padding: 9px 13px;
cursor: pointer;
user-select: none;
`;
const titleWrap = document.createElement("div");
titleWrap.style.display = "flex";
titleWrap.style.alignItems = "center";
titleWrap.style.gap = "10px";
titleWrap.style.flex = "1";
titleWrap.style.minWidth = "0";
if (options.badge) {
const badge = document.createElement("span");
badge.className = "hf-badge";
badge.textContent = options.badge;
titleWrap.appendChild(badge);
} else if (options.title) {
const title = document.createElement("div");
title.textContent = options.title;
title.style.fontWeight = "700";
title.style.fontSize = "13px";
title.style.color = "var(--hf-accent-text)";
titleWrap.appendChild(title);
}
const right = document.createElement("div");
right.className = "hf-panel-right";
right.style.cssText = `
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
flex-shrink: 0;
`;
const subtitle = document.createElement("div");
subtitle.className = "hf-muted hf-panel-subtitle";
subtitle.style.cssText = `
font-size: 11px;
max-width: min(280px, 40vw);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
`;
const gear = document.createElement("button");
gear.type = "button";
gear.className = "hf-panel-gear";
gear.title = "Settings";
gear.innerHTML = "⚙";
gear.style.cssText = `
font-size: 15px;
color: var(--hf-text-muted);
background: none;
border: none;
cursor: pointer;
padding: 0 4px;
line-height: 1;
flex-shrink: 0;
position: relative;
z-index: 2;
`;
const openSettings = (event) => {
event.preventDefault();
event.stopPropagation();
options.onSettings?.();
};
gear.addEventListener("mousedown", openSettings);
gear.addEventListener("click", openSettings);
const toggle = document.createElement("span");
toggle.className = "hf-panel-chev";
toggle.textContent = "\u25BC";
toggle.style.cssText = "font-size:12px;color:var(--hf-text-muted);transition:transform .2s;flex-shrink:0;";
right.appendChild(subtitle);
if (options.onSettings) {
right.appendChild(gear);
}
right.appendChild(toggle);
header.appendChild(titleWrap);
header.appendChild(right);
const tabBar = document.createElement("div");
tabBar.className = "hf-panel-tabs";
tabBar.style.cssText = `
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 8px;
`;
const body = document.createElement("div");
body.className = "hf-panel-body";
if (growPageBody) {
body.style.maxHeight = "none";
body.style.overflow = "visible";
}
root.appendChild(header);
if (options.tabs.length > 1) {
root.appendChild(tabBar);
}
root.appendChild(body);
let collapsed = readPanelCollapsed(options.id, options.collapsedLegacyKeys);
let activeTabId = options.tabs[0]?.id ?? "";
const tabButtons = /* @__PURE__ */ new Map();
const setCollapsed = (value) => {
collapsed = value;
const hideBody = collapsed;
if (options.tabs.length > 1) {
tabBar.style.display = collapsed ? "none" : "flex";
}
body.style.display = hideBody ? "none" : "block";
toggle.style.transform = collapsed ? "rotate(-90deg)" : "";
root.classList.toggle("is-collapsed", collapsed);
setLocal(panelCollapsedKey(options.id), value);
};
let renderGeneration = 0;
const renderActiveTab = async () => {
const generation = ++renderGeneration;
const tab = options.tabs.find((item) => item.id === activeTabId);
body.replaceChildren();
if (!tab) {
body.textContent = "No tab selected.";
return;
}
const content = await tab.render();
if (generation !== renderGeneration) {
return;
}
body.replaceChildren();
body.appendChild(content);
};
const setTab = (id) => {
activeTabId = id;
tabButtons.forEach((btn, tabId) => {
btn.classList.toggle("is-active", tabId === id);
});
renderActiveTab();
};
if (options.tabs.length > 1) {
for (const tab of options.tabs) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "hf-btn";
btn.textContent = tab.label;
btn.addEventListener("click", (event) => {
event.stopPropagation();
setTab(tab.id);
});
tabButtons.set(tab.id, btn);
tabBar.appendChild(btn);
}
}
header.addEventListener("click", (event) => {
if (
/** @type {Element} */
event.target.closest(".hf-panel-gear")
) {
return;
}
if (
/** @type {Element} */
event.target.closest("button")
) {
return;
}
setCollapsed(!collapsed);
});
gear.addEventListener("mouseenter", () => {
gear.style.color = "#fff";
});
gear.addEventListener("mouseleave", () => {
gear.style.color = "var(--hf-text-muted)";
});
setTab(activeTabId);
setCollapsed(collapsed);
let stopWatching = null;
const ensureMounted = () => {
if (isFixed) {
if (root.parentElement !== document.body) {
document.body.appendChild(root);
}
return;
}
if (mountConfig) {
mountElement(mountConfig, root);
}
};
if (isFixed) {
ensureMounted();
} else if (mountConfig) {
stopWatching = watchMount(mountConfig, () => root);
}
const panel2 = {
element: root,
setTab,
getActiveTabId: () => activeTabId,
refresh: () => {
if (options.getSubtitle) {
subtitle.textContent = options.getSubtitle() ?? "";
}
renderActiveTab();
},
setSubtitle: (text) => {
subtitle.textContent = text;
},
ensureMounted,
destroy: () => {
stopWatching?.();
root.remove();
PANELS.delete(options.id);
}
};
if (options.getSubtitle) {
subtitle.textContent = options.getSubtitle() ?? "";
}
PANELS.set(options.id, panel2);
return panel2;
}
function applyPanelLayout(el, isFixed, position) {
if (!isFixed) {
el.style.cssText = `
position: static;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
`;
return;
}
el.style.cssText = `
position: fixed;
z-index: 9999;
width: min(420px, calc(100vw - 24px));
max-height: min(70vh, 560px);
display: flex;
flex-direction: column;
overflow: hidden;
`;
applyFixedPosition(el, position);
}
function applyFixedPosition(el, position) {
el.style.top = "";
el.style.right = "";
el.style.bottom = "";
el.style.left = "";
switch (position) {
case "top-right":
el.style.top = "12px";
el.style.right = "12px";
break;
case "bottom-left":
el.style.bottom = "12px";
el.style.left = "12px";
break;
case "bottom-right":
default:
el.style.bottom = "12px";
el.style.right = "12px";
break;
}
}
function emptyState(message) {
const div = document.createElement("div");
div.className = "hf-muted";
div.textContent = message;
return div;
}
var ui = {
createPanel,
emptyState
};
// src/shared/percent.js
function parsePercent(raw, fallback = 0) {
if (raw === null || raw === void 0 || raw === "") {
return fallback;
}
if (typeof raw === "number") {
return Number.isFinite(raw) ? raw : fallback;
}
const cleaned = String(raw).trim().replace("%", "").replace(",", ".");
const value = parseFloat(cleaned);
return Number.isFinite(value) ? value : fallback;
}
function weightToPercent(weight) {
return Math.round(weight * 1e3) / 10;
}
function weightSharePercent(weight, weights) {
const sum = Object.values(weights).reduce((total, w) => total + (w > 0 ? w : 0), 0);
if (sum <= 0 || weight <= 0) {
return 0;
}
return weight / sum * 100;
}
function percentToWeight(raw, fallback = 0) {
const pct = parsePercent(raw, fallback * 100);
return pct / 100;
}
function weightsToShares(weights) {
const sum = Object.values(weights).reduce((total, w) => total + (w > 0 ? w : 0), 0);
if (sum <= 0) {
return {};
}
const out = {};
for (const [stat, w] of Object.entries(weights)) {
out[stat] = w > 0 ? w / sum * 100 : 0;
}
return out;
}
function normalizeWeightsToShares(weights) {
const sum = Object.values(weights).reduce((total, w) => total + (w > 0 ? w : 0), 0);
if (sum <= 0) {
return weights;
}
const out = {};
for (const [stat, w] of Object.entries(weights)) {
out[stat] = w > 0 ? w / sum * 100 : 0;
}
return out;
}
function parseShareInputs(inputsByStat) {
const raw = {};
let sum = 0;
for (const [stat, value] of Object.entries(inputsByStat)) {
const pct = Math.max(0, parsePercent(value, 0));
raw[stat] = pct;
sum += pct;
}
if (sum <= 0) {
return raw;
}
const out = {};
for (const [stat, pct] of Object.entries(raw)) {
out[stat] = pct > 0 ? pct / sum * 100 : 0;
}
return out;
}
function bonusesToMultiplier(bonuses) {
const f = parsePercent(bonuses?.faction, 0);
const e = parsePercent(bonuses?.education, 0);
const p = parsePercent(bonuses?.property, 0);
return (1 + f / 100) * (1 + e / 100) * (1 + p / 100);
}
function multiplierToTotalPercent(multiplier) {
if (!Number.isFinite(multiplier) || multiplier <= 1) {
return 0;
}
return Math.round((multiplier - 1) * 1e3) / 10;
}
function formatPercent(pct, digits = 1) {
const rounded = Math.round(pct * Math.pow(10, digits)) / Math.pow(10, digits);
const text = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(digits);
return `${text}%`;
}
function formatWeightSummary(weights, shortLabels) {
const parts = Object.entries(weights).filter(([, w]) => w > 0).sort((a, b) => b[1] - a[1]).map(([stat, w]) => `${shortLabels[stat] ?? stat} ${formatPercent(weightSharePercent(w, weights), 0)}`);
return parts.join(" \xB7 ");
}
var percent = {
parsePercent,
weightToPercent,
weightSharePercent,
percentToWeight,
weightsToShares,
normalizeWeightsToShares,
parseShareInputs,
bonusesToMultiplier,
multiplierToTotalPercent,
formatPercent,
formatWeightSummary
};
// src/shared/bars.js
var FILL_SELECTORS = [
'[class*="bar-fill"]',
'[class*="barFill"]',
'[class*="progress-fill"]',
'[class*="progressFill"]',
'[class*="progressBar"]',
'[class*="progress-bar"]'
].join(", ");
function applyBarFill(box, pct) {
const clamped = Math.max(0, Math.min(100, pct));
let updated = false;
box.querySelectorAll(FILL_SELECTORS).forEach((fill) => {
if (!(fill instanceof HTMLElement)) {
return;
}
fill.style.width = `${clamped}%`;
fill.style.transform = "";
updated = true;
});
const progressbar = box.matches('[role="progressbar"]') ? box : box.querySelector('[role="progressbar"]');
if (progressbar instanceof HTMLElement) {
progressbar.setAttribute("aria-valuenow", String(Math.round(clamped)));
const inner = progressbar.firstElementChild;
if (inner instanceof HTMLElement) {
inner.style.width = `${clamped}%`;
inner.style.transform = `scaleX(${clamped / 100})`;
inner.style.transformOrigin = "left center";
updated = true;
}
}
if (!updated) {
for (const child of box.children) {
if (!(child instanceof HTMLElement) || child.tagName === "P") {
continue;
}
if (child.querySelector(FILL_SELECTORS)) {
continue;
}
child.style.width = `${clamped}%`;
child.style.transform = `scaleX(${clamped / 100})`;
child.style.transformOrigin = "left center";
updated = true;
}
}
}
function applyBarVisual(box, bar) {
if (!box || !bar?.max) {
return;
}
const val = box.querySelector('p[class*="bar-value"], [class*="bar-value"]');
if (val) {
val.textContent = `${bar.current}/${bar.max}`;
}
const pct = bar.max > 0 ? bar.current / bar.max * 100 : 0;
applyBarFill(box, pct);
box.querySelectorAll("[aria-valuemax]").forEach((el) => {
el.setAttribute("aria-valuenow", String(bar.current));
el.setAttribute("aria-valuemax", String(bar.max));
});
}
function findBarBox(label) {
let labels = document.querySelectorAll('p[class*="bar-name"]');
if (!labels.length) {
labels = document.querySelectorAll(".wai");
}
for (const el of labels) {
if (el.textContent.trim().replace(":", "") === label) {
return el.closest('[class*="bar-stats"]') || el.parentElement;
}
}
const slug = label.toLowerCase();
const link = document.querySelector(
`a.bar-link[class*="${slug}"], a[class*="bar-link"][class*="${slug}"]`
);
return link;
}
function syncBarVisuals(bars2) {
if (bars2.energy) {
applyBarVisual(findBarBox("Energy"), bars2.energy);
}
if (bars2.happy) {
applyBarVisual(findBarBox("Happy"), bars2.happy);
}
if (bars2.nerve) {
applyBarVisual(findBarBox("Nerve"), bars2.nerve);
}
if (bars2.life) {
applyBarVisual(findBarBox("Life"), bars2.life);
}
}
var bars = {
applyBarVisual,
findBarBox,
syncBarVisuals
};
// src/shared/index.js
var HF = {
version: "1.0.0",
theme,
storage,
dom,
mount,
api,
ui,
percent,
bars
};
// src/racing/race-log.js
var LOG_FILTER_KEY = "racing.logFilter";
var LOG_FILTER_LEGACY_KEY = "racing.logFilterCurrent";
function buildCarKey(car) {
return `${car.model}|${car.note ?? ""}`.toLowerCase();
}
function createRaceEntry(entry) {
const car = entry.car ?? { model: "Unknown", note: "" };
const id = entry.id ?? `race-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return {
id,
timestamp: entry.timestamp ?? Date.now(),
track: entry.track ?? "Unknown",
type: entry.type ?? "unknown",
car,
carKey: buildCarKey(car),
result: entry.result ?? {},
source: entry.source ?? "api"
};
}
function getLogFilter() {
const stored = storage.getLocal(LOG_FILTER_KEY, null);
if (stored === "track" || stored === "track-car") {
return stored;
}
const legacy = storage.getLocal(LOG_FILTER_LEGACY_KEY, false);
if (legacy === true || legacy === "true") {
storage.setLocal(LOG_FILTER_KEY, "track-car");
storage.removeLocal(LOG_FILTER_LEGACY_KEY);
return "track-car";
}
return "all";
}
function setLogFilter(mode) {
storage.setLocal(LOG_FILTER_KEY, mode);
}
function matchesTrack(race, track) {
return race.track.toLowerCase() === track.toLowerCase();
}
function matchesCurrentContext(race, context) {
if (!context.track) {
return false;
}
if (!matchesTrack(race, context.track)) {
return false;
}
const car = context.car;
if (!car?.model || car.model === "Your car") {
return true;
}
if (race.car.model.toLowerCase() !== car.model.toLowerCase()) {
return false;
}
if (car.note) {
return buildCarKey(race.car) === buildCarKey(car);
}
return true;
}
function formatTrackCarFilterLabel(context) {
const car = context.car;
if (!car?.model || car.model === "Your car") {
return context.track ?? "This track & car";
}
const carLabel = car.note ? `${car.model} (${car.note})` : car.model;
return `${context.track} \xB7 ${carLabel}`;
}
function applyLogFilter(races, filter, context) {
if (filter === "all" || !context.track) {
return races;
}
if (filter === "track") {
return races.filter((race) => matchesTrack(race, context.track));
}
return races.filter((race) => matchesCurrentContext(race, context));
}
function emptyLogMessage(filter) {
if (filter === "track") {
return "No races logged for this track yet.";
}
if (filter === "track-car") {
return "No races logged for this track and car yet.";
}
return "No races logged yet.";
}
function filterHintText(filter) {
if (filter === "track") {
return "All cars on this track";
}
if (filter === "track-car") {
return "This track and car only";
}
return "";
}
function createCell(text, className) {
const td = document.createElement("td");
td.textContent = text;
td.style.color = "var(--hf-text)";
if (className) {
td.className = className;
}
return td;
}
function createHeaderCell(text) {
const th = document.createElement("th");
th.textContent = text;
th.style.color = "var(--hf-accent-text)";
return th;
}
function renderRaceLogTable(races, options = {}) {
const wrap = document.createElement("div");
wrap.className = "hf-racing-log";
const context = options.context ?? {};
const filter = options.filter ?? "all";
const hasContext = Boolean(context.track);
if (hasContext) {
const toolbar = document.createElement("div");
toolbar.className = "hf-racing-log-toolbar";
const trackBtn = document.createElement("button");
trackBtn.type = "button";
trackBtn.className = `hf-btn${filter === "track" ? " is-active" : ""}`;
trackBtn.textContent = filter === "track" ? context.track : "This track";
trackBtn.addEventListener("click", (event) => {
event.stopPropagation();
options.onFilterChange?.(filter === "track" ? "all" : "track");
});
toolbar.appendChild(trackBtn);
const trackCarBtn = document.createElement("button");
trackCarBtn.type = "button";
trackCarBtn.className = `hf-btn${filter === "track-car" ? " is-active" : ""}`;
trackCarBtn.textContent = filter === "track-car" ? formatTrackCarFilterLabel(context) : "This track & car";
trackCarBtn.addEventListener("click", (event) => {
event.stopPropagation();
options.onFilterChange?.(filter === "track-car" ? "all" : "track-car");
});
toolbar.appendChild(trackCarBtn);
const hintText = filterHintText(filter);
if (hintText) {
const hint = document.createElement("span");
hint.className = "hf-racing-log-hint";
hint.textContent = hintText;
toolbar.appendChild(hint);
}
wrap.appendChild(toolbar);
}
const visible = applyLogFilter(races, filter, context);
if (visible.length === 0) {
const empty = document.createElement("div");
empty.className = "hf-muted";
empty.textContent = emptyLogMessage(filter);
wrap.appendChild(empty);
return wrap;
}
const table = document.createElement("table");
table.className = "hf-table";
const thead = document.createElement("thead");
const headerRow = document.createElement("tr");
for (const label of ["When", "Track", "Car", "Pos"]) {
headerRow.appendChild(createHeaderCell(label));
}
thead.appendChild(headerRow);
table.appendChild(thead);
const tbody = document.createElement("tbody");
for (const race of visible.slice(0, 50)) {
const tr = document.createElement("tr");
const when = new Date(race.timestamp).toLocaleString();
const carLabel = race.car.note ? `${race.car.model} (${race.car.note})` : race.car.model;
const pos = race.result.position != null ? String(race.result.position) : "\u2014";
const posClass = race.result.position != null && race.result.position <= 3 ? `hf-pos--${race.result.position}` : "";
tr.appendChild(createCell(when));
tr.appendChild(createCell(race.track));
tr.appendChild(createCell(carLabel));
tr.appendChild(createCell(pos, posClass));
tbody.appendChild(tr);
}
table.appendChild(tbody);
wrap.appendChild(table);
return wrap;
}
var pendingRace = null;
function getPendingContext() {
return {
track: pendingRace?.track,
car: pendingRace?.car
};
}
function setPendingContext(context) {
pendingRace = {
...pendingRace,
track: context.track ?? pendingRace?.track,
type: context.type ?? pendingRace?.type
};
}
function setPendingCar(car) {
pendingRace = {
...pendingRace,
car
};
}
function watchRaceFlows(onTrack) {
document.body.addEventListener("click", (event) => {
const target = (
/** @type {Element} */
event.target
);
const joinBtn = target.closest(".join-wrap a.link.btn-action-tab, .join-wrap a.btn-action-form");
if (joinBtn) {
const row = joinBtn.closest(".active-row");
const trackEl = row?.querySelector(".track");
if (trackEl) {
const track = dom.normalizeText(trackEl.textContent.split("(")[0]);
onTrack(track, "custom");
}
}
const officialJoin = target.closest('a[href*="section=changeRacingCar"][href*="step=getInRace"]');
if (officialJoin) {
setTimeout(() => {
const enlisted = document.querySelector(".enlisted-btn-wrap");
if (enlisted) {
const track = dom.normalizeText(enlisted.textContent.split(" - Official race")[0]);
onTrack(track, "official");
}
}, 150);
}
});
const createBtn = () => document.querySelector('[name="createCustomRace"]');
const attachCreate = () => {
const btn = createBtn();
if (!btn || btn.dataset.hfBound) {
return;
}
btn.dataset.hfBound = "1";
btn.addEventListener("click", () => {
setTimeout(() => {
const selectMenu = document.querySelector(".ui-selectmenu");
const mobileSelect = document.querySelector("#select-racing-track");
let track = "";
if (selectMenu) {
track = dom.normalizeText(selectMenu.textContent);
} else if (mobileSelect instanceof HTMLSelectElement) {
track = dom.normalizeText(mobileSelect.options[mobileSelect.selectedIndex]?.text ?? "");
}
if (track) {
onTrack(track, "custom");
}
}, 150);
});
};
attachCreate();
return dom.onAnchorClick(attachCreate);
}
var raceLog = {
buildCarKey,
createRaceEntry,
getLogFilter,
setLogFilter,
getPendingContext,
renderRaceLogTable,
setPendingContext,
setPendingCar,
watchRaceFlows
};
// src/racing/templates.js
var STORAGE_KEY = "racing.templates";
var DEFAULT_GUIDE_TEMPLATES = [
{ name: "Mudpit: Colina Tanprice DS3", noteRecogniser: "DS3", source: "guide" },
{ name: "Two Islands: Edomondo NSX DL2", noteRecogniser: "DL2", source: "guide" },
{ name: "Parkland: Edomondo NSX DS3", noteRecogniser: "DS3", source: "guide" },
{ name: "Hammerhead: Edomondo NSX DS2", noteRecogniser: "DS2", source: "guide" },
{ name: "Stone Park: Echo R8 DS3", noteRecogniser: "DS3", source: "guide" },
{ name: "Withdrawal: Veloria LFA TL3", noteRecogniser: "TL3", source: "guide" },
{ name: "Speedway: Veloria LFA TL3", noteRecogniser: "TL3", source: "guide" },
{ name: "Uptown: Veloria LFA TL3", noteRecogniser: "TL3", source: "guide" },
{ name: "Underdog: Edomondo NSX TS2", noteRecogniser: "TS2", source: "guide" },
{ name: "Commerce: Edomondo NSX TS2", noteRecogniser: "TS2", source: "guide" },
{ name: "Sewage: Edomondo NSX TS2", noteRecogniser: "TS2", source: "guide" },
{ name: "Industrial: Edomondo NSX TS3", noteRecogniser: "TS3", source: "guide" },
{ name: "Vector: Edomondo NSX TS3", noteRecogniser: "TS3", source: "guide" },
{ name: "Meltdown: Edomondo NSX TS3", noteRecogniser: "TS3", source: "guide" },
{ name: "Docks: Volt GT TS3", noteRecogniser: "TS3", source: "guide" },
{ name: "Convict: Mercia SLR TL3", noteRecogniser: "TL3", source: "guide" }
];
function migrateLegacyTemplates() {
try {
const legacy = localStorage.getItem("carTemplates");
if (!legacy) {
return null;
}
const parsed = JSON.parse(legacy);
if (!Array.isArray(parsed)) {
return null;
}
return parsed.map((item) => ({
name: String(item.name),
noteRecogniser: item.noteRecogniser ? String(item.noteRecogniser) : "",
source: (
/** @type {'guide'} */
"guide"
)
}));
} catch {
return null;
}
}
function loadTemplates() {
const stored = storage.getLocal(STORAGE_KEY, null);
if (Array.isArray(stored) && stored.length > 0) {
return stored;
}
const migrated = migrateLegacyTemplates();
const initial = migrated ?? DEFAULT_GUIDE_TEMPLATES;
storage.setLocal(STORAGE_KEY, initial);
return initial;
}
function saveTemplates(templates2) {
storage.setLocal(STORAGE_KEY, templates2);
}
function getTemplatesForTrack(track) {
return loadTemplates().filter((template) => {
const trackName = template.name.split(":")[0]?.trim();
return trackName?.toLowerCase() === track.toLowerCase();
});
}
function getOwnedCarsFromDom() {
const owned = [];
document.querySelectorAll("li").forEach((li) => {
const modelEl = li.querySelector('[class^="model-car-name-"]');
if (!modelEl) {
return;
}
const model = modelEl.textContent.trim();
const fullText = li.textContent.replace(/\s+/g, " ").trim();
const note = fullText.includes(model) ? fullText.replace(model, "").trim() : "";
owned.push({ model, note, element: li });
});
return owned;
}
function resolveTemplatesForTrack(track) {
const templates2 = getTemplatesForTrack(track);
const ownedCars = getOwnedCarsFromDom();
const owned = [];
const reference = [];
for (const template of templates2) {
const carPart = template.name.split(":").slice(1).join(":").trim();
const modelGuess = carPart.split(" ").slice(0, -1).join(" ").trim();
const note = template.noteRecogniser ?? "";
const match = ownedCars.find((car) => {
const modelMatch = car.model.includes(modelGuess) || modelGuess.includes(car.model);
const noteMatch = !note || car.note.includes(note) || car.element.textContent.includes(note);
return modelMatch && noteMatch;
});
if (match) {
owned.push({ ...template, match });
} else {
reference.push(template);
}
}
return { owned, reference };
}
function formatGuideLabel(template) {
const carPart = template.name.split(":").slice(1).join(":").trim();
return carPart || template.name;
}
function renderTemplateSummary(track) {
const wrap = document.createElement("div");
if (!track) {
wrap.className = "hf-muted";
wrap.textContent = "Join or create a race to see track-specific suggestions.";
return wrap;
}
const { owned, reference } = resolveTemplatesForTrack(track);
if (owned.length === 0 && reference.length === 0) {
const empty = document.createElement("div");
empty.className = "hf-muted";
empty.textContent = "No templates saved for this track.";
wrap.appendChild(empty);
return wrap;
}
const appendRow = (item, kind) => {
const row = document.createElement("div");
row.className = "hf-guide-row";
const badge = document.createElement("span");
badge.className = "hf-badge";
badge.textContent = kind === "owned" ? "yours" : "guide";
row.appendChild(badge);
row.appendChild(document.createTextNode(` ${formatGuideLabel(item)}`));
wrap.appendChild(row);
};
for (const item of owned) {
appendRow(item, "owned");
}
for (const item of reference) {
appendRow(item, "guide");
}
return wrap;
}
var templates = {
loadTemplates,
saveTemplates,
getTemplatesForTrack,
formatGuideLabel,
getOwnedCarsFromDom,
resolveTemplatesForTrack,
renderTemplateSummary
};
// src/racing/suggest.js
function bucketPlacement(position) {
if (position === 1) {
return "first";
}
if (position === 2) {
return "second";
}
if (position === 3) {
return "third";
}
return "out";
}
var SVG_NS = "http://www.w3.org/2000/svg";
var ICON_SIZE = 14;
var TONE_COLORS = {
gold: "#e8c547",
silver: "#c4c9d4",
bronze: "#c98a5a",
out: "#8a8d96"
};
var PLACEMENT_CHIPS = [
{ key: "first", label: "1st", tone: "gold" },
{ key: "second", label: "2nd", tone: "silver" },
{ key: "third", label: "3rd", tone: "bronze" },
{ key: "out", label: "out", tone: "out" }
];
function createPlacementIcon(tone) {
const color = TONE_COLORS[tone] ?? TONE_COLORS.out;
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 16 16");
svg.setAttribute("width", String(ICON_SIZE));
svg.setAttribute("height", String(ICON_SIZE));
svg.setAttribute("focusable", "false");
svg.setAttribute("aria-hidden", "true");
svg.classList.add("hf-placement-icon");
if (tone === "out") {
const ring = document.createElementNS(SVG_NS, "circle");
ring.setAttribute("cx", "8");
ring.setAttribute("cy", "8");
ring.setAttribute("r", "5.5");
ring.setAttribute("fill", "none");
ring.setAttribute("stroke", color);
ring.setAttribute("stroke-width", "1.4");
svg.appendChild(ring);
const slash = document.createElementNS(SVG_NS, "path");
slash.setAttribute("d", "M5.2 5.2l5.6 5.6M10.8 5.2l-5.6 5.6");
slash.setAttribute("fill", "none");
slash.setAttribute("stroke", color);
slash.setAttribute("stroke-width", "1.4");
slash.setAttribute("stroke-linecap", "round");
svg.appendChild(slash);
return svg;
}
const cup = document.createElementNS(SVG_NS, "path");
cup.setAttribute(
"d",
"M4 2.5h8v1.8c0 1.35-.85 2.55-2.1 3.15L11.2 9.8H12v1.2H4V9.8h.8l1.3-2.35C4.85 6.85 4 5.65 4 4.3V2.5z"
);
cup.setAttribute("fill", color);
svg.appendChild(cup);
const base = document.createElementNS(SVG_NS, "path");
base.setAttribute("d", "M6.2 12.2h3.6v1.3H6.2v-1.3z");
base.setAttribute("fill", color);
svg.appendChild(base);
return svg;
}
function renderPlacementRecord(placements) {
const row = document.createElement("div");
row.className = "hf-placement-row";
for (const chip of PLACEMENT_CHIPS) {
const count = placements[chip.key];
const el = document.createElement("span");
el.className = `hf-placement-chip hf-placement-chip--${chip.tone}${count === 0 ? " is-zero" : ""}`;
el.title = `${count}\xD7 ${chip.label}`;
el.appendChild(createPlacementIcon(chip.tone));
const countEl = document.createElement("span");
countEl.className = "hf-placement-count";
countEl.textContent = String(count);
el.appendChild(countEl);
row.appendChild(el);
}
return row;
}
function aggregateTrackStats(races) {
const map = /* @__PURE__ */ new Map();
for (const race of races) {
const key = buildCarKey(race.car);
const current = map.get(key) ?? {
model: race.car.model,
note: race.car.note ?? "",
races: 0,
placements: { first: 0, second: 0, third: 0, out: 0 },
positionSum: 0,
countedPositions: 0,
lastUsed: 0
};
current.races += 1;
current.lastUsed = Math.max(current.lastUsed, race.timestamp);
current.placements[bucketPlacement(race.result.position)] += 1;
if (typeof race.result.position === "number") {
current.positionSum += race.result.position;
current.countedPositions += 1;
}
map.set(key, current);
}
return [...map.values()].map((item) => ({
carKey: buildCarKey({ model: item.model, note: item.note }),
model: item.model,
note: item.note,
races: item.races,
placements: item.placements,
avgPosition: item.countedPositions ? item.positionSum / item.countedPositions : 0,
lastUsed: item.lastUsed
})).sort((a, b) => {
if (b.placements.first !== a.placements.first) {
return b.placements.first - a.placements.first;
}
if (b.placements.second !== a.placements.second) {
return b.placements.second - a.placements.second;
}
if (b.placements.third !== a.placements.third) {
return b.placements.third - a.placements.third;
}
if (a.placements.out !== b.placements.out) {
return a.placements.out - b.placements.out;
}
if (a.avgPosition && b.avgPosition) {
return a.avgPosition - b.avgPosition;
}
return b.lastUsed - a.lastUsed;
});
}
function renderTrackSuggestions(races, limit = 3) {
const wrap = document.createElement("div");
const stats = aggregateTrackStats(races).slice(0, limit);
if (stats.length === 0) {
wrap.className = "hf-muted";
wrap.textContent = "No personal history for this track yet.";
return wrap;
}
const list = document.createElement("div");
for (const item of stats) {
const row = document.createElement("div");
row.className = "hf-racing-car-row";
const label = item.note ? `${item.model} (${item.note})` : item.model;
const avg = item.avgPosition ? item.avgPosition.toFixed(1) : "\u2014";
const last = new Date(item.lastUsed).toLocaleDateString();
const name = document.createElement("div");
const strong = document.createElement("strong");
strong.textContent = label;
name.appendChild(strong);
row.appendChild(name);
row.appendChild(renderPlacementRecord(item.placements));
const meta = document.createElement("div");
meta.className = "hf-dim";
meta.textContent = `${item.races} races \xB7 avg ${avg} \xB7 last ${last}`;
row.appendChild(meta);
list.appendChild(row);
}
wrap.appendChild(list);
return wrap;
}
var suggest = {
aggregateTrackStats,
renderPlacementRecord,
renderTrackSuggestions
};
// src/racing/tracks.js
var TRACK_ID_FALLBACK = {
6: "Uptown",
7: "Withdrawal",
8: "Underdog",
9: "Parkland",
10: "Docks",
11: "Commerce",
12: "Two Islands",
15: "Industrial",
16: "Vector",
17: "Mudpit",
18: "Hammerhead",
19: "Sewage",
20: "Meltdown",
21: "Speedway",
22: "Stone Park",
23: "Convict"
};
var TRACK_NAMES = [...new Set(Object.values(TRACK_ID_FALLBACK))].sort(
(a, b) => b.length - a.length
);
function parseTrackFromTitle(text) {
const normalized = text.replace(/\s+/g, " ").trim();
if (!normalized) {
return null;
}
for (const track of TRACK_NAMES) {
if (normalized.startsWith(track) || normalized.includes(`${track} -`)) {
return track;
}
}
const segment = normalized.split(" - ")[0]?.trim();
return segment || null;
}
// src/racing/page-context.js
function getRaceIdFromLeaderboard() {
const firstDriver = document.querySelector("#leaderBoard > li[data-id]");
if (!firstDriver?.dataset.id) {
return null;
}
const raceId = firstDriver.dataset.id.split("-")[0];
return /^\d+$/.test(raceId) ? Number(raceId) : null;
}
function detectCurrentCarFromDom() {
const modelEl = document.querySelector(
'#racingupdates .car-selected [class^="model-car-name-"], .car-selected-wrap [class^="model-car-name-"], [class^="model-car-name-"]'
);
if (modelEl) {
const model = modelEl.textContent.trim();
const wrap = modelEl.closest(".car-selected, .car-selected-wrap, li, .msg");
const fullText = wrap?.textContent.replace(/\s+/g, " ").trim() ?? "";
const note = fullText.replace(model, "").trim();
return { model, note };
}
const carName = document.querySelector(".msg.right-round b");
if (carName) {
return { model: carName.textContent.trim(), note: "" };
}
return { model: "Your car", note: "" };
}
function detectLiveStatsFromDom() {
const scopes = [
document.querySelector(".racing-stats"),
document.querySelector(".race-stats"),
document.querySelector(".drivers-list .statistics"),
document.querySelector("#racingupdates")
].filter(Boolean);
for (const scope of scopes) {
const text = scope.textContent ?? "";
const positionMatch = text.match(/Position[:\s]*(\d+)\s*\/\s*(\d+)/i);
if (positionMatch) {
return { position: Number(positionMatch[1]) };
}
}
return {};
}
function detectActiveRaceFromDom() {
const raceRoot = document.querySelector("#racingupdates");
if (!raceRoot) {
return null;
}
const trackInfo = document.querySelector("div.track-info");
const trackHeader = document.querySelector(
'.drivers-list .title-black, .drivers-list div[class^="title"]'
);
let trackName = trackInfo?.getAttribute("title")?.trim() ?? "";
const headerText = trackHeader?.textContent?.trim() ?? "";
if (!trackName && headerText) {
trackName = parseTrackFromTitle(headerText) ?? "";
}
if (!trackName) {
return null;
}
const track = parseTrackFromTitle(headerText) ?? parseTrackFromTitle(trackName) ?? trackName;
const raceId = getRaceIdFromLeaderboard() ?? 0;
return {
raceId,
track,
title: headerText || trackName,
type: "unknown",
car: detectCurrentCarFromDom(),
live: detectLiveStatsFromDom(),
source: "dom"
};
}
function detectJoinFlowTrack() {
const trackEl = document.querySelector(".active-row .track");
if (trackEl) {
const raw = trackEl.textContent.split("(")[0]?.trim() ?? "";
const parsed = parseTrackFromTitle(raw) ?? raw;
return parsed || null;
}
const enlisted = document.querySelector(".enlisted-btn-wrap");
if (enlisted) {
const raw = enlisted.textContent.split(" - Official race")[0]?.trim() ?? "";
const parsed = parseTrackFromTitle(raw) ?? raw;
return parsed || null;
}
return null;
}
function getRaceContextFingerprint() {
const live = detectActiveRaceFromDom();
if (live) {
return `live:${live.raceId}|${live.track}`;
}
const joinTrack = detectJoinFlowTrack();
if (joinTrack) {
return `join:${joinTrack}`;
}
return "";
}
function mutationsOnlyInsidePanel(mutations, panelId) {
const panelRoot = document.getElementById(panelId);
if (!panelRoot) {
return false;
}
return mutations.every((mutation) => {
const target = mutation.target;
return target instanceof Node && panelRoot.contains(target);
});
}
function watchRaceContext(onChange, options = {}) {
const panelId = options.panelId ?? "smart-racing-panel";
let lastFingerprint = getRaceContextFingerprint();
let timer = null;
const check = () => {
const next = getRaceContextFingerprint();
if (next === lastFingerprint) {
return;
}
lastFingerprint = next;
onChange(next);
};
const scheduleCheck = () => {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(check, 150);
};
const observer = new MutationObserver((mutations) => {
if (mutationsOnlyInsidePanel(mutations, panelId)) {
return;
}
scheduleCheck();
});
observer.observe(document.body, { childList: true, subtree: true });
scheduleCheck();
const onNavClick = () => {
scheduleCheck();
};
document.body.addEventListener("click", onNavClick);
return () => {
if (timer) {
clearTimeout(timer);
}
observer.disconnect();
document.body.removeEventListener("click", onNavClick);
};
}
// src/racing/api.js
var RACE_CACHE_MS = 60 * 1e3;
var REFERENCE_CACHE_MS = 12 * 60 * 60 * 1e3;
function toTimestampMs(value) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) {
return Date.now();
}
return n > 1e12 ? n : n * 1e3;
}
var referenceCache = null;
var lastEnrichedRaceId = null;
var refreshInFlight = null;
var raceSnapshot = {
status: "idle",
fetchedAt: 0,
races: [],
active: null,
error: ""
};
function isActiveRaceStatus(status) {
const normalized = String(status ?? "").toLowerCase().replace(/\s+/g, "_");
return normalized === "in_progress" || normalized === "open" || normalized === "running" || normalized === "started";
}
function userIsInRace(race, userId) {
if (!userId) {
return false;
}
const results = (
/** @type {Array<Record<string, unknown>>} */
race.results ?? []
);
if (results.some((row) => Number(row.driver_id) === userId)) {
return true;
}
const participants = race.participants;
if (participants && typeof participants === "object") {
const values = Object.values(participants);
if (values.some((value) => Number(value) === userId)) {
return true;
}
}
return false;
}
function getTornUserId() {
const el = document.getElementById("torn-user");
if (!el?.value) {
return null;
}
try {
const parsed = JSON.parse(el.value);
return Number(parsed?.id) || null;
} catch {
return null;
}
}
function referenceCacheFresh(force = false) {
if (force || !referenceCache) {
return false;
}
return Date.now() - referenceCache.fetchedAt < REFERENCE_CACHE_MS;
}
async function fetchTrackMap(apiKey, force = false) {
if (referenceCacheFresh(force)) {
return referenceCache.tracks;
}
const map = new Map(Object.entries(TRACK_ID_FALLBACK).map(([id, name]) => [Number(id), name]));
try {
const data = (
/** @type {{ tracks?: Array<{ id?: number, title?: string, name?: string }> }} */
await tornGet("racing/tracks", {}, apiKey)
);
for (const track of data.tracks ?? []) {
const id = Number(track.id);
const name = track.title ?? track.name;
if (id && name) {
map.set(id, name);
}
}
} catch {
}
return map;
}
async function fetchCarMap(apiKey, force = false) {
if (referenceCacheFresh(force)) {
return referenceCache.cars;
}
const map = /* @__PURE__ */ new Map();
try {
const data = (
/** @type {{ cars?: Array<Record<string, unknown>> }} */
await tornGet("racing/cars", {}, apiKey)
);
for (const car of data.cars ?? []) {
const id = Number(car.id);
if (!id) {
continue;
}
map.set(id, {
model: String(car.car_item_name ?? car.name ?? "Unknown car"),
note: String(car.name ?? "").trim()
});
}
} catch {
}
return map;
}
function resolveTrackName(trackId, title, trackMap) {
if (trackId && trackMap.has(trackId)) {
return trackMap.get(trackId);
}
if (title) {
return parseTrackFromTitle(title) ?? title.split(" - ")[0]?.trim() ?? "Unknown";
}
return "Unknown";
}
function mapRaceToEntry(race, userId, trackMap, carMap) {
const results = (
/** @type {Array<Record<string, unknown>>} */
race.results ?? []
);
const userResult = userId ? results.find((row) => Number(row.driver_id) === userId) : results[0];
if (!userResult && race.status === "finished") {
return null;
}
const carId = Number(userResult?.car_id);
const carFromGarage = carId ? carMap.get(carId) : null;
const track = resolveTrackName(Number(race.track_id), String(race.title ?? ""), trackMap);
const schedule = (
/** @type {{ start?: number, end?: number } | undefined} */
race.schedule
);
const tsMs = toTimestampMs(
userResult?.time_ended ?? schedule?.end ?? schedule?.start ?? Date.now() / 1e3
);
return createRaceEntry({
id: `api-race-${race.id}`,
timestamp: tsMs,
track,
type: race.is_official ? "official" : "custom",
car: {
model: String(userResult?.car_item_name ?? carFromGarage?.model ?? "Unknown"),
note: carFromGarage?.note ?? ""
},
result: {
position: userResult?.position != null ? Number(userResult.position) : void 0,
time: userResult?.race_time != null ? Number(userResult.race_time) : void 0
},
source: "api"
});
}
function mapActiveRace(race, userId, trackMap, carMap) {
if (!isActiveRaceStatus(race.status)) {
return null;
}
if (!userIsInRace(race, userId)) {
return null;
}
const track = resolveTrackName(Number(race.track_id), String(race.title ?? ""), trackMap);
const results = (
/** @type {Array<Record<string, unknown>>} */
race.results ?? []
);
const userResult = userId ? results.find((row) => Number(row.driver_id) === userId) : null;
const carId = Number(userResult?.car_id);
const carFromGarage = carId ? carMap.get(carId) : null;
return {
raceId: Number(race.id),
track,
title: String(race.title ?? track),
type: race.is_official ? "official" : "custom",
car: {
model: String(userResult?.car_item_name ?? carFromGarage?.model ?? "Your car"),
note: carFromGarage?.note ?? "",
carId: carId || void 0
},
live: {
position: userResult?.position != null ? Number(userResult.position) : void 0
},
source: "api"
};
}
async function fetchActiveRaceFromRacingList(apiKey, userId, trackMap, carMap) {
try {
const data = (
/** @type {{ races?: Array<Record<string, unknown>> }} */
await tornGet("racing/races", { limit: 100, sort: "desc" }, apiKey)
);
for (const race of data.races ?? []) {
if (!isActiveRaceStatus(race.status)) {
continue;
}
const mapped = mapActiveRace(race, userId, trackMap, carMap);
if (mapped) {
return mapped;
}
}
} catch {
}
return null;
}
async function enrichActiveRaceFromApi(apiKey, raceId, userId, trackMap, carMap, domFallback) {
if (!raceId) {
return domFallback;
}
try {
const data = (
/** @type {{ race?: Record<string, unknown> } & Record<string, unknown>} */
await tornGet(`racing/${raceId}/race`, {}, apiKey)
);
const race = (
/** @type {Record<string, unknown>} */
data.race ?? data
);
const mapped = mapActiveRace(race, userId, trackMap, carMap) ?? mapActiveRace({ ...race, status: "in_progress" }, userId, trackMap, carMap);
if (mapped) {
return {
...mapped,
car: domFallback.car.note || !mapped.car.model.includes("Your") ? domFallback.car : mapped.car,
live: {
...mapped.live,
position: domFallback.live.position ?? mapped.live.position
}
};
}
} catch {
}
return domFallback;
}
function findActiveInUserRaces(rawRaces, userId, trackMap, carMap) {
for (const race of rawRaces) {
if (!isActiveRaceStatus(race.status)) {
continue;
}
const mapped = mapActiveRace(race, userId, trackMap, carMap);
if (mapped) {
return mapped;
}
}
return null;
}
async function resolveActiveRace(apiKey, userId, trackMap, carMap, rawRaces) {
let active = findActiveInUserRaces(rawRaces, userId, trackMap, carMap);
if (!active) {
active = await fetchActiveRaceFromRacingList(apiKey, userId, trackMap, carMap);
}
const domActive = detectActiveRaceFromDom();
if (domActive) {
if (apiKey && domActive.raceId && domActive.raceId !== lastEnrichedRaceId) {
lastEnrichedRaceId = domActive.raceId;
return enrichActiveRaceFromApi(apiKey, domActive.raceId, userId, trackMap, carMap, domActive);
}
return domActive;
}
return active;
}
function mergeDomLive(current, domActive) {
return {
...current,
track: domActive.track || current.track,
title: domActive.title || current.title,
car: domActive.car.note || domActive.car.model && domActive.car.model !== "Your car" ? domActive.car : current.car,
live: {
...current.live,
position: domActive.live.position ?? current.live.position
},
source: current.source ?? domActive.source
};
}
async function refreshActiveRaceContext(apiKey, options = {}) {
const forceEnrich = Boolean(options.forceEnrich);
const domActive = detectActiveRaceFromDom();
if (domActive) {
const sameRace = raceSnapshot.active?.raceId === domActive.raceId && domActive.raceId;
if (apiKey && domActive.raceId && (forceEnrich || domActive.raceId !== lastEnrichedRaceId)) {
const userId = getTornUserId();
const trackMap = await fetchTrackMap(apiKey);
const carMap = await fetchCarMap(apiKey);
if (!referenceCacheFresh(false)) {
referenceCache = { tracks: trackMap, cars: carMap, fetchedAt: Date.now() };
}
lastEnrichedRaceId = domActive.raceId;
raceSnapshot.active = await enrichActiveRaceFromApi(
apiKey,
domActive.raceId,
userId,
trackMap,
carMap,
domActive
);
return raceSnapshot.active;
}
if (sameRace && raceSnapshot.active) {
raceSnapshot.active = mergeDomLive(raceSnapshot.active, domActive);
return raceSnapshot.active;
}
raceSnapshot.active = domActive;
return domActive;
}
lastEnrichedRaceId = null;
if (apiKey && referenceCache) {
const userId = getTornUserId();
raceSnapshot.active = await fetchActiveRaceFromRacingList(
apiKey,
userId,
referenceCache.tracks,
referenceCache.cars
);
return raceSnapshot.active;
}
raceSnapshot.active = null;
return null;
}
async function refreshRaceDataInner(apiKey, force = false) {
if (!apiKey) {
raceSnapshot = {
status: "missing-key",
fetchedAt: Date.now(),
races: [],
active: null,
error: ""
};
await refreshActiveRaceContext("");
return raceSnapshot;
}
if (!force && raceSnapshot.fetchedAt && Date.now() - raceSnapshot.fetchedAt < RACE_CACHE_MS) {
await refreshActiveRaceContext(apiKey);
return raceSnapshot;
}
try {
const userId = getTornUserId();
const [trackMap, carMap, raceData] = await Promise.all([
fetchTrackMap(apiKey, force),
fetchCarMap(apiKey, force),
tornGet("user/races", { limit: 100, sort: "desc" }, apiKey)
]);
if (!referenceCacheFresh(force)) {
referenceCache = { tracks: trackMap, cars: carMap, fetchedAt: Date.now() };
}
const rawRaces = (
/** @type {Array<Record<string, unknown>>} */
/** @type {{ races?: unknown[] }} */
raceData.races ?? []
);
const races = rawRaces.filter((race) => race.status === "finished").map((race) => mapRaceToEntry(race, userId, trackMap, carMap)).filter((entry) => entry != null);
lastEnrichedRaceId = null;
const active = await resolveActiveRace(apiKey, userId, trackMap, carMap, rawRaces);
raceSnapshot = {
status: "ok",
fetchedAt: Date.now(),
races,
active,
error: ""
};
} catch (error) {
raceSnapshot = {
status: "error",
fetchedAt: Date.now(),
races: raceSnapshot.races,
active: raceSnapshot.active,
error: error instanceof Error ? error.message : "API error"
};
}
return raceSnapshot;
}
async function refreshRaceData(apiKey, force = false) {
if (refreshInFlight) {
return refreshInFlight;
}
refreshInFlight = refreshRaceDataInner(apiKey, force).finally(() => {
refreshInFlight = null;
});
return refreshInFlight;
}
function getRaceSnapshot() {
return raceSnapshot;
}
function getRacesForTrackFromApi(track) {
const needle = track.toLowerCase();
return raceSnapshot.races.filter((race) => race.track.toLowerCase() === needle);
}
// src/racing/settings-ui.js
function renderSettingsPanel(options = {}) {
const wrap = document.createElement("div");
const settings = loadSettings();
const snapshot = getRaceSnapshot();
wrap.innerHTML = `
<div style="display:flex;flex-direction:column;gap:8px;">
<label style="display:flex;flex-direction:column;gap:4px;">
<strong>Torn API key</strong>
<input type="password" class="hf-api-key" name="torn-api-key" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore data-form-type="other" placeholder="Paste key from Preferences \u2192 API" value="" style="padding:4px 6px;border-radius:4px;border:1px solid var(--hf-accent-muted);background:rgba(0,0,0,0.25);color:var(--hf-text);">
</label>
<span class="hf-muted">
Create a key at <strong>Preferences \u2192 API</strong> with at least <strong>Minimal Access</strong>.
</span>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button type="button" class="hf-btn hf-save-key">Save key</button>
<button type="button" class="hf-btn hf-refresh-api">Refresh data</button>
</div>
<div class="hf-api-status hf-muted"></div>
</div>
`;
const input = (
/** @type {HTMLInputElement} */
wrap.querySelector(".hf-api-key")
);
input.value = settings.apiKey;
const status = (
/** @type {HTMLElement} */
wrap.querySelector(".hf-api-status")
);
status.textContent = formatApiStatus(snapshot);
wrap.querySelector(".hf-save-key")?.addEventListener("click", () => {
saveSettings({ apiKey: input.value.trim() });
status.textContent = "API key saved.";
options.onSave?.();
});
wrap.querySelector(".hf-refresh-api")?.addEventListener("click", async () => {
const btn = (
/** @type {HTMLButtonElement | null} */
wrap.querySelector(".hf-refresh-api")
);
if (!btn || btn.disabled) {
return;
}
const label = btn.textContent || "Refresh data";
btn.disabled = true;
btn.textContent = "Refreshing...";
try {
await options.onRefresh?.();
} finally {
if (btn.isConnected) {
btn.disabled = false;
btn.textContent = label;
}
}
});
return wrap;
}
function formatApiStatus(snapshot) {
if (snapshot.status === "missing-key") {
return "";
}
if (snapshot.status === "error") {
return `API error: ${snapshot.error}`;
}
if (snapshot.status === "ok") {
const active = snapshot.active ? ` Active race: ${/** @type {{ track: string, source?: string }} */
snapshot.active.track} (${snapshot.active.source ?? "api"}).` : "";
return `Loaded ${snapshot.races?.length ?? 0} races from the API.${active}`;
}
return "";
}
var settingsUi = {
renderSettingsPanel
};
// src/racing/main.js
var activeTrack = "";
var lastUiSig = "";
var panel = null;
async function init() {
HF.theme.injectStyles();
panel = ui.createPanel({
id: "smart-racing-panel",
badge: "Smart Racing",
mount: "racing",
collapsedLegacyKeys: [
"smart.torn.panel.smart-racing-panel.collapsed",
"hf.torn.panel.hf-racing-panel.collapsed",
"hf.torn.panel.smart-racing-panel.collapsed"
],
tabs: [
{
id: "quick",
label: "Quick pick",
render: async () => renderQuickPickTab()
},
{
id: "log",
label: "Race log",
render: async () => renderRaceLogTab()
},
{
id: "settings",
label: "Settings",
render: async () => settingsUi.renderSettingsPanel({
onSave: () => {
lastUiSig = "";
},
onRefresh: async () => {
await refreshFromApi(true, { forceUi: true });
}
})
}
]
});
raceLog.watchRaceFlows(async (track, type) => {
activeTrack = track;
raceLog.setPendingContext({ track, type });
updateContextualUi();
});
HF.dom.onAnchorClick(async () => {
panel?.ensureMounted();
await refreshFromApi(false);
updateContextualUi();
});
document.body.addEventListener("click", (event) => {
const target = (
/** @type {Element} */
event.target
);
const li = target.closest("li");
const modelEl = li?.querySelector('[class^="model-car-name-"]');
if (!modelEl) {
return;
}
raceLog.setPendingCar({
model: modelEl.textContent.trim(),
note: li.textContent.replace(modelEl.textContent, "").trim()
});
});
await refreshFromApi(false, { forceUi: true });
watchRaceContext(() => {
void onRaceContextChanged();
}, { panelId: "smart-racing-panel" });
window.setInterval(async () => {
await refreshFromApi(false);
}, 60 * 1e3);
}
async function onRaceContextChanged() {
const { apiKey } = loadSettings();
await refreshActiveRaceContext(apiKey);
applyActiveRaceContext();
updateContextualUi();
}
function buildUiSig() {
const snapshot = getRaceSnapshot();
const active = snapshot.active;
return [
snapshot.status,
snapshot.error,
snapshot.races.length,
active?.raceId ?? "",
active?.track ?? "",
active?.car?.model ?? "",
active?.car?.note ?? "",
activeTrack,
raceLog.getLogFilter()
].join("|");
}
function shouldPreserveUi() {
if (panel?.getActiveTabId() === "settings") {
return true;
}
const active = document.activeElement;
if (active && panel?.element.contains(active)) {
const tag = active.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || active.isContentEditable) {
return true;
}
}
const selection = window.getSelection();
if (selection && !selection.isCollapsed && panel?.element.contains(selection.anchorNode)) {
return true;
}
return false;
}
function patchLiveUi() {
if (!panel || panel.getActiveTabId() !== "quick") {
return false;
}
const liveEl = panel.element.querySelector(".hf-racing-live");
const snapshot = getRaceSnapshot();
if (!liveEl || !snapshot.active) {
return false;
}
const carLabel = snapshot.active.car.note ? `${snapshot.active.car.model} (${snapshot.active.car.note})` : snapshot.active.car.model;
const pos = snapshot.active.live.position ? `Position ${snapshot.active.live.position}` : "Race in progress";
liveEl.textContent = `${carLabel} \xB7 ${pos}`;
return true;
}
function refreshPanelIfNeeded(options = {}) {
const force = Boolean(options.force);
const sig = buildUiSig();
if (!force && sig === lastUiSig) {
patchLiveUi();
return;
}
if (!force && shouldPreserveUi()) {
patchLiveUi();
return;
}
lastUiSig = sig;
panel?.refresh();
}
async function refreshFromApi(force, options = {}) {
const { apiKey } = loadSettings();
await refreshRaceData(apiKey, force);
applyActiveRaceContext();
refreshPanelIfNeeded({ force: Boolean(options.forceUi) });
}
function applyActiveRaceContext() {
const snapshot = getRaceSnapshot();
if (snapshot.active?.track) {
activeTrack = snapshot.active.track;
return;
}
const joinTrack = detectJoinFlowTrack();
if (joinTrack) {
activeTrack = joinTrack;
return;
}
activeTrack = "";
}
function updateContextualUi() {
applyActiveRaceContext();
refreshPanelIfNeeded();
}
async function renderQuickPickTab() {
const wrap = document.createElement("div");
const snapshot = getRaceSnapshot();
const { apiKey } = loadSettings();
if (!apiKey) {
wrap.appendChild(ui.emptyState("Add your Torn API key in Settings."));
return wrap;
}
if (snapshot.status === "error") {
wrap.appendChild(ui.emptyState(`API error: ${snapshot.error}`));
return wrap;
}
if (snapshot.status === "missing-key") {
wrap.appendChild(ui.emptyState("Add your Torn API key in Settings."));
return wrap;
}
const track = snapshot.active?.track || activeTrack;
if (!track) {
const onRacePage = document.querySelector("#racingupdates");
const hint = onRacePage ? "On a race page but could not read the track name. Try Refresh data, or reload the page." : "No active race detected. Join a race, or open the live race view.";
wrap.appendChild(ui.emptyState(hint));
return wrap;
}
activeTrack = track;
const heading = document.createElement("div");
heading.style.cssText = "font-weight:bold;font-size:13px;margin-bottom:6px;";
heading.textContent = track;
wrap.appendChild(heading);
if (snapshot.active) {
const carLabel = snapshot.active.car.note ? `${snapshot.active.car.model} (${snapshot.active.car.note})` : snapshot.active.car.model;
const pos = snapshot.active.live.position ? `Position ${snapshot.active.live.position}` : "Race in progress";
const live = document.createElement("div");
live.className = "hf-racing-live";
live.textContent = `${carLabel} \xB7 ${pos}`;
wrap.appendChild(live);
}
const races = getRacesForTrackFromApi(track);
wrap.appendChild(suggest.renderTrackSuggestions(races));
const guideSection = document.createElement("div");
guideSection.className = "hf-section-break";
guideSection.appendChild(templates.renderTemplateSummary(track));
wrap.appendChild(guideSection);
return wrap;
}
async function renderRaceLogTab() {
const snapshot = getRaceSnapshot();
const { apiKey } = loadSettings();
if (!apiKey) {
return ui.emptyState("Add your Torn API key in Settings.");
}
if (snapshot.status === "error") {
return ui.emptyState(`API error: ${snapshot.error}`);
}
const pending = raceLog.getPendingContext();
const context = {
track: snapshot.active?.track || pending.track || activeTrack,
car: snapshot.active?.car || pending.car
};
return raceLog.renderRaceLogTable(snapshot.races, {
context,
filter: raceLog.getLogFilter(),
onFilterChange: (mode) => {
raceLog.setLogFilter(mode);
refreshPanelIfNeeded({ force: true });
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();