Auto Refresh, Themes, Stats Overlay and more!
// ==UserScript==
// @name Nitro Type - Race Options
// @namespace https://nitrotype.info
// @version 2.1.0
// @description Auto Refresh, Themes, Stats Overlay and more!
// @author Captain.Loveridge
// @match *://*.nitrotype.com/race
// @match *://*.nitrotype.com/race/*
// @match *://*.nitrotype.com/settings/mods*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @require https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js#sha512-qTXRIMyZIFb8iQcfjXWCO8+M5Tbc38Qi5WzdPOYZHIlZpzBHG3L3by84BBBOiRGiEb7KKtAOAs5qYdUiZiQNNQ==
// @require https://cdnjs.cloudflare.com/ajax/libs/dexie/3.2.1/dexie.min.js#sha512-ybuxSW2YL5rQG/JjACOUKLiosgV80VUfJWs4dOpmSWZEGwdfdsy2ldvDSQ806dDXGmg9j/csNycIbqsrcqW6tQ==
// @require https://cdnjs.cloudflare.com/ajax/libs/interact.js/1.10.27/interact.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/pixi.js/6.5.4/browser/pixi.min.js
// @license MIT
// ==/UserScript==
/* global Dexie moment NTGLOBALS PIXI interact */
const enableStats = GM_getValue('enableStats', true);
//// GENERAL VISUAL OPTIONS ////
const hideTrack = GM_getValue('hideTrack', false);
const hideNotifications = GM_getValue('hideNotifications', true);
const AUTO_CLOSE_REWARD_POPUP = GM_getValue('AUTO_CLOSE_REWARD_POPUP', true);
const ENABLE_MINI_MAP = GM_getValue('ENABLE_MINI_MAP', false);
const MINI_MAP_POSITION = GM_getValue('MINI_MAP_POSITION', 'bottom');
const ENABLE_ALT_WPM_COUNTER = GM_getValue('ENABLE_ALT_WPM_COUNTER', true);
const ENABLE_ALT_WPM_COUNTDOWN = GM_getValue('ENABLE_ALT_WPM_COUNTDOWN', true);
const HIDE_PREPARE_FOR_RACE_ICON = GM_getValue('HIDE_PREPARE_FOR_RACE_ICON', false);
const ENABLE_PERFECT_NITROS = GM_getValue('ENABLE_PERFECT_NITROS', true);
const ENABLE_PRECISE_ACCURACY = GM_getValue('ENABLE_PRECISE_ACCURACY', false);
const PERFECT_NITRO_HIGHLIGHT_COLOR = GM_getValue('PERFECT_NITRO_HIGHLIGHT_COLOR', '#FFFFFF');
const PERFECT_NITRO_SCAN_INTERVAL_MS = GM_getValue('PERFECT_NITRO_SCAN_INTERVAL_MS', 100);
const PERFECT_NITRO_ITALIC = GM_getValue('PERFECT_NITRO_ITALIC', false);
const PERFECT_NITRO_RAINBOW = GM_getValue('PERFECT_NITRO_RAINBOW', false);
const PERFECT_NITRO_HIGHLIGHT_OPACITY = GM_getValue('PERFECT_NITRO_HIGHLIGHT_OPACITY', 0.5);
const PERFECT_NITRO_ENABLE_HIGHLIGHT = GM_getValue('PERFECT_NITRO_ENABLE_HIGHLIGHT', true);
const PERFECT_NITRO_OVERRIDE_TEXT_COLOR = GM_getValue('PERFECT_NITRO_OVERRIDE_TEXT_COLOR', false);
const PERFECT_NITRO_TEXT_COLOR = GM_getValue('PERFECT_NITRO_TEXT_COLOR', '#FFFFFF');
const HIDE_CHAT_AND_STICKERS = GM_getValue('HIDE_CHAT_AND_STICKERS', false);
const HIDE_FINISH_FLAG = GM_getValue('HIDE_FINISH_FLAG', false);
const ENABLE_RACER_BADGES_IN_RACE = GM_getValue('ENABLE_RACER_BADGES_IN_RACE', true);
const THEME_ENABLE_DARK_MODE = GM_getValue('THEME_ENABLE_DARK_MODE', false);
const THEME_DARK_MODE_SYNC_SYSTEM = GM_getValue('THEME_DARK_MODE_SYNC_SYSTEM', false);
const THEME_COLOR_FOREGROUND = GM_getValue('THEME_COLOR_FOREGROUND', '#FFFFFF');
const THEME_COLOR_FOREGROUND_ACTIVE = GM_getValue('THEME_COLOR_FOREGROUND_ACTIVE', '#000000');
const THEME_COLOR_FOREGROUND_TYPED = GM_getValue('THEME_COLOR_FOREGROUND_TYPED', '#5B5B5B');
const THEME_COLOR_BACKGROUND = GM_getValue('THEME_COLOR_BACKGROUND', '#000000');
const THEME_COLOR_BACKGROUND_ACTIVE = GM_getValue('THEME_COLOR_BACKGROUND_ACTIVE', '#FFFFFF');
const THEME_COLOR_BACKGROUND_INCORRECT = GM_getValue('THEME_COLOR_BACKGROUND_INCORRECT', '#FF0000');
const THEME_OVERRIDE_FOREGROUND = GM_getValue('THEME_OVERRIDE_FOREGROUND', false);
const THEME_OVERRIDE_FOREGROUND_ACTIVE = GM_getValue('THEME_OVERRIDE_FOREGROUND_ACTIVE', false);
const THEME_OVERRIDE_FOREGROUND_TYPED = GM_getValue('THEME_OVERRIDE_FOREGROUND_TYPED', false);
const THEME_HIDE_TYPED_TEXT = GM_getValue('THEME_HIDE_TYPED_TEXT', false);
const THEME_OVERRIDE_BACKGROUND = GM_getValue('THEME_OVERRIDE_BACKGROUND', false);
const THEME_OVERRIDE_BACKGROUND_ACTIVE = GM_getValue('THEME_OVERRIDE_BACKGROUND_ACTIVE', false);
const THEME_OVERRIDE_BACKGROUND_INCORRECT = GM_getValue('THEME_OVERRIDE_BACKGROUND_INCORRECT', false);
const THEME_FONT_FAMILY_DEFAULT_CSS = '"Roboto Mono", "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace';
const THEME_FONT_WEIGHT_DEFAULT = 400;
const THEME_FONT_BOLD = GM_getValue('THEME_FONT_BOLD', false);
const THEME_FONT_WEIGHT = GM_getValue('THEME_FONT_WEIGHT', 700);
const THEME_FONT_ITALIC = GM_getValue('THEME_FONT_ITALIC', false);
const THEME_DARK_MODE_FOREGROUND = "#E7EEF8";
const THEME_DARK_MODE_FOREGROUND_ACTIVE = "#101623";
const THEME_DARK_MODE_FOREGROUND_TYPED = "#8FA3BA";
const THEME_DARK_MODE_BACKGROUND = "#0A121E";
const THEME_DARK_MODE_BACKGROUND_ACTIVE = "#1C99F4";
const THEME_DARK_MODE_BACKGROUND_INCORRECT = "#D62F3A";
const THEME_FONT_FAMILY_PRESET = GM_getValue('THEME_FONT_FAMILY_PRESET', "__default__");
const THEME_FONT_SIZE_PRESET = GM_getValue('THEME_FONT_SIZE_PRESET', "__default__");
const THEME_SINGLE_LINE_FONT_SIZE_PRESET = GM_getValue('THEME_SINGLE_LINE_FONT_SIZE_PRESET', "__default__");
const THEME_ENABLE_RAINBOW_TYPED_TEXT = GM_getValue('THEME_ENABLE_RAINBOW_TYPED_TEXT', false);
const THEME_RAINBOW_TYPED_TEXT_SPEED_SECONDS = GM_getValue('THEME_RAINBOW_TYPED_TEXT_SPEED_SECONDS', 10);
////// AUTO RELOAD OPTIONS /////
const greedyStatsReload = GM_getValue('greedyStatsReload', true);
const MIN_PERFECT_NITRO_SCAN_INTERVAL_MS = 100;
const COUNTDOWN_TICK_INTERVAL_MS = 50;
const greedyStatsReloadInt = GM_getValue('greedyStatsReloadInt', 50);
const reloadOnStats = GM_getValue('reloadOnStats', true);
//// BETTER STATS OPTIONS /////
const RACES_OUTSIDE_CURRENT_TEAM = GM_getValue('RACES_OUTSIDE_CURRENT_TEAM', 0);
const TEAM_RACES_BUGGED = GM_getValue('TEAM_RACES_BUGGED', 0);
const config = {
///// ALT WPM COUNTER CONFIG //////
targetWPM: GM_getValue('targetWPM', 79.5),
indicateWPMWithin: GM_getValue('indicateWPMWithin', 2),
timerRefreshIntervalMS: GM_getValue('timerRefreshIntervalMS', 25),
dif: GM_getValue('dif', 0.8),
raceLatencyMS: 140,
///// CUSTOM MINIMAP CONFIG ////// (hardcoded)
colors: {
me: 0xD62F3A,
opponentPlayer: 0x167AC3,
opponentBot: 0xbbbbbb,
opponentWampus: 0xFFA500,
nitro: 0xef9e18,
raceLane: 0x555555,
startLine: 0x929292,
finishLine: 0x929292
},
trackLocally: true,
moveDestination: {
enabled: true,
alpha: 0.3,
}
};
const THEME_FONT_FAMILY_PRESETS = [
{ value: "__default__", label: "Default", css: THEME_FONT_FAMILY_DEFAULT_CSS },
{ value: "roboto_mono", label: "Roboto Mono", css: '"Roboto Mono", "Courier New", Courier, monospace' },
{ value: "montserrat", label: "Montserrat", css: '"Montserrat", "Helvetica Neue", Helvetica, Arial, sans-serif' },
{ value: "poppins", label: "Poppins", css: '"Poppins", "Segoe UI", Tahoma, sans-serif' },
{ value: "nunito", label: "Nunito", css: '"Nunito", "Trebuchet MS", sans-serif' },
{ value: "oswald", label: "Oswald", css: '"Oswald", "Arial Narrow", sans-serif' },
{ value: "space_mono", label: "Space Mono", css: '"Space Mono", "Courier New", monospace' },
];
const THEME_FONT_SIZE_PRESETS = [
{ value: "__default__", label: "Default", px: 18 },
{ value: "8", label: "8px", px: 8 },
{ value: "10", label: "10px", px: 10 },
{ value: "12", label: "12px", px: 12 },
{ value: "14", label: "14px", px: 14 },
{ value: "16", label: "16px", px: 16 },
{ value: "18", label: "18px", px: 18 },
{ value: "20", label: "20px", px: 20 },
{ value: "24", label: "24px", px: 24 },
{ value: "28", label: "28px", px: 28 },
{ value: "32", label: "32px", px: 32 },
{ value: "36", label: "36px", px: 36 },
];
const RACER_BADGES_RACE_TOGGLE_KEY = "ntcfg_racer_badges_in_race_enabled";
const syncRacerBadgesRaceToggle = (enabled) => {
try {
localStorage.setItem(RACER_BADGES_RACE_TOGGLE_KEY, enabled ? "1" : "0");
} catch {
// ignore storage errors
}
};
syncRacerBadgesRaceToggle(ENABLE_RACER_BADGES_IN_RACE);
const normalizeHexColorValue = (value, fallback = "#FFFFFF") => {
const normalizedFallback = /^#[0-9A-Fa-f]{6}$/.test(String(fallback || "").trim())
? String(fallback).toUpperCase()
: "#FFFFFF";
const raw = String(value || "").trim();
if (/^#[0-9A-Fa-f]{6}$/.test(raw)) {
return raw.toUpperCase();
}
if (/^#[0-9A-Fa-f]{3}$/.test(raw)) {
return `#${raw[1]}${raw[1]}${raw[2]}${raw[2]}${raw[3]}${raw[3]}`.toUpperCase();
}
return normalizedFallback;
};
const hexToRgba = (hex, alpha = 1) => {
const h = normalizeHexColorValue(hex, "#FFFFFF");
const r = parseInt(h.slice(1, 3), 16);
const g = parseInt(h.slice(3, 5), 16);
const b = parseInt(h.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${Math.min(1, Math.max(0, alpha))})`;
};
const hexToRgbChannels = (hex) => {
const h = normalizeHexColorValue(hex, "#FFFFFF");
return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
};
const blendedLuminance = (fgHex, bgHex, alpha) => {
const [fr, fg, fb] = hexToRgbChannels(fgHex);
const [br, bg, bb] = hexToRgbChannels(bgHex);
const a = Math.min(1, Math.max(0, alpha));
const r = (fr * a + br * (1 - a)) / 255;
const g = (fg * a + bg * (1 - a)) / 255;
const b = (fb * a + bb * (1 - a)) / 255;
const linear = (ch) => (ch <= 0.03928 ? ch / 12.92 : Math.pow((ch + 0.055) / 1.055, 2.4));
return (0.2126 * linear(r)) + (0.7152 * linear(g)) + (0.0722 * linear(b));
};
const normalizeHighlightOpacity = (value, fallback = 0.5) => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return Number(fallback) || 0.5;
return Math.min(1, Math.max(0.05, Math.round(parsed * 100) / 100));
};
const normalizeRainbowSpeedSeconds = (value, fallback = 10) => {
const normalizedFallback = Number.isFinite(Number(fallback)) ? Number(fallback) : 10;
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return normalizedFallback;
}
return Math.min(60, Math.max(1, parsed));
};
const normalizeThemeFontSizePx = (value, fallback = 18) => {
const normalizedFallback = Number.isFinite(Number(fallback)) ? Number(fallback) : 18;
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return normalizedFallback;
}
return Math.min(72, Math.max(8, Math.round(parsed)));
};
const normalizeThemeFontFamilyValue = (value, fallback = THEME_FONT_FAMILY_DEFAULT_CSS) => {
const raw = String(value ?? "").trim();
if (!raw) {
return String(fallback);
}
const cleaned = raw.replace(/[{}<>;\r\n]/g, "").trim().slice(0, 180);
return cleaned || String(fallback);
};
const normalizeThemePresetValue = (value, presets, fallback = "__default__") => {
const raw = String(value ?? "").trim();
if (Array.isArray(presets) && presets.some((preset) => preset?.value === raw)) {
return raw;
}
return fallback;
};
const normalizeMiniMapPositionValue = (value, fallback = "bottom") => {
const normalizedFallback = String(fallback || "").trim().toLowerCase() === "top" ? "top" : "bottom";
const raw = String(value || "").trim().toLowerCase();
if (raw === "top" || raw === "bottom") {
return raw;
}
return normalizedFallback;
};
const NTCFG_MOD_MENU_PATH = "/settings/mods";
const NTCFG_RACE_OPTIONS_MANIFEST_ID = "race-options";
const NTCFG_RACE_OPTIONS_MANIFEST_KEY = `ntcfg:manifest:${NTCFG_RACE_OPTIONS_MANIFEST_ID}`;
const NTCFG_RACE_OPTIONS_VALUE_PREFIX = `ntcfg:${NTCFG_RACE_OPTIONS_MANIFEST_ID}:`;
const NTCFG_RACE_OPTIONS_BRIDGE_VERSION = "1.0.0-bridge.3";
const RACE_OPTIONS_STORAGE_VERSION = 1;
const RACE_OPTIONS_STORAGE_VERSION_KEY = `${NTCFG_RACE_OPTIONS_VALUE_PREFIX}__storage_version`;
const NTCFG_RACE_OPTIONS_SETTING_SYNC_EVENT = "NTCFG_RACE_OPTIONS_SETTING_SYNC";
const isNtcfgRaceOptionsModMenuRoute = () => location.pathname === NTCFG_MOD_MENU_PATH || location.pathname.startsWith(`${NTCFG_MOD_MENU_PATH}/`);
const isNtcfgRaceOptionsRaceRoute = () => /^\/race(?:\/|$)/.test(location.pathname);
const mapPresetOptionsForManifest = (presets) => presets.map((preset) => ({
value: preset.value,
label: preset.label
}));
const RACE_OPTIONS_SHARED_SETTINGS = {
hideTrack: {
type: "boolean",
label: "Hide Track",
default: false,
group: "General",
description: "Hides the visual racetrack area to reduce on-screen clutter."
},
hideNotifications: {
type: "boolean",
label: "Hide Notifications",
default: true,
group: "General",
description: "Blocks growl popups during races."
},
AUTO_CLOSE_REWARD_POPUP: {
type: "boolean",
label: "Auto close reward popup",
default: true,
group: "General",
description: "Automatically dismisses reward and takeover popups if they appear."
},
HIDE_PREPARE_FOR_RACE_ICON: {
type: "boolean",
label: 'Hide "Prepare for your race" floating icon',
default: false,
group: "General",
description: "Removes the floating prep icon overlay."
},
ENABLE_MINI_MAP: {
type: "boolean",
label: "Enable Mini Map",
default: false,
group: "General",
description: "Shows a compact lane map on the race page."
},
MINI_MAP_POSITION: {
type: "select",
label: "Mini Map Position",
default: "bottom",
group: "General",
description: "Choose where the mini map appears relative to the race track.",
options: [
{ value: "bottom", label: "Bottom (below track)" },
{ value: "top", label: "Top (above track)" }
],
visibleWhen: { key: "ENABLE_MINI_MAP", eq: true }
},
ENABLE_RACER_BADGES_IN_RACE: {
type: "boolean",
label: "Enable Racer Badges (In-Race)",
default: true,
group: "General",
description: "Controls whether Racer Badges injects race nameplate badges."
},
HIDE_CHAT_AND_STICKERS: {
type: "boolean",
label: "Hide Chat and Stickers",
default: false,
group: "General",
description: "Hides the in-race chat and sticker panel."
},
HIDE_FINISH_FLAG: {
type: "boolean",
label: "Hide Finish Flag",
default: false,
group: "General",
description: "Hides the finish flag icon in the race text."
},
reloadOnStats: {
type: "boolean",
label: "Enable Auto Reload",
default: true,
group: "Auto Reload",
description: "Reload the page after results update."
},
greedyStatsReload: {
type: "boolean",
label: "Enable FAST RELOAD",
default: true,
group: "Auto Reload",
description: "Aggressively checks for result updates to reload sooner."
},
greedyStatsReloadInt: {
type: "number",
label: "FAST RELOAD - Check Interval",
default: 50,
group: "Auto Reload",
description: "How often FAST RELOAD checks for updates (milliseconds). Lower is faster but heavier.",
min: 1,
max: 1000,
step: 1,
warn: { below: 50, message: "Warning: values below 50ms can cause stats to fail to save. 50ms is the fastest recommended setting." }
},
enableStats: {
type: "boolean",
label: "Enable Stats",
default: true,
group: "Stats",
description: "Shows the custom Racing Stats panel."
},
ENABLE_PRECISE_ACCURACY: {
type: "boolean",
label: "Enable Precise Accuracy",
default: false,
group: "Stats",
description: "Uses full-text error math for the live in-race accuracy metric."
},
RACES_OUTSIDE_CURRENT_TEAM: {
type: "number",
label: "Races Outside Current Team",
default: 0,
group: "Stats",
description: "Subtract races done outside your current team from team-race math.",
min: 0,
max: 999999,
step: 1
},
TEAM_RACES_BUGGED: {
type: "number",
label: "Bugged team count (0 if no)",
default: 0,
group: "Stats",
description: "Manual correction added to team race totals when Nitro Type data is wrong.",
min: 0,
max: 999999,
step: 1
},
ENABLE_ALT_WPM_COUNTER: {
type: "boolean",
label: "Enable Alt. WPM",
default: true,
group: "Alt. WPM",
description: "Shows the draggable pace helper during the race. Countdown can be toggled separately."
},
ENABLE_ALT_WPM_COUNTDOWN: {
type: "boolean",
label: "Enable Countdown",
default: true,
group: "Alt. WPM",
description: "Shows the draggable race-start countdown even if Alt. WPM is turned off."
},
targetWPM: {
type: "number",
label: "Target WPM (1 = No Sandbagging)",
default: 79.5,
group: "Alt. WPM",
description: "Your pacing target. Lower values encourage slower controlled finishes.",
visibleWhen: { key: "ENABLE_ALT_WPM_COUNTER", eq: true },
min: 1,
max: 300,
step: 0.1
},
indicateWPMWithin: {
type: "number",
label: "Alt. WPM: Yellow when +X WPM",
default: 2,
group: "Alt. WPM",
description: "Highlight threshold near your target WPM.",
visibleWhen: { key: "ENABLE_ALT_WPM_COUNTER", eq: true },
min: 0,
max: 30,
step: 0.1
},
timerRefreshIntervalMS: {
type: "number",
label: "Alt. WPM: Refresh int.",
default: 25,
group: "Alt. WPM",
description: "How often the helper updates in milliseconds.",
visibleWhen: { key: "ENABLE_ALT_WPM_COUNTER", eq: true },
min: 5,
max: 1000,
step: 1
},
dif: {
type: "number",
label: "Alt. WPM: +X WPM Delay",
default: 0.8,
group: "Alt. WPM",
description: "Small offset applied to displayed possible WPM.",
visibleWhen: { key: "ENABLE_ALT_WPM_COUNTER", eq: true },
min: 0,
max: 30,
step: 0.1
},
ENABLE_PERFECT_NITROS: {
type: "boolean",
label: "Enable Perfect Nitros",
default: true,
group: "Perfect Nitros",
description: "Highlights the best long-word nitro target."
},
PERFECT_NITRO_ENABLE_HIGHLIGHT: {
type: "boolean",
label: "Highlight background",
default: true,
group: "Perfect Nitros",
description: "Background color and opacity for the perfect nitro word.",
compound: [
{ type: "color", key: "PERFECT_NITRO_HIGHLIGHT_COLOR", default: "#FFFFFF" },
{
type: "select", key: "PERFECT_NITRO_HIGHLIGHT_OPACITY", default: 0.5, options: [
{ value: 0.25, label: "25%" },
{ value: 0.5, label: "50%" },
{ value: 0.75, label: "75%" },
{ value: 1, label: "100%" }
]
}
]
},
PERFECT_NITRO_HIGHLIGHT_COLOR: {
type: "color",
label: "Perfect Nitro Highlight Color",
default: "#FFFFFF",
group: "Perfect Nitros",
description: "Background color for the perfect nitro word."
},
PERFECT_NITRO_HIGHLIGHT_OPACITY: {
type: "select",
label: "Perfect Nitro Highlight Opacity",
default: 0.5,
group: "Perfect Nitros",
description: "Background opacity for the perfect nitro word.",
options: [
{ value: 0.25, label: "25%" },
{ value: 0.5, label: "50%" },
{ value: 0.75, label: "75%" },
{ value: 1, label: "100%" }
]
},
PERFECT_NITRO_OVERRIDE_TEXT_COLOR: {
type: "boolean",
label: "Text color",
default: false,
group: "Perfect Nitros",
description: "Custom text color instead of auto-contrast.",
compound: [
{ type: "color", key: "PERFECT_NITRO_TEXT_COLOR", default: "#FFFFFF" }
]
},
PERFECT_NITRO_TEXT_COLOR: {
type: "color",
label: "Perfect Nitro Text Color",
default: "#FFFFFF",
group: "Perfect Nitros",
description: "Custom text color for the highlighted perfect nitro word."
},
PERFECT_NITRO_ITALIC: {
type: "boolean",
label: "Italic Perfect Nitro Word",
default: false,
group: "Perfect Nitros",
description: "Applies italic styling to the highlighted perfect nitro word."
},
PERFECT_NITRO_RAINBOW: {
type: "boolean",
label: "Rainbow Perfect Nitro Word",
default: false,
group: "Perfect Nitros",
description: "Applies a cycling rainbow animation to the highlighted perfect nitro word."
},
PERFECT_NITRO_SCAN_INTERVAL_MS: {
type: "number",
label: "Perfect Nitro Scan Interval (ms)",
default: 100,
group: "Perfect Nitros",
description: "How frequently to scan for the best nitro word.",
min: 25,
max: 5000,
step: 1
},
THEME_ENABLE_DARK_MODE: {
type: "boolean",
label: "Dark mode",
default: false,
group: "Theme",
description: "Uses the darker theme palette on the race page.",
compound: [
{
type: "select", key: "THEME_DARK_MODE_SYNC_SYSTEM", default: "always", options: [
{ value: "always", label: "Always" },
{ value: "system", label: "Same as system" }
]
}
]
},
THEME_DARK_MODE_SYNC_SYSTEM: {
type: "boolean",
label: "Sync Dark Mode with System",
default: false,
group: "Theme",
description: "Automatically follows your operating system dark mode."
},
THEME_FONT_FAMILY_PRESET: {
type: "select",
label: "Font family",
default: "__default__",
group: "Theme",
description: "Preview typography for the race text.",
options: mapPresetOptionsForManifest(THEME_FONT_FAMILY_PRESETS)
},
THEME_FONT_SIZE_PRESET: {
type: "select",
label: "Font size",
default: "__default__",
group: "Theme",
description: "Applies a preset font size to multi-line race text.",
options: mapPresetOptionsForManifest(THEME_FONT_SIZE_PRESETS)
},
THEME_SINGLE_LINE_FONT_SIZE_PRESET: {
type: "select",
label: "Single-line font size",
default: "__default__",
group: "Theme",
description: "Applies a preset font size when single-line mode is enabled.",
options: mapPresetOptionsForManifest(THEME_FONT_SIZE_PRESETS)
},
THEME_FONT_BOLD: {
type: "boolean",
label: "Bold text",
default: false,
group: "Theme",
description: "Applies bold styling to the race text.",
compound: [
{
type: "select", key: "THEME_FONT_WEIGHT", default: 700, options: [
{ value: 300, label: "Light" },
{ value: 500, label: "Medium" },
{ value: 600, label: "Semi-Bold" },
{ value: 700, label: "Bold" },
{ value: 800, label: "Extra-Bold" },
{ value: 900, label: "Black" }
]
}
]
},
THEME_FONT_WEIGHT: {
type: "select",
label: "Font weight",
default: 700,
group: "Theme",
description: "Controls the weight used when bold text is enabled.",
options: [
{ value: 300, label: "Light" },
{ value: 500, label: "Medium" },
{ value: 600, label: "Semi-Bold" },
{ value: 700, label: "Bold" },
{ value: 800, label: "Extra-Bold" },
{ value: 900, label: "Black" }
]
},
THEME_FONT_ITALIC: {
type: "boolean",
label: "Italic text",
default: false,
group: "Theme",
description: "Applies italic styling to the race text."
},
THEME_OVERRIDE_BACKGROUND: {
type: "boolean",
label: "Background color",
default: false,
group: "Theme",
description: "Enables a custom text area background.",
compound: [
{ type: "color", key: "THEME_COLOR_BACKGROUND", default: "#000000" }
]
},
THEME_COLOR_BACKGROUND: {
type: "color",
label: "Background Color",
default: "#000000",
group: "Theme",
description: "Main background color used for text theming."
},
THEME_OVERRIDE_FOREGROUND: {
type: "boolean",
label: "Text color",
default: false,
group: "Theme",
description: "Enables a custom text color for upcoming letters.",
compound: [
{ type: "color", key: "THEME_COLOR_FOREGROUND", default: "#FFFFFF" }
]
},
THEME_COLOR_FOREGROUND: {
type: "color",
label: "Foreground Color",
default: "#FFFFFF",
group: "Theme",
description: "Main text color for upcoming characters."
},
THEME_OVERRIDE_FOREGROUND_ACTIVE: {
type: "boolean",
label: "Active letter color",
default: false,
group: "Theme",
description: "Enables a custom foreground for the active letter.",
compound: [
{ type: "color", key: "THEME_COLOR_FOREGROUND_ACTIVE", default: "#000000" }
]
},
THEME_COLOR_FOREGROUND_ACTIVE: {
type: "color",
label: "Active Foreground Color",
default: "#000000",
group: "Theme",
description: "Foreground color for the active character."
},
THEME_OVERRIDE_BACKGROUND_ACTIVE: {
type: "boolean",
label: "Active letter background",
default: false,
group: "Theme",
description: "Enables a custom background behind the active letter.",
compound: [
{ type: "color", key: "THEME_COLOR_BACKGROUND_ACTIVE", default: "#FFFFFF" }
]
},
THEME_COLOR_BACKGROUND_ACTIVE: {
type: "color",
label: "Active Background Color",
default: "#FFFFFF",
group: "Theme",
description: "Background color for the active character."
},
THEME_OVERRIDE_FOREGROUND_TYPED: {
type: "boolean",
label: "Typed letter color",
default: false,
group: "Theme",
description: "Enables a custom foreground for typed text.",
compound: [
{ type: "color", key: "THEME_COLOR_FOREGROUND_TYPED", default: "#5B5B5B" }
]
},
THEME_COLOR_FOREGROUND_TYPED: {
type: "color",
label: "Typed Foreground Color",
default: "#5B5B5B",
group: "Theme",
description: "Foreground color for typed text."
},
THEME_OVERRIDE_BACKGROUND_INCORRECT: {
type: "boolean",
label: "Incorrect letter background",
default: false,
group: "Theme",
description: "Enables a custom background for incorrect letters.",
compound: [
{ type: "color", key: "THEME_COLOR_BACKGROUND_INCORRECT", default: "#FF0000" }
]
},
THEME_COLOR_BACKGROUND_INCORRECT: {
type: "color",
label: "Incorrect Background Color",
default: "#FF0000",
group: "Theme",
description: "Background color for incorrect characters."
},
THEME_HIDE_TYPED_TEXT: {
type: "boolean",
label: "Hide typed letters",
default: false,
group: "Theme",
description: "Makes completed text invisible while racing."
},
THEME_ENABLE_RAINBOW_TYPED_TEXT: {
type: "boolean",
label: "Rainbow typed text",
default: false,
group: "Theme",
description: "Applies animated color cycling to typed text."
},
THEME_RAINBOW_TYPED_TEXT_SPEED_SECONDS: {
type: "number",
label: "Rainbow speed (seconds)",
default: 10,
group: "Theme",
description: "Controls how quickly the typed text rainbow animation cycles.",
min: 1,
max: 60,
step: 0.5,
visibleWhen: { key: "THEME_ENABLE_RAINBOW_TYPED_TEXT", eq: true },
presets: [
{ label: "Fast", value: 5 },
{ label: "Normal", value: 10 },
{ label: "Slow", value: 15 },
{ label: "Very Slow", value: 25 }
]
},
DEBUG_LOGGING: {
type: "boolean",
label: "Debug Logging",
default: false,
group: "Advanced",
description: "Enable verbose console logging for troubleshooting."
}
};
const getNtcfgRaceOptionsStorageKey = (settingKey) => `${NTCFG_RACE_OPTIONS_VALUE_PREFIX}${settingKey}`;
const normalizeNtcfgRaceOptionsNumber = (value, meta) => {
const fallback = Number(meta.default);
const parsed = Number(value);
let normalized = Number.isFinite(parsed) ? parsed : fallback;
const min = Number(meta.min);
const max = Number(meta.max);
const step = Number(meta.step);
if (Number.isFinite(step) && step >= 1) {
normalized = Math.round(normalized);
}
if (Number.isFinite(min)) {
normalized = Math.max(min, normalized);
}
if (Number.isFinite(max)) {
normalized = Math.min(max, normalized);
}
return normalized;
};
const coerceNtcfgRaceOptionsValue = (settingKey, value) => {
const meta = RACE_OPTIONS_SHARED_SETTINGS[settingKey];
if (!meta) return value;
if (settingKey === "MINI_MAP_POSITION") {
return normalizeMiniMapPositionValue(value, meta.default);
}
if (settingKey === "THEME_FONT_FAMILY_PRESET") {
return normalizeThemePresetValue(value, THEME_FONT_FAMILY_PRESETS, meta.default);
}
if (settingKey === "THEME_FONT_SIZE_PRESET") {
return normalizeThemePresetValue(value, THEME_FONT_SIZE_PRESETS, meta.default);
}
if (settingKey === "THEME_SINGLE_LINE_FONT_SIZE_PRESET") {
return normalizeThemePresetValue(value, THEME_FONT_SIZE_PRESETS, meta.default);
}
if (settingKey === "THEME_FONT_WEIGHT") {
return Math.min(900, Math.max(100, Math.round(normalizeNtcfgRaceOptionsNumber(value, meta) || meta.default)));
}
if (settingKey === "PERFECT_NITRO_HIGHLIGHT_OPACITY") {
return normalizeHighlightOpacity(value, meta.default);
}
if (settingKey === "THEME_RAINBOW_TYPED_TEXT_SPEED_SECONDS") {
return normalizeRainbowSpeedSeconds(value, meta.default);
}
if (meta.type === "boolean") {
if (typeof value === "string") {
const raw = value.trim().toLowerCase();
if (raw === "false" || raw === "0" || raw === "off") return false;
if (raw === "true" || raw === "1" || raw === "on") return true;
}
return !!value;
}
if (meta.type === "color") {
return normalizeHexColorValue(value, meta.default);
}
if (meta.type === "number") {
return normalizeNtcfgRaceOptionsNumber(value, meta);
}
if (meta.type === "select") {
const raw = String(value ?? "").trim();
const options = Array.isArray(meta.options) ? meta.options : [];
return options.some((option) => String(option.value) === raw) ? raw : meta.default;
}
return String(value ?? meta.default);
};
const writeNtcfgRaceOptionsValue = (settingKey, value) => {
try {
const serialized = JSON.stringify(value);
if (localStorage.getItem(getNtcfgRaceOptionsStorageKey(settingKey)) !== serialized) {
localStorage.setItem(getNtcfgRaceOptionsStorageKey(settingKey), serialized);
}
} catch {
// ignore storage sync failures
}
};
const readNtcfgRaceOptionsValue = (settingKey) => {
const meta = RACE_OPTIONS_SHARED_SETTINGS[settingKey];
if (!meta) return undefined;
try {
const normalized = coerceNtcfgRaceOptionsValue(settingKey, GM_getValue(settingKey, meta.default));
writeNtcfgRaceOptionsValue(settingKey, normalized);
return normalized;
} catch {
try {
const raw = localStorage.getItem(getNtcfgRaceOptionsStorageKey(settingKey));
if (raw == null) return meta.default;
return coerceNtcfgRaceOptionsValue(settingKey, JSON.parse(raw));
} catch {
return meta.default;
}
}
};
const dispatchNtcfgRaceOptionsSettingSync = (settingKey, value) => {
try {
window.dispatchEvent(new CustomEvent(NTCFG_RACE_OPTIONS_SETTING_SYNC_EVENT, {
detail: {
key: settingKey,
value
}
}));
} catch {
// ignore sync event failures
}
};
const syncNtcfgRaceOptionsSettingFromGM = (settingKey) => {
const meta = RACE_OPTIONS_SHARED_SETTINGS[settingKey];
if (!meta) return;
const normalized = coerceNtcfgRaceOptionsValue(settingKey, GM_getValue(settingKey, meta.default));
writeNtcfgRaceOptionsValue(settingKey, normalized);
if (settingKey === "ENABLE_RACER_BADGES_IN_RACE") {
syncRacerBadgesRaceToggle(normalized);
}
};
const syncAllNtcfgRaceOptionsSettingsFromGM = () => {
Object.keys(RACE_OPTIONS_SHARED_SETTINGS).forEach(syncNtcfgRaceOptionsSettingFromGM);
};
const registerNtcfgRaceOptionsManifest = () => {
try {
const manifest = {
id: NTCFG_RACE_OPTIONS_MANIFEST_ID,
name: "Race Options",
version: NTCFG_RACE_OPTIONS_BRIDGE_VERSION,
scriptVersion: typeof GM_info !== 'undefined' ? GM_info.script.version : '',
storageVersion: RACE_OPTIONS_STORAGE_VERSION,
supportsGlobalReset: true,
description: "Race overlays, pacing tools, and theme controls for the race screen.",
sections: [
{ id: 'general', title: 'General', subtitle: 'Visual behavior and quality-of-life controls.', resetButton: true },
{ id: 'auto-reload', title: 'Auto Reload', subtitle: 'Race-end refresh controls.', resetButton: true },
{ id: 'stats', title: 'Stats', subtitle: 'Adjust stat correction and offset values.', resetButton: true },
{ id: 'alt-wpm', title: 'Alt. WPM', subtitle: 'Countdown and pace helper settings.', resetButton: true },
{ id: 'perfect-nitros', title: 'Perfect Nitros', subtitle: 'Longest-word highlight settings.', resetButton: true, preview: { type: 'perfect-nitro' } },
{ id: 'theme', title: 'Theme', subtitle: 'Customize the colors on the racetrack.', resetButton: true, preview: { type: 'theme' } },
{ id: 'advanced', title: 'Advanced', subtitle: 'Debug and diagnostic controls.', resetButton: true }
],
settings: RACE_OPTIONS_SHARED_SETTINGS
};
const serialized = JSON.stringify(manifest);
if (localStorage.getItem(NTCFG_RACE_OPTIONS_MANIFEST_KEY) !== serialized) {
localStorage.setItem(NTCFG_RACE_OPTIONS_MANIFEST_KEY, serialized);
}
} catch {
// ignore manifest registration failures
}
};
const setNtcfgRaceOptionsValue = (settingKey, value) => {
const meta = RACE_OPTIONS_SHARED_SETTINGS[settingKey];
if (!meta) {
GM_setValue(settingKey, value);
dispatchNtcfgRaceOptionsSettingSync(settingKey, value);
return value;
}
const normalized = coerceNtcfgRaceOptionsValue(settingKey, value);
GM_setValue(settingKey, normalized);
if (settingKey === "ENABLE_RACER_BADGES_IN_RACE") {
syncRacerBadgesRaceToggle(normalized);
}
writeNtcfgRaceOptionsValue(settingKey, normalized);
dispatchNtcfgRaceOptionsSettingSync(settingKey, normalized);
return normalized;
};
// Direct apply: always writes through to GM (used for same-tab ntcfg:change events from mod menu)
const applyNtcfgRaceOptionsValueDirect = (settingKey, value) => {
const meta = RACE_OPTIONS_SHARED_SETTINGS[settingKey];
if (!meta) return;
const normalized = coerceNtcfgRaceOptionsValue(settingKey, value);
setNtcfgRaceOptionsValue(settingKey, normalized);
};
// Deduped apply: compares against GM before writing (used for cross-tab storage events)
const applyNtcfgRaceOptionsValueIfChanged = (settingKey, value) => {
const meta = RACE_OPTIONS_SHARED_SETTINGS[settingKey];
if (!meta) return;
const normalized = coerceNtcfgRaceOptionsValue(settingKey, value);
const currentValue = coerceNtcfgRaceOptionsValue(settingKey, GM_getValue(settingKey, meta.default));
if (JSON.stringify(currentValue) !== JSON.stringify(normalized)) {
setNtcfgRaceOptionsValue(settingKey, normalized);
}
};
const dispatchRaceOptionsActionResult = (requestId, status, error = "") => {
if (!requestId) return;
try {
document.dispatchEvent(new CustomEvent("ntcfg:action-result", {
detail: {
requestId,
script: NTCFG_RACE_OPTIONS_MANIFEST_ID,
status,
error
}
}));
} catch {
// ignore dispatch failures
}
};
const resetRaceOptionsSettingsToDefaults = () => {
Object.entries(RACE_OPTIONS_SHARED_SETTINGS).forEach(([settingKey, meta]) => {
if (!meta || meta.type === 'note' || meta.type === 'action') return;
setNtcfgRaceOptionsValue(settingKey, meta.default);
});
try { GM_deleteValue('savedTimestamp'); } catch { /* ignore */ }
try { localStorage.removeItem('nt_sandbagging_tool'); } catch { /* ignore */ }
};
document.addEventListener("ntcfg:change", (event) => {
if (event?.detail?.script !== NTCFG_RACE_OPTIONS_MANIFEST_ID) return;
applyNtcfgRaceOptionsValueDirect(event.detail.key, event.detail.value);
});
document.addEventListener("ntcfg:action", (event) => {
const detail = event?.detail || {};
if (detail.script !== '*') return;
if (detail.key !== 'clear-settings' || detail.scope !== 'prefs+caches') return;
try {
resetRaceOptionsSettingsToDefaults();
GM_setValue(RACE_OPTIONS_STORAGE_VERSION_KEY, RACE_OPTIONS_STORAGE_VERSION);
registerNtcfgRaceOptionsManifest();
syncAllNtcfgRaceOptionsSettingsFromGM();
document.dispatchEvent(new CustomEvent("ntcfg:manifest-updated", {
detail: { script: NTCFG_RACE_OPTIONS_MANIFEST_ID }
}));
dispatchRaceOptionsActionResult(detail.requestId, 'success');
} catch (error) {
dispatchRaceOptionsActionResult(detail.requestId, 'error', error?.message || String(error));
}
});
window.addEventListener("storage", (event) => {
const storageKey = String(event?.key || "");
if (!storageKey.startsWith(NTCFG_RACE_OPTIONS_VALUE_PREFIX) || event.newValue == null) return;
const settingKey = storageKey.slice(NTCFG_RACE_OPTIONS_VALUE_PREFIX.length);
if (!RACE_OPTIONS_SHARED_SETTINGS[settingKey]) return;
try {
applyNtcfgRaceOptionsValueIfChanged(settingKey, JSON.parse(event.newValue));
} catch {
// ignore invalid synced payloads
}
});
registerNtcfgRaceOptionsManifest();
syncAllNtcfgRaceOptionsSettingsFromGM();
try { GM_setValue(RACE_OPTIONS_STORAGE_VERSION_KEY, RACE_OPTIONS_STORAGE_VERSION); } catch { /* ignore */ }
// Alive signal — write BEFORE dispatching manifest-updated so the mod menu
// sees this script as alive when it re-renders in response to the event.
try { localStorage.setItem('ntcfg:alive:' + NTCFG_RACE_OPTIONS_MANIFEST_ID, String(Date.now())); } catch { /* ignore */ }
try {
document.dispatchEvent(new CustomEvent("ntcfg:manifest-updated", {
detail: {
script: NTCFG_RACE_OPTIONS_MANIFEST_ID
}
}));
} catch {
// ignore event dispatch failures
}
function initNTRouteHelper(targetWindow = window) {
const hostWindow = targetWindow || window;
const existing = hostWindow.NTRouteHelper;
if (existing && existing.__ntRouteHelperReady && typeof existing.subscribe === 'function') {
return existing;
}
const helper = existing || {};
const listeners = helper.listeners instanceof Set ? helper.listeners : new Set();
let currentKey = `${hostWindow.location.pathname}${hostWindow.location.search}${hostWindow.location.hash}`;
const notify = (reason) => {
const nextKey = `${hostWindow.location.pathname}${hostWindow.location.search}${hostWindow.location.hash}`;
if (reason !== 'init' && nextKey === currentKey) return;
const previousKey = currentKey;
currentKey = nextKey;
listeners.forEach((listener) => {
try {
listener({
reason,
previous: previousKey,
current: nextKey,
pathname: hostWindow.location.pathname
});
} catch (error) {
console.error('[NTRouteHelper] listener error', error);
}
});
helper.currentKey = currentKey;
};
if (!helper.__ntRouteHelperWrapped) {
const wrapHistoryMethod = (methodName) => {
const current = hostWindow.history[methodName];
if (typeof current !== 'function' || current.__ntRouteHelperWrapped) return;
const wrapped = function () {
const result = current.apply(this, arguments);
queueMicrotask(() => notify(methodName));
return result;
};
wrapped.__ntRouteHelperWrapped = true;
wrapped.__ntRouteHelperOriginal = current;
hostWindow.history[methodName] = wrapped;
};
wrapHistoryMethod('pushState');
wrapHistoryMethod('replaceState');
hostWindow.addEventListener('popstate', () => queueMicrotask(() => notify('popstate')));
helper.__ntRouteHelperWrapped = true;
}
helper.listeners = listeners;
helper.currentKey = currentKey;
helper.version = '1.0.0';
helper.__ntRouteHelperReady = true;
helper.subscribe = function (listener, options = {}) {
if (typeof listener !== 'function') return () => { };
listeners.add(listener);
if (options.immediate !== false) {
try {
listener({
reason: 'init',
previous: currentKey,
current: currentKey,
pathname: hostWindow.location.pathname
});
} catch (error) {
console.error('[NTRouteHelper] immediate listener error', error);
}
}
return () => listeners.delete(listener);
};
helper.notify = notify;
hostWindow.NTRouteHelper = helper;
return helper;
}
const ntRaceOptionsRouteHelper = initNTRouteHelper(window);
let ntRaceOptionsPendingReload = false;
if (!isNtcfgRaceOptionsRaceRoute()) {
ntRaceOptionsRouteHelper.subscribe(() => {
if (!isNtcfgRaceOptionsRaceRoute() || ntRaceOptionsPendingReload) return;
ntRaceOptionsPendingReload = true;
window.location.reload();
}, { immediate: false });
}
if (isNtcfgRaceOptionsRaceRoute()) {
ntRaceOptionsRouteHelper.subscribe(() => {
if (isNtcfgRaceOptionsRaceRoute() || ntRaceOptionsPendingReload) return;
ntRaceOptionsPendingReload = true;
window.location.reload();
}, { immediate: false });
const themeSystemDarkModeMediaQuery = typeof window.matchMedia === "function"
? window.matchMedia("(prefers-color-scheme: dark)")
: null;
const isThemeSystemDarkModeEnabled = () => !!themeSystemDarkModeMediaQuery?.matches;
const getThemeEngineOptions = () => {
const fontFamilyPreset = normalizeThemePresetValue(
GM_getValue("THEME_FONT_FAMILY_PRESET", THEME_FONT_FAMILY_PRESET),
THEME_FONT_FAMILY_PRESETS
);
const fontSizePreset = normalizeThemePresetValue(
GM_getValue("THEME_FONT_SIZE_PRESET", THEME_FONT_SIZE_PRESET),
THEME_FONT_SIZE_PRESETS
);
const singleLineFontSizePreset = normalizeThemePresetValue(
GM_getValue("THEME_SINGLE_LINE_FONT_SIZE_PRESET", THEME_SINGLE_LINE_FONT_SIZE_PRESET),
THEME_FONT_SIZE_PRESETS
);
const fontFamilyPresetOption = THEME_FONT_FAMILY_PRESETS.find((preset) => preset.value === fontFamilyPreset) || THEME_FONT_FAMILY_PRESETS[0];
const fontSizePresetOption = THEME_FONT_SIZE_PRESETS.find((preset) => preset.value === fontSizePreset) || THEME_FONT_SIZE_PRESETS[0];
const singleLineFontSizePresetOption = THEME_FONT_SIZE_PRESETS.find((preset) => preset.value === singleLineFontSizePreset) || THEME_FONT_SIZE_PRESETS[0];
const darkModeEnabled = GM_getValue("THEME_ENABLE_DARK_MODE", THEME_ENABLE_DARK_MODE);
const darkModeSyncSystem = GM_getValue("THEME_DARK_MODE_SYNC_SYSTEM", THEME_DARK_MODE_SYNC_SYSTEM);
const darkModeEffective = darkModeSyncSystem ? isThemeSystemDarkModeEnabled() : !!darkModeEnabled;
return {
darkModeEnabled: !!darkModeEnabled,
darkModeSyncSystem: !!darkModeSyncSystem,
darkModeEffective,
foreground: normalizeHexColorValue(GM_getValue("THEME_COLOR_FOREGROUND", THEME_COLOR_FOREGROUND), THEME_COLOR_FOREGROUND),
foregroundActive: normalizeHexColorValue(GM_getValue("THEME_COLOR_FOREGROUND_ACTIVE", THEME_COLOR_FOREGROUND_ACTIVE), THEME_COLOR_FOREGROUND_ACTIVE),
foregroundTyped: normalizeHexColorValue(GM_getValue("THEME_COLOR_FOREGROUND_TYPED", THEME_COLOR_FOREGROUND_TYPED), THEME_COLOR_FOREGROUND_TYPED),
background: normalizeHexColorValue(GM_getValue("THEME_COLOR_BACKGROUND", THEME_COLOR_BACKGROUND), THEME_COLOR_BACKGROUND),
backgroundActive: normalizeHexColorValue(GM_getValue("THEME_COLOR_BACKGROUND_ACTIVE", THEME_COLOR_BACKGROUND_ACTIVE), THEME_COLOR_BACKGROUND_ACTIVE),
backgroundIncorrect: normalizeHexColorValue(GM_getValue("THEME_COLOR_BACKGROUND_INCORRECT", THEME_COLOR_BACKGROUND_INCORRECT), THEME_COLOR_BACKGROUND_INCORRECT),
overrideForeground: GM_getValue("THEME_OVERRIDE_FOREGROUND", THEME_OVERRIDE_FOREGROUND),
overrideForegroundActive: GM_getValue("THEME_OVERRIDE_FOREGROUND_ACTIVE", THEME_OVERRIDE_FOREGROUND_ACTIVE),
overrideForegroundTyped: GM_getValue("THEME_OVERRIDE_FOREGROUND_TYPED", THEME_OVERRIDE_FOREGROUND_TYPED),
hideTypedText: GM_getValue("THEME_HIDE_TYPED_TEXT", THEME_HIDE_TYPED_TEXT),
overrideBackground: GM_getValue("THEME_OVERRIDE_BACKGROUND", THEME_OVERRIDE_BACKGROUND),
overrideBackgroundActive: GM_getValue("THEME_OVERRIDE_BACKGROUND_ACTIVE", THEME_OVERRIDE_BACKGROUND_ACTIVE),
overrideBackgroundIncorrect: GM_getValue("THEME_OVERRIDE_BACKGROUND_INCORRECT", THEME_OVERRIDE_BACKGROUND_INCORRECT),
fontFamilyPreset,
fontFamilyCss: fontFamilyPreset !== "__default__" && typeof fontFamilyPresetOption?.css === "string"
? normalizeThemeFontFamilyValue(fontFamilyPresetOption.css, THEME_FONT_FAMILY_DEFAULT_CSS)
: null,
fontSizePreset,
fontSizePx: fontSizePreset !== "__default__" && Number.isFinite(Number(fontSizePresetOption?.px))
? normalizeThemeFontSizePx(fontSizePresetOption.px, 18)
: null,
singleLineFontSizePreset,
singleLineFontSizePx: singleLineFontSizePreset !== "__default__" && Number.isFinite(Number(singleLineFontSizePresetOption?.px))
? normalizeThemeFontSizePx(singleLineFontSizePresetOption.px, 18)
: null,
fontBold: !!GM_getValue("THEME_FONT_BOLD", THEME_FONT_BOLD),
fontItalic: !!GM_getValue("THEME_FONT_ITALIC", THEME_FONT_ITALIC),
fontWeight: GM_getValue("THEME_FONT_BOLD", THEME_FONT_BOLD)
? Math.min(900, Math.max(100, Math.round(Number(GM_getValue("THEME_FONT_WEIGHT", THEME_FONT_WEIGHT)) || 700)))
: THEME_FONT_WEIGHT_DEFAULT,
fontStyle: GM_getValue("THEME_FONT_ITALIC", THEME_FONT_ITALIC) ? "italic" : "normal",
enableRainbowTypedText: GM_getValue("THEME_ENABLE_RAINBOW_TYPED_TEXT", THEME_ENABLE_RAINBOW_TYPED_TEXT),
rainbowTypedTextSpeedSeconds: normalizeRainbowSpeedSeconds(
GM_getValue("THEME_RAINBOW_TYPED_TEXT_SPEED_SECONDS", THEME_RAINBOW_TYPED_TEXT_SPEED_SECONDS),
THEME_RAINBOW_TYPED_TEXT_SPEED_SECONDS
),
};
};
const THEME_ENGINE_STYLE_ID = "nt-theme-engine-style";
const THEME_ENGINE_UPDATED_EVENT = "NT_THEME_ENGINE_UPDATED";
const PERFECT_NITRO_UPDATED_EVENT = "NT_PERFECT_NITRO_UPDATED";
// Capture NT's native line-height before any theme overrides are applied
let _ntNativeLineHeight = null;
const captureNativeLineHeight = () => {
if (_ntNativeLineHeight !== null) return;
const letter = document.querySelector('.dash-copy .dash-letter');
const target = letter || document.querySelector('.dash-copy');
if (!target) return;
const computed = window.getComputedStyle(target);
const raw = computed.lineHeight;
if (raw && raw !== "normal") {
_ntNativeLineHeight = raw;
}
};
const applyRaceThemeEngine = () => {
captureNativeLineHeight();
const options = getThemeEngineOptions();
const currentStyle = document.getElementById(THEME_ENGINE_STYLE_ID);
const rules = [];
const lineHeightRule = _ntNativeLineHeight ? `line-height: ${_ntNativeLineHeight} !important;` : "";
if (options.darkModeEffective) {
rules.push(`.dash-copyContainer { background: ${THEME_DARK_MODE_BACKGROUND} !important; }`);
rules.push(`.dash-copy { color: ${THEME_DARK_MODE_FOREGROUND}; }`);
if (!options.enableRainbowTypedText && !options.hideTypedText) {
rules.push(`.dash-letter.is-typed { color: ${THEME_DARK_MODE_FOREGROUND_TYPED} !important; opacity: 1; }`);
}
rules.push(`.dash-letter.is-waiting { color: ${THEME_DARK_MODE_FOREGROUND_ACTIVE} !important; background: ${THEME_DARK_MODE_BACKGROUND_ACTIVE} !important; }`);
rules.push(`.dash-letter.is-incorrect { color: ${THEME_DARK_MODE_FOREGROUND_ACTIVE} !important; background: ${THEME_DARK_MODE_BACKGROUND_INCORRECT} !important; }`);
}
if (options.overrideForeground) {
rules.push(`.dash-copy { color: ${options.foreground}; }`);
}
if (options.overrideForegroundActive) {
rules.push(`.dash-letter.is-incorrect, .dash-letter.is-waiting { color: ${options.foregroundActive} !important; }`);
}
if (options.overrideForegroundTyped && !options.enableRainbowTypedText && !options.hideTypedText) {
rules.push(`.dash-letter.is-typed { color: ${options.foregroundTyped} !important; opacity: 1; }`);
}
if (options.overrideBackground) {
rules.push(`.dash-copyContainer { background: ${options.background} !important; }`);
}
if (options.overrideBackgroundActive) {
rules.push(`.dash-letter.is-waiting { background: ${options.backgroundActive} !important; }`);
}
if (options.overrideBackgroundIncorrect) {
rules.push(`.dash-letter.is-incorrect { background: ${options.backgroundIncorrect} !important; }`);
}
if (options.fontFamilyCss) {
rules.push(`.dash-copy, .dash-copy .dash-letter { font-family: ${options.fontFamilyCss} !important; }`);
}
const isSingleLine = !!document.querySelector('.dash-actions button:has(.icon-sololine).is-on');
const effectiveFontSizePx = isSingleLine ? options.singleLineFontSizePx : options.fontSizePx;
if (Number.isFinite(effectiveFontSizePx)) {
rules.push(`.dash-copy, .dash-copy .dash-letter { font-size: ${effectiveFontSizePx}px !important; ${lineHeightRule} }`);
}
if (options.fontBold || options.fontFamilyCss || Number.isFinite(effectiveFontSizePx)) {
rules.push(`.dash-copy, .dash-copy .dash-letter { font-weight: ${options.fontWeight} !important; }`);
}
if (options.fontItalic) {
rules.push(`.dash-copy, .dash-copy .dash-letter { font-style: italic !important; }`);
}
if (options.enableRainbowTypedText && !options.hideTypedText) {
rules.push(`
@keyframes ntcfg-race-rainbow-text {
0% { color: blue; }
10% { color: #ff005d; }
20% { color: #f0f; }
30% { color: black; }
40% { color: #7500ff; }
50% { color: blue; }
60% { color: #f0f; }
70% { color: black; }
80% { color: black; }
90% { color: red; }
100% { color: red; }
}
.dash-letter.is-correct,
.dash-letter.is-typed:not(.is-incorrect):not(.is-waiting):not(.is-active):not(.is-current) {
animation: ntcfg-race-rainbow-text ${options.rainbowTypedTextSpeedSeconds}s infinite alternate;
-webkit-animation: ntcfg-race-rainbow-text ${options.rainbowTypedTextSpeedSeconds}s infinite alternate;
opacity: 1;
}
`);
}
if (options.hideTypedText) {
rules.push(`
.dash-letter.is-typed:not(.is-active):not(.is-current),
.dash-letter.is-correct:not(.is-active):not(.is-current) {
color: transparent !important;
-webkit-text-fill-color: transparent !important;
text-shadow: none !important;
opacity: 0 !important;
}
`);
}
if (rules.length === 0) {
if (currentStyle) {
currentStyle.remove();
}
return;
}
const style = currentStyle || document.createElement("style");
style.id = THEME_ENGINE_STYLE_ID;
style.textContent = rules.join("\n");
if (!currentStyle) {
document.head.appendChild(style);
}
};
const handleThemeSystemColorSchemeChange = () => {
if (!GM_getValue("THEME_DARK_MODE_SYNC_SYSTEM", THEME_DARK_MODE_SYNC_SYSTEM)) {
return;
}
applyRaceThemeEngine();
window.dispatchEvent(new CustomEvent(THEME_ENGINE_UPDATED_EVENT));
};
if (themeSystemDarkModeMediaQuery) {
if (typeof themeSystemDarkModeMediaQuery.addEventListener === "function") {
themeSystemDarkModeMediaQuery.addEventListener("change", handleThemeSystemColorSchemeChange);
} else if (typeof themeSystemDarkModeMediaQuery.addListener === "function") {
themeSystemDarkModeMediaQuery.addListener(handleThemeSystemColorSchemeChange);
}
}
const maybeApplyLiveThemeSetting = (variableName) => {
if (String(variableName).startsWith("THEME_")) {
applyRaceThemeEngine();
window.dispatchEvent(new CustomEvent(THEME_ENGINE_UPDATED_EVENT));
}
if (String(variableName).startsWith("PERFECT_NITRO_") || variableName === "ENABLE_PERFECT_NITROS") {
window.dispatchEvent(new CustomEvent(PERFECT_NITRO_UPDATED_EVENT));
}
};
applyRaceThemeEngine();
// Observe single-line / multi-line toggle changes to re-apply font size override
const setupSingleLineModeObserver = () => {
let lastSingleLine = null;
const check = () => {
const isSingleLine = !!document.querySelector('.dash-actions button:has(.icon-sololine).is-on');
if (lastSingleLine !== isSingleLine) {
lastSingleLine = isSingleLine;
applyRaceThemeEngine();
window.dispatchEvent(new CustomEvent(THEME_ENGINE_UPDATED_EVENT));
}
};
const observer = new MutationObserver(check);
const startObserving = () => {
const dashActions = document.querySelector('.dash-actions');
if (dashActions) {
observer.observe(dashActions, { attributes: true, subtree: true, attributeFilter: ['class'] });
check();
return true;
}
return false;
};
if (!startObserving()) {
// dash-actions may not exist yet; watch for it
const bodyObserver = new MutationObserver(() => {
if (startObserving()) {
bodyObserver.disconnect();
}
});
bodyObserver.observe(document.body, { childList: true, subtree: true });
}
};
setupSingleLineModeObserver();
const NTCFG_FOCUS_LOCK_EVENT = "NT_RACE_OPTIONS_FOCUS_LOCK";
const NTCFG_IFRAME_CLICK_EVENT = "NT_RACE_OPTIONS_IFRAME_CLICKED";
if (!window.__ntcfgFocusPatchInstalled) {
window.__ntcfgFocusPatchInstalled = true;
window.__ntcfgFocusLocked = false;
window.addEventListener("message", (event) => {
if (event?.data?.type === NTCFG_FOCUS_LOCK_EVENT) {
window.__ntcfgFocusLocked = !!event.data.locked;
}
});
window.addEventListener("mousedown", () => {
if (window.__ntcfgFocusLocked) {
window.__ntcfgFocusLocked = false;
if (window.top && window.top !== window) {
window.top.postMessage({ type: NTCFG_IFRAME_CLICK_EVENT }, "*");
}
}
window.focus();
}, true);
const ntcfgOriginalFocus = HTMLElement.prototype.focus;
HTMLElement.prototype.focus = function () {
if (window.__ntcfgFocusLocked) return;
return ntcfgOriginalFocus.apply(this, arguments);
};
}
// Perfect Nitro helper functions (top-level so preview can access them)
const getEffectiveThemeBackground = () => {
const themeOptions = getThemeEngineOptions();
if (themeOptions.overrideBackground) return normalizeHexColorValue(themeOptions.background, "#FFFFFF");
if (themeOptions.darkModeEffective) return THEME_DARK_MODE_BACKGROUND;
return "#E9EAEB"; // NT default light background for .dash-copyContainer
};
const getPerfectNitroTextStyle = (highlightColor, opacity = 0.5) => {
const bgHex = getEffectiveThemeBackground();
const fgHex = normalizeHexColorValue(highlightColor, "#FFFFFF");
const luminance = blendedLuminance(fgHex, bgHex, opacity);
const color = luminance > 0.5 ? "#101623" : "#E7EEF8";
return {
color,
shadow: color === "#101623" ? "0 1px 1px rgba(255, 255, 255, 0.25)" : "0 1px 1px rgba(0, 0, 0, 0.35)",
};
};
const getPerfectNitroOptions = () => {
const rawInterval = Number(GM_getValue("PERFECT_NITRO_SCAN_INTERVAL_MS", PERFECT_NITRO_SCAN_INTERVAL_MS));
const intervalMs = Number.isFinite(rawInterval) && rawInterval > 0
? Math.max(MIN_PERFECT_NITRO_SCAN_INTERVAL_MS, Math.round(rawInterval))
: MIN_PERFECT_NITRO_SCAN_INTERVAL_MS;
const rawColor = String(GM_getValue("PERFECT_NITRO_HIGHLIGHT_COLOR", PERFECT_NITRO_HIGHLIGHT_COLOR) || "").trim();
const rawTextColor = String(GM_getValue("PERFECT_NITRO_TEXT_COLOR", PERFECT_NITRO_TEXT_COLOR) || "").trim();
return {
enabled: GM_getValue("ENABLE_PERFECT_NITROS", ENABLE_PERFECT_NITROS),
intervalMs,
highlightColor: rawColor || "#FFFFFF",
enableHighlight: !!GM_getValue("PERFECT_NITRO_ENABLE_HIGHLIGHT", PERFECT_NITRO_ENABLE_HIGHLIGHT),
italic: !!GM_getValue("PERFECT_NITRO_ITALIC", PERFECT_NITRO_ITALIC),
rainbow: !!GM_getValue("PERFECT_NITRO_RAINBOW", PERFECT_NITRO_RAINBOW),
highlightOpacity: normalizeHighlightOpacity(GM_getValue("PERFECT_NITRO_HIGHLIGHT_OPACITY", PERFECT_NITRO_HIGHLIGHT_OPACITY)),
overrideTextColor: !!GM_getValue("PERFECT_NITRO_OVERRIDE_TEXT_COLOR", PERFECT_NITRO_OVERRIDE_TEXT_COLOR),
textColor: normalizeHexColorValue(rawTextColor || "#FFFFFF", "#FFFFFF"),
};
};
// Create UI elements
const createUI = () => {
if (!document.getElementById("ntcfg-style")) {
const style = document.createElement("style");
style.id = "ntcfg-style";
style.textContent = `
.ntcfg-overlay {
position: fixed;
inset: 0;
z-index: 100000;
display: none;
align-items: center;
justify-content: center;
}
.ntcfg-overlay.is-open {
display: flex;
}
.ntcfg-backdrop {
position: absolute;
inset: 0;
background: rgba(6, 12, 18, 0.72);
backdrop-filter: blur(2px);
}
.ntcfg-card {
position: relative;
width: min(980px, calc(100vw - 28px));
max-height: calc(100vh - 28px);
border-radius: 20px;
overflow: hidden;
display: flex;
flex-direction: column;
color: #fff;
background-color: #20222e;
background-image: url(/dist/site/images/backgrounds/bg-noise.png);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
}
.ntcfg-header {
padding: 18px 22px;
background: url(/dist/site/images/backgrounds/bg-noise.png) top left repeat, linear-gradient(90deg, #1c99f4 60%, #167ac3 100%);
}
.ntcfg-title {
margin: 0;
font-family: "Montserrat", sans-serif;
font-size: 30px;
line-height: 1.1;
font-weight: 600;
text-shadow: 0 2px 2px rgba(2, 2, 2, 0.25);
}
.ntcfg-subtitle {
margin: 6px 0 0;
color: rgba(255, 255, 255, 0.92);
font-size: 13px;
}
.ntcfg-layout {
display: flex;
gap: 18px;
min-height: 0;
padding: 18px;
}
.ntcfg-sidebar {
width: 240px;
max-width: 240px;
display: flex;
flex-direction: column;
gap: 2px;
}
.ntcfg-nav-btn {
box-shadow: none;
justify-content: flex-start;
width: 100%;
backface-visibility: hidden;
background: #393c50;
border: 1px solid transparent;
color: #a6aac1;
cursor: pointer;
display: inline-flex;
align-items: center;
font-family: "Montserrat", sans-serif;
font-size: 14px;
overflow: hidden;
padding: 13px 14px;
position: relative;
text-align: left;
transition: all 0.12s linear;
}
.ntcfg-nav-btn:first-child {
border-radius: 5px 5px 0 0;
}
.ntcfg-nav-btn:last-child {
border-radius: 0 0 5px 5px;
}
.ntcfg-nav-btn:hover {
background: #585e7d;
color: #e2e3eb;
}
.ntcfg-nav-btn.is-active {
background: #167ac3 !important;
color: #fff;
text-shadow: 0 2px 2px rgba(2, 2, 2, 0.25);
}
.ntcfg-content {
flex: 1;
min-width: 0;
max-height: calc(100vh - 250px);
overflow: auto;
border-radius: 10px;
background: #2b2e3f;
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 18px;
}
.ntcfg-content::-webkit-scrollbar {
width: 8px;
}
.ntcfg-content::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.25);
border-radius: 8px;
}
.ntcfg-content::-webkit-scrollbar-thumb {
background: rgba(28, 153, 244, 0.8);
border-radius: 8px;
}
.ntcfg-panel[hidden] {
display: none !important;
}
.ntcfg-panel-title {
margin: 0;
font-size: 20px;
font-family: "Montserrat", sans-serif;
}
.ntcfg-panel-subtitle {
margin: 6px 0 16px;
color: #b5bad3;
font-size: 13px;
}
.ntcfg-fields {
display: flex;
flex-direction: column;
gap: 12px;
}
.ntcfg-field {
display: block;
}
.ntcfg-field-title {
color: #d8dcf2;
font-size: 13px;
margin-bottom: 6px;
}
.ntcfg-field-help {
margin-top: 5px;
color: #99a4c5;
font-size: 12px;
line-height: 1.35;
}
.ntcfg-field-warning {
color: #ffb8b8;
}
.ntcfg-input {
width: 100%;
box-sizing: border-box;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
background: #1d2030;
color: #eef3ff;
padding: 10px;
font-size: 13px;
}
.ntcfg-input:focus {
outline: none;
border-color: #1c99f4;
box-shadow: 0 0 0 2px rgba(28, 153, 244, 0.28);
}
.ntcfg-input.ntcfg-input-warning {
border-color: rgba(255, 90, 90, 0.85);
box-shadow: 0 0 0 2px rgba(255, 90, 90, 0.2);
}
.ntcfg-color-row {
display: flex;
gap: 8px;
}
.ntcfg-color-picker {
width: 52px;
min-width: 52px;
padding: 0;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: #1d2030;
cursor: pointer;
}
.ntcfg-color-hex {
flex: 1;
text-transform: uppercase;
}
.ntcfg-checkbox {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: #252839;
padding: 10px 12px;
}
.ntcfg-checkbox-label {
color: #d8dcf2;
font-size: 13px;
}
.ntcfg-checkbox-copy {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.ntcfg-checkbox-help {
color: #99a4c5;
font-size: 12px;
line-height: 1.35;
}
.ntcfg-checkbox-controls {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.ntcfg-checkbox-controls .ntcfg-color-picker {
width: 38px;
min-width: 38px;
height: 30px;
}
.ntcfg-checkbox-controls .ntcfg-input {
width: 84px;
padding: 6px 8px;
font-size: 12px;
}
.ntcfg-checkbox-controls select.ntcfg-input {
width: auto;
}
.ntcfg-switch {
position: relative;
width: 40px;
height: 24px;
flex: 0 0 auto;
display: inline-block;
cursor: pointer;
}
.ntcfg-switch input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
margin: 0;
z-index: 2;
}
.ntcfg-switch-track {
display: block;
width: 100%;
height: 100%;
border-radius: 999px;
background: #585e7d;
transition: background 0.15s ease;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.ntcfg-switch-track::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transition: transform 0.1s ease;
}
.ntcfg-switch input:checked + .ntcfg-switch-track {
background: #d62f3a;
box-shadow: 0 2px 20px rgba(214, 47, 58, 0.35);
}
.ntcfg-switch input:checked + .ntcfg-switch-track::after {
transform: translateX(16px);
}
.ntcfg-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 0 18px 18px;
}
.ntcfg-action {
border: 0;
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
cursor: pointer;
color: #fff;
background: #393c50;
}
.ntcfg-action:hover {
filter: brightness(1.1);
}
.ntcfg-action.ntcfg-primary {
background: #167ac3;
}
.ntcfg-inline-action {
align-self: flex-start;
}
.ntcfg-theme-preview-label {
color: #d8dcf2;
font-size: 13px;
margin-bottom: 4px;
}
.ntcfg-theme-preview-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
margin-bottom: 12px;
}
.ntcfg-theme-preview {
width: 100%;
box-sizing: border-box;
padding: 12px;
margin-bottom: 0;
border-radius: 5px;
background: #e9eaeb;
color: #2e3141;
}
.ntcfg-theme-preview span {
font-family: "Roboto Mono", "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace;
display: inline-block;
padding: 2px;
font-size: 18px;
white-space: pre;
}
.ntcfg-theme-preview span.ntcfg-theme-char-active {
background: #1c99f4;
color: #fff;
}
.ntcfg-theme-preview span.ntcfg-theme-char-incorrect {
background: #d62f3a;
color: #fff;
}
.ntcfg-theme-preview span.ntcfg-theme-char-typed {
color: #2e3141;
opacity: 0.5;
}
.ntcfg-theme-preview span.ntcfg-theme-char-rainbow {
animation: ntcfg-preview-rainbow-text 10s infinite alternate;
-webkit-animation: ntcfg-preview-rainbow-text 10s infinite alternate;
opacity: 1;
}
@keyframes ntcfg-preview-rainbow-text {
0% { color: blue; }
10% { color: #ff005d; }
20% { color: #f0f; }
30% { color: black; }
40% { color: #7500ff; }
50% { color: blue; }
60% { color: #f0f; }
70% { color: black; }
80% { color: black; }
90% { color: red; }
100% { color: red; }
}
.ntcfg-theme-settings {
display: flex;
flex-direction: column;
gap: 8px;
}
.ntcfg-theme-setting {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: #252839;
padding: 8px 10px;
}
.ntcfg-theme-setting-title {
color: #d8dcf2;
font-size: 13px;
}
.ntcfg-theme-setting-controls {
display: flex;
align-items: center;
gap: 8px;
}
.ntcfg-theme-color-compact {
display: flex;
align-items: center;
gap: 6px;
}
.ntcfg-theme-color-compact .ntcfg-color-picker {
width: 38px;
min-width: 38px;
height: 30px;
}
.ntcfg-theme-color-compact .ntcfg-input {
width: 96px;
padding: 6px 8px;
font-size: 12px;
}
.ntcfg-theme-input-compact {
width: auto;
padding: 6px 8px;
font-size: 12px;
}
.ntcfg-theme-input-select {
width: 180px;
}
.ntcfg-theme-input-number {
width: 96px;
}
.ntcfg-theme-input-preset {
width: 132px;
}
@media (min-width: 900px) {
.ntcfg-theme-preview-grid {
grid-template-columns: 1fr 1fr;
}
}
.ntcfg-sticky-preview {
position: sticky;
top: 0;
z-index: 10;
background: #2b2e3f;
padding-bottom: 12px;
margin-bottom: 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.ntcfg-pn-preview {
width: 100%;
box-sizing: border-box;
padding: 12px;
border-radius: 5px;
background: #e9eaeb;
color: #2e3141;
margin-bottom: 0;
overflow: hidden;
word-wrap: break-word;
}
.ntcfg-pn-preview span {
font-family: "Roboto Mono", "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace;
display: inline-block;
padding: 2px;
font-size: 18px;
white-space: pre;
}
.ntcfg-pn-preview-rainbow {
animation: ntcfg-pn-preview-rainbow 3s linear infinite;
-webkit-animation: ntcfg-pn-preview-rainbow 3s linear infinite;
}
@keyframes ntcfg-pn-preview-rainbow {
0% { color: #FF0000; }
14% { color: #FF8C00; }
28% { color: #FFD700; }
42% { color: #00CC00; }
57% { color: #0066FF; }
71% { color: #7B00FF; }
85% { color: #FF00FF; }
100% { color: #FF0000; }
}
.ntcfg-open-btn {
position: fixed;
left: 10px;
bottom: 10px;
z-index: 99999;
border: 0;
border-radius: 8px;
padding: 7px 10px;
cursor: pointer;
color: #dff7ff;
background: rgba(0, 0, 0, 0.82);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
}
.ntcfg-open-btn:hover {
background: rgba(15, 25, 36, 0.94);
}
`;
document.head.appendChild(style);
}
const overlay = document.createElement("div");
overlay.className = "ntcfg-overlay";
overlay.innerHTML = `
<div class="ntcfg-backdrop"></div>
<div class="ntcfg-card" role="dialog" aria-label="Nitro Type script configuration">
<div class="ntcfg-header">
<h2 class="ntcfg-title">Configuration</h2>
<p class="ntcfg-subtitle">Changes save instantly.</p>
</div>
<div class="ntcfg-layout">
<div class="ntcfg-sidebar"></div>
<div class="ntcfg-content"></div>
</div>
<div class="ntcfg-footer">
<button type="button" class="ntcfg-action ntcfg-primary ntcfg-save">Save and Reload</button>
<button type="button" class="ntcfg-action ntcfg-close">Close</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const sidebar = overlay.querySelector(".ntcfg-sidebar");
const content = overlay.querySelector(".ntcfg-content");
const saveBtn = overlay.querySelector(".ntcfg-save");
const closeBtn = overlay.querySelector(".ntcfg-close");
const backdrop = overlay.querySelector(".ntcfg-backdrop");
const broadcastFocusLock = (locked) => {
window.__ntcfgFocusLocked = !!locked;
for (let i = 0; i < window.frames.length; i++) {
try {
window.frames[i].postMessage({ type: NTCFG_FOCUS_LOCK_EVENT, locked: !!locked }, "*");
} catch {
// ignore
}
}
};
window.addEventListener("message", (event) => {
if (event?.data?.type === NTCFG_IFRAME_CLICK_EVENT) {
broadcastFocusLock(false);
}
});
const sections = new Map();
const settingInputBindings = new Map();
const bindSettingInput = (variableName, syncFn) => {
settingInputBindings.set(variableName, syncFn);
};
const syncSettingInput = (variableName, value) => {
const syncFn = settingInputBindings.get(variableName);
if (typeof syncFn === "function") {
syncFn(value);
}
};
window.addEventListener(NTCFG_RACE_OPTIONS_SETTING_SYNC_EVENT, (event) => {
const variableName = event?.detail?.key;
if (!variableName) return;
syncSettingInput(variableName, event.detail.value);
});
const selectSection = (id) => {
sections.forEach((section, key) => {
const active = key === id;
section.panel.hidden = !active;
section.button.classList.toggle("is-active", active);
});
};
const createSection = (id, title, subtitle) => {
const button = document.createElement("button");
button.type = "button";
button.className = "ntcfg-nav-btn";
button.textContent = title;
sidebar.appendChild(button);
const panel = document.createElement("section");
panel.className = "ntcfg-panel";
panel.hidden = true;
panel.innerHTML = `
<h3 class="ntcfg-panel-title">${title}</h3>
<p class="ntcfg-panel-subtitle">${subtitle}</p>
`;
const fields = document.createElement("div");
fields.className = "ntcfg-fields";
panel.appendChild(fields);
content.appendChild(panel);
button.addEventListener("click", () => selectSection(id));
sections.set(id, { button, panel, fields });
if (sections.size === 1) {
selectSection(id);
}
return fields;
};
const addCheckbox = (root, labelText, variableName, defaultValue, helpText = "", onChange = null) => {
const saveSetting = (value) => {
setNtcfgRaceOptionsValue(variableName, value);
maybeApplyLiveThemeSetting(variableName);
};
const row = document.createElement("label");
row.className = "ntcfg-checkbox";
const title = document.createElement("span");
title.className = "ntcfg-checkbox-label";
title.textContent = labelText;
const copy = document.createElement("span");
copy.className = "ntcfg-checkbox-copy";
copy.appendChild(title);
if (helpText) {
const help = document.createElement("span");
help.className = "ntcfg-checkbox-help";
help.textContent = helpText;
copy.appendChild(help);
}
const switchRoot = document.createElement("span");
switchRoot.className = "ntcfg-switch";
const input = document.createElement("input");
input.type = "checkbox";
input.checked = GM_getValue(variableName, defaultValue);
input.addEventListener("change", () => {
saveSetting(input.checked);
if (typeof onChange === "function") {
onChange(input.checked);
}
});
bindSettingInput(variableName, (value) => {
input.checked = !!value;
});
const track = document.createElement("span");
track.className = "ntcfg-switch-track";
switchRoot.append(input, track);
row.append(copy, switchRoot);
root.appendChild(row);
};
const addNumberInput = (root, labelText, variableName, defaultValue, helpText = "", options = {}) => {
const { minRecommended = null, warningText = "" } = options;
const saveSetting = (value) => {
setNtcfgRaceOptionsValue(variableName, value);
maybeApplyLiveThemeSetting(variableName);
};
const field = document.createElement("label");
field.className = "ntcfg-field";
const title = document.createElement("div");
title.className = "ntcfg-field-title";
title.textContent = labelText;
const input = document.createElement("input");
input.className = "ntcfg-input";
input.type = "number";
input.value = GM_getValue(variableName, defaultValue);
const warning = document.createElement("div");
warning.className = "ntcfg-field-help ntcfg-field-warning";
warning.hidden = true;
const updateWarningState = (value) => {
const shouldWarn = Number.isFinite(minRecommended) && Number.isFinite(value) && value < minRecommended;
warning.hidden = !shouldWarn;
if (shouldWarn) {
warning.textContent = warningText || `Warning: values below ${minRecommended} are not recommended.`;
input.classList.add("ntcfg-input-warning");
} else {
warning.textContent = "";
input.classList.remove("ntcfg-input-warning");
}
};
input.addEventListener("input", () => {
updateWarningState(parseFloat(input.value));
});
input.addEventListener("change", () => {
const parsed = parseFloat(input.value);
if (!Number.isFinite(parsed)) {
return;
}
saveSetting(parsed);
updateWarningState(parsed);
});
bindSettingInput(variableName, (value) => {
input.value = String(value ?? defaultValue);
updateWarningState(parseFloat(input.value));
});
field.append(title, input);
if (helpText) {
const help = document.createElement("div");
help.className = "ntcfg-field-help";
help.textContent = helpText;
field.appendChild(help);
}
if (Number.isFinite(minRecommended)) {
field.appendChild(warning);
updateWarningState(parseFloat(input.value));
}
root.appendChild(field);
};
const addTextInput = (root, labelText, variableName, defaultValue, helpText = "") => {
const saveSetting = (value) => {
setNtcfgRaceOptionsValue(variableName, value);
maybeApplyLiveThemeSetting(variableName);
};
const field = document.createElement("label");
field.className = "ntcfg-field";
const title = document.createElement("div");
title.className = "ntcfg-field-title";
title.textContent = labelText;
const input = document.createElement("input");
input.className = "ntcfg-input";
input.type = "text";
input.value = GM_getValue(variableName, defaultValue);
input.addEventListener("change", () => {
const value = String(input.value).trim();
saveSetting(value || defaultValue);
});
bindSettingInput(variableName, (value) => {
input.value = String(value ?? defaultValue);
});
field.append(title, input);
if (helpText) {
const help = document.createElement("div");
help.className = "ntcfg-field-help";
help.textContent = helpText;
field.appendChild(help);
}
root.appendChild(field);
};
const addSelectInput = (root, labelText, variableName, defaultValue, options = [], helpText = "", onChange = null) => {
const saveSetting = (value) => {
setNtcfgRaceOptionsValue(variableName, value);
maybeApplyLiveThemeSetting(variableName);
};
const field = document.createElement("label");
field.className = "ntcfg-field";
const title = document.createElement("div");
title.className = "ntcfg-field-title";
title.textContent = labelText;
const select = document.createElement("select");
select.className = "ntcfg-input";
const safeOptions = Array.isArray(options) ? options.filter((option) => option && typeof option.value !== "undefined") : [];
safeOptions.forEach((optionDef) => {
const option = document.createElement("option");
option.value = String(optionDef.value);
option.textContent = String(optionDef.label ?? optionDef.value);
select.appendChild(option);
});
const normalizeSelectedValue = (value) => {
const raw = String(value ?? "").trim();
if (safeOptions.some((optionDef) => String(optionDef.value) === raw)) {
return raw;
}
return String(defaultValue);
};
select.value = normalizeSelectedValue(GM_getValue(variableName, defaultValue));
select.addEventListener("change", () => {
const normalized = normalizeSelectedValue(select.value);
select.value = normalized;
saveSetting(normalized);
if (typeof onChange === "function") {
onChange(normalized);
}
});
bindSettingInput(variableName, (value) => {
const normalized = normalizeSelectedValue(value);
select.value = normalized;
if (typeof onChange === "function") {
onChange(normalized);
}
});
field.append(title, select);
if (helpText) {
const help = document.createElement("div");
help.className = "ntcfg-field-help";
help.textContent = helpText;
field.appendChild(help);
}
root.appendChild(field);
return { field, select };
};
const normalizeHexColor = (value, fallback = "#FFFFFF") => {
const raw = String(value || "").trim();
if (/^#[0-9A-Fa-f]{6}$/.test(raw)) {
return raw.toUpperCase();
}
if (/^#[0-9A-Fa-f]{3}$/.test(raw)) {
return `#${raw[1]}${raw[1]}${raw[2]}${raw[2]}${raw[3]}${raw[3]}`.toUpperCase();
}
return fallback.toUpperCase();
};
const addColorInput = (root, labelText, variableName, defaultValue, helpText = "") => {
const saveSetting = (value) => {
setNtcfgRaceOptionsValue(variableName, value);
maybeApplyLiveThemeSetting(variableName);
};
const field = document.createElement("label");
field.className = "ntcfg-field";
const title = document.createElement("div");
title.className = "ntcfg-field-title";
title.textContent = labelText;
const row = document.createElement("div");
row.className = "ntcfg-color-row";
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.className = "ntcfg-color-picker";
const hexInput = document.createElement("input");
hexInput.type = "text";
hexInput.className = "ntcfg-input ntcfg-color-hex";
hexInput.maxLength = 7;
hexInput.placeholder = "#FFFFFF";
const startingHex = normalizeHexColor(GM_getValue(variableName, defaultValue), normalizeHexColor(defaultValue));
colorInput.value = startingHex;
hexInput.value = startingHex;
bindSettingInput(variableName, (value) => {
const normalized = normalizeHexColor(value, normalizeHexColor(defaultValue));
colorInput.value = normalized;
hexInput.value = normalized;
});
colorInput.addEventListener("input", () => {
const hex = normalizeHexColor(colorInput.value, startingHex);
hexInput.value = hex;
saveSetting(hex);
});
const commitHex = () => {
const hex = normalizeHexColor(hexInput.value, colorInput.value || startingHex);
hexInput.value = hex;
colorInput.value = hex;
saveSetting(hex);
};
hexInput.addEventListener("change", commitHex);
hexInput.addEventListener("blur", commitHex);
row.append(colorInput, hexInput);
field.append(title, row);
if (helpText) {
const help = document.createElement("div");
help.className = "ntcfg-field-help";
help.textContent = helpText;
field.appendChild(help);
}
root.appendChild(field);
};
const addSectionResetButton = (root, labelText, defaults, onAfterReset = null) => {
const resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.className = "ntcfg-action ntcfg-inline-action";
resetButton.textContent = labelText;
resetButton.addEventListener("click", () => {
Object.entries(defaults).forEach(([variableName, value]) => {
setNtcfgRaceOptionsValue(variableName, value);
syncSettingInput(variableName, value);
maybeApplyLiveThemeSetting(variableName);
});
if (typeof onAfterReset === "function") {
onAfterReset(defaults);
}
});
root.appendChild(resetButton);
};
const general = createSection("general", "General", "Visual behavior and quality-of-life controls.");
addCheckbox(general, 'Hide Track', 'hideTrack', hideTrack, "Hides the visual racetrack area to reduce on-screen clutter.");
addCheckbox(general, 'Hide Notifications', 'hideNotifications', hideNotifications, "Blocks growl popups during races.");
addCheckbox(general, 'Auto close reward popup', 'AUTO_CLOSE_REWARD_POPUP', AUTO_CLOSE_REWARD_POPUP, "Automatically dismisses reward/takeover popups if they appear.");
addCheckbox(general, 'Hide "Prepare for your race" floating icon', 'HIDE_PREPARE_FOR_RACE_ICON', HIDE_PREPARE_FOR_RACE_ICON, "Removes the floating prep icon overlay.");
let miniMapPositionInput = null;
const setMiniMapPositionVisibility = (enabled) => {
if (miniMapPositionInput?.field) {
miniMapPositionInput.field.hidden = !enabled;
}
};
addCheckbox(
general,
'Enable Mini Map',
'ENABLE_MINI_MAP',
ENABLE_MINI_MAP,
"Shows a compact lane map on the race page.",
(checked) => setMiniMapPositionVisibility(checked)
);
miniMapPositionInput = addSelectInput(
general,
"Mini Map Position",
"MINI_MAP_POSITION",
normalizeMiniMapPositionValue(MINI_MAP_POSITION),
[
{ value: "bottom", label: "Bottom (below track)" },
{ value: "top", label: "Top (above track)" },
],
"Choose where the mini map appears relative to the race track."
);
setMiniMapPositionVisibility(GM_getValue("ENABLE_MINI_MAP", ENABLE_MINI_MAP));
addCheckbox(
general,
'Enable Racer Badges (In-Race)',
'ENABLE_RACER_BADGES_IN_RACE',
ENABLE_RACER_BADGES_IN_RACE,
"Controls whether Racer Badges injects race nameplate badges.",
(checked) => syncRacerBadgesRaceToggle(checked)
);
addCheckbox(general, 'Hide Chat and Stickers', 'HIDE_CHAT_AND_STICKERS', HIDE_CHAT_AND_STICKERS, "Hides the in-race chat and sticker panel.");
addCheckbox(general, 'Hide Finish Flag', 'HIDE_FINISH_FLAG', HIDE_FINISH_FLAG, "Hides the finish flag icon in the race text.");
const generalDefaults = {
hideTrack: false,
hideNotifications: true,
AUTO_CLOSE_REWARD_POPUP: true,
HIDE_PREPARE_FOR_RACE_ICON: false,
ENABLE_MINI_MAP: false,
MINI_MAP_POSITION: "bottom",
ENABLE_RACER_BADGES_IN_RACE: true,
HIDE_CHAT_AND_STICKERS: false,
HIDE_FINISH_FLAG: false,
};
addSectionResetButton(general, "Reset General to Defaults", generalDefaults, (defaults) => {
setMiniMapPositionVisibility(!!defaults.ENABLE_MINI_MAP);
syncRacerBadgesRaceToggle(!!defaults.ENABLE_RACER_BADGES_IN_RACE);
});
const autoReload = createSection("autoreload", "Auto Reload", "Race-end refresh controls.");
addCheckbox(autoReload, 'Enable Auto Reload', 'reloadOnStats', reloadOnStats, "Reload the page after results update.");
addCheckbox(autoReload, 'Enable FAST RELOAD', 'greedyStatsReload', greedyStatsReload, "Aggressively checks for result updates to reload sooner.");
addNumberInput(
autoReload,
'FAST RELOAD - Check Interval',
'greedyStatsReloadInt',
greedyStatsReloadInt,
"How often FAST RELOAD checks for updates (milliseconds). Lower = faster but heavier.",
{
minRecommended: 50,
warningText: "Warning: values below 50ms can cause stats to fail to save. 50ms is the fastest recommended setting.",
}
);
const autoReloadDefaults = {
reloadOnStats: true,
greedyStatsReload: true,
greedyStatsReloadInt: 50,
};
addSectionResetButton(autoReload, "Reset Auto Reload to Defaults", autoReloadDefaults);
const stats = createSection("stats", "Stats", "Adjust stat correction/offset values.");
addCheckbox(stats, 'Enable Stats', 'enableStats', enableStats, "Shows the custom Racing Stats panel.");
addCheckbox(stats, 'Enable Precise Accuracy', 'ENABLE_PRECISE_ACCURACY', ENABLE_PRECISE_ACCURACY, "Uses full-text error math for the live in-race accuracy metric.");
addNumberInput(stats, 'Races Outside Current Team', 'RACES_OUTSIDE_CURRENT_TEAM', RACES_OUTSIDE_CURRENT_TEAM, "Subtract races done outside your current team from team-race math.");
addNumberInput(stats, 'Bugged team count (0 if no)', 'TEAM_RACES_BUGGED', TEAM_RACES_BUGGED, "Manual correction added to team race totals when NT data is wrong.");
const statsDefaults = {
enableStats: true,
ENABLE_PRECISE_ACCURACY: false,
RACES_OUTSIDE_CURRENT_TEAM: 0,
TEAM_RACES_BUGGED: 0,
};
addSectionResetButton(stats, "Reset Stats to Defaults", statsDefaults);
const altWpm = createSection("altwpm", "Alt. WPM", "Countdown and pace helper settings.");
addCheckbox(altWpm, 'Enable Alt. WPM', 'ENABLE_ALT_WPM_COUNTER', ENABLE_ALT_WPM_COUNTER, "Shows the draggable pace helper during the race.");
addCheckbox(altWpm, 'Enable Countdown', 'ENABLE_ALT_WPM_COUNTDOWN', ENABLE_ALT_WPM_COUNTDOWN, "Shows the draggable race-start countdown even if Alt. WPM is turned off.");
addNumberInput(altWpm, 'Target WPM (1 = No Sandbagging)', 'targetWPM', config.targetWPM, "Your pacing target. Lower values encourage slower controlled finishes.");
addNumberInput(altWpm, 'Alt. WPM: Yellow when +X WPM', 'indicateWPMWithin', config.indicateWPMWithin, "Highlight threshold near your target WPM.");
addNumberInput(altWpm, 'Alt. WPM: Refresh int.', 'timerRefreshIntervalMS', config.timerRefreshIntervalMS, "How often the helper updates (milliseconds).");
addNumberInput(altWpm, 'Alt. WPM: +X WPM Delay', 'dif', config.dif, "Small offset applied to displayed possible WPM.");
const altWpmDefaults = {
ENABLE_ALT_WPM_COUNTER: true,
ENABLE_ALT_WPM_COUNTDOWN: true,
targetWPM: 79.5,
indicateWPMWithin: 2,
timerRefreshIntervalMS: 25,
dif: 0.8,
};
addSectionResetButton(altWpm, "Reset Alt. WPM to Defaults", altWpmDefaults);
const perfectNitro = createSection("perfectnitro", "Perfect Nitros", "Longest-word highlight settings.");
// Perfect Nitro Preview
const pnPreviewText = "the quick brown fox jumps";
const pnHighlightWord = "jumps"; // the "longest" word to highlight
const pnHighlightStart = pnPreviewText.indexOf(pnHighlightWord);
const pnHighlightEnd = pnHighlightStart + pnHighlightWord.length;
const createPerfectNitroPreview = () => {
const preview = document.createElement("div");
preview.className = "ntcfg-pn-preview";
const render = () => {
const options = getPerfectNitroOptions();
const themeOptions = getThemeEngineOptions();
preview.innerHTML = "";
// Set background to match effective theme background
if (themeOptions.darkModeEffective) {
preview.style.background = THEME_DARK_MODE_BACKGROUND;
preview.style.color = THEME_DARK_MODE_FOREGROUND;
} else if (themeOptions.overrideBackground) {
preview.style.background = themeOptions.background;
preview.style.color = "";
} else {
preview.style.background = "";
preview.style.color = "";
}
// Apply font family from theme
if (themeOptions.fontFamilyCss) {
preview.style.fontFamily = themeOptions.fontFamilyCss;
} else {
preview.style.fontFamily = "";
}
const bgRgba = options.enableHighlight ? hexToRgba(options.highlightColor, options.highlightOpacity) : null;
const textStyle = options.overrideTextColor
? { color: options.textColor, shadow: null }
: options.enableHighlight
? getPerfectNitroTextStyle(options.highlightColor, options.highlightOpacity)
: { color: null, shadow: null };
Array.from(pnPreviewText).forEach((char, index) => {
const span = document.createElement("span");
span.textContent = char;
const isHighlighted = index >= pnHighlightStart && index < pnHighlightEnd;
if (isHighlighted) {
if (bgRgba) {
span.style.backgroundColor = bgRgba;
}
if (textStyle.color) {
span.style.color = textStyle.color;
}
if (textStyle.shadow) {
span.style.textShadow = textStyle.shadow;
}
if (options.italic) {
span.style.fontStyle = "italic";
}
if (options.rainbow) {
span.classList.add("ntcfg-pn-preview-rainbow");
}
} else {
// Untyped text style
if (themeOptions.overrideForeground) {
span.style.color = themeOptions.foreground;
} else if (themeOptions.darkModeEffective) {
span.style.color = THEME_DARK_MODE_FOREGROUND;
}
}
preview.appendChild(span);
});
};
render();
return { preview, render };
};
const pnStickyWrapper = document.createElement("div");
pnStickyWrapper.className = "ntcfg-sticky-preview";
const pnPreviewLabel = document.createElement("div");
pnPreviewLabel.className = "ntcfg-theme-preview-label";
pnPreviewLabel.textContent = "Preview:";
pnStickyWrapper.appendChild(pnPreviewLabel);
const pnPreview = createPerfectNitroPreview();
pnStickyWrapper.appendChild(pnPreview.preview);
perfectNitro.appendChild(pnStickyWrapper);
const renderPnPreview = () => { pnPreview.render(); };
window.addEventListener(PERFECT_NITRO_UPDATED_EVENT, renderPnPreview);
window.addEventListener(THEME_ENGINE_UPDATED_EVENT, renderPnPreview);
addCheckbox(perfectNitro, 'Enable Perfect Nitros', 'ENABLE_PERFECT_NITROS', ENABLE_PERFECT_NITROS, "Highlights the best long-word nitro target.");
// Highlight background toggle with inline color + opacity
(() => {
const row = document.createElement("div");
row.className = "ntcfg-checkbox";
const copy = document.createElement("span");
copy.className = "ntcfg-checkbox-copy";
const title = document.createElement("span");
title.className = "ntcfg-checkbox-label";
title.textContent = "Highlight background";
const help = document.createElement("span");
help.className = "ntcfg-checkbox-help";
help.textContent = "Background color and opacity for the perfect nitro word.";
copy.append(title, help);
const controls = document.createElement("span");
controls.className = "ntcfg-checkbox-controls";
// Color picker (compact)
const colorWrap = document.createElement("span");
colorWrap.className = "ntcfg-checkbox-controls";
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.className = "ntcfg-color-picker";
const hexInput = document.createElement("input");
hexInput.type = "text";
hexInput.className = "ntcfg-input";
hexInput.maxLength = 7;
hexInput.placeholder = "#FFFFFF";
const startingHex = normalizeHexColor(GM_getValue("PERFECT_NITRO_HIGHLIGHT_COLOR", PERFECT_NITRO_HIGHLIGHT_COLOR));
colorInput.value = startingHex;
hexInput.value = startingHex;
bindSettingInput("PERFECT_NITRO_HIGHLIGHT_COLOR", (value) => {
const n = normalizeHexColor(value);
colorInput.value = n;
hexInput.value = n;
});
colorInput.addEventListener("input", () => {
const hex = normalizeHexColor(colorInput.value, startingHex);
hexInput.value = hex;
setNtcfgRaceOptionsValue("PERFECT_NITRO_HIGHLIGHT_COLOR", hex);
maybeApplyLiveThemeSetting("PERFECT_NITRO_HIGHLIGHT_COLOR");
});
const commitHex = () => {
const hex = normalizeHexColor(hexInput.value, colorInput.value || startingHex);
hexInput.value = hex;
colorInput.value = hex;
setNtcfgRaceOptionsValue("PERFECT_NITRO_HIGHLIGHT_COLOR", hex);
maybeApplyLiveThemeSetting("PERFECT_NITRO_HIGHLIGHT_COLOR");
};
hexInput.addEventListener("change", commitHex);
hexInput.addEventListener("blur", commitHex);
colorWrap.append(colorInput, hexInput);
// Opacity select
const opacitySelect = document.createElement("select");
opacitySelect.className = "ntcfg-input ntcfg-theme-input-compact";
const opacityOptions = [
{ value: "0.25", label: "25%" },
{ value: "0.5", label: "50%" },
{ value: "0.75", label: "75%" },
{ value: "1", label: "100%" },
];
opacityOptions.forEach((opt) => {
const option = document.createElement("option");
option.value = opt.value;
option.textContent = opt.label;
opacitySelect.appendChild(option);
});
const normalizeOpacitySelect = (val) => {
const n = String(normalizeHighlightOpacity(val));
return opacityOptions.some((o) => o.value === n) ? n : "0.5";
};
opacitySelect.value = normalizeOpacitySelect(GM_getValue("PERFECT_NITRO_HIGHLIGHT_OPACITY", PERFECT_NITRO_HIGHLIGHT_OPACITY));
opacitySelect.addEventListener("change", () => {
setNtcfgRaceOptionsValue("PERFECT_NITRO_HIGHLIGHT_OPACITY", opacitySelect.value);
maybeApplyLiveThemeSetting("PERFECT_NITRO_HIGHLIGHT_OPACITY");
});
bindSettingInput("PERFECT_NITRO_HIGHLIGHT_OPACITY", (value) => {
opacitySelect.value = normalizeOpacitySelect(value);
});
// Toggle
const isOn = GM_getValue("PERFECT_NITRO_ENABLE_HIGHLIGHT", PERFECT_NITRO_ENABLE_HIGHLIGHT);
colorWrap.hidden = !isOn;
opacitySelect.hidden = !isOn;
const switchRoot = document.createElement("span");
switchRoot.className = "ntcfg-switch";
const toggleInput = document.createElement("input");
toggleInput.type = "checkbox";
toggleInput.checked = isOn;
toggleInput.addEventListener("change", () => {
setNtcfgRaceOptionsValue("PERFECT_NITRO_ENABLE_HIGHLIGHT", toggleInput.checked);
colorWrap.hidden = !toggleInput.checked;
opacitySelect.hidden = !toggleInput.checked;
maybeApplyLiveThemeSetting("PERFECT_NITRO_ENABLE_HIGHLIGHT");
});
bindSettingInput("PERFECT_NITRO_ENABLE_HIGHLIGHT", (value) => {
toggleInput.checked = !!value;
colorWrap.hidden = !value;
opacitySelect.hidden = !value;
});
const track = document.createElement("span");
track.className = "ntcfg-switch-track";
switchRoot.append(toggleInput, track);
controls.append(colorWrap, opacitySelect, switchRoot);
row.append(copy, controls);
perfectNitro.appendChild(row);
})();
// Text color override toggle with inline color picker
(() => {
const row = document.createElement("div");
row.className = "ntcfg-checkbox";
const copy = document.createElement("span");
copy.className = "ntcfg-checkbox-copy";
const title = document.createElement("span");
title.className = "ntcfg-checkbox-label";
title.textContent = "Text color";
const help = document.createElement("span");
help.className = "ntcfg-checkbox-help";
help.textContent = "Custom text color instead of auto-contrast.";
copy.append(title, help);
const controls = document.createElement("span");
controls.className = "ntcfg-checkbox-controls";
// Color picker (compact)
const colorWrap = document.createElement("span");
colorWrap.className = "ntcfg-checkbox-controls";
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.className = "ntcfg-color-picker";
const hexInput = document.createElement("input");
hexInput.type = "text";
hexInput.className = "ntcfg-input";
hexInput.maxLength = 7;
hexInput.placeholder = "#FFFFFF";
const startingHex = normalizeHexColor(GM_getValue("PERFECT_NITRO_TEXT_COLOR", PERFECT_NITRO_TEXT_COLOR));
colorInput.value = startingHex;
hexInput.value = startingHex;
bindSettingInput("PERFECT_NITRO_TEXT_COLOR", (value) => {
const n = normalizeHexColor(value);
colorInput.value = n;
hexInput.value = n;
});
colorInput.addEventListener("input", () => {
const hex = normalizeHexColor(colorInput.value, startingHex);
hexInput.value = hex;
setNtcfgRaceOptionsValue("PERFECT_NITRO_TEXT_COLOR", hex);
maybeApplyLiveThemeSetting("PERFECT_NITRO_TEXT_COLOR");
});
const commitHex = () => {
const hex = normalizeHexColor(hexInput.value, colorInput.value || startingHex);
hexInput.value = hex;
colorInput.value = hex;
setNtcfgRaceOptionsValue("PERFECT_NITRO_TEXT_COLOR", hex);
maybeApplyLiveThemeSetting("PERFECT_NITRO_TEXT_COLOR");
};
hexInput.addEventListener("change", commitHex);
hexInput.addEventListener("blur", commitHex);
colorWrap.append(colorInput, hexInput);
// Toggle
const isOn = GM_getValue("PERFECT_NITRO_OVERRIDE_TEXT_COLOR", PERFECT_NITRO_OVERRIDE_TEXT_COLOR);
colorWrap.hidden = !isOn;
const switchRoot = document.createElement("span");
switchRoot.className = "ntcfg-switch";
const toggleInput = document.createElement("input");
toggleInput.type = "checkbox";
toggleInput.checked = isOn;
toggleInput.addEventListener("change", () => {
setNtcfgRaceOptionsValue("PERFECT_NITRO_OVERRIDE_TEXT_COLOR", toggleInput.checked);
colorWrap.hidden = !toggleInput.checked;
maybeApplyLiveThemeSetting("PERFECT_NITRO_OVERRIDE_TEXT_COLOR");
});
bindSettingInput("PERFECT_NITRO_OVERRIDE_TEXT_COLOR", (value) => {
toggleInput.checked = !!value;
colorWrap.hidden = !value;
});
const track = document.createElement("span");
track.className = "ntcfg-switch-track";
switchRoot.append(toggleInput, track);
controls.append(colorWrap, switchRoot);
row.append(copy, controls);
perfectNitro.appendChild(row);
})();
addCheckbox(perfectNitro, 'Italic Perfect Nitro Word', 'PERFECT_NITRO_ITALIC', PERFECT_NITRO_ITALIC, "Applies italic styling to the highlighted perfect nitro word.");
addCheckbox(perfectNitro, 'Rainbow Perfect Nitro Word', 'PERFECT_NITRO_RAINBOW', PERFECT_NITRO_RAINBOW, "Applies a cycling rainbow color animation to the highlighted perfect nitro word.");
addNumberInput(perfectNitro, 'Perfect Nitro Scan Interval (ms)', 'PERFECT_NITRO_SCAN_INTERVAL_MS', PERFECT_NITRO_SCAN_INTERVAL_MS, "How frequently to scan for the best nitro word.");
const perfectNitroDefaults = {
ENABLE_PERFECT_NITROS: true,
PERFECT_NITRO_ENABLE_HIGHLIGHT: true,
PERFECT_NITRO_HIGHLIGHT_COLOR: "#FFFFFF",
PERFECT_NITRO_HIGHLIGHT_OPACITY: 0.5,
PERFECT_NITRO_OVERRIDE_TEXT_COLOR: false,
PERFECT_NITRO_TEXT_COLOR: "#FFFFFF",
PERFECT_NITRO_ITALIC: false,
PERFECT_NITRO_RAINBOW: false,
PERFECT_NITRO_SCAN_INTERVAL_MS: 100,
};
addSectionResetButton(perfectNitro, "Reset Perfect Nitros to Defaults", perfectNitroDefaults);
const themeEngine = createSection("themeengine", "Theme", "Customize the colors on the racetrack.");
const themePreviewText = "The Quick Brown Fox is afraid of The Big Black";
const createThemePreview = (cursorIndex, incorrect = false) => {
const preview = document.createElement("div");
preview.className = "ntcfg-theme-preview";
const render = () => {
const options = getThemeEngineOptions();
preview.innerHTML = "";
if (options.darkModeEffective) {
preview.style.background = THEME_DARK_MODE_BACKGROUND;
preview.style.color = THEME_DARK_MODE_FOREGROUND;
} else if (options.overrideBackground) {
preview.style.background = options.background;
preview.style.color = "";
} else {
preview.style.background = "";
preview.style.color = "";
}
if (options.fontFamilyCss) {
preview.style.fontFamily = options.fontFamilyCss;
} else {
preview.style.fontFamily = "";
}
preview.style.fontWeight = String(options.fontWeight || THEME_FONT_WEIGHT_DEFAULT);
preview.style.fontStyle = options.fontItalic ? "italic" : "";
Array.from(themePreviewText).forEach((char, index) => {
const span = document.createElement("span");
if (index === cursorIndex) {
span.classList.add(incorrect ? "ntcfg-theme-char-incorrect" : "ntcfg-theme-char-active");
} else if (index < cursorIndex) {
span.classList.add("ntcfg-theme-char-typed");
if (options.enableRainbowTypedText && !options.hideTypedText) {
span.classList.add("ntcfg-theme-char-rainbow");
span.style.animationDuration = `${options.rainbowTypedTextSpeedSeconds}s`;
span.style.webkitAnimationDuration = `${options.rainbowTypedTextSpeedSeconds}s`;
}
}
span.textContent = char;
if (index > cursorIndex && options.overrideForeground) {
span.style.color = options.foreground;
} else if (index > cursorIndex && options.darkModeEffective) {
span.style.color = THEME_DARK_MODE_FOREGROUND;
}
if (index === cursorIndex && options.overrideForegroundActive) {
span.style.color = options.foregroundActive;
} else if (index === cursorIndex && options.darkModeEffective) {
span.style.color = THEME_DARK_MODE_FOREGROUND_ACTIVE;
}
if (index < cursorIndex && options.overrideForegroundTyped && !options.enableRainbowTypedText) {
span.style.color = options.foregroundTyped;
span.style.opacity = "1";
} else if (index < cursorIndex && options.darkModeEffective && !options.enableRainbowTypedText) {
span.style.color = THEME_DARK_MODE_FOREGROUND_TYPED;
span.style.opacity = "1";
}
if (index < cursorIndex && options.hideTypedText) {
span.style.color = "transparent";
span.style.webkitTextFillColor = "transparent";
span.style.textShadow = "none";
span.style.opacity = "0";
}
if (index === cursorIndex && !incorrect && options.overrideBackgroundActive) {
span.style.background = options.backgroundActive;
} else if (index === cursorIndex && !incorrect && options.darkModeEffective) {
span.style.background = THEME_DARK_MODE_BACKGROUND_ACTIVE;
}
if (index === cursorIndex && incorrect && options.overrideBackgroundIncorrect) {
span.style.background = options.backgroundIncorrect;
} else if (index === cursorIndex && incorrect && options.darkModeEffective) {
span.style.background = THEME_DARK_MODE_BACKGROUND_INCORRECT;
}
if (options.fontFamilyCss) {
span.style.fontFamily = options.fontFamilyCss;
}
if (Number.isFinite(options.fontSizePx)) {
span.style.fontSize = `${options.fontSizePx}px`;
if (_ntNativeLineHeight) {
span.style.lineHeight = _ntNativeLineHeight;
}
}
span.style.fontWeight = String(options.fontWeight || THEME_FONT_WEIGHT_DEFAULT);
if (options.fontItalic) {
span.style.fontStyle = "italic";
}
preview.appendChild(span);
});
};
render();
return { preview, render };
};
const stickyPreviewWrapper = document.createElement("div");
stickyPreviewWrapper.className = "ntcfg-sticky-preview";
const previewLabel = document.createElement("div");
previewLabel.className = "ntcfg-theme-preview-label";
previewLabel.textContent = "Preview:";
stickyPreviewWrapper.appendChild(previewLabel);
const normalPreview = createThemePreview(7, false);
const incorrectPreview = createThemePreview(7, true);
const previewGrid = document.createElement("div");
previewGrid.className = "ntcfg-theme-preview-grid";
previewGrid.append(normalPreview.preview, incorrectPreview.preview);
stickyPreviewWrapper.appendChild(previewGrid);
themeEngine.appendChild(stickyPreviewWrapper);
const renderThemePreviews = () => {
normalPreview.render();
incorrectPreview.render();
};
window.addEventListener(THEME_ENGINE_UPDATED_EVENT, renderThemePreviews);
const themeSettingsRoot = document.createElement("div");
themeSettingsRoot.className = "ntcfg-theme-settings";
themeEngine.append(themeSettingsRoot);
const addThemeOption = (labelText, toggleName, toggleDefault, colorName, colorDefault) => {
const row = document.createElement("div");
row.className = "ntcfg-theme-setting";
const title = document.createElement("div");
title.className = "ntcfg-theme-setting-title";
title.textContent = labelText;
const controls = document.createElement("div");
controls.className = "ntcfg-theme-setting-controls";
const switchRoot = document.createElement("span");
switchRoot.className = "ntcfg-switch";
const toggleInput = document.createElement("input");
toggleInput.type = "checkbox";
toggleInput.checked = GM_getValue(toggleName, toggleDefault);
toggleInput.addEventListener("change", () => {
setNtcfgRaceOptionsValue(toggleName, toggleInput.checked);
maybeApplyLiveThemeSetting(toggleName);
});
bindSettingInput(toggleName, (value) => {
toggleInput.checked = !!value;
});
const track = document.createElement("span");
track.className = "ntcfg-switch-track";
switchRoot.append(toggleInput, track);
const colorWrap = document.createElement("div");
colorWrap.className = "ntcfg-theme-color-compact";
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.className = "ntcfg-color-picker";
const hexInput = document.createElement("input");
hexInput.type = "text";
hexInput.className = "ntcfg-input ntcfg-color-hex";
hexInput.maxLength = 7;
hexInput.placeholder = "#FFFFFF";
const startingHex = normalizeHexColor(GM_getValue(colorName, colorDefault), normalizeHexColor(colorDefault));
colorInput.value = startingHex;
hexInput.value = startingHex;
bindSettingInput(colorName, (value) => {
const normalized = normalizeHexColor(value, normalizeHexColor(colorDefault));
colorInput.value = normalized;
hexInput.value = normalized;
});
const saveColor = (value) => {
const hex = normalizeHexColor(value, colorInput.value || startingHex);
colorInput.value = hex;
hexInput.value = hex;
setNtcfgRaceOptionsValue(colorName, hex);
maybeApplyLiveThemeSetting(colorName);
};
colorInput.addEventListener("input", () => saveColor(colorInput.value));
hexInput.addEventListener("change", () => saveColor(hexInput.value));
hexInput.addEventListener("blur", () => saveColor(hexInput.value));
colorWrap.append(colorInput, hexInput);
controls.append(colorWrap, switchRoot);
row.append(title, controls);
themeSettingsRoot.append(row);
};
const addThemeToggleOption = (labelText, toggleName, toggleDefault, onChange = null) => {
const row = document.createElement("div");
row.className = "ntcfg-theme-setting";
const title = document.createElement("div");
title.className = "ntcfg-theme-setting-title";
title.textContent = labelText;
const controls = document.createElement("div");
controls.className = "ntcfg-theme-setting-controls";
const switchRoot = document.createElement("span");
switchRoot.className = "ntcfg-switch";
const toggleInput = document.createElement("input");
toggleInput.type = "checkbox";
toggleInput.checked = GM_getValue(toggleName, toggleDefault);
toggleInput.addEventListener("change", () => {
setNtcfgRaceOptionsValue(toggleName, toggleInput.checked);
maybeApplyLiveThemeSetting(toggleName);
if (typeof onChange === "function") {
onChange(toggleInput.checked);
}
});
bindSettingInput(toggleName, (value) => {
toggleInput.checked = !!value;
if (typeof onChange === "function") {
onChange(toggleInput.checked);
}
});
const track = document.createElement("span");
track.className = "ntcfg-switch-track";
switchRoot.append(toggleInput, track);
controls.append(switchRoot);
row.append(title, controls);
themeSettingsRoot.append(row);
return { row, input: toggleInput };
};
const addThemeSelectOption = (labelText, valueName, valueDefault, options, onChange = null) => {
const row = document.createElement("div");
row.className = "ntcfg-theme-setting";
const title = document.createElement("div");
title.className = "ntcfg-theme-setting-title";
title.textContent = labelText;
const controls = document.createElement("div");
controls.className = "ntcfg-theme-setting-controls";
const select = document.createElement("select");
select.className = "ntcfg-input ntcfg-theme-input-compact ntcfg-theme-input-select";
const safeOptions = Array.isArray(options) ? options.filter(Boolean) : [];
safeOptions.forEach((optionDef) => {
const option = document.createElement("option");
option.value = String(optionDef.value);
option.textContent = String(optionDef.label ?? optionDef.value);
select.appendChild(option);
});
const normalizeSelectedValue = (value) => {
const raw = String(value ?? "").trim();
if (safeOptions.some((optionDef) => String(optionDef.value) === raw)) {
return raw;
}
return String(valueDefault);
};
select.value = normalizeSelectedValue(GM_getValue(valueName, valueDefault));
select.addEventListener("change", () => {
const normalized = normalizeSelectedValue(select.value);
select.value = normalized;
setNtcfgRaceOptionsValue(valueName, normalized);
maybeApplyLiveThemeSetting(valueName);
if (typeof onChange === "function") {
onChange(normalized);
}
});
bindSettingInput(valueName, (value) => {
const normalized = normalizeSelectedValue(value);
select.value = normalized;
if (typeof onChange === "function") {
onChange(normalized);
}
});
controls.append(select);
row.append(title, controls);
themeSettingsRoot.append(row);
return { row, select };
};
const addThemeNumberOption = (
labelText,
valueName,
valueDefault,
{
min = 1,
max = 60,
step = 1,
presets = [],
normalizeValue = (value, fallback) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : Number(fallback);
},
} = {}
) => {
const row = document.createElement("div");
row.className = "ntcfg-theme-setting";
const title = document.createElement("div");
title.className = "ntcfg-theme-setting-title";
title.textContent = labelText;
const controls = document.createElement("div");
controls.className = "ntcfg-theme-setting-controls";
const input = document.createElement("input");
input.type = "number";
input.className = "ntcfg-input ntcfg-theme-input-compact ntcfg-theme-input-number";
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.value = String(normalizeValue(GM_getValue(valueName, valueDefault), valueDefault));
const presetSelect = document.createElement("select");
presetSelect.className = "ntcfg-input ntcfg-theme-input-compact ntcfg-theme-input-preset";
const customPresetValue = "__custom__";
const customOption = document.createElement("option");
customOption.value = customPresetValue;
customOption.textContent = "Custom";
presetSelect.appendChild(customOption);
const normalizedPresets = Array.isArray(presets)
? presets
.map((preset) => {
if (!preset || typeof preset !== "object") return null;
const value = normalizeValue(preset.value, valueDefault);
const label = String(preset.label || `${value}s`).trim();
return { value, label };
})
.filter(Boolean)
: [];
normalizedPresets.forEach((preset) => {
const option = document.createElement("option");
option.value = String(preset.value);
option.textContent = `${preset.label} (${preset.value}s)`;
presetSelect.appendChild(option);
});
const syncPresetFromValue = (value) => {
const matched = normalizedPresets.find((preset) => preset.value === value);
presetSelect.value = matched ? String(matched.value) : customPresetValue;
};
const save = () => {
const normalized = normalizeValue(input.value, valueDefault);
input.value = String(normalized);
syncPresetFromValue(normalized);
setNtcfgRaceOptionsValue(valueName, normalized);
maybeApplyLiveThemeSetting(valueName);
};
input.addEventListener("change", save);
input.addEventListener("blur", save);
presetSelect.addEventListener("change", () => {
if (presetSelect.value === customPresetValue) {
return;
}
const normalized = normalizeValue(presetSelect.value, valueDefault);
input.value = String(normalized);
setNtcfgRaceOptionsValue(valueName, normalized);
maybeApplyLiveThemeSetting(valueName);
});
bindSettingInput(valueName, (value) => {
const normalized = normalizeValue(value, valueDefault);
input.value = String(normalized);
syncPresetFromValue(normalized);
});
controls.append(input);
if (normalizedPresets.length > 0) {
controls.append(presetSelect);
syncPresetFromValue(normalizeValue(input.value, valueDefault));
}
row.append(title, controls);
themeSettingsRoot.append(row);
return { row, input, presetSelect };
};
// — Dark mode —
// Dark mode toggle with inline "Always" / "Same as system" dropdown
(() => {
const row = document.createElement("div");
row.className = "ntcfg-theme-setting";
const title = document.createElement("div");
title.className = "ntcfg-theme-setting-title";
title.textContent = "Dark mode";
const controls = document.createElement("div");
controls.className = "ntcfg-theme-setting-controls";
const modeSelect = document.createElement("select");
modeSelect.className = "ntcfg-input ntcfg-theme-input-compact ntcfg-theme-input-select";
const modeOptions = [
{ value: "always", label: "Always" },
{ value: "system", label: "Same as system" },
];
modeOptions.forEach((opt) => {
const option = document.createElement("option");
option.value = opt.value;
option.textContent = opt.label;
modeSelect.appendChild(option);
});
const isSyncSystem = GM_getValue("THEME_DARK_MODE_SYNC_SYSTEM", THEME_DARK_MODE_SYNC_SYSTEM);
modeSelect.value = isSyncSystem ? "system" : "always";
const isDarkOn = GM_getValue("THEME_ENABLE_DARK_MODE", THEME_ENABLE_DARK_MODE);
modeSelect.hidden = !isDarkOn;
modeSelect.addEventListener("change", () => {
const isSystem = modeSelect.value === "system";
setNtcfgRaceOptionsValue("THEME_DARK_MODE_SYNC_SYSTEM", isSystem);
maybeApplyLiveThemeSetting("THEME_DARK_MODE_SYNC_SYSTEM");
});
bindSettingInput("THEME_DARK_MODE_SYNC_SYSTEM", (value) => {
modeSelect.value = value ? "system" : "always";
});
const switchRoot = document.createElement("span");
switchRoot.className = "ntcfg-switch";
const toggleInput = document.createElement("input");
toggleInput.type = "checkbox";
toggleInput.checked = isDarkOn;
toggleInput.addEventListener("change", () => {
setNtcfgRaceOptionsValue("THEME_ENABLE_DARK_MODE", toggleInput.checked);
modeSelect.hidden = !toggleInput.checked;
if (!toggleInput.checked) {
setNtcfgRaceOptionsValue("THEME_DARK_MODE_SYNC_SYSTEM", false);
modeSelect.value = "always";
}
maybeApplyLiveThemeSetting("THEME_ENABLE_DARK_MODE");
});
bindSettingInput("THEME_ENABLE_DARK_MODE", (value) => {
toggleInput.checked = !!value;
modeSelect.hidden = !value;
});
const track = document.createElement("span");
track.className = "ntcfg-switch-track";
switchRoot.append(toggleInput, track);
controls.append(modeSelect, switchRoot);
row.append(title, controls);
themeSettingsRoot.append(row);
})();
// — Font —
addThemeSelectOption(
"Font family",
"THEME_FONT_FAMILY_PRESET",
"__default__",
THEME_FONT_FAMILY_PRESETS.map((preset) => ({ value: preset.value, label: preset.label }))
);
addThemeSelectOption(
"Font size",
"THEME_FONT_SIZE_PRESET",
"__default__",
THEME_FONT_SIZE_PRESETS.map((preset) => ({ value: preset.value, label: preset.label }))
);
addThemeSelectOption(
"Single-line font size",
"THEME_SINGLE_LINE_FONT_SIZE_PRESET",
"__default__",
THEME_FONT_SIZE_PRESETS.map((preset) => ({ value: preset.value, label: preset.label }))
);
// Bold text toggle with inline weight select
(() => {
const row = document.createElement("div");
row.className = "ntcfg-theme-setting";
const title = document.createElement("div");
title.className = "ntcfg-theme-setting-title";
title.textContent = "Bold text";
const controls = document.createElement("div");
controls.className = "ntcfg-theme-setting-controls";
const weightSelect = document.createElement("select");
weightSelect.className = "ntcfg-input ntcfg-theme-input-compact ntcfg-theme-input-select";
const weightOptions = [
{ value: "300", label: "Light" },
{ value: "500", label: "Medium" },
{ value: "600", label: "Semi-Bold" },
{ value: "700", label: "Bold" },
{ value: "800", label: "Extra-Bold" },
{ value: "900", label: "Black" },
];
weightOptions.forEach((opt) => {
const option = document.createElement("option");
option.value = opt.value;
option.textContent = opt.label;
weightSelect.appendChild(option);
});
const normalizeWeight = (val) => {
const s = String(val ?? "700").trim();
return weightOptions.some((o) => o.value === s) ? s : "700";
};
weightSelect.value = normalizeWeight(GM_getValue("THEME_FONT_WEIGHT", THEME_FONT_WEIGHT));
weightSelect.addEventListener("change", () => {
const normalized = normalizeWeight(weightSelect.value);
weightSelect.value = normalized;
setNtcfgRaceOptionsValue("THEME_FONT_WEIGHT", normalized);
maybeApplyLiveThemeSetting("THEME_FONT_WEIGHT");
});
bindSettingInput("THEME_FONT_WEIGHT", (value) => {
weightSelect.value = normalizeWeight(value);
});
const isBoldOn = GM_getValue("THEME_FONT_BOLD", THEME_FONT_BOLD);
weightSelect.hidden = !isBoldOn;
const switchRoot = document.createElement("span");
switchRoot.className = "ntcfg-switch";
const toggleInput = document.createElement("input");
toggleInput.type = "checkbox";
toggleInput.checked = isBoldOn;
toggleInput.addEventListener("change", () => {
setNtcfgRaceOptionsValue("THEME_FONT_BOLD", toggleInput.checked);
weightSelect.hidden = !toggleInput.checked;
maybeApplyLiveThemeSetting("THEME_FONT_BOLD");
});
bindSettingInput("THEME_FONT_BOLD", (value) => {
toggleInput.checked = !!value;
weightSelect.hidden = !value;
});
const track = document.createElement("span");
track.className = "ntcfg-switch-track";
switchRoot.append(toggleInput, track);
controls.append(weightSelect, switchRoot);
row.append(title, controls);
themeSettingsRoot.append(row);
})();
addThemeToggleOption("Italic text", "THEME_FONT_ITALIC", THEME_FONT_ITALIC);
// — Colors —
addThemeOption("Background color", "THEME_OVERRIDE_BACKGROUND", THEME_OVERRIDE_BACKGROUND, "THEME_COLOR_BACKGROUND", THEME_COLOR_BACKGROUND);
addThemeOption("Text color", "THEME_OVERRIDE_FOREGROUND", THEME_OVERRIDE_FOREGROUND, "THEME_COLOR_FOREGROUND", THEME_COLOR_FOREGROUND);
addThemeOption("Active letter color", "THEME_OVERRIDE_FOREGROUND_ACTIVE", THEME_OVERRIDE_FOREGROUND_ACTIVE, "THEME_COLOR_FOREGROUND_ACTIVE", THEME_COLOR_FOREGROUND_ACTIVE);
addThemeOption("Active letter background", "THEME_OVERRIDE_BACKGROUND_ACTIVE", THEME_OVERRIDE_BACKGROUND_ACTIVE, "THEME_COLOR_BACKGROUND_ACTIVE", THEME_COLOR_BACKGROUND_ACTIVE);
addThemeOption("Typed letter color", "THEME_OVERRIDE_FOREGROUND_TYPED", THEME_OVERRIDE_FOREGROUND_TYPED, "THEME_COLOR_FOREGROUND_TYPED", THEME_COLOR_FOREGROUND_TYPED);
addThemeOption("Incorrect letter background", "THEME_OVERRIDE_BACKGROUND_INCORRECT", THEME_OVERRIDE_BACKGROUND_INCORRECT, "THEME_COLOR_BACKGROUND_INCORRECT", THEME_COLOR_BACKGROUND_INCORRECT);
// — Effects —
addThemeToggleOption("Hide typed letters", "THEME_HIDE_TYPED_TEXT", THEME_HIDE_TYPED_TEXT);
let rainbowSpeedOption = null;
const setRainbowOptionsVisibility = (enabled) => {
if (rainbowSpeedOption?.row) {
rainbowSpeedOption.row.hidden = !enabled;
}
};
addThemeToggleOption(
"Rainbow typed text",
"THEME_ENABLE_RAINBOW_TYPED_TEXT",
THEME_ENABLE_RAINBOW_TYPED_TEXT,
setRainbowOptionsVisibility
);
rainbowSpeedOption = addThemeNumberOption(
"Rainbow speed (seconds)",
"THEME_RAINBOW_TYPED_TEXT_SPEED_SECONDS",
THEME_RAINBOW_TYPED_TEXT_SPEED_SECONDS,
{
min: 1,
max: 60,
step: 0.5,
normalizeValue: normalizeRainbowSpeedSeconds,
presets: [
{ label: "Fast", value: 5 },
{ label: "Normal", value: 10 },
{ label: "Slow", value: 15 },
{ label: "Very Slow", value: 25 },
],
}
);
setRainbowOptionsVisibility(GM_getValue("THEME_ENABLE_RAINBOW_TYPED_TEXT", THEME_ENABLE_RAINBOW_TYPED_TEXT));
const themeDefaults = {
THEME_ENABLE_DARK_MODE: false,
THEME_DARK_MODE_SYNC_SYSTEM: false,
THEME_OVERRIDE_BACKGROUND: false,
THEME_OVERRIDE_FOREGROUND: false,
THEME_OVERRIDE_FOREGROUND_ACTIVE: false,
THEME_OVERRIDE_FOREGROUND_TYPED: false,
THEME_HIDE_TYPED_TEXT: false,
THEME_OVERRIDE_BACKGROUND_ACTIVE: false,
THEME_OVERRIDE_BACKGROUND_INCORRECT: false,
THEME_FONT_SIZE_PRESET: "__default__",
THEME_SINGLE_LINE_FONT_SIZE_PRESET: "__default__",
THEME_FONT_FAMILY_PRESET: "__default__",
THEME_FONT_BOLD: false,
THEME_FONT_WEIGHT: 700,
THEME_FONT_ITALIC: false,
THEME_ENABLE_RAINBOW_TYPED_TEXT: false,
THEME_RAINBOW_TYPED_TEXT_SPEED_SECONDS: 10,
THEME_COLOR_BACKGROUND,
THEME_COLOR_FOREGROUND,
THEME_COLOR_FOREGROUND_ACTIVE,
THEME_COLOR_FOREGROUND_TYPED,
THEME_COLOR_BACKGROUND_ACTIVE,
THEME_COLOR_BACKGROUND_INCORRECT,
};
addSectionResetButton(themeEngine, "Reset Theme to Defaults", themeDefaults, () => {
renderThemePreviews();
});
const openPanel = () => {
overlay.classList.add("is-open");
broadcastFocusLock(true);
const firstFocusable = overlay.querySelector("input, button");
if (firstFocusable) {
setTimeout(() => firstFocusable.focus(), 0);
}
};
const closePanel = () => {
overlay.classList.remove("is-open");
broadcastFocusLock(false);
};
saveBtn.addEventListener("click", () => location.reload());
closeBtn.addEventListener("click", closePanel);
backdrop.addEventListener("click", closePanel);
const stopEvent = (e) => { e.stopPropagation(); };
overlay.addEventListener("mousedown", (e) => { e.stopPropagation(); broadcastFocusLock(true); }, false);
overlay.addEventListener("click", stopEvent, false);
overlay.addEventListener("keydown", (e) => {
stopEvent(e);
if (e.key === "Escape") {
closePanel();
}
}, true);
overlay.addEventListener("keyup", stopEvent, true);
overlay.addEventListener("mouseenter", () => broadcastFocusLock(true));
overlay.addEventListener("mouseleave", () => {
if (!overlay.contains(document.activeElement)) {
broadcastFocusLock(false);
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && overlay.classList.contains("is-open")) {
closePanel();
}
});
const configureButton = document.createElement('button');
configureButton.textContent = 'Configure';
configureButton.className = "ntcfg-open-btn";
configureButton.addEventListener("click", () => {
if (overlay.classList.contains("is-open")) {
closePanel();
return;
}
openPanel();
});
document.body.appendChild(configureButton);
};
createUI();
/** Finds the React Component from given dom. */
const findReact = (dom, traverseUp = 0) => {
const key = Object.keys(dom).find((key) => key.startsWith("__reactFiber$"))
const domFiber = dom[key]
if (domFiber == null) return null
const getCompFiber = (fiber) => {
let parentFiber = fiber?.return
while (typeof parentFiber?.type == "string") {
parentFiber = parentFiber?.return
}
return parentFiber
}
let compFiber = getCompFiber(domFiber)
for (let i = 0; i < traverseUp && compFiber; i++) {
compFiber = getCompFiber(compFiber)
}
return compFiber?.stateNode
}
var my_race_started = false;
const RACE_VISUAL_STYLE_ID = "ntcfg-race-visual-live-style";
const isRaceOptionEnabledNow = (settingKey, fallback = false) => {
const liveValue = readNtcfgRaceOptionsValue(settingKey)
return typeof liveValue === "undefined" ? !!fallback : !!liveValue
}
const applyRaceVisualStyles = () => {
const rules = [];
if (isRaceOptionEnabledNow("hideTrack", false)) {
rules.push(`.racev3-track { opacity: 0 !important; margin-top: -400px !important; }`);
}
if (isRaceOptionEnabledNow("hideNotifications", true)) {
rules.push(`.growls { display: none !important; }`);
}
if (isRaceOptionEnabledNow("HIDE_CHAT_AND_STICKERS", false)) {
rules.push(`.raceChat { display: none !important; }`);
}
if (isRaceOptionEnabledNow("HIDE_FINISH_FLAG", false)) {
rules.push(`.dash-letter > img[alt="Finish"] { display: none !important; }`);
}
let styleNode = document.getElementById(RACE_VISUAL_STYLE_ID);
if (rules.length === 0) {
styleNode?.remove();
return;
}
if (!styleNode) {
styleNode = document.createElement("style");
styleNode.id = RACE_VISUAL_STYLE_ID;
document.head.appendChild(styleNode);
}
styleNode.textContent = rules.join("\n");
}
applyRaceVisualStyles()
function isRaceOptionsDebugEnabled() {
return readNtcfgRaceOptionsValue('DEBUG_LOGGING') === true;
}
/** Create a Console Logger with some prefixing. */
const createLogger = (namespace) => {
const logPrefix = (prefix = "") => {
const formatMessage = `%c[${namespace}]${prefix ? `%c[${prefix}]` : ""}`
let args = [console, `${formatMessage}%c`, "background-color: #D62F3A; color: #fff; font-weight: bold"]
if (prefix) {
args = args.concat("background-color: #4f505e; color: #fff; font-weight: bold")
}
return args.concat("color: unset")
}
const bindConsoleMethod = (methodName, prefix) => Function.prototype.bind.apply(console[methodName], logPrefix(prefix))
const bindGatedMethod = (methodName, prefix) => (...args) => {
if (!isRaceOptionsDebugEnabled()) return
bindConsoleMethod(methodName, prefix)(...args)
}
return {
info: (prefix) => bindGatedMethod("info", prefix),
warn: (prefix) => bindConsoleMethod("warn", prefix),
error: (prefix) => bindConsoleMethod("error", prefix),
log: (prefix) => bindGatedMethod("log", prefix),
debug: (prefix) => bindGatedMethod("debug", prefix),
}
}
function logstats() {
const raceContainer = document.getElementById("raceContainer"),
canvasTrack = raceContainer?.querySelector("canvas"),
raceObj = raceContainer ? findReact(raceContainer) : null;
const currentUserID = raceObj.props.user.userID;
const currentUserResult = raceObj.state.racers.find((r) => r.userID === currentUserID)
if (!currentUserResult || !currentUserResult.progress || typeof currentUserResult.place === "undefined") {
console.log("STATS LOGGER: Unable to find race results");
return
}
const {
typed,
skipped,
startStamp,
completeStamp,
errors
} = currentUserResult.progress,
wpm = Math.round((typed - skipped) / 5 / ((completeStamp - startStamp) / 6e4)),
time = ((completeStamp - startStamp) / 1e3).toFixed(2),
acc = ((1 - errors / (typed - skipped)) * 100).toFixed(2),
points = Math.round((100 + wpm / 2) * (1 - errors / (typed - skipped))),
place = currentUserResult.place
console.log(`STATS LOGGER: ${place} | ${acc}% Acc | ${wpm} WPM | ${points} points | ${time} secs`)
}
const logging = createLogger("Nitro Type Racing Stats")
/** Auto close reward popup takeover (optional). */
const setupAutoCloseRewardPopup = () => {
const getRootContainer = () => document.getElementById("root")
const lastAttemptByNode = new WeakMap()
const shouldAttempt = (node) => {
const now = Date.now()
const prev = lastAttemptByNode.get(node) || 0
if (now - prev < 500) return false
lastAttemptByNode.set(node, now)
return true
}
const getReactFiber = (dom) => {
if (!dom) return null
const key = Object.keys(dom).find((k) => k.startsWith("__reactFiber$") || k.startsWith("__reactInternalInstance$"))
return key ? dom[key] : null
}
const findCloseFn = (dom) => {
let fiber = getReactFiber(dom)
let steps = 0
while (fiber && steps++ < 60) {
const props = fiber.memoizedProps || fiber.pendingProps || null
if (props && typeof props.close === "function") {
return props.close
}
const stateNodeProps = fiber.stateNode?.props
if (stateNodeProps && typeof stateNodeProps.close === "function") {
return stateNodeProps.close
}
fiber = fiber.return
}
return null
}
const closeEventsRewardsPopup = (node) => {
if (!node || !node.classList?.contains("events--rewards")) return false
if (!node.classList.contains("is-visible")) return false
if (!shouldAttempt(node)) return true
const actions = node.querySelector?.(".events--rewards--actions")
const buttons = actions ? Array.from(actions.querySelectorAll(".btn")) : []
const byText = (t) => buttons.find((b) => (b.textContent || "").trim().toLowerCase() === t)
const closeBtn =
byText("close") ||
byText("continue") ||
actions?.querySelector?.(".btn--primary") ||
buttons[buttons.length - 1] ||
null
if (closeBtn && typeof closeBtn.click === "function") {
closeBtn.click()
return true
}
const closeFn = findCloseFn(node)
if (typeof closeFn === "function") {
try {
closeFn()
return true
} catch {
// ignore and fall through
}
}
// Last resort: hide it so it can't block the UI.
try {
node.style.display = "none"
node.style.pointerEvents = "none"
node.classList.remove("is-visible")
} catch {
// ignore
}
return true
}
const closeTakeoverTempPopup = (node) => {
if (!node || !node.classList?.contains("takeoverTemp")) return false
if (!shouldAttempt(node)) return true
const closeBtn =
node.querySelector?.('[data-testid="close"]') ||
node.querySelector?.('button[aria-label="Close"]') ||
node.querySelector?.('[aria-label="Close"]') ||
node.querySelector?.('button[title="Close"]') ||
node.querySelector?.('button[class*="close"]') ||
node.querySelector?.('[role="button"][aria-label="Close"]') ||
null
if (closeBtn && typeof closeBtn.click === "function") {
closeBtn.click()
return true
}
const closeFn = findCloseFn(node)
if (typeof closeFn === "function") {
try {
closeFn()
return true
} catch {
// ignore and fall through
}
}
// Last resort: hide it so it can't block the UI.
try {
node.style.display = "none"
node.style.pointerEvents = "none"
} catch {
// ignore
}
return true
}
const scanAndClose = () => {
// Allow toggling without reload.
if (!GM_getValue("AUTO_CLOSE_REWARD_POPUP", true)) return
const root = getRootContainer()
if (!root) return
root.querySelectorAll(".events--rewards.is-visible").forEach((n) => closeEventsRewardsPopup(n))
root.querySelectorAll(".takeoverTemp").forEach((n) => closeTakeoverTempPopup(n))
}
// Scan immediately and then keep scanning; rewards popups often toggle visibility via class changes.
scanAndClose()
const observer = new MutationObserver(() => scanAndClose())
const root = getRootContainer()
if (root) {
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] })
}
setInterval(scanAndClose, 1000)
}
setupAutoCloseRewardPopup()
/* Config storage */
const db = new Dexie("NTRacingStats")
db.version(1).stores({
backupStatData: "userID",
})
db.open().catch(function (e) {
logging.error("Init")("Failed to open up the racing stat cache database", e)
})
////////////
// Init //
////////////
let raceContainer = document.getElementById("raceContainer"),
raceObj = raceContainer ? findReact(raceContainer) : null,
server = raceObj?.server,
currentUser = raceObj?.props.user
const resolveRaceBootstrap = () => {
raceContainer = document.getElementById("raceContainer")
raceObj = raceContainer ? findReact(raceContainer) : null
server = raceObj?.server
currentUser = raceObj?.props.user
return !!(raceContainer && raceObj && server && currentUser)
}
if (!resolveRaceBootstrap()) {
logging.info("Init")("Race container not ready, waiting...")
let initAttempts = 0
const initMaxAttempts = 100
const initCheck = () => {
initAttempts++
if (resolveRaceBootstrap()) {
logging.info("Init")("Race container found after " + initAttempts + " attempts")
_initContinue()
return
}
if (initAttempts >= initMaxAttempts) {
logging.info("Init")("No active race on this page, stats features inactive")
return
}
setTimeout(initCheck, 150)
}
setTimeout(initCheck, 150)
return
}
_initContinue()
return
function _initContinue() {
if (!currentUser?.loggedIn) {
logging.error("Init")("Not available for Guest Racing")
return
}
const getLiveRaceOptionValue = (settingKey, fallback = undefined) => {
const liveValue = readNtcfgRaceOptionsValue(settingKey)
return typeof liveValue === "undefined" ? fallback : liveValue
}
const isLiveRaceOptionEnabled = (settingKey, fallback = false) => !!getLiveRaceOptionValue(settingKey, fallback)
const getLiveRaceOptionNumber = (settingKey, fallback = 0) => {
const parsed = Number(getLiveRaceOptionValue(settingKey, fallback))
return Number.isFinite(parsed) ? parsed : Number(fallback) || 0
}
const setupPreciseAccuracyMetric = () => {
if (!GM_getValue("ENABLE_PRECISE_ACCURACY", ENABLE_PRECISE_ACCURACY)) {
return
}
const styleId = "ntcfg-precise-accuracy-style"
if (!document.getElementById(styleId)) {
const style = document.createElement("style")
style.id = styleId
style.appendChild(
document.createTextNode(`
.dash-metrics .accuracy-rounded { font-size: 14px; }
`)
)
document.head.appendChild(style)
}
const state = {
lessonLength: 0,
skipped: 0,
errors: 0,
accuracyNode: null,
roundedValueNode: null,
active: false,
}
const getAccuracyNode = () => raceContainer.querySelector(".dash-metrics .list-item:nth-of-type(2) span.h4")
const ensureRoundedValueNode = (accuracyNode) => {
const parent = accuracyNode?.parentNode
if (!parent) return null
let roundedWrap = parent.querySelector(".ntcfg-accuracy-rounded-wrap")
if (!roundedWrap) {
roundedWrap = document.createElement("div")
roundedWrap.className = "ntcfg-accuracy-rounded-wrap"
roundedWrap.innerHTML = `<span class="accuracy-rounded">100</span><span class="tsxs tc-ts ttu mlxxs">%</span>`
parent.append(roundedWrap)
}
return roundedWrap.querySelector(".accuracy-rounded")
}
const refreshAccuracy = () => {
if (!state.active || !state.accuracyNode || state.lessonLength <= 0) {
return
}
const denominator = state.lessonLength - state.skipped
if (denominator <= 0) {
return
}
const score = Math.max(0, Math.min(100, (1 - state.errors / denominator) * 100))
state.accuracyNode.textContent = score.toFixed(2)
if (state.roundedValueNode) {
state.roundedValueNode.textContent = String(Math.round(score))
}
}
server.on("status", (e) => {
if (e.status !== "countdown") {
return
}
if (!GM_getValue("ENABLE_PRECISE_ACCURACY", ENABLE_PRECISE_ACCURACY)) {
state.active = false
return
}
const lessonLength = Number(e.lessonLength)
if (!Number.isFinite(lessonLength) || lessonLength <= 0) {
state.active = false
return
}
const accuracyNode = getAccuracyNode()
if (!accuracyNode) {
state.active = false
return
}
state.lessonLength = lessonLength
state.skipped = 0
state.errors = 0
state.accuracyNode = accuracyNode
state.roundedValueNode = ensureRoundedValueNode(accuracyNode)
state.active = true
state.accuracyNode.textContent = "100.00"
if (state.roundedValueNode) {
state.roundedValueNode.textContent = "100"
}
})
server.on("update", () => {
if (!state.active) {
return
}
refreshAccuracy()
})
if (typeof raceObj.incrementTyped === "function" && !raceObj.__ntcfgPreciseAccuracyPatched) {
const originalIncrementTyped = raceObj.incrementTyped
raceObj.incrementTyped = function () {
const result = originalIncrementTyped.apply(this, arguments)
const data = arguments[0]
state.skipped = Number(raceObj?.typedStats?.skipped || 0)
state.errors = Number(raceObj?.typedStats?.errors || 0)
let canRefresh = false
if (typeof data?.skipped === "number") {
canRefresh = state.errors > 0
}
if (typeof data?.error === "number") {
canRefresh = true
}
if (canRefresh) {
refreshAccuracy()
}
return result
}
raceObj.__ntcfgPreciseAccuracyPatched = true
}
}
setupPreciseAccuracyMetric()
raceContainer.addEventListener('click', (event) => {
const hiddenInput = document.querySelector('.race-hiddenInput');
if (hiddenInput && typeof hiddenInput.click === "function") {
hiddenInput.click();
}
});
//////////////////
// Components //
//////////////////
/** Styles for the following components. */
const style = document.createElement("style")
style.appendChild(
document.createTextNode(`
/* Race page tweaks (kept). */
.racev3-track {
margin-top: -30px;
}
.header-bar--return-to-garage{
display: none !important;
}
.dropdown {
display: none !important;
}
.header-nav {
display: none !important;
}
.logo-SVG {
height: 50% !important;
width: 50% !important;
}
/* Racing Stats UI theme (restored). */
#raceContainer {
margin-bottom: 0;
}
.nt-stats-root {
background-color: #222;
box-sizing: border-box;
}
.nt-stats-body {
display: flex;
justify-content: space-between;
padding: 8px;
}
.nt-stats-root.nt-stats-root--wide .nt-stats-body {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
column-gap: 24px;
align-items: start;
}
.nt-stats-left-section, .nt-stats-right-section {
display: flex;
flex-direction: column;
row-gap: 8px;
}
.nt-stats-right-section {
flex-grow: 1;
margin-left: 15px;
}
.nt-stats-root.nt-stats-root--wide .nt-stats-right-section {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 28px;
row-gap: 0;
align-items: center;
margin-left: 0;
}
.nt-stats-root.nt-stats-root--wide .nt-stats-info {
justify-self: center;
align-self: center;
max-width: 100%;
}
.nt-stats-root.nt-stats-root--wide .nt-stats-season-progress {
justify-self: end;
margin: 0;
}
.nt-stats-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 8px;
color: rgba(255, 255, 255, 0.8);
background-color: #03111a;
font-size: 12px;
}
.nt-stats-toolbar-status {
display: flex;
}
.nt-stats-toolbar-status .nt-stats-toolbar-status-item {
padding: 0 8px;
background-color: #0a2c42;
}
.nt-stats-toolbar-status .nt-stats-toolbar-status-item-alt {
padding: 0 8px;
background-color: #22465c;
}
.nt-stats-daily-challenges {
width: 350px;
}
.nt-stats-daily-challenges .daily-challenge-progress--badge {
z-index: 0;
}
.nt-stats-season-progress {
padding: 8px;
margin: 0 auto;
border-radius: 8px;
background-color: #1b83d0;
box-shadow: 0 28px 28px 0 rgb(2 2 2 / 5%), 0 17px 17px 0 rgb(2 2 2 / 20%), 0 8px 8px 0 rgb(2 2 2 / 15%);
}
.nt-stats-season-progress .season-progress-widget {
width: 350px;
}
.nt-stats-season-progress .season-progress-widget--level-progress-bar {
transition: width 0.3s ease;
}
.nt-stats-season-progress .season-progress-widget--level-progress {
border-radius: 999px;
overflow: hidden;
background: rgba(0, 0, 0, 0.22);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.35);
}
.nt-stats-season-progress .season-progress-widget--level-progress-bar {
border-radius: 999px;
background: linear-gradient(90deg, #f7d14a 0%, #f3a81b 55%, #f07d1b 100%);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.25) inset, 0 2px 6px rgba(0, 0, 0, 0.25);
}
.nt-stats-season-progress img.season-reward-mini-previewImg {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.nt-stats-season-progress .season-reward-mini-preview--preview .seasonReward-cardVis--money {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.nt-stats-season-progress .season-reward-mini-preview--preview .rarity-badge {
transform: scale(0.9);
transform-origin: center;
}
/* Cash already shows the amount; hide the corner label box to avoid a blank square. */
.nt-stats-season-progress .season-reward-mini-preview--money .season-reward-mini-preview--label {
display: none !important;
}
.nt-stats-info {
text-align: center;
color: #eee;
font-size: 14px;
}
.nt-stats-metric-row {
margin-bottom: 4px;
}
.nt-stats-metric-separator {
color: rgba(255, 255, 255, 0.35);
}
.nt-stats-metric-heading {
color: rgba(255, 255, 255, 0.75);
}
.nt-stats-metric-value, .nt-stats-metric-suffix {
font-weight: 600;
}
.nt-stats-metric-suffix {
color: #aaa;
}
`)
)
document.head.appendChild(style)
/** Populates daily challenge data merges in the given progress. */
const mergeDailyChallengeData = (progress) => {
const {
CHALLENGES,
CHALLENGE_TYPES
} = NTGLOBALS,
now = Math.floor(Date.now() / 1000)
return CHALLENGES.filter((c) => c.expiration > now)
.slice(0, 3)
.map((c, i) => {
const userProgress = progress.find((p) => p.challengeID === c.challengeID),
challengeType = CHALLENGE_TYPES[c.type],
field = challengeType[1],
title = challengeType[0].replace(/\$\{goal\}/, c.goal).replace(/\$\{field\}/, `${challengeType[1]}${c.goal !== 1 ? "s" : ""}`)
return {
...c,
title,
field,
goal: c.goal,
progress: userProgress?.progress || 0,
}
})
}
/** Grab NT Racing Stats from various sources. */
const getStats = async () => {
//await new Promise(resolve => setTimeout(resolve, 3000));
let backupUserStats = null
try {
backupUserStats = await db.backupStatData.get(currentUser.userID)
} catch (ex) {
logging.warn("Update")("Unable to get backup stats", ex)
}
try {
const persistStorageStats = JSON.parse(JSON.parse(localStorage.getItem("persist:nt")).user),
user = !backupUserStats || typeof backupUserStats.lastConsecRace !== "number" || persistStorageStats.lastConsecRace >= backupUserStats.lastConsecRace ?
persistStorageStats :
backupUserStats,
dailyChallenges = mergeDailyChallengeData(user.challenges)
return {
user,
dailyChallenges
}
} catch (ex) {
logging.error("Update")("Unable to get stats", ex)
}
return Promise.reject(new Error("Unable to get stats"))
}
/** Grab Summary Stats. */
const getSummaryStats = () => {
const authToken = localStorage.getItem("player_token")
return fetch("/api/v2/stats/summary", {
headers: {
Authorization: `Bearer ${authToken}`,
},
})
.then((r) => r.json())
.then((r) => {
return {
seasonBoard: r?.results?.racingStats?.find((b) => b.board === "season"),
dailyBoard: r?.results?.racingStats?.find((b) => b.board === "daily"),
}
})
.catch((err) => Promise.reject(err))
}
/** Grab Stats from Team Data. */
const getTeamStats = () => {
if (!currentUser?.tag) {
return Promise.reject(new Error("User is not in a team"))
}
const authToken = localStorage.getItem("player_token")
return fetch(`/api/v2/teams/${currentUser.tag}`, {
headers: {
Authorization: `Bearer ${authToken}`,
},
})
.then((r) => r.json())
.then((r) => {
return {
leaderboard: r?.results?.leaderboard,
motd: r?.results?.motd,
info: r?.results?.info,
stats: r?.results?.stats,
member: r?.results?.members?.find((u) => u.userID === currentUser.userID),
season: r?.results?.season?.find((u) => u.userID === currentUser.userID),
}
})
.catch((err) => Promise.reject(err))
}
/** Stat Manager widget (basically a footer with settings button). */
const ToolbarWidget = ((user) => {
const root = document.createElement("div")
root.classList.add("nt-stats-toolbar")
root.innerHTML = `
<div>
NOTE: Team Stats and Season Stats are cached.
</div>
<div class="nt-stats-toolbar-status">
<div class="nt-stats-toolbar-status-item">
<span class=" nt-cash-status as-nitro-cash--prefix">N/A</span>
</div>
<div class="nt-stats-toolbar-status-item-alt">
📦 Mystery Box: <span class="mystery-box-status">N/A</span>
</div>
</div>`
/** Mystery Box **/
const rewardCountdown = user.rewardCountdown,
mysteryBoxStatus = root.querySelector(".mystery-box-status")
let isDisabled = Date.now() < user.rewardCountdown * 1e3,
timer = null
const syncCountdown = () => {
isDisabled = Date.now() < user.rewardCountdown * 1e3
if (!isDisabled) {
if (timer) {
clearInterval(timer)
}
mysteryBoxStatus.textContent = "Claim Now!"
return
}
mysteryBoxStatus.textContent = moment(user.rewardCountdown * 1e3).fromNow(false)
}
syncCountdown()
if (isDisabled) {
timer = setInterval(syncCountdown, 6e3)
}
/** NT Cash. */
const amountNode = root.querySelector(".nt-cash-status")
return {
root,
updateStats: (user) => {
if (typeof user?.money === "number") {
amountNode.textContent = `$${user.money.toLocaleString()}`
}
},
}
})(raceObj.props.user)
/** Daily Challenge widget. */
const DailyChallengeWidget = (() => {
const root = document.createElement("div")
root.classList.add("nt-stats-daily-challenges", "profile-dailyChallenges", "card", "card--open", "card--d", "card--grit", "card--shadow-l")
root.innerHTML = `
<div class="daily-challenge-list--heading">
<h4>Daily Challenges</h4>
<div class="daily-challenge-list--arriving">
<div class="daily-challenge-list--arriving-label">
<svg class="icon icon-recent-time"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="/dist/site/images/icons/icons.css.1494.svg#icon-recent-time"></use></svg>
New <span></span>
</div>
</div>
</div>
<div class="daily-challenge-list--challenges"></div>`
const dailyChallengesContainer = root.querySelector(".daily-challenge-list--challenges"),
dailyChallengesExpiry = root.querySelector(".daily-challenge-list--arriving-label span")
const dailyChallengeItem = document.createElement("div")
dailyChallengeItem.classList.add("raceResults--dailyChallenge")
dailyChallengeItem.innerHTML = `
<div class="daily-challenge-progress">
<div class="daily-challenge-progress--info">
<div class="daily-challenge-progress--requirements">
<div class="daily-challenge-progress--name">
<div style="height: 19px;">
<div align="left" style="white-space: nowrap; pavgSpeedosition: absolute; transform: translate(0%, 0px) scale(1, 1); left: 0px;">
</div>
</div>
</div>
<div class="daily-challenge-progress--status"></div>
</div>
<div class="daily-challenge-progress--progress">
<div class="daily-challenge-progress--progress-bar-container">
<div class="daily-challenge-progress--progress-bar" style="width: 40%"></div>
<div class="daily-challenge-progress--progress-bar--earned" style="width: 40%"></div>
</div>
</div>
</div>
<div class="daily-challenge-progress--badge">
<div class="daily-challenge-progress--success"></div>
<div class="daily-challenge-progress--xp">
<span class="daily-challenge-progress--value"></span><span class="daily-challenge-progress--divider">/</span><span class="daily-challenge-progress--target"></span>
</div>
<div class="daily-challenge-progress--label"></div>
</div>
</div>`
const updateDailyChallengeNode = (node, challenge) => {
let progressPercentage = challenge.goal > 0 ? (challenge.progress / challenge.goal) * 100 : 0
if (challenge.progress === challenge.goal) {
progressPercentage = 100
node.querySelector(".daily-challenge-progress").classList.add("is-complete")
} else {
node.querySelector(".daily-challenge-progress").classList.remove("is-complete")
}
node.querySelector(".daily-challenge-progress--name div div").textContent = challenge.title
node.querySelector(".daily-challenge-progress--label").textContent = `${challenge.field}s`
node.querySelector(".daily-challenge-progress--value").textContent = challenge.progress
node.querySelector(".daily-challenge-progress--target").textContent = challenge.goal
node.querySelector(".daily-challenge-progress--status").textContent = `Earn ${Math.floor(challenge.reward / 100) / 10}k XP`
node.querySelectorAll(".daily-challenge-progress--progress-bar, .daily-challenge-progress--progress-bar--earned").forEach((bar) => {
bar.style.width = `${progressPercentage}%`
})
}
let dailyChallengeNodes = null
getStats().then(({
dailyChallenges
}) => {
const dailyChallengeFragment = document.createDocumentFragment()
dailyChallengeNodes = dailyChallenges.map((c) => {
const node = dailyChallengeItem.cloneNode(true)
updateDailyChallengeNode(node, c)
dailyChallengeFragment.append(node)
return node
})
dailyChallengesContainer.append(dailyChallengeFragment)
})
const updateStats = (data) => {
if (!data || !dailyChallengeNodes || data.length === 0) {
return
}
if (data[0] && data[0].expiration) {
const t = 1000 * data[0].expiration
if (!isNaN(t)) {
dailyChallengesExpiry.textContent = moment(t).fromNow()
}
}
data.forEach((c, i) => {
if (dailyChallengeNodes[i]) {
updateDailyChallengeNode(dailyChallengeNodes[i], c)
}
})
}
return {
root,
updateStats,
}
})()
/** Display Season Progress and next Reward. */
const SeasonProgressWidget = ((raceObj) => {
const currentSeason = NTGLOBALS.ACTIVE_SEASONS.find((s) => {
const now = Date.now()
return now >= s.startStamp * 1e3 && now <= s.endStamp * 1e3
})
const seasonRewards = raceObj.props?.seasonRewards,
user = raceObj.props?.user
let cachedSeasonAssetKey = null
const extractAssetKeyFromPerkImgSrc = (src) => {
const m = String(src || "").match(/\/dist\/site\/images\/seasons\/([^/]+)\/perk\.png/i)
return m ? m[1] : null
}
const resolveSeasonAssetKey = () => {
const direct =
(typeof currentSeason?.assetKey === "string" && currentSeason.assetKey) ||
(typeof currentSeason?.asset_key === "string" && currentSeason.asset_key) ||
(typeof raceObj?.props?.season?.assetKey === "string" && raceObj.props.season.assetKey) ||
(typeof raceObj?.props?.season?.asset_key === "string" && raceObj.props.season.asset_key) ||
null
if (direct) return direct
// DOM hints (if NT has already rendered perk art anywhere).
const perkImg =
document.querySelector('img.seasonReward-cardVisImg[src*="/dist/site/images/seasons/"][src*="/perk.png"]') ||
document.querySelector('img[src*="/dist/site/images/seasons/"][src*="/perk.png"]')
const domSrc = perkImg?.getAttribute?.("src") || perkImg?.src || null
const fromDom = extractAssetKeyFromPerkImgSrc(domSrc)
if (fromDom) return fromDom
// Bootstrap JSON embedded in inline scripts.
const scripts = Array.from(document.scripts || [])
for (const s of scripts) {
if (s.src) continue
const txt = s.textContent
if (!txt || txt.length > 2000000) continue
const m = txt.match(/"assetKey"\s*:\s*"([a-zA-Z0-9_-]+)"/)
if (m && m[1]) return m[1]
}
// Common JSON data containers (framework-dependent).
const nextDataEl = document.getElementById("__NEXT_DATA__")
if (nextDataEl && nextDataEl.textContent) {
try {
const parsed = JSON.parse(nextDataEl.textContent)
const key =
parsed?.props?.pageProps?.currentSeason?.assetKey ||
parsed?.props?.pageProps?.season?.assetKey ||
null
if (typeof key === "string" && key) return key
} catch {
// ignore
}
}
return null
}
const getSeasonAssetKey = () => {
if (cachedSeasonAssetKey) return cachedSeasonAssetKey
const key = resolveSeasonAssetKey()
if (typeof key === "string" && key) {
cachedSeasonAssetKey = key
return cachedSeasonAssetKey
}
return null
}
const root = document.createElement("div")
root.classList.add("nt-stats-season-progress", "theme--pDefault")
root.innerHTML = `
<div class="season-progress-widget">
<div class="season-progress-widget--info">
<div class="season-progress-widget--title">Season Progress${currentSeason ? "" : " (starting soon)"}</div>
<div class="season-progress-widget--current-xp"></div>
<div class="season-progress-widget--current-level">
<div class="season-progress-widget--current-level--prefix">Level</div>
<div class="season-progress-widget--current-level--number"></div>
</div>
<div class="season-progress-widget--level-progress">
<div class="season-progress-widget--level-progress-bar" style="width: 0%;"></div>
</div>
</div>
<div class="season-progress-widget--next-reward">
<div class="season-progress-widget--next-reward--display">
<div class="season-reward-mini-preview">
<div class="season-reward-mini-preview--locked">
<div class="tooltip--season tooltip--xs tooltip--c" data-ttcopy="Upgrade to Nitro Gold to Unlock!">
<svg class="icon icon-lock"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="/dist/site/images/icons/icons.css.svg#icon-lock"></use></svg>
</div>
</div>
<a class="season-reward-mini-preview" href="/season">
<div class="season-reward-mini-preview--frame">
<div class="rarity-frame rarity-frame--small">
<div class="rarity-frame--extra"></div>
<div class="rarity-frame--content">
<div class="season-reward-mini-preview--preview"></div>
<div class="season-reward-mini-preview--label"></div>
</div>
</div>
</div>
</a>
</div>
</div>
</div>
</div>`
const xpTextNode = root.querySelector(".season-progress-widget--current-xp"),
xpProgressBarNode = root.querySelector(".season-progress-widget--level-progress-bar"),
levelNode = root.querySelector(".season-progress-widget--current-level--number"),
nextRewardRootNode = root.querySelector(".season-reward-mini-preview"),
nextRewardTypeLabelNode = root.querySelector(".season-reward-mini-preview--label"),
nextRewardTypeLockedNode = root.querySelector(".season-reward-mini-preview--locked"),
nextRewardTypePreviewNode = root.querySelector(".season-reward-mini-preview--preview"),
nextRewardTypePreviewImgNode = document.createElement("img"),
nextRewardRarityFrameNode = root.querySelector(".rarity-frame.rarity-frame--small")
nextRewardTypePreviewImgNode.classList.add("season-reward-mini-previewImg")
nextRewardTypePreviewImgNode.decoding = "async"
nextRewardTypePreviewImgNode.loading = "lazy"
if (!currentSeason) {
nextRewardRootNode.remove()
}
/** Work out how much experience required to reach specific level. */
const getExperienceRequired = (lvl) => {
if (lvl < 1) {
lvl = 1
}
const {
startingLevels,
experiencePerStartingLevel,
experiencePerAchievementLevel,
experiencePerExtraLevels
} = NTGLOBALS.SEASON_LEVELS
let totalExpRequired = 0,
amountExpRequired = experiencePerStartingLevel
for (let i = 1; i < lvl; i++) {
if (i <= startingLevels) {
totalExpRequired += experiencePerStartingLevel
} else if (currentSeason && i > currentSeason.totalRewards) {
totalExpRequired += experiencePerExtraLevels
amountExpRequired = experiencePerExtraLevels
} else {
totalExpRequired += experiencePerAchievementLevel
amountExpRequired = experiencePerAchievementLevel
}
}
return [amountExpRequired, totalExpRequired]
}
/** Get next reward object from `seasonRewards`. */
const getNextReward = (currentXP) => {
currentXP = currentXP || user.experience
if (!seasonRewards || seasonRewards.length === 0) {
return null
}
if (user.experience === 0) {
return seasonRewards[0] || null
}
let claimed = false
let nextReward = seasonRewards.find((r, i) => {
if (!r.bonus && (claimed || r.experience === currentXP)) {
claimed = true
return false
}
return r.experience > currentXP || i + 1 === seasonRewards.length
})
if (!nextReward) {
nextReward = seasonRewards[seasonRewards.length - 1]
}
return nextReward || null
}
const setRewardPreviewText = (text) => {
nextRewardTypePreviewImgNode.remove()
nextRewardTypePreviewNode.innerHTML = ""
nextRewardTypePreviewNode.textContent = text
}
const setRewardPreviewImage = (src, fallback = "?") => {
let fallbackText = "?"
let fallbackSrc = null
if (typeof fallback === "string") {
fallbackText = fallback
} else if (fallback && typeof fallback === "object") {
fallbackText = typeof fallback.fallbackText === "string" ? fallback.fallbackText : "?"
fallbackSrc = typeof fallback.fallbackSrc === "string" ? fallback.fallbackSrc : null
}
nextRewardTypePreviewImgNode.onerror = () => {
if (fallbackSrc && nextRewardTypePreviewImgNode.src !== fallbackSrc) {
nextRewardTypePreviewImgNode.onerror = () => {
setRewardPreviewText(fallbackText)
}
nextRewardTypePreviewImgNode.src = fallbackSrc
return
}
setRewardPreviewText(fallbackText)
}
nextRewardTypePreviewImgNode.src = src
nextRewardTypePreviewNode.innerHTML = ""
nextRewardTypePreviewNode.append(nextRewardTypePreviewImgNode)
}
return {
root,
refreshTeamRaceCorrections: () => {
syncCorrectedTeamRaces()
},
updateStats: (data) => {
// XP Progress
if (typeof data.experience === "number") {
const [amountExpRequired, totalExpRequired] = getExperienceRequired(data.level + 1),
progress = Math.max(5, ((amountExpRequired - (totalExpRequired - data.experience)) / amountExpRequired) * 100.0) || 5
xpTextNode.textContent = `${(amountExpRequired - (totalExpRequired - data.experience)).toLocaleString()} / ${amountExpRequired / 1e3}k XP`
xpProgressBarNode.style.width = `${progress}%`
}
levelNode.textContent = currentSeason && data.level > currentSeason.totalRewards + 1 ? `∞${data.level - currentSeason.totalRewards - 1}` : data.level || 1
// Next Reward
if (typeof data.experience !== "number") {
return
}
const nextReward = getNextReward(data.experience)
const nextRewardID =
nextReward?.achievementID ||
nextReward?.achievementId ||
nextReward?.achievement_id ||
nextReward?.id ||
null
const achievementsList =
Array.isArray(NTGLOBALS?.ACHIEVEMENTS?.LIST)
? NTGLOBALS.ACHIEVEMENTS.LIST
: Array.isArray(NTGLOBALS?.ACHIEVEMENTS?.list)
? NTGLOBALS.ACHIEVEMENTS.list
: Array.isArray(NTGLOBALS?.ACHIEVEMENTS)
? NTGLOBALS.ACHIEVEMENTS
: null
const achievement =
nextRewardID && achievementsList
? achievementsList.find((a) => String(a?.achievementID || a?.achievementId || a?.id || "") === String(nextRewardID))
: null
const reward = achievement?.reward || nextReward?.reward || null
if (!reward || typeof reward.type !== "string") {
// Don't hard-fail; just remove any stale preview so the widget doesn't look broken.
setRewardPreviewText("?")
nextRewardTypeLabelNode.textContent = ""
return
}
const { type, value } = reward
const rewardType = String(type).toLowerCase()
if (["loot", "car"].includes(rewardType)) {
const v = String(value)
const item =
rewardType === "loot"
? NTGLOBALS.LOOT?.find((l) => String(l.lootID) === v)
: NTGLOBALS.CARS?.find((l) => String(l.carID) === v)
if (!item) {
logging.warn("Update")(`Unable to find next reward ${rewardType}`, reward)
setRewardPreviewText("?")
nextRewardTypeLabelNode.textContent = rewardType
return
}
nextRewardRootNode.className = `season-reward-mini-preview season-reward-mini-preview--${rewardType === "loot" ? item?.type : "car"}`
nextRewardTypeLabelNode.textContent = rewardType === "loot" ? item.type || "???" : "car"
nextRewardRarityFrameNode.className = `rarity-frame rarity-frame--small${item.options?.rarity ? ` rarity-frame--${item.options.rarity}` : ""}`
if (item?.type === "title") {
setRewardPreviewText(`"${item.name}"`)
} else {
const src =
rewardType === "loot"
? (item.options?.src || item.options?.smallSrc || item.options?.small_src || "")
: item.options?.smallSrc
? `/cars/${item.options.smallSrc}`
: item.options?.src || item.options?.small_src
? `/cars/${item.options.small_src}`
: ""
if (!src) {
setRewardPreviewText("?")
} else {
setRewardPreviewImage(src)
}
}
} else if (rewardType === "money") {
const moneyValue = typeof value === "number" ? value : Number(value)
const moneyText = isNaN(moneyValue) ? String(value) : moneyValue.toLocaleString()
nextRewardTypeLabelNode.textContent = ""
nextRewardRootNode.className = "season-reward-mini-preview season-reward-mini-preview--money"
nextRewardRarityFrameNode.className = "rarity-frame rarity-frame--small rarity-frame--legendary"
nextRewardTypePreviewImgNode.remove()
nextRewardTypePreviewNode.innerHTML = `
<div class="seasonReward-cardVis seasonReward-cardVis--money">
<div class="rarity-badge as-nitro-cash rarity-badge--cash rarity-badge--cash">
<div class="rarity-badge--extra"></div>
<div class="rarity-badge--content">$${moneyText}</div>
</div>
</div>`
} else if (rewardType === "perk") {
const perkIcon = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#2ff3ff"/>
<stop offset="1" stop-color="#9b6bff"/>
</linearGradient>
</defs>
<rect x="10" y="10" width="44" height="44" rx="10" fill="#0b2233"/>
<path d="M28 12h8l-4 18h10L26 52l4-18H20z" fill="url(#g)"/>
</svg>`
)}`
const seasonAssetKey = getSeasonAssetKey()
const seasonPerkSrc = seasonAssetKey ? `/dist/site/images/seasons/${seasonAssetKey}/perk.png` : null
const perkID = String(value)
const matchPerkID = (p) => String(p?.perkID || p?.perkId || p?.perk_id || p?.id || "") === perkID
const findPerk = (collection) => {
if (!collection) return null
if (Array.isArray(collection)) return collection.find(matchPerkID) || null
if (Array.isArray(collection?.LIST)) return collection.LIST.find(matchPerkID) || null
if (Array.isArray(collection?.list)) return collection.list.find(matchPerkID) || null
if (typeof collection === "object") {
const direct = collection[perkID] || collection[Number(perkID)]
if (direct) return direct
try {
return Object.values(collection).find(matchPerkID) || null
} catch {
return null
}
}
return null
}
const perk =
findPerk(NTGLOBALS?.PERKS) ||
findPerk(NTGLOBALS?.PERK) ||
null
const perkSrc =
perk?.options?.src ||
perk?.options?.icon ||
perk?.options?.iconSrc ||
perk?.options?.icon_src ||
perk?.options?.smallSrc ||
perk?.options?.small_src ||
perk?.src ||
perk?.icon ||
perk?.image ||
null
nextRewardRootNode.className = "season-reward-mini-preview season-reward-mini-preview--perk"
nextRewardTypeLabelNode.textContent = "PERK"
nextRewardRarityFrameNode.className = `rarity-frame rarity-frame--small${perk?.options?.rarity ? ` rarity-frame--${perk.options.rarity}` : ""}`
if (seasonPerkSrc) {
setRewardPreviewImage(seasonPerkSrc, { fallbackSrc: perkSrc || perkIcon, fallbackText: "PERK" })
} else if (perkSrc) {
setRewardPreviewImage(perkSrc, { fallbackSrc: perkIcon, fallbackText: "PERK" })
} else {
// Perk metadata differs between Nitro Type versions; always show something.
setRewardPreviewImage(perkIcon, "PERK")
if (!perk) {
logging.warn("Update")("Unable to resolve PERK metadata from NTGLOBALS", reward)
}
}
} else {
logging.warn("Update")(`Unhandled next reward type ${rewardType}`, reward)
setRewardPreviewText("?")
nextRewardTypeLabelNode.textContent = rewardType
return
}
const isFree = typeof achievement?.free === "boolean" ? achievement.free : typeof nextReward?.free === "boolean" ? nextReward.free : true
if (!isFree && user.membership === "basic") {
nextRewardRootNode.firstElementChild.before(nextRewardTypeLockedNode)
} else {
nextRewardTypeLockedNode.remove()
}
},
}
})(raceObj)
/** Displays list of player stats. */
const StatWidget = (() => {
const root = document.createElement("div")
root.classList.add("nt-stats-info")
root.innerHTML = `
<div class="nt-stats-metric-row">
<span class="nt-stats-metric nt-stats-metric-total-races">
<span class="nt-stats-metric-heading">Races:</span>
<span class="nt-stats-metric-value">0</span>
</span>
${currentUser.tag
? `<span class="nt-stats-metric-separator">|</span>
<span class="nt-stats-metric nt-stats-metric-team-races">
<span class="nt-stats-metric-heading">Team:</span>
<span class="nt-stats-metric-value">N/A</span>
</span>`
: ``
}
<span class="nt-stats-metric-separator">|</span>
<span class="nt-stats-metric nt-stats-metric-rta">
<span class="nt-stats-metric-heading">Real time:</span>
<span class="nt-stats-metric-value">00:00</span>
</span>
<span class="nt-stats-metric-separator">|</span>
<span class="nt-stats-metric nt-stats-metric-session-races">
<span class="nt-stats-metric-heading">Session:</span>
<span class="nt-stats-metric-value">0</span>
</span>
</div>
<div class="nt-stats-metric-row">
<span class="nt-stats-metric-separator">|</span>
<span class="nt-stats-metric nt-stats-metric-playtime">
<span class="nt-stats-metric-heading">Playtime:</span>
<span class="nt-stats-metric-value">0</span>
</span>
<span class="nt-stats-metric-separator">|</span>
<span class="nt-stats-metric nt-stats-metric-season-xp">
<span class="nt-stats-metric-heading">Season XP:</span>
<span class="nt-stats-metric-value">0</span>
</span>
</div>
<div class="nt-stats-metric-row">
<span class="nt-stats-metric nt-stats-metric-avg-speed">
<span class="nt-stats-metric-heading">Avg:</span>
<span class="nt-stats-metric-value">0</span>
<span class="nt-stats-metric-suffix">WPM</span>
</span>
<span class="nt-stats-metric-separator">|</span>
<span class="nt-stats-metric nt-stats-metric-avg-accuracy">
<span class="nt-stats-metric-value">0</span>
<span class="nt-stats-metric-suffix nt-stats-metric-suffix-no-space">%</span>
</span>
<span class="nt-stats-metric-separator">|</span>
<span class="nt-stats-metric nt-stats-metric-avg-time">
<span class="nt-stats-metric-value">0</span>
<span class="nt-stats-metric-suffix nt-stats-metric-suffix-no-space">s</span>
</span>
<span class="nt-stats-metric-separator">|</span>
<span class="nt-stats-metric nt-stats-metric-last-speed">
<span class="nt-stats-metric-heading">Last:</span>
<span class="nt-stats-metric-value">N/A</span>
<span class="nt-stats-metric-suffix">WPM</span>
</span>
<span class="nt-stats-metric-separator">|</span>
<span class="nt-stats-metric nt-stats-metric-last-accuracy">
<span class="nt-stats-metric-value">N/A</span>
<span class="nt-stats-metric-suffix nt-stats-metric-suffix-no-space">%</span>
</span>
<span class="nt-stats-metric-separator">|</span>
<span class="nt-stats-metric nt-stats-metric-last-time">
<span class="nt-stats-metric-value">N/A</span>
<span class="nt-stats-metric-suffix nt-stats-metric-suffix-no-space">s</span>
</span>
</div>
`
let currentTime = JSON.parse(JSON.parse(localStorage.getItem("persist:nt")).user).lastConsecRace;
let greedyReloadTimerId = null;
const isGreedyReloadEnabled = () => isLiveRaceOptionEnabled("greedyStatsReload", true);
const shouldReloadOnStats = () => isLiveRaceOptionEnabled("reloadOnStats", true);
const getGreedyReloadDelay = () => Math.max(10, Math.round(getLiveRaceOptionNumber("greedyStatsReloadInt", 50)));
const clearGreedyReloadTimer = () => {
if (greedyReloadTimerId !== null) {
clearTimeout(greedyReloadTimerId);
greedyReloadTimerId = null;
}
};
const scheduleGreedyReloadCheck = (lasttime = currentTime) => {
clearGreedyReloadTimer();
if (!isGreedyReloadEnabled()) {
return;
}
greedyReloadTimerId = setTimeout(() => checkendgreedy(lasttime), getGreedyReloadDelay());
};
function checkendgreedy(lasttime) {
if (!isGreedyReloadEnabled()) {
clearGreedyReloadTimer();
return;
}
if (document.querySelector('.modal--raceError')) {
clearGreedyReloadTimer();
location.reload();
return;
}
const newtime = JSON.parse(JSON.parse(localStorage.getItem("persist:nt")).user).lastConsecRace;
if (newtime > lasttime) {
clearGreedyReloadTimer();
getStats().then(({
user,
}) => {
StatWidget.updateStats(user)
if (shouldReloadOnStats()) {
if (my_race_started) {
location.reload()
} else {
document.querySelector('.race-hiddenInput')?.click()
currentTime = newtime;
scheduleGreedyReloadCheck(currentTime);
}
}
})
return;
}
scheduleGreedyReloadCheck(lasttime);
}
scheduleGreedyReloadCheck(currentTime);
const totalRacesNode = root.querySelector(".nt-stats-metric-total-races .nt-stats-metric-value"),
sessionRacesNode = root.querySelector(".nt-stats-metric-session-races .nt-stats-metric-value"),
seasonXPNode = root.querySelector(".nt-stats-metric-season-xp .nt-stats-metric-value"),
teamRacesNode = currentUser.tag ? root.querySelector(".nt-stats-metric-team-races .nt-stats-metric-value") : null,
avgSpeedNode = root.querySelector(".nt-stats-metric-avg-speed .nt-stats-metric-value"),
avgAccuracyNode = root.querySelector(".nt-stats-metric-avg-accuracy .nt-stats-metric-value"),
lastSpeedNode = root.querySelector(".nt-stats-metric-last-speed .nt-stats-metric-value"),
lastAccuracyNode = root.querySelector(".nt-stats-metric-last-accuracy .nt-stats-metric-value"),
lastTimeNode = root.querySelector(".nt-stats-metric-last-time .nt-stats-metric-value"),
playtimeNode = root.querySelector(".nt-stats-metric-playtime .nt-stats-metric-value"),
rtaNode = root.querySelector(".nt-stats-metric-rta .nt-stats-metric-value"),
avgTimeNode = root.querySelector(".nt-stats-metric-avg-time .nt-stats-metric-value")
let cachedRawTotalRaces = null
let cachedRawTeamRaces = null
let cachedTeamRaces = null
const syncCorrectedTeamRaces = () => {
const racesOutsideCurrentTeam = getLiveRaceOptionNumber("RACES_OUTSIDE_CURRENT_TEAM", 0)
const buggedTeamCount = getLiveRaceOptionNumber("TEAM_RACES_BUGGED", 0)
if (typeof cachedRawTotalRaces === "number" && racesOutsideCurrentTeam > 0) {
cachedTeamRaces = cachedRawTotalRaces - racesOutsideCurrentTeam + buggedTeamCount
} else if (typeof cachedRawTeamRaces === "number") {
cachedTeamRaces = cachedRawTeamRaces + buggedTeamCount
} else {
cachedTeamRaces = null
}
if (teamRacesNode) {
teamRacesNode.textContent = typeof cachedTeamRaces === "number" ? cachedTeamRaces.toLocaleString() : "N/A"
}
}
// Function to save the current timestamp using GM_setValue
function saveTimestamp() {
const currentTimestamp = Date.now(); // Get current time in milliseconds since Unix epoch
setNtcfgRaceOptionsValue("savedTimestamp", currentTimestamp.toString()); // Convert to string and save the timestamp
}
// Function to load the timestamp and calculate the time difference
function loadTimeDif() {
const savedTimestampStr = GM_getValue("savedTimestamp", null); // Load the saved timestamp as a string
if (savedTimestampStr === null) {
return null;
}
// Convert the retrieved string back to a number
const savedTimestamp = parseInt(savedTimestampStr, 10);
// Validate the loaded timestamp
if (isNaN(savedTimestamp)) {
return null;
}
const currentTimestamp = Date.now(); // Get the current timestamp
const timeDiff = currentTimestamp - savedTimestamp; // Calculate the difference in milliseconds
// Convert the time difference to minutes and seconds
const minutes = Math.floor(timeDiff / 60000); // Convert to minutes
const seconds = Math.floor((timeDiff % 60000) / 1000); // Convert remaining milliseconds to seconds
// Format the time difference as "00:00 MM:SS"
const formattedTimeDiff = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
return formattedTimeDiff;
}
function formatPlayTime(seconds) {
let hours = Math.floor(seconds / 3600);
let minutes = Math.floor((seconds % 3600) / 60);
let remainingSeconds = seconds % 60;
return `${hours}h ${minutes}m ${remainingSeconds}s`;
}
const getRaceEntries = (data) => {
if (!data || typeof data.lastRaces !== "string") return []
const entries = data.lastRaces
.split("|")
.map((s) => s.trim())
.filter(Boolean)
const parsed = []
for (const entry of entries) {
const parts = entry.split(",").map((p) => p.trim())
if (parts.length < 3) continue
const chars = Number(parts[0])
const duration = Number(parts[1])
const errors = Number(parts[2])
if (!Number.isFinite(chars) || !Number.isFinite(duration) || !Number.isFinite(errors)) continue
if (chars <= 0 || duration <= 0) continue
parsed.push({ chars, duration, errors })
}
return parsed
}
const calcWpm = (chars, duration) => (chars / duration) * 12
function getLastRaceMetrics(data) {
const races = getRaceEntries(data)
if (races.length === 0) return null
const last = races[races.length - 1]
const wpm = calcWpm(last.chars, last.duration)
const acc = ((last.chars - last.errors) * 100) / last.chars
return {
wpm: wpm.toFixed(2),
acc: acc.toFixed(2),
time: last.duration.toFixed(2),
}
}
function getAverageMetrics(data) {
const races = getRaceEntries(data)
if (races.length === 0) return null
let totalDuration = 0
let totalWpm = 0
let totalAcc = 0
for (const r of races) {
totalDuration += r.duration
totalWpm += calcWpm(r.chars, r.duration)
totalAcc += ((r.chars - r.errors) * 100) / r.chars
}
return {
wpm: (totalWpm / races.length).toFixed(2),
acc: (totalAcc / races.length).toFixed(2),
time: (totalDuration / races.length).toFixed(2),
}
}
function timeSinceLastLogin(data) {
let lastLogin = data.lastLogin; // Timestamp of last login (in seconds)
let currentTime = Math.floor(Date.now() / 1000); // Current time in seconds
currentTime = data.lastConsecRace;
let elapsedTime = currentTime - lastLogin; // Time since last login in seconds
let minutes = Math.floor(elapsedTime / 60);
let seconds = elapsedTime % 60;
// Format the output as "MM:SS"
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
function handleSessionRaces(data) {
const sessionRaces = data.sessionRaces; // Get sessionRaces from data
if (sessionRaces === 0) {
const lastSavedTimestampStr = GM_getValue("savedTimestamp", null);
if (lastSavedTimestampStr !== null) {
const lastSavedTimestamp = parseInt(lastSavedTimestampStr, 10);
// Check if the last saved timestamp was less than 30 minutes ago
// otherwise, it is not possible, because game resets session after at least 30 minutes
// necessary, because it might call save function multiple times for same session at the end of the race
// it would not fix value if page was loaded at first race and it was not succesful
// so value would overshoot in that case by whenever frist race attempt of the session started
const fifteenMinutesInMs = 30 * 60 * 1000;
const currentTimestamp = Date.now();
if (currentTimestamp - lastSavedTimestamp < fifteenMinutesInMs) {
return; // Exit the function to avoid saving again
}
}
// If no recent timestamp or no timestamp at all, save the current time
saveTimestamp();
rtaNode.textContent = "00:00";
} else {
// If sessionRaces is not 0, load the time difference
const timeDifference = loadTimeDif();
if (timeDifference !== null) {
rtaNode.textContent = timeDifference;
} else {
// If the script starts mid-session, we can't infer session start.
// Start the timer "now" so we don't show a confusing N/A forever.
saveTimestamp();
rtaNode.textContent = "00:00";
}
}
}
return {
root,
refreshTeamRaceCorrections: () => {
syncCorrectedTeamRaces()
},
updateStats: (data) => {
if (typeof data?.playTime === "number") {
playtimeNode.textContent = formatPlayTime(data.playTime);
}
if (typeof data?.experience === "number") {
seasonXPNode.textContent = data.experience.toLocaleString();
} else if (typeof data?.experience === "string") {
const value = parseInt(data.experience, 10)
if (!isNaN(value)) {
seasonXPNode.textContent = value.toLocaleString()
}
}
if (typeof data?.lastRaces === "string") {
const avg = getAverageMetrics(data)
if (avg) {
avgTimeNode.textContent = avg.time
avgSpeedNode.textContent = avg.wpm
avgAccuracyNode.textContent = avg.acc
}
const last = getLastRaceMetrics(data)
if (last) {
lastSpeedNode.textContent = last.wpm
lastAccuracyNode.textContent = last.acc
lastTimeNode.textContent = last.time
} else {
lastSpeedNode.textContent = "N/A"
lastAccuracyNode.textContent = "N/A"
lastTimeNode.textContent = "N/A"
}
}
if (typeof data?.racesPlayed === "number") {
cachedRawTotalRaces = data.racesPlayed
totalRacesNode.textContent = data.racesPlayed.toLocaleString();
syncCorrectedTeamRaces()
}
if (typeof data?.teamRaces === "number") {
cachedRawTeamRaces = data.teamRaces
syncCorrectedTeamRaces()
}
if (typeof data?.sessionRaces === "number") {
sessionRacesNode.textContent = data.sessionRaces.toLocaleString();
handleSessionRaces(data);
}
if ((typeof data?.avgAcc === "string" || typeof data?.avgAcc === "number") && avgAccuracyNode.textContent === "0") {
avgAccuracyNode.textContent = data.avgAcc
}
if (typeof data?.avgSpeed === "number") {
//avgSpeed.textContent = data.avgSpeed
} else if (typeof data?.avgScore === "number") {
//avgSpeed.textContent = data.avgScore
}
},
}
})()
////////////
// Main //
////////////
const isStatsPanelEnabled = () => isLiveRaceOptionEnabled("enableStats", true);
let statsPanelRoot = null;
let statsPanelLayoutObserver = null;
let statsPanelResizeObserver = null;
let statsPanelLayoutFrame = null;
const ensureStatsPanelRoot = () => {
if (statsPanelRoot) {
return statsPanelRoot;
}
const root = document.createElement("div"),
body = document.createElement("div");
root.classList.add("nt-stats-root");
root.dataset.ntcfgStatsPanel = "1";
body.classList.add("nt-stats-body");
const leftSection = document.createElement("div");
leftSection.classList.add("nt-stats-left-section");
leftSection.append(DailyChallengeWidget.root);
const rightSection = document.createElement("div");
rightSection.classList.add("nt-stats-right-section");
rightSection.append(StatWidget.root, SeasonProgressWidget.root);
body.append(leftSection, rightSection);
root.append(body, ToolbarWidget.root);
statsPanelRoot = root;
return statsPanelRoot;
};
const getMountedStatsPanelRoot = () => statsPanelRoot && statsPanelRoot.isConnected
? statsPanelRoot
: document.querySelector(".nt-stats-root[data-ntcfg-stats-panel=\"1\"]");
const resetStatsPanelLayout = (root = getMountedStatsPanelRoot()) => {
if (!root) return;
root.classList.remove("nt-stats-root--wide");
root.style.removeProperty("width");
root.style.removeProperty("max-width");
root.style.removeProperty("margin-left");
root.style.removeProperty("margin-right");
};
const syncStatsPanelLayout = () => {
const root = getMountedStatsPanelRoot();
const mountTarget = raceContainer?.parentElement;
if (!root || !raceContainer || !mountTarget) {
resetStatsPanelLayout(root);
return;
}
const raceRect = raceContainer.getBoundingClientRect();
const parentRect = mountTarget.getBoundingClientRect();
if (!Number.isFinite(raceRect.width) || !Number.isFinite(parentRect.width) || raceRect.width <= 0 || parentRect.width <= 0) {
resetStatsPanelLayout(root);
return;
}
const widthDelta = Math.abs(raceRect.width - parentRect.width);
const leftDelta = raceRect.left - parentRect.left;
root.classList.toggle("nt-stats-root--wide", widthDelta >= 100);
if (widthDelta < 1 && Math.abs(leftDelta) < 1) {
resetStatsPanelLayout(root);
return;
}
root.style.width = `${raceRect.width}px`;
root.style.maxWidth = "none";
root.style.marginLeft = `${leftDelta}px`;
root.style.marginRight = "0";
};
const queueStatsPanelLayoutSync = () => {
if (statsPanelLayoutFrame !== null) {
return;
}
statsPanelLayoutFrame = window.requestAnimationFrame(() => {
statsPanelLayoutFrame = null;
syncStatsPanelLayout();
});
};
const ensureStatsPanelLayoutObserver = () => {
if (statsPanelLayoutObserver || statsPanelResizeObserver) {
return;
}
const handleLayoutChange = () => queueStatsPanelLayoutSync();
if (typeof ResizeObserver === "function") {
statsPanelResizeObserver = new ResizeObserver(handleLayoutChange);
if (raceContainer) {
statsPanelResizeObserver.observe(raceContainer);
}
if (raceContainer?.parentElement) {
statsPanelResizeObserver.observe(raceContainer.parentElement);
}
}
statsPanelLayoutObserver = new MutationObserver(handleLayoutChange);
if (raceContainer) {
statsPanelLayoutObserver.observe(raceContainer, { attributes: true, attributeFilter: ["class", "style"] });
}
if (raceContainer?.parentElement) {
statsPanelLayoutObserver.observe(raceContainer.parentElement, { attributes: true, attributeFilter: ["class", "style"] });
}
window.addEventListener("resize", handleLayoutChange);
};
const syncStatsPanelMount = () => {
const existingRoot = getMountedStatsPanelRoot();
if (!isStatsPanelEnabled()) {
resetStatsPanelLayout(existingRoot);
existingRoot?.remove();
return;
}
const mountTarget = raceContainer?.parentElement;
if (!mountTarget) {
return;
}
const root = ensureStatsPanelRoot();
if (!root.isConnected) {
mountTarget.append(root);
}
ensureStatsPanelLayoutObserver();
queueStatsPanelLayoutSync();
};
syncStatsPanelMount();
/* Add stats into race page with current values */
getStats().then(({
user,
dailyChallenges
}) => {
StatWidget.updateStats(user)
SeasonProgressWidget.updateStats(user)
DailyChallengeWidget.updateStats(dailyChallenges)
ToolbarWidget.updateStats(user)
logging.info("Update")("Start of race")
syncStatsPanelMount()
}).catch((error) => {
logging.warn("Update")("Stats panel mounted without initial stats payload", error)
syncStatsPanelMount()
})
getTeamStats().then(
(data) => {
const {
member,
} = data
StatWidget.updateStats({
teamRaces: member?.played,
})
},
(err) => {
if (err.message !== "User is not in a team") {
return Promise.reject(err)
}
}
)
/** Broadcast Channel to let other windows know that stats updated. */
const MESSGAE_LAST_RACE_UPDATED = "last_race_updated",
MESSAGE_DAILY_CHALLANGE_UPDATED = "stats_daily_challenge_updated",
MESSAGE_USER_STATS_UPDATED = "stats_user_updated"
const statChannel = new BroadcastChannel("NTRacingStats")
statChannel.onmessage = (e) => {
const [type, payload] = e.data
switch (type) {
case MESSGAE_LAST_RACE_UPDATED:
getStats().then(({
user,
dailyChallenges
}) => {
StatWidget.updateStats(user)
SeasonProgressWidget.updateStats(user)
DailyChallengeWidget.updateStats(dailyChallenges)
ToolbarWidget.updateStats(user)
})
break
case MESSAGE_DAILY_CHALLANGE_UPDATED:
DailyChallengeWidget.updateStats(payload)
break
case MESSAGE_USER_STATS_UPDATED:
StatWidget.updateStats(payload)
SeasonProgressWidget.updateStats(payload)
break
}
}
/** Sync Daily Challenge data. */
server.on("setup", (e) => {
const dailyChallenges = mergeDailyChallengeData(e.challenges)
DailyChallengeWidget.updateStats(dailyChallenges)
statChannel.postMessage([MESSAGE_DAILY_CHALLANGE_UPDATED, dailyChallenges])
})
/** Sync some of the User Stat data. */
server.on("joined", (e) => {
if (e.userID !== currentUser.userID) {
return
}
const payload = {
level: e.profile?.level,
racesPlayed: e.profile?.racesPlayed,
sessionRaces: e.profile?.sessionRaces,
avgSpeed: e.profile?.avgSpeed,
}
StatWidget.updateStats(payload)
SeasonProgressWidget.updateStats(payload)
statChannel.postMessage([MESSAGE_USER_STATS_UPDATED, payload])
})
/** Track Race Finish exact time. */
let hasCollectedResultStats = false
server.on("update", (e) => {
const me = e?.racers?.find((r) => r.userID === currentUser.userID)
if (me.progress.completeStamp > 0 && me.rewards?.current && !hasCollectedResultStats) {
hasCollectedResultStats = true
db.backupStatData.put({
...me.rewards.current,
challenges: me.challenges,
userID: currentUser.userID
}).then(() => {
statChannel.postMessage([MESSGAE_LAST_RACE_UPDATED])
})
}
})
/** Mutation observer to check if Racing Result has shown up. */
const resultObserver = new MutationObserver(([mutation], observer) => {
for (const node of mutation.addedNodes) {
if (node.classList?.contains("race-results")) {
observer.disconnect()
logging.info("Update")("Race Results received")
//AUTO RELOAD
//logstats();
//setTimeout(() => location.reload(), autoReloadMS);
//AUTO RELOAD
getStats().then(({
user,
dailyChallenges
}) => {
StatWidget.updateStats(user)
SeasonProgressWidget.updateStats(user)
DailyChallengeWidget.updateStats(dailyChallenges)
ToolbarWidget.updateStats(user)
if (isLiveRaceOptionEnabled("reloadOnStats", true)) {
location.reload()
}
})
break
}
}
})
resultObserver.observe(raceContainer, {
childList: true,
subtree: true
})
///MINI MAP
PIXI.utils.skipHello()
style.appendChild(
document.createTextNode(`
.nt-racing-mini-map-root canvas {
display: block;
}`))
document.head.appendChild(style)
const racingMiniMap = new PIXI.Application({
width: 1024,
height: 100,
backgroundColor: config.colors.background,
backgroundAlpha: 0.66
}),
container = document.createElement("div");
container.className = "nt-racing-mini-map-root"
///////////////////////
// Prepare Objects //
///////////////////////
const RACER_WIDTH = 28,
CROSSING_LINE_WIDTH = 32,
PADDING = 2,
racers = Array(5).fill(null),
currentUserID = raceObj.props.user.userID
// Draw mini racetrack
const raceTrackBG = new PIXI.TilingSprite(PIXI.Texture.EMPTY, racingMiniMap.renderer.width, racingMiniMap.renderer.height),
startLine = PIXI.Sprite.from(PIXI.Texture.WHITE),
finishLine = PIXI.Sprite.from(PIXI.Texture.WHITE)
startLine.x = CROSSING_LINE_WIDTH
startLine.y = 0
startLine.width = 1
startLine.height = racingMiniMap.renderer.height
startLine.tint = config.colors.startLine
finishLine.x = racingMiniMap.renderer.width - CROSSING_LINE_WIDTH - 1
finishLine.y = 0
finishLine.width = 1
finishLine.height = racingMiniMap.renderer.height
finishLine.tint = config.colors.finishLine
raceTrackBG.addChild(startLine, finishLine)
for (let i = 1; i < 5; i++) {
const lane = PIXI.Sprite.from(PIXI.Texture.WHITE)
lane.x = 0
lane.y = i * (racingMiniMap.renderer.height / 5)
lane.width = racingMiniMap.renderer.width
lane.height = 1
lane.tint = config.colors.raceLane
raceTrackBG.addChild(lane)
}
racingMiniMap.stage.addChild(raceTrackBG)
/* Mini Map movement animation update. */
function animateRacerTicker() {
const r = this
const lapse = Date.now() - r.lastUpdated
if (r.sprite.x < r.toX) {
const distance = r.toX - r.fromX
r.sprite.x = r.fromX + Math.min(distance, distance * (lapse / r.moveMS))
if (r.ghostSprite && r.sprite.x === r.ghostSprite.x) {
r.ghostSprite.renderable = false
}
}
if (r.skipped > 0) {
const nitroTargetWidth = r.nitroToX - r.nitroFromX
if (r.nitroSprite.width < nitroTargetWidth) {
r.nitroSprite.width = Math.min(nitroTargetWidth, r.sprite.x - r.nitroFromX)
} else if (r.nitroSprite.width === nitroTargetWidth && r.nitroSprite.alpha > 0 && !r.nitroDisableFade) {
if (r.nitroSprite.alpha === 1) {
r.nitroStartFadeStamp = Date.now() - 1
}
r.nitroSprite.alpha = Math.max(0, 1 - ((Date.now() - r.nitroStartFadeStamp) / 1e3))
}
}
if (r.completeStamp !== null && r.sprite.x === r.toX && r.nitroSprite.alpha === 0) {
racingMiniMap.ticker.remove(animateRacerTicker, this)
}
}
/* Handle adding in players on the mini map. */
server.on("joined", (e) => {
//console.log(my_race_started);
my_race_started = true;
const {
lane,
userID
} = e
let color = config.colors.opponentBot
if (userID === currentUserID) {
color = config.colors.me
} else if (!e.robot) {
color = config.colors.opponentPlayer
} else if (e.profile.specialRobot === "wampus") {
color = config.colors.opponentWampus
}
if (racers[lane]) {
racers[lane].ghostSprite.tint = color
racers[lane].sprite.tint = color
racers[lane].sprite.x = 0 - RACER_WIDTH + PADDING
racers[lane].lastUpdated = Date.now()
racers[lane].fromX = racers[lane].sprite.x
racers[lane].toX = PADDING
racers[lane].sprite.renderable = true
return
}
const r = PIXI.Sprite.from(PIXI.Texture.WHITE)
r.x = 0 - RACER_WIDTH + PADDING
r.y = PADDING + (lane > 0 ? 1 : 0) + (lane * (racingMiniMap.renderer.height / 5))
r.tint = color
r.width = RACER_WIDTH
r.height = 16 - (lane > 0 ? 1 : 0)
const n = PIXI.Sprite.from(PIXI.Texture.WHITE)
n.y = r.y + ((16 - (lane > 0 ? 1 : 0)) / 2) - 1
n.renderable = false
n.tint = config.colors.nitro
n.width = 1
n.height = 2
racers[lane] = {
lane,
sprite: r,
userID: userID,
ghostSprite: null,
nitroSprite: n,
lastUpdated: Date.now(),
fromX: r.x,
toX: PADDING,
skipped: 0,
nitroStartFadeStamp: null,
nitroFromX: null,
nitroToX: null,
nitroDisableFade: false,
moveMS: 250,
completeStamp: null,
}
if (config.moveDestination.enabled) {
const g = PIXI.Sprite.from(PIXI.Texture.WHITE)
g.x = PADDING
g.y = PADDING + (lane > 0 ? 1 : 0) + (lane * (racingMiniMap.renderer.height / 5))
g.tint = color
g.alpha = config.moveDestination.alpha
g.width = RACER_WIDTH
g.height = 16 - (lane > 0 ? 1 : 0)
g.renderable = false
racers[lane].ghostSprite = g
racingMiniMap.stage.addChild(g)
}
racingMiniMap.stage.addChild(n)
racingMiniMap.stage.addChild(r)
racingMiniMap.ticker.add(animateRacerTicker, racers[lane])
})
/* Handle any players leaving the race track. */
server.on("left", (e) => {
const lane = racers.findIndex((r) => r?.userID === e)
if (racers[lane]) {
racers[lane].sprite.renderable = false
racers[lane].ghostSprite.renderable = false
racers[lane].nitroSprite.renderable = false
}
})
/* Handle race map progress position updates. */
server.on("update", (e) => {
let moveFinishMS = 100
const payloadUpdateRacers = e.racers.slice().sort((a, b) => {
if (a.progress.completeStamp === b.progress.completeStamp) {
return 0
}
if (a.progress.completeStamp === null) {
return 1
}
return a.progress.completeStamp > 0 && b.progress.completeStamp > 0 && a.progress.completeStamp > b.progress.completeStamp ? 1 : -1
})
for (let i = 0; i < payloadUpdateRacers.length; i++) {
const r = payloadUpdateRacers[i],
{
completeStamp,
skipped
} = r.progress,
racerObj = racers[r.lane]
if (!racerObj || racerObj.completeStamp > 0 || (r.userID === currentUserID && completeStamp <= 0 && config.trackLocally)) {
continue
}
if (r.disqualified) {
racingMiniMap.ticker.remove(animateRacerTicker, racerObj)
racingMiniMap.stage.removeChild(racerObj.sprite, racerObj.nitroSprite)
if (racerObj.ghostSprite) {
racingMiniMap.stage.removeChild(racerObj.ghostSprite)
}
racerObj.sprite.destroy()
racerObj.ghostSprite.destroy()
racerObj.nitroSprite.destroy()
racers[r.lane] = null
continue
}
racerObj.lastUpdated = Date.now()
racerObj.fromX = racerObj.sprite.x
if (racerObj.completeStamp === null && completeStamp > 0) {
racerObj.completeStamp = completeStamp
racerObj.toX = racingMiniMap.renderer.width - RACER_WIDTH - PADDING
racerObj.moveMS = moveFinishMS
if (racerObj.nitroDisableFade) {
racerObj.nitroToX = racingMiniMap.renderer.width - RACER_WIDTH - PADDING
racerObj.nitroDisableFade = false
}
} else {
racerObj.moveMS = 1e3
racerObj.toX = r.progress.percentageFinished * (racingMiniMap.renderer.width - RACER_WIDTH - CROSSING_LINE_WIDTH - PADDING - 1)
racerObj.sprite.x = racerObj.fromX
}
if (racerObj.ghostSprite) {
racerObj.ghostSprite.x = racerObj.toX
racerObj.ghostSprite.renderable = true
}
if (skipped !== racerObj.skipped) {
if (racerObj.skipped === 0) {
racerObj.nitroFromX = racerObj.fromX
racerObj.nitroSprite.x = racerObj.fromX
racerObj.nitroSprite.renderable = true
}
racerObj.skipped = skipped // because infinite nitros exist? :/
racerObj.nitroToX = racerObj.toX
racerObj.nitroSprite.alpha = 1
if (racerObj.completeStamp !== null) {
racerObj.nitroToX = racingMiniMap.renderer.width - RACER_WIDTH - PADDING
}
}
if (completeStamp > 0 && i + 1 < payloadUpdateRacers.length) {
const nextRacer = payloadUpdateRacers[i + 1],
nextRacerObj = racers[nextRacer?.lane]
if (nextRacerObj && nextRacerObj.completeStamp === null && nextRacer.progress.completeStamp > 0 && nextRacer.progress.completeStamp > completeStamp) {
moveFinishMS += 100
}
}
}
})
if (config.trackLocally) {
let lessonLength = 0
server.on("status", (e) => {
if (e.status === "countdown") {
lessonLength = e.lessonLength
}
})
const originalSendPlayerUpdate = server.sendPlayerUpdate
server.sendPlayerUpdate = (data) => {
originalSendPlayerUpdate(data)
const racerObj = racers.find((r) => r?.userID === currentUserID)
if (!racerObj) {
return
}
const percentageFinished = (data.t / (lessonLength || 1))
racerObj.lastUpdated = Date.now()
racerObj.fromX = racerObj.sprite.x
racerObj.moveMS = 100
racerObj.toX = percentageFinished * (racingMiniMap.renderer.width - RACER_WIDTH - CROSSING_LINE_WIDTH - PADDING - 1)
racerObj.sprite.x = racerObj.fromX
if (racerObj.ghostSprite) {
racerObj.ghostSprite.x = racerObj.toX
racerObj.ghostSprite.renderable = true
}
if (data.s) {
if (racerObj.skipped === 0) {
racerObj.nitroFromX = racerObj.fromX
racerObj.nitroSprite.x = racerObj.fromX
racerObj.nitroSprite.renderable = true
}
racerObj.skipped = data.s // because infinite nitros exist? but I'm not going to test that! :/
racerObj.nitroToX = racerObj.toX
racerObj.nitroSprite.alpha = 1
racerObj.nitroDisableFade = percentageFinished === 1
if (racerObj.completeStamp !== null) {
racerObj.nitroToX = racingMiniMap.renderer.width - RACER_WIDTH - PADDING
}
}
}
}
/////////////
// Final //
/////////////
const syncMiniMapMount = () => {
if (!isLiveRaceOptionEnabled("ENABLE_MINI_MAP", false)) {
container.remove()
return
}
if (!container.contains(racingMiniMap.view)) {
container.append(racingMiniMap.view)
}
const miniMapPosition = normalizeMiniMapPositionValue(
getLiveRaceOptionValue("MINI_MAP_POSITION", MINI_MAP_POSITION),
MINI_MAP_POSITION
)
if (miniMapPosition === "top") {
raceContainer.before(container)
} else {
raceContainer.after(container)
}
}
syncMiniMapMount()
//alt wpm thingy
/** Get Nitro Word Length. */
const nitroWordLength = (words, i) => {
let wordLength = words[i].length + 1
if (i > 0 && i + 1 < words.length) {
wordLength++
}
return wordLength
}
const normalizeDashWord = (word) => String(word || "").replace(/\s/g, "")
const getLargestWordIndexes = (wordList) => {
let largest = 0
const indexes = []
for (let i = 0; i < wordList.length; i++) {
const normalized = normalizeDashWord(wordList[i])
const len = normalized.length
if (len === 0) continue
if (len > largest) {
largest = len
indexes.length = 0
indexes.push(i)
} else if (len === largest) {
indexes.push(i)
}
}
return indexes
}
let perfectNitroHighlightInterval = null
let perfectNitroHighlightAttempts = 0
let perfectNitroUsedThisRace = false
const PERFECT_NITRO_MAX_ATTEMPTS = 250
const resetPerfectNitroUsageState = () => {
perfectNitroUsedThisRace = false
}
const consumePerfectNitroHighlights = () => {
if (perfectNitroUsedThisRace) {
return
}
perfectNitroUsedThisRace = true
stopPerfectNitroHighlighter()
clearPerfectNitroHighlights()
}
const clearPerfectNitroHighlights = () => {
document.querySelectorAll('.dash-word[data-nt-perfect-nitro="1"]').forEach((node) => {
node.style.removeProperty("background-color")
node.style.removeProperty("font-style")
node.querySelectorAll(".dash-letter").forEach((letter) => {
letter.style.removeProperty("font-style")
letter.classList.remove("ntcfg-perfect-nitro-rainbow")
})
node.removeAttribute("data-nt-perfect-nitro")
})
const textStyle = document.getElementById("ntcfg-perfect-nitro-text-style")
if (textStyle) textStyle.remove()
const rainbowStyle = document.getElementById("ntcfg-perfect-nitro-rainbow-style")
if (rainbowStyle) rainbowStyle.remove()
}
const ensurePerfectNitroTextStyle = (color, shadow) => {
let style = document.getElementById("ntcfg-perfect-nitro-text-style")
if (!style) {
style = document.createElement("style")
style.id = "ntcfg-perfect-nitro-text-style"
document.head.appendChild(style)
}
const colorRule = color ? `color: ${color} !important;` : ""
const shadowRule = shadow ? `text-shadow: ${shadow} !important;` : ""
style.textContent = `.dash-word[data-nt-perfect-nitro="1"] .dash-letter:not(.is-waiting):not(.is-incorrect) { ${colorRule} ${shadowRule} }`
}
const ensurePerfectNitroRainbowStyle = () => {
if (document.getElementById("ntcfg-perfect-nitro-rainbow-style")) return
const style = document.createElement("style")
style.id = "ntcfg-perfect-nitro-rainbow-style"
style.textContent = `
@keyframes ntcfg-perfect-nitro-rainbow {
0% { color: #FF0000; }
14% { color: #FF8C00; }
28% { color: #FFD700; }
42% { color: #00CC00; }
57% { color: #0066FF; }
71% { color: #7B00FF; }
85% { color: #FF00FF; }
100% { color: #FF0000; }
}
.ntcfg-perfect-nitro-rainbow {
animation: ntcfg-perfect-nitro-rainbow 3s linear infinite !important;
-webkit-animation: ntcfg-perfect-nitro-rainbow 3s linear infinite !important;
}
`
document.head.appendChild(style)
}
const stopPerfectNitroHighlighter = () => {
if (perfectNitroHighlightInterval !== null) {
clearInterval(perfectNitroHighlightInterval)
perfectNitroHighlightInterval = null
}
}
const applyPerfectNitroHighlight = (indexes, options) => {
const dashWords = Array.from(document.getElementsByClassName("dash-word"))
if (dashWords.length === 0 || indexes.length === 0) {
return false
}
if (indexes.some((idx) => !dashWords[idx])) {
return false
}
clearPerfectNitroHighlights()
const { highlightColor, highlightOpacity, enableHighlight, italic, rainbow, overrideTextColor, textColor } = options
const bgRgba = enableHighlight ? hexToRgba(highlightColor, highlightOpacity) : null
const adaptiveTextStyle = enableHighlight
? getPerfectNitroTextStyle(highlightColor, highlightOpacity)
: { color: null, shadow: null }
const textStyle = overrideTextColor
? { color: textColor, shadow: null }
: rainbow
? { color: null, shadow: adaptiveTextStyle.shadow }
: adaptiveTextStyle
if (rainbow) {
ensurePerfectNitroRainbowStyle()
}
// Use a stylesheet rule for text color so it dynamically
// excludes the active cursor and incorrect indicator letters
if (textStyle.color || textStyle.shadow) {
ensurePerfectNitroTextStyle(textStyle.color, textStyle.shadow)
}
let applied = 0
indexes.forEach((idx) => {
const node = dashWords[idx]
if (!node) return
node.dataset.ntPerfectNitro = "1"
if (bgRgba) {
node.style.backgroundColor = bgRgba
}
if (italic) {
node.style.fontStyle = "italic"
}
node.querySelectorAll(".dash-letter").forEach((letter) => {
if (rainbow) {
letter.classList.add("ntcfg-perfect-nitro-rainbow")
}
if (italic) {
letter.style.fontStyle = "italic"
}
})
applied++
})
return applied > 0
}
const startPerfectNitroHighlighter = (lesson = "") => {
stopPerfectNitroHighlighter()
perfectNitroHighlightAttempts = 0
const startupOptions = getPerfectNitroOptions()
if (!startupOptions.enabled || perfectNitroUsedThisRace) {
clearPerfectNitroHighlights()
return
}
const lessonWords = typeof lesson === "string" ? lesson.split(" ") : []
const lessonIndexes = getLargestWordIndexes(lessonWords)
const tick = () => {
const options = getPerfectNitroOptions()
if (!options.enabled || perfectNitroUsedThisRace) {
stopPerfectNitroHighlighter()
clearPerfectNitroHighlights()
return
}
perfectNitroHighlightAttempts++
let indexes = lessonIndexes
if (indexes.length === 0) {
const currentWords = Array.from(document.getElementsByClassName("dash-word")).map((node) => node.textContent || "")
indexes = getLargestWordIndexes(currentWords)
}
if (applyPerfectNitroHighlight(indexes, options)) {
stopPerfectNitroHighlighter()
return
}
if (perfectNitroHighlightAttempts >= PERFECT_NITRO_MAX_ATTEMPTS) {
stopPerfectNitroHighlighter()
}
}
tick()
if (perfectNitroHighlightInterval === null) {
perfectNitroHighlightInterval = setInterval(tick, startupOptions.intervalMs)
}
}
/** Get Player Avg using lastRaces data. */
const getPlayerAvg = (prefix, raceObj, lastRaces) => {
const raceLogs = (lastRaces || raceObj.props.user.lastRaces)
.split("|")
.map((r) => {
const data = r.split(","),
typed = parseInt(data[0], 10),
time = parseFloat(data[1]),
errs = parseInt(data[2])
if (isNaN(typed) || isNaN(time) || isNaN(errs)) {
return false
}
return {
time,
acc: 1 - errs / typed,
wpm: typed / 5 / (time / 60),
}
})
.filter((r) => r !== false)
const avgSpeed = raceLogs.reduce((prev, current) => prev + current.wpm, 0.0) / Math.max(raceLogs.length, 1)
logging.info(prefix)("Avg Speed", avgSpeed)
console.table(raceLogs, ["time", "acc", "wpm"])
return avgSpeed
}
///////////////
// Backend //
///////////////
if (config.targetWPM <= 0) {
logging.error("Init")("Invalid target WPM value")
return
}
let raceTimeLatency = null
/** Styles for the following components. */
const styleNew = document.createElement("style")
styleNew.appendChild(
document.createTextNode(`
/* Some Overrides */
.race-results {
z-index: 6;
}
/* Sandbagging Tool */
.nt-evil-sandbagging-root {
position: absolute;
top: 0px;
left: 0px;
z-index: 5;
color: #E7EEF8;
touch-action: none;
}
.nt-evil-sandbagging-metric-value {
font-weight: 600;
font-family: "Roboto Mono", "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace;
}
.nt-evil-sandbagging-metric-suffix {
color: #8FA3BA;
}
.nt-evil-sandbagging-live {
padding: 5px;
border-radius: 8px;
color: #D62F3A;
background-color: rgba(10, 18, 30, 0.88);
border: 1px solid rgba(22, 122, 195, 0.42);
text-align: center;
}
.nt-evil-sandbagging-live span.live-wpm-inactive {
opacity: 1;
}
.nt-evil-sandbagging-live > span:not(.live-wpm-inactive) .nt-evil-sandbagging-metric-value {
color: #167AC3;
}
.nt-evil-sandbagging-best-live-wpm {
font-size: 10px;
}
.nt-evil-sandbagging-section {
padding: 5px;
border-top: 1px solid rgba(255, 255, 255, 0.15);
font-size: 10px;
text-align: center;
}
.nt-evil-sandbagging-stats {
background-color: rgba(10, 18, 30, 0.95);
border-top-color: rgba(22, 122, 195, 0.45);
}
.nt-evil-sandbagging-results {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
background-color: rgba(17, 34, 53, 0.95);
border-top-color: rgba(214, 47, 58, 0.45);
}`)
)
document.head.appendChild(styleNew);
/** Manages and displays the race timer. */
const RaceTimer = ((config) => {
// Restore widget settings
let widgetSettings = null
try {
const data = localStorage.getItem("nt_sandbagging_tool")
if (typeof data === "string") {
widgetSettings = JSON.parse(data)
}
} catch {
widgetSettings = null
}
if (widgetSettings === null) {
widgetSettings = { x: 384, y: 285 }
}
// Setup Widget
const root = document.createElement("div")
root.classList.add("nt-evil-sandbagging-root", "has-live-wpm")
root.dataset.x = widgetSettings.x
root.dataset.y = widgetSettings.y
root.style.transform = `translate(${parseFloat(root.dataset.x) || 0}px, ${parseFloat(root.dataset.y) || 0}px)`
root.innerHTML = `
<div class="nt-evil-sandbagging-live">
<span class="nt-evil-sandbagging-current-live-wpm live-wpm-inactive">
<small class="nt-evil-sandbagging-metric-suffix">Prepare for your race!</small><span class="nt-evil-sandbagging-live-wpm nt-evil-sandbagging-metric-value"></span>
</span>
<span class="nt-evil-sandbagging-best-live-wpm live-wpm-inactive">
(<span class="nt-evil-sandbagging-metric-value">0.00</span> <small class="nt-evil-sandbagging-metric-suffix">WPM</small>)
</span>
</div>
<div class="nt-evil-sandbagging-section nt-evil-sandbagging-stats">
Timer: <span class="nt-evil-sandbagging-live-time nt-evil-sandbagging-metric-value">0.00</span> / <span class="nt-evil-sandbagging-target-time nt-evil-sandbagging-metric-value">0.00</span> <small class="nt-evil-sandbagging-metric-suffix">sec</small> |
Target: <span class="nt-evil-sandbagging-target-wpm nt-evil-sandbagging-metric-value">${config.targetWPM}</span> <small class="nt-evil-sandbagging-metric-suffix">WPM</small> |
Avg: <span class="nt-evil-sandbagging-current-avg-wpm nt-evil-sandbagging-metric-value">?</span> <small class="nt-evil-sandbagging-metric-suffix">WPM</small>
</div>
<div class="nt-evil-sandbagging-section nt-evil-sandbagging-results">
Time: <span class="nt-evil-sandbagging-result-time nt-evil-sandbagging-metric-value">?</span> <small class="nt-evil-sandbagging-metric-suffix">secs</small> |
Speed: <span class="nt-evil-sandbagging-result-wpm nt-evil-sandbagging-metric-value">?</span> <small class="nt-evil-sandbagging-metric-suffix">WPM</small> |
Avg: <span class="nt-evil-sandbagging-new-avg-wpm nt-evil-sandbagging-metric-value">?</span> <small class="nt-evil-sandbagging-metric-suffix">WPM</small> |
Latency: <span class="nt-evil-sandbagging-latency nt-evil-sandbagging-metric-value">?</span> <small class="nt-evil-sandbagging-metric-suffix">ms</small>
</div>`
const liveContainerNode = root.querySelector(".nt-evil-sandbagging-live"),
liveCurrentWPMContainerNode = liveContainerNode.querySelector(".nt-evil-sandbagging-current-live-wpm"),
liveWPMValueNode = liveCurrentWPMContainerNode.querySelector(".nt-evil-sandbagging-live-wpm"),
liveBestWPMContainerNode = liveContainerNode.querySelector(".nt-evil-sandbagging-best-live-wpm"),
liveBestWPMValueNode = liveBestWPMContainerNode.querySelector(".nt-evil-sandbagging-metric-value"),
statContainerNode = root.querySelector(".nt-evil-sandbagging-stats"),
liveTimeNode = statContainerNode.querySelector(".nt-evil-sandbagging-live-time"),
targetTimeNode = statContainerNode.querySelector(".nt-evil-sandbagging-target-time"),
targetWPMNode = statContainerNode.querySelector(".nt-evil-sandbagging-target-wpm"),
currentAvgWPMNode = statContainerNode.querySelector(".nt-evil-sandbagging-current-avg-wpm"),
resultContainerNode = root.querySelector(".nt-evil-sandbagging-results"),
resultTimeNode = resultContainerNode.querySelector(".nt-evil-sandbagging-result-time"),
resultWPMNode = resultContainerNode.querySelector(".nt-evil-sandbagging-result-wpm"),
resultNewAvgWPMNode = resultContainerNode.querySelector(".nt-evil-sandbagging-new-avg-wpm"),
resultLatencyNode = resultContainerNode.querySelector(".nt-evil-sandbagging-latency")
resultContainerNode.remove()
statContainerNode.style.display = 'none';
liveBestWPMContainerNode.style.display = 'none';
resultContainerNode.style.display = 'none';
let timer = null,
targetWPM = config.targetWPM || 79.49,
startTime = null,
finishTime = null,
skipLength = null,
bestSkipLength = null,
lessonLength = null,
onTargetTimeUpdate = null,
onTimeUpdate = null
/** Updates the race timer metrics. */
const refreshCurrentTime = () => {
if (startTime === null) {
logging.warn("Update")("Invalid last time, unable to update current timer")
return
}
if (finishTime !== null) {
return
}
let diff = Date.now() - startTime
if (onTimeUpdate) {
onTimeUpdate(diff)
}
liveTimeNode.textContent = (diff / 1e3).toFixed(2);
diff /= 6e4;
const suffixwpm = liveCurrentWPMContainerNode.querySelector(".nt-evil-sandbagging-metric-suffix");
const currentWPM = (lessonLength - skipLength) / 5 / diff,
bestWPM = (lessonLength - bestSkipLength) / 5 / diff
if (currentWPM < (config.targetWPM + 20)) {
liveWPMValueNode.textContent = (currentWPM - config.dif).toFixed(1);
suffixwpm.style.display = 'block';
}
else {
suffixwpm.style.display = 'none';
liveWPMValueNode.textContent = "Just type...!"
}
liveBestWPMValueNode.textContent = bestWPM.toFixed(2)
if (currentWPM - targetWPM <= config.indicateWPMWithin) {
liveCurrentWPMContainerNode.classList.remove("live-wpm-inactive")
}
if (bestWPM - targetWPM <= config.indicateWPMWithin) {
liveBestWPMContainerNode.classList.remove("live-wpm-inactive")
}
timer = setTimeout(refreshCurrentTime, config.timerRefreshIntervalMS)
}
/** Toggle whether to show best wpm counter or not (the small text). */
const toggleBestLiveWPM = (show) => {
if (show) {
liveContainerNode.append(liveBestWPMContainerNode)
} else {
liveBestWPMContainerNode.remove()
}
}
/** Save widget settings. */
const saveSettings = () => {
localStorage.setItem("nt_sandbagging_tool", JSON.stringify(widgetSettings))
}
saveSettings()
/** Setup draggable widget. */
interact(root).draggable({
modifiers: [
interact.modifiers.restrictRect({
//restriction: "parent",
endOnly: true,
}),
],
listeners: {
move: (event) => {
const target = event.target,
x = (parseFloat(target.dataset.x) || 0) + event.dx,
y = (parseFloat(target.dataset.y) || 0) + event.dy
target.style.transform = "translate(" + x + "px, " + y + "px)"
target.dataset.x = x
target.dataset.y = y
widgetSettings.x = x
widgetSettings.y = y
saveSettings()
},
},
})
return {
root,
setTargetWPM: (wpm) => {
targetWPM = wpm
if (targetWPMNode) {
targetWPMNode.textContent = Number.isFinite(Number(wpm)) ? String(Number(wpm)) : String(wpm)
}
const baseSkipLength = skipLength !== null ? skipLength : bestSkipLength
if (lessonLength !== null && baseSkipLength !== null && Number(targetWPM) > 0) {
const newTime = ((lessonLength - baseSkipLength) / 5 / targetWPM) * 60
if (Number.isFinite(newTime)) {
if (onTargetTimeUpdate) {
onTargetTimeUpdate(newTime * 1e3)
}
targetTimeNode.textContent = newTime.toFixed(2)
}
}
},
setLessonLength: (l) => {
lessonLength = l
},
getLessonLength: () => lessonLength,
setSkipLength: (l) => {
skipLength = l
toggleBestLiveWPM(false)
if (skipLength !== bestSkipLength) {
const newTime = ((lessonLength - skipLength) / 5 / targetWPM) * 60
if (onTargetTimeUpdate) {
onTargetTimeUpdate(newTime * 1e3)
}
targetTimeNode.textContent = newTime.toFixed(2)
}
},
setBestSkipLength: (l) => {
bestSkipLength = l
const newTime = ((lessonLength - bestSkipLength) / 5 / targetWPM) * 60
if (onTargetTimeUpdate) {
onTargetTimeUpdate(newTime * 1e3)
}
targetTimeNode.textContent = newTime.toFixed(2)
},
start: (t) => {
if (timer) {
clearTimeout(timer)
}
finishTime = null
const normalizedStartTime = Number(t)
startTime = Number.isFinite(normalizedStartTime)
? Math.min(normalizedStartTime, Date.now())
: Date.now()
refreshCurrentTime()
},
stop: () => {
if (timer) {
finishTime = Date.now()
clearTimeout(timer)
}
},
setCurrentAvgSpeed: (wpm) => {
currentAvgWPMNode.textContent = wpm.toFixed(2)
},
reportFinishResults: (speed, avgSpeed, actualStartTime, actualFinishTime) => {
const latency = actualFinishTime - finishTime,
output = (latency / 1e3).toFixed(2)
resultTimeNode.textContent = ((actualFinishTime - actualStartTime) / 1e3).toFixed(2)
resultWPMNode.textContent = speed.toFixed(2)
liveWPMValueNode.textContent = speed.toFixed(2)
resultNewAvgWPMNode.textContent = avgSpeed.toFixed(2)
resultLatencyNode.textContent = latency
toggleBestLiveWPM(false)
root.append(resultContainerNode)
logging.info("Finish")(`Race Finish acknowledgement latency: ${output} secs (${latency}ms)`)
return output
},
setOnTargetTimeUpdate: (c) => {
onTargetTimeUpdate = c
},
setOnTimeUpdate: (c) => {
onTimeUpdate = c
},
}
})(config)
window.NTRaceTimer = RaceTimer
let currentRaceStatus = null
const isAltWpmCounterEnabledLive = () => isLiveRaceOptionEnabled("ENABLE_ALT_WPM_COUNTER", true)
const isAltWpmCountdownEnabledLive = () => isLiveRaceOptionEnabled("ENABLE_ALT_WPM_COUNTDOWN", true)
const resetRaceTimerLiveDisplay = () => {
const liveWpmNode = RaceTimer?.root?.querySelector(".nt-evil-sandbagging-live-wpm")
const suffixNode = RaceTimer?.root?.querySelector(".nt-evil-sandbagging-current-live-wpm .nt-evil-sandbagging-metric-suffix")
if (!liveWpmNode || !suffixNode) {
return
}
suffixNode.style.display = ""
suffixNode.textContent = "Prepare for your race!"
liveWpmNode.textContent = ""
}
const syncRaceTimerVisibility = () => {
if (!RaceTimer?.root?.isConnected) {
return
}
const shouldHideUntilCountdown = isLiveRaceOptionEnabled("HIDE_PREPARE_FOR_RACE_ICON", false)
const altWpmEnabled = isAltWpmCounterEnabledLive()
const countdownEnabled = isAltWpmCountdownEnabledLive()
const showIdle = !shouldHideUntilCountdown && (altWpmEnabled || countdownEnabled) && currentRaceStatus !== "countdown" && currentRaceStatus !== "racing"
const showCountdown = countdownEnabled && currentRaceStatus === "countdown"
const showAltWpm = altWpmEnabled && currentRaceStatus === "racing"
const shouldShowNow = showIdle || showCountdown || showAltWpm
RaceTimer.root.style.display = shouldShowNow ? "" : "none"
}
const syncRaceTimerMount = () => {
if (!isAltWpmCounterEnabledLive() && !isAltWpmCountdownEnabledLive()) {
stopRaceStartCountdown()
RaceTimer.root.remove()
return
}
if (!isAltWpmCountdownEnabledLive()) {
stopRaceStartCountdown()
}
if (!RaceTimer.root.isConnected) {
raceContainer.append(RaceTimer.root)
}
syncRaceTimerVisibility()
}
/** Track Racing League for analysis. */
server.on("setup", (e) => {
if (e.scores && e.scores.length === 2) {
const [from, to] = e.scores
logging.info("Init")("Racing League", JSON.stringify({ from, to, trackLeader: e.trackLeader }))
RaceTimer.setCurrentAvgSpeed(getPlayerAvg("Init", raceObj))
}
})
let countdownTimer = null
let countdownTargetStamp = null
const stopRaceStartCountdown = () => {
if (countdownTimer !== null) {
clearTimeout(countdownTimer)
countdownTimer = null
}
countdownTargetStamp = null
}
const updateRaceStartCountdown = () => {
const wpmtextnode = RaceTimer.root.querySelector(".nt-evil-sandbagging-live-wpm")
if (!wpmtextnode || !Number.isFinite(countdownTargetStamp)) {
stopRaceStartCountdown()
return
}
const remainingMs = Math.max(0, countdownTargetStamp - Date.now())
wpmtextnode.textContent = (remainingMs / 1e3).toFixed(2)
if (remainingMs <= 0) {
countdownTimer = null
return
}
countdownTimer = setTimeout(updateRaceStartCountdown, COUNTDOWN_TICK_INTERVAL_MS)
}
/** Track whether to start the timer and manage target goals. */
server.on("status", (e) => {
currentRaceStatus = e.status
syncRaceTimerVisibility()
if (e.status === "countdown") {
resetPerfectNitroUsageState()
startPerfectNitroHighlighter(e.lesson)
resetRaceTimerLiveDisplay()
const countdownEnabled = isAltWpmCountdownEnabledLive()
const wpmtextnode = RaceTimer.root.querySelector(".nt-evil-sandbagging-live-wpm");
const wpmsuffix = RaceTimer.root.querySelector(".nt-evil-sandbagging-current-live-wpm .nt-evil-sandbagging-metric-suffix");
if (countdownEnabled && wpmtextnode && wpmsuffix) {
const nextCountdownTargetStamp = Number.isFinite(Number(e.startStamp))
? Number(e.startStamp) - config.raceLatencyMS
: Date.now() + 4e3
wpmsuffix.textContent = "Race starts in... ";
const shouldRestartCountdown = countdownTimer === null || countdownTargetStamp !== nextCountdownTargetStamp
countdownTargetStamp = nextCountdownTargetStamp
if (shouldRestartCountdown) {
stopRaceStartCountdown()
countdownTargetStamp = nextCountdownTargetStamp
updateRaceStartCountdown()
}
} else {
stopRaceStartCountdown()
}
RaceTimer.setLessonLength(e.lessonLength)
const words = e.lesson.split(" ")
let mostLetters = null,
nitroWordCount = 0
words.forEach((_, i) => {
let wordLength = nitroWordLength(words, i)
if (mostLetters === null || mostLetters < wordLength) {
mostLetters = wordLength
}
})
RaceTimer.setBestSkipLength(mostLetters)
} else if (e.status === "racing") {
startPerfectNitroHighlighter()
stopRaceStartCountdown()
if (!isAltWpmCounterEnabledLive()) {
syncRaceTimerVisibility()
return
}
const wpmsuffix = RaceTimer.root.querySelector(".nt-evil-sandbagging-current-live-wpm .nt-evil-sandbagging-metric-suffix");
wpmsuffix.textContent = "Possible WPM: ";
RaceTimer.start(e.startStamp - config.raceLatencyMS)
const originalSendPlayerUpdate = server.sendPlayerUpdate
server.sendPlayerUpdate = (data) => {
originalSendPlayerUpdate(data)
if (data.t >= RaceTimer.getLessonLength()) {
RaceTimer.stop()
}
if (typeof data.s === "number") {
if (data.s > 0) {
consumePerfectNitroHighlights()
}
RaceTimer.setSkipLength(data.s)
}
}
}
})
/** Track Race Finish exact time. */
server.on("update", (e) => {
const me = e?.racers?.find((r) => r.userID === currentUserID)
if (me?.progress?.skipped > 0) {
consumePerfectNitroHighlights()
}
if (raceTimeLatency === null && me.progress.completeStamp > 0 && me.rewards) {
const { typed, skipped, startStamp, completeStamp } = me.progress
raceTimeLatency = RaceTimer.reportFinishResults(
(typed - skipped) / 5 / ((completeStamp - startStamp) / 6e4),
getPlayerAvg("Finish", raceObj, me.rewards.current.lastRaces),
startStamp,
completeStamp
)
}
})
/////////////
// Final //
/////////////
const syncRaceOptionsRuntimeSetting = (event) => {
const settingKey = event?.detail?.key
if (!settingKey) {
return
}
if (settingKey === "enableStats") {
syncStatsPanelMount()
return
}
if (["hideTrack", "hideNotifications", "HIDE_CHAT_AND_STICKERS", "HIDE_FINISH_FLAG"].includes(settingKey)) {
applyRaceVisualStyles()
}
if (["ENABLE_MINI_MAP", "MINI_MAP_POSITION"].includes(settingKey)) {
syncMiniMapMount()
}
if (["ENABLE_ALT_WPM_COUNTER", "ENABLE_ALT_WPM_COUNTDOWN", "HIDE_PREPARE_FOR_RACE_ICON"].includes(settingKey)) {
syncRaceTimerMount()
}
if (["targetWPM", "indicateWPMWithin", "timerRefreshIntervalMS", "dif"].includes(settingKey)) {
config[settingKey] = getLiveRaceOptionNumber(settingKey, config[settingKey])
if (settingKey === "targetWPM") {
RaceTimer.setTargetWPM(config.targetWPM)
}
}
if (["RACES_OUTSIDE_CURRENT_TEAM", "TEAM_RACES_BUGGED"].includes(settingKey)) {
StatWidget.refreshTeamRaceCorrections()
}
}
window.addEventListener(NTCFG_RACE_OPTIONS_SETTING_SYNC_EVENT, syncRaceOptionsRuntimeSetting)
syncRaceTimerMount()
} // end _initContinue
} // end race-only runtime