Pick a merit build and view priorities with target levels.
// ==UserScript==
// @name Torn Merit Build Planner
// @namespace https://www.torn.com/
// @version 1.1.1
// @description Pick a merit build and view priorities with target levels.
// @author PFangy
// @match https://www.torn.com/page.php?sid=awards*
// @grant none
// @license MIT
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
// Usage:
// 1) Open Torn merits page.
// 2) Pick a build in the build panel.
// 3) Use the Edit button to open bulk merit editor.
// 4) Save priority + target values for your active build.
// Presets are only starter templates and can be changed any time.
const SCRIPT_GUARD_KEY = "__tornMeritBuildPlannerLoaded";
if (window[SCRIPT_GUARD_KEY]) return;
window[SCRIPT_GUARD_KEY] = true;
const STORAGE_KEYS = {
activeRoleId: "tmrp.activeRoleId",
customRoles: "tmrp.customRoles",
showOnlyPrioritized: "tmrp.showOnlyPrioritized"
};
// These are starter presets. Players can clone and adapt them.
const ROLE_PRESETS = {
chain_attacker: { label: "Chain Attacker", rules: {
"brawn": { priority: "high", target: 8, reason: "Core damage for fast chain hits." },
"protection": { priority: "high", target: 8, reason: "Useful sustain in long chains." },
"sharpness": { priority: "high", target: 8, reason: "Hit consistency and tempo." },
"evasion": { priority: "high", target: 8, reason: "Defense support during heavy activity." },
"critical hit rate": { priority: "high", target: 7, reason: "Strong chain conversion value." },
"life points": { priority: "medium", target: 6, reason: "Extra safety between fights." },
"stealth": { priority: "medium", target: 5, reason: "Situational outgoing attack utility." }
} },
ranked_war: { label: "Ranked War Fighter", rules: {
"brawn": { priority: "high", target: 8, reason: "Primary offense in wars." },
"protection": { priority: "high", target: 9, reason: "Very high survivability value." },
"sharpness": { priority: "high", target: 7, reason: "Keeps hit chance stable." },
"evasion": { priority: "high", target: 7, reason: "Helps avoid incoming damage." },
"life points": { priority: "high", target: 7, reason: "Longer war endurance." },
"critical hit rate": { priority: "medium", target: 6, reason: "Good but not first priority." },
"hospitalizing": { priority: "medium", target: 6, reason: "War pressure utility." }
} },
mugger: { label: "Mugger", rules: {
"masterful looting": { priority: "high", target: 10, reason: "Main income multiplier for mugging." },
"stealth": { priority: "high", target: 8, reason: "Very useful for outgoing attacks." },
"brawn": { priority: "high", target: 7, reason: "Needed to secure wins quickly." },
"sharpness": { priority: "medium", target: 6, reason: "Supports reliable hits." },
"critical hit rate": { priority: "medium", target: 6, reason: "Better combat efficiency." }
} },
};
const PRIORITY_ORDER = ["high", "medium", "low"];
const MERIT_CARD_SELECTORS = [
"li[class*='merit']",
"div[class*='merit-item']",
"div[class*='meritItem']",
"div[class*='merit_row']",
"div[class*='meritRow']",
"article[class*='merit']",
"div[class*='merit']"
].join(",");
const TITLE_SELECTORS = ["h1","h2","h3","h4","h5","h6","strong","b","[class*='title']","[class*='name']","span"].join(",");
const RENDER_DEBOUNCE_MS = 45;
let mutationObserver;
let processTimer;
let lastUrl = location.href;
let cachedPanel;
let cachedPanelRoleSelect;
let cachedPanelOnlyToggle;
let panelRoleOptionsSignature = "";
let cachedModal;
let cachedImportModal;
let cachedExportModal;
let isProcessingPage = false;
let observerPaused = false;
const cardSegmentsCache = new WeakMap();
const DEBUG_PERF = localStorage.getItem("tmrp.debugPerf") === "1";
function normalizeKey(value) {
return (value || "").toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function debugPerf(...parts) {
if (!DEBUG_PERF) return;
// Keep logging simple and explicit so users can copy/paste evidence.
console.log("[TMRP PERF]", ...parts);
}
function withPerfLabel(label, fn) {
const start = performance.now();
const result = fn();
const elapsed = performance.now() - start;
if (elapsed > 12) debugPerf(`${label} took ${elapsed.toFixed(1)}ms`);
return result;
}
function getCookieValue(name) {
const parts = (document.cookie || "").split(";");
for (const part of parts) {
const [rawKey, ...rest] = part.trim().split("=");
if (rawKey === name) return rest.join("=");
}
return "";
}
function isDarkModeEnabled() {
const cookieValue = (getCookieValue("darkModeEnabled") || "").toLowerCase();
if (cookieValue === "true" || cookieValue === "1") return true;
if (cookieValue === "false" || cookieValue === "0") return false;
// Fallback: detect likely dark UI when cookie is missing.
const bodyClass = (document.body?.className || "").toLowerCase();
if (bodyClass.includes("dark")) return true;
if (bodyClass.includes("light")) return false;
return window.matchMedia?.("(prefers-color-scheme: dark)")?.matches === true;
}
function applyThemeMode() {
document.documentElement.setAttribute("data-tmrp-theme", isDarkModeEnabled() ? "dark" : "light");
}
function safeParse(raw, fallback) {
try { return JSON.parse(raw); } catch (_) { return fallback; }
}
function isMeritsPage() {
if (/sid=merits|tab=merits|merits\.php/i.test(location.href)) return true;
const pageTitle = document.querySelector("h1, h2, .title-black, .title");
return /merits/i.test(pageTitle?.textContent || "");
}
function clearPlannerUiForNonMerits() {
if (cachedPanel?.isConnected) cachedPanel.remove();
cachedPanel = null;
cachedPanelRoleSelect = null;
cachedPanelOnlyToggle = null;
panelRoleOptionsSignature = "";
for (const legend of document.querySelectorAll(".tmrp-table-legend")) {
legend.remove();
}
for (const segment of document.querySelectorAll("[data-tmrp-segment='1']")) {
segment.removeAttribute("style");
segment.removeAttribute("data-tmrp-segment");
}
for (const row of document.querySelectorAll(".tmrp-priority-row, .tmrp-hidden")) {
row.classList.remove("tmrp-priority-row", "p-high", "p-medium", "p-low", "tmrp-hidden");
delete row.dataset.tmrpRingSignature;
}
}
const state = {
activeRoleId: localStorage.getItem(STORAGE_KEYS.activeRoleId) || "combat",
customRoles: safeParse(localStorage.getItem(STORAGE_KEYS.customRoles), {}) || {},
showOnlyPrioritized: localStorage.getItem(STORAGE_KEYS.showOnlyPrioritized) === "1"
};
function saveState() {
localStorage.setItem(STORAGE_KEYS.activeRoleId, state.activeRoleId);
localStorage.setItem(STORAGE_KEYS.customRoles, JSON.stringify(state.customRoles));
localStorage.setItem(STORAGE_KEYS.showOnlyPrioritized, state.showOnlyPrioritized ? "1" : "0");
}
function getAllRoles() {
return { ...ROLE_PRESETS, ...state.customRoles };
}
function getActiveRole() {
const roles = getAllRoles();
if (!roles[state.activeRoleId]) state.activeRoleId = "combat";
return roles[state.activeRoleId] || ROLE_PRESETS.combat;
}
function findTitleElement(card) {
for (const el of card.querySelectorAll(TITLE_SELECTORS)) {
const text = (el.textContent || "").trim();
if (text.length < 2 || text.length > 80) continue;
return el;
}
return null;
}
function getMeritCards() {
return Array.from(document.querySelectorAll(MERIT_CARD_SELECTORS)).filter((card) => {
const text = (card.textContent || "").trim();
if (!(card instanceof HTMLElement)) return false;
if (text.length < 2 || text.length > 280) return false;
// Only process real merit rows, not top summary blocks.
const aria = card.getAttribute("aria-label") || "";
if (card.tagName === "LI" && /upgraded\s*:/i.test(aria)) return true;
// Fallback for possible DOM changes: row must include icon + merit info/title.
const hasIconWrap = !!card.querySelector("div[class*='iconWrap']");
const hasInfo = !!card.querySelector("div[class*='meritInfo']");
const hasTitle = !!card.querySelector("p[class*='title']");
return hasIconWrap && hasInfo && hasTitle;
});
}
function getCardSegments(card) {
let segments = cardSegmentsCache.get(card);
if (!segments || segments.some((segment) => !segment.isConnected)) {
segments = Array.from(card.querySelectorAll("svg[class*='progressBar'] path[class*='segment']"));
cardSegmentsCache.set(card, segments);
}
return segments;
}
function clearTargetRing(card) {
for (const segment of card.querySelectorAll("[data-tmrp-segment='1']")) {
segment.removeAttribute("style");
segment.removeAttribute("data-tmrp-segment");
}
delete card.dataset.tmrpRingSignature;
}
function getPriorityMarkerClass(priority) {
if (priority === "high") return "p-high";
if (priority === "medium") return "p-medium";
return "p-low";
}
function hasPlannedRuleData(rule) {
if (!rule) return false;
const hasPriority = PRIORITY_ORDER.includes(rule.priority);
const target = Number.parseInt(rule.target, 10);
const hasTarget = Number.isInteger(target) && target > 0;
const hasReason = typeof rule.reason === "string" && rule.reason.trim().length > 0;
return hasPriority || hasTarget || hasReason;
}
function renderTargetRing(card, rule) {
const segments = getCardSegments(card);
if (!segments.length) return;
const target = Number.parseInt(rule?.target, 10);
if (!Number.isInteger(target) || target < 1 || target > 10) {
clearTargetRing(card);
return;
}
// Keep Torn's existing filled progress (green) untouched.
const currentProgressCount = segments.filter((segment) =>
(segment.className?.baseVal || segment.className || "").includes("progress")
).length;
const ringSignature = `${target}|${currentProgressCount}|${segments.length}`;
if (card.dataset.tmrpRingSignature === ringSignature) return;
clearTargetRing(card);
const targetClamped = Math.min(target, segments.length);
for (let i = 0; i < segments.length; i += 1) {
// Only color the "remaining-to-target" part. Never override current progress.
if (i < currentProgressCount || i >= targetClamped) continue;
const segment = segments[i];
segment.dataset.tmrpSegment = "1";
segment.style.stroke = "var(--tmrp-target-fill)";
segment.style.opacity = "1";
}
card.dataset.tmrpRingSignature = ringSignature;
}
function renderTableLegend() {
const existingLegends = document.querySelectorAll(".tmrp-table-legend");
if (existingLegends.length > 1) {
for (let i = 1; i < existingLegends.length; i += 1) existingLegends[i].remove();
}
const existingLegend = existingLegends[0];
if (existingLegend?.isConnected) {
if (existingLegend.closest("#merits-panel, div[class*='meritsTab']")) return;
existingLegend.remove();
}
const legend = document.createElement("div");
legend.className = "tmrp-table-legend";
legend.innerHTML = "Priority line colors: <span class='tmrp-legend-high'>red</span> = high, <span class='tmrp-legend-medium'>yellow</span> = medium, <span class='tmrp-legend-low'>blue</span> = low. <br><br><span class='tmrp-legend-target'>Purple</span> segments show remaining steps to target.";
// Place legend in merits panel right below top divider and above first merit list.
const meritsPanel = document.querySelector("#merits-panel, div[class*='meritsTab']");
const topDivider = meritsPanel?.querySelector("div[class*='blackLine']");
if (topDivider?.parentElement) {
topDivider.insertAdjacentElement("afterend", legend);
return;
}
// Fallback for future DOM changes.
const cards = getMeritCards();
if (!cards.length) return;
const firstCard = cards[0];
const parent = firstCard.parentElement;
if (!parent) return;
parent.insertBefore(legend, firstCard);
}
function getRuleForMerit(role, meritTitle) {
const meritKey = normalizeKey(meritTitle);
if (!meritKey || !role?.rules) return null;
if (role.rules[meritKey]) return role.rules[meritKey];
const keys = Object.keys(role.rules);
const fuzzy = keys.find((k) => meritKey.includes(k) || k.includes(meritKey));
return fuzzy ? role.rules[fuzzy] : null;
}
function ensureEditableRole() {
if (!ROLE_PRESETS[state.activeRoleId]) return state.activeRoleId;
const base = getActiveRole();
const id = `custom_${Date.now()}`;
state.customRoles[id] = { label: `${base.label} (Custom)`, rules: { ...base.rules } };
state.activeRoleId = id;
saveState();
return id;
}
function createRole() {
openEditModal(true);
}
function getMeritTitle(card, titleEl) {
if (card.dataset.tmrpMeritTitle) return card.dataset.tmrpMeritTitle;
const raw = (titleEl?.childNodes?.[0]?.textContent || titleEl?.textContent || "").trim();
const clean = raw.replace(/\s+/g, " ").trim();
if (clean) card.dataset.tmrpMeritTitle = clean;
return clean;
}
function getCurrentLevel(card) {
const aria = card.getAttribute("aria-label") || "";
const match = aria.match(/upgraded:\s*(\d+)\s*\/\s*10/i);
if (match) return Number.parseInt(match[1], 10) || 0;
// Fallback when aria-label format changes.
const title = card.querySelector("p[class*='title']");
const text = (title?.textContent || "").replace(/\s+/g, " ");
const textMatch = text.match(/(\d+)\s*\/\s*10/);
return textMatch ? Number.parseInt(textMatch[1], 10) || 0 : 0;
}
function getMeritRowsForEditor() {
const rows = [];
const seen = new Set();
for (const card of getMeritCards()) {
const titleEl = findTitleElement(card);
const meritTitle = getMeritTitle(card, titleEl);
const meritKey = normalizeKey(meritTitle);
if (!meritKey || seen.has(meritKey)) continue;
seen.add(meritKey);
rows.push({ title: meritTitle, key: meritKey, current: getCurrentLevel(card) });
}
return rows;
}
function pauseObserverDuringModal(isPaused) {
observerPaused = !!isPaused;
debugPerf("observerPaused =", observerPaused);
}
function shieldPlannerInputEvents(root) {
if (!root || root.dataset.tmrpEventShield === "1") return;
root.dataset.tmrpEventShield = "1";
const stopIfPlannerModalEvent = (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const modal = target.closest(".tmrp-modal,.tmrp-data-modal");
if (!modal) return;
// Keep planner modal events local. This blocks heavy delegated page handlers.
event.stopPropagation();
if (event.type === "change" && target.matches("select")) {
debugPerf("modal select change", {
className: target.className,
value: target.value
});
}
};
// Use bubbling phase so target behavior remains fully native.
const bubbleEvents = ["click", "mousedown", "mouseup", "pointerdown", "pointerup", "keydown", "keyup", "input", "change"];
for (const eventName of bubbleEvents) {
root.addEventListener(eventName, stopIfPlannerModalEvent, false);
}
root.addEventListener("pointerdown", (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const select = target.closest("select");
if (!select || !select.closest(".tmrp-modal,.tmrp-data-modal")) return;
debugPerf("modal select pointerdown", {
className: select.className,
value: select.value
});
}, true);
}
function closeEditModal() {
if (!cachedModal) return;
cachedModal.classList.remove("is-open");
if (!isAnyPlannerModalOpen()) pauseObserverDuringModal(false);
}
function getRoleNameInputValue() {
return (cachedModal?.querySelector(".tmrp-role-name-input")?.value || "").trim();
}
function isRoleNameTaken(name, ignoreRoleId) {
const normalized = normalizeKey(name);
if (!normalized) return false;
for (const [id, role] of Object.entries(getAllRoles())) {
if (ignoreRoleId && id === ignoreRoleId) continue;
if (normalizeKey(role?.label) === normalized) return true;
}
return false;
}
function validateRoleName(name, ignoreRoleId) {
if (!name) {
window.alert("Build name cannot be empty.");
return false;
}
if (isRoleNameTaken(name, ignoreRoleId)) {
window.alert("A build with this name already exists.");
return false;
}
return true;
}
function openEditModal(isNewRoleMode) {
if (!cachedModal || !cachedModal.isConnected) {
cachedModal = document.createElement("div");
cachedModal.className = "tmrp-modal-backdrop";
cachedModal.innerHTML = `<div class="tmrp-modal" role="dialog" aria-modal="true" aria-label="Edit merit build plan"><div class="tmrp-modal-header"><h5>Edit Build Plan</h5></div><div class="tmrp-modal-role-row"><label>Build Name</label><input type="text" class="tmrp-role-name-input" maxlength="60" placeholder="Type build name"></div><div class="tmrp-modal-body"><table class="tmrp-edit-table"><thead><tr><th>Merit</th><th>Now</th><th>Priority</th><th>Target</th><th>Reason</th></tr></thead><tbody></tbody></table></div><div class="tmrp-modal-actions"><button type="button" class="tmrp-modal-reset">Reset Build</button><button type="button" class="tmrp-modal-delete">Delete Build</button><button type="button" class="tmrp-modal-save-new">Save As New</button><button type="button" class="tmrp-modal-cancel">Cancel</button><button type="button" class="tmrp-modal-save">Save</button></div></div>`;
document.body.appendChild(cachedModal);
cachedModal.addEventListener("click", (e) => {
if (e.target === cachedModal) closeEditModal();
});
shieldPlannerInputEvents(cachedModal);
}
const role = isNewRoleMode ? { label: "", rules: {} } : getActiveRole();
const editingRoleId = isNewRoleMode ? "" : state.activeRoleId;
cachedModal.dataset.mode = isNewRoleMode ? "new" : "existing";
cachedModal.dataset.editingRoleId = editingRoleId;
cachedModal.querySelector(".tmrp-role-name-input").value = role?.label || "";
const tbody = cachedModal.querySelector("tbody");
const meritRows = getMeritRowsForEditor();
tbody.innerHTML = meritRows.map((row) => {
const rule = role?.rules?.[row.key] || null;
const priority = rule?.priority || "none";
const target = Number.isInteger(rule?.target) ? rule.target : 0;
const reason = escapeHtml(rule?.reason || "");
return `<tr data-merit-key="${row.key}" data-merit-title="${row.title}"><td>${row.title}</td><td>${row.current}/10</td><td><select class="tmrp-priority-input"><option value="none"${priority === "none" ? " selected" : ""}>none</option><option value="high"${priority === "high" ? " selected" : ""}>high</option><option value="medium"${priority === "medium" ? " selected" : ""}>medium</option><option value="low"${priority === "low" ? " selected" : ""}>low</option></select></td><td><input class="tmrp-target-input" type="number" min="0" max="10" step="1" value="${target}"></td><td><input class="tmrp-reason-input" type="text" maxlength="180" placeholder="Optional reason" value="${reason}"></td></tr>`;
}).join("");
cachedModal.querySelector(".tmrp-modal-cancel").onclick = closeEditModal;
cachedModal.querySelector(".tmrp-modal-save").onclick = saveEditModal;
cachedModal.querySelector(".tmrp-modal-save-new").onclick = saveEditModalAsNew;
cachedModal.querySelector(".tmrp-modal-delete").onclick = deleteActiveRoleFromModal;
cachedModal.querySelector(".tmrp-modal-reset").onclick = resetCurrentRoleFromModal;
const showExistingOnly = !isNewRoleMode;
const isPredefinedRole = showExistingOnly && !!ROLE_PRESETS[state.activeRoleId];
cachedModal.querySelector(".tmrp-modal-save-new").hidden = !showExistingOnly;
cachedModal.querySelector(".tmrp-modal-delete").hidden = !showExistingOnly;
cachedModal.querySelector(".tmrp-modal-reset").hidden = !isPredefinedRole;
cachedModal.classList.add("is-open");
pauseObserverDuringModal(true);
}
function collectRulesFromModalRows() {
const newRules = {};
const rows = cachedModal?.querySelectorAll("tbody tr[data-merit-key]") || [];
for (const row of rows) {
const meritKey = row.getAttribute("data-merit-key") || "";
const priority = row.querySelector(".tmrp-priority-input")?.value || "none";
const targetRaw = row.querySelector(".tmrp-target-input")?.value || "0";
const reasonRaw = row.querySelector(".tmrp-reason-input")?.value || "";
const target = Math.max(0, Math.min(10, Number.parseInt(targetRaw, 10) || 0));
const reason = reasonRaw.trim();
const hasPriority = PRIORITY_ORDER.includes(priority);
if (!meritKey) continue;
// Save rows when at least one user field has meaningful content.
if (!hasPriority && target === 0 && !reason) continue;
newRules[meritKey] = {
priority: hasPriority ? priority : "none",
target,
reason
};
}
return newRules;
}
function saveEditModal() {
const roleName = getRoleNameInputValue();
const isNewMode = cachedModal?.dataset.mode === "new";
const editingRoleId = cachedModal?.dataset.editingRoleId || "";
const resolvedEditingRoleId = editingRoleId || (!isNewMode ? state.activeRoleId : "");
const isEditingPredefined = !isNewMode && !!ROLE_PRESETS[resolvedEditingRoleId];
const ignoreRoleIdForValidation = isNewMode || isEditingPredefined ? "" : resolvedEditingRoleId;
if (!validateRoleName(roleName, ignoreRoleIdForValidation)) return;
let roleId;
if (isNewMode) {
roleId = `custom_${Date.now()}`;
state.customRoles[roleId] = { label: roleName, rules: {} };
state.activeRoleId = roleId;
} else {
// For custom builds, update the same id; for presets, create editable copy.
roleId = isEditingPredefined ? ensureEditableRole() : (resolvedEditingRoleId || ensureEditableRole());
state.activeRoleId = roleId;
}
const role = state.customRoles[roleId];
role.label = roleName;
role.rules = collectRulesFromModalRows();
saveState();
closeEditModal();
scheduleProcess(true);
}
function saveEditModalAsNew() {
const label = getRoleNameInputValue();
const editingRoleId = cachedModal?.dataset.editingRoleId || state.activeRoleId;
const sourceLabel = getAllRoles()?.[editingRoleId]?.label || "";
if (normalizeKey(label) === normalizeKey(sourceLabel)) {
window.alert("Change the build name before using Save As New.");
return;
}
if (!validateRoleName(label, "")) return;
const id = `custom_${Date.now()}`;
state.customRoles[id] = { label, rules: collectRulesFromModalRows() };
state.activeRoleId = id;
saveState();
closeEditModal();
scheduleProcess(true);
}
function resetCurrentRoleFromModal() {
if (!ROLE_PRESETS[state.activeRoleId]) return;
if (!window.confirm("Reset current predefined build view to default values?")) return;
// Predefined roles are immutable; this just discards unsaved modal edits.
closeEditModal();
scheduleProcess(true);
}
function deleteActiveRoleFromModal() {
if (!state.customRoles[state.activeRoleId]) {
window.alert("Built-in preset builds cannot be deleted.");
return;
}
if (!window.confirm("Delete current custom build?")) return;
delete state.customRoles[state.activeRoleId];
state.activeRoleId = "combat";
saveState();
closeEditModal();
scheduleProcess(true);
}
function buildRolePayload() {
const active = getActiveRole();
return {
version: 1,
exportedAt: new Date().toISOString(),
build: {
name: active?.label || "Imported Build",
rules: { ...(active?.rules || {}) }
}
};
}
function sanitizeImportedRules(rawRules) {
const rules = {};
if (!rawRules || typeof rawRules !== "object") return rules;
for (const [key, value] of Object.entries(rawRules)) {
const meritKey = normalizeKey(key);
const priority = String(value?.priority || "").toLowerCase();
const targetRaw = Number.parseInt(String(value?.target ?? ""), 10);
const target = Number.isInteger(targetRaw) ? Math.max(0, Math.min(10, targetRaw)) : 0;
const hasPriority = PRIORITY_ORDER.includes(priority);
const reason = typeof value?.reason === "string" ? value.reason.trim() : "";
if (!meritKey) continue;
if (!hasPriority && target === 0 && !reason) continue;
rules[meritKey] = {
priority: hasPriority ? priority : "none",
target,
reason
};
}
return rules;
}
function extractRoleNameFromJsonText(rawText) {
const parsed = safeParse(rawText, null);
if (!parsed || typeof parsed !== "object") return "";
const buildData = parsed.build && typeof parsed.build === "object" ? parsed.build : parsed;
return String(buildData.name || buildData.label || "").trim();
}
function importRoleFromJsonText(rawText, providedName) {
const parsed = safeParse(rawText, null);
if (!parsed || typeof parsed !== "object") throw new Error("Invalid JSON content.");
const buildData = parsed.build && typeof parsed.build === "object" ? parsed.build : parsed;
const importedName = String(buildData.name || buildData.label || "Imported Build").trim() || "Imported Build";
const importedRules = sanitizeImportedRules(buildData.rules);
if (!Object.keys(importedRules).length) throw new Error("No valid merit rules found in import.");
const finalName = String(providedName || importedName).trim() || importedName;
const id = `custom_${Date.now()}`;
state.customRoles[id] = { label: finalName, rules: importedRules };
state.activeRoleId = id;
saveState();
}
function closeImportModal() {
if (!cachedImportModal) return;
cachedImportModal.querySelector(".tmrp-data-textarea").value = "";
cachedImportModal.querySelector(".tmrp-file-name").textContent = "No file selected";
cachedImportModal.querySelector(".tmrp-import-file").value = "";
cachedImportModal.querySelector(".tmrp-import-role-name").value = "";
cachedImportModal.querySelector(".tmrp-file-picker").classList.remove("is-dragging");
cachedImportModal.classList.remove("is-open");
if (!isAnyPlannerModalOpen()) pauseObserverDuringModal(false);
}
function openImportModal() {
if (!cachedImportModal || !cachedImportModal.isConnected) {
cachedImportModal = document.createElement("div");
cachedImportModal.className = "tmrp-data-modal-backdrop";
cachedImportModal.innerHTML = `<div class="tmrp-data-modal" role="dialog" aria-modal="true" aria-label="Import merit build"><div class="tmrp-data-modal-header"><h5>Import Build</h5></div><div class="tmrp-data-modal-body"><p>Paste build JSON below or load from file.</p><div class="tmrp-data-role-row"><label>Build Name</label><input type="text" class="tmrp-import-role-name" maxlength="60" placeholder="Type build name"></div><textarea class="tmrp-data-textarea" spellcheck="false" wrap="soft"></textarea><div class="tmrp-data-tools"><input type="file" class="tmrp-import-file" accept=".json,application/json,text/plain"><button type="button" class="tmrp-file-picker"><span class="tmrp-file-picker-icon">📄</span><span class="tmrp-file-picker-label">Click to choose JSON file</span><span class="tmrp-file-name">No file selected</span></button></div></div><div class="tmrp-data-modal-actions"><button type="button" class="tmrp-import-cancel">Cancel</button><button type="button" class="tmrp-import-apply">Import</button></div></div>`;
document.body.appendChild(cachedImportModal);
const readImportedFile = (file) => {
if (!file) return;
cachedImportModal.querySelector(".tmrp-file-name").textContent = file.name;
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result || "");
cachedImportModal.querySelector(".tmrp-data-textarea").value = text;
const suggestedName = extractRoleNameFromJsonText(text);
const nameInput = cachedImportModal.querySelector(".tmrp-import-role-name");
if (suggestedName && !nameInput.value.trim()) nameInput.value = suggestedName;
};
reader.readAsText(file);
};
cachedImportModal.addEventListener("click", (e) => {
if (e.target === cachedImportModal) closeImportModal();
});
shieldPlannerInputEvents(cachedImportModal);
cachedImportModal.querySelector(".tmrp-import-cancel").onclick = closeImportModal;
cachedImportModal.querySelector(".tmrp-data-textarea").addEventListener("input", (e) => {
const suggestedName = extractRoleNameFromJsonText(e.target.value || "");
const nameInput = cachedImportModal.querySelector(".tmrp-import-role-name");
if (suggestedName && !nameInput.value.trim()) nameInput.value = suggestedName;
});
cachedImportModal.querySelector(".tmrp-file-picker").onclick = () => cachedImportModal.querySelector(".tmrp-import-file").click();
cachedImportModal.querySelector(".tmrp-import-file").addEventListener("change", (e) => {
readImportedFile(e.target.files?.[0]);
});
const picker = cachedImportModal.querySelector(".tmrp-file-picker");
picker.addEventListener("dragover", (e) => {
e.preventDefault();
picker.classList.add("is-dragging");
});
picker.addEventListener("dragleave", () => {
picker.classList.remove("is-dragging");
});
picker.addEventListener("drop", (e) => {
e.preventDefault();
picker.classList.remove("is-dragging");
readImportedFile(e.dataTransfer?.files?.[0]);
});
cachedImportModal.querySelector(".tmrp-import-apply").onclick = () => {
const text = cachedImportModal.querySelector(".tmrp-data-textarea").value.trim();
if (!text) return window.alert("Paste JSON content or load a file first.");
const buildName = (cachedImportModal.querySelector(".tmrp-import-role-name").value || "").trim();
if (!validateRoleName(buildName, "")) return;
try {
importRoleFromJsonText(text, buildName);
closeImportModal();
closeEditModal();
scheduleProcess(true);
} catch (error) {
window.alert(`Import failed: ${error.message}`);
}
};
}
cachedImportModal.querySelector(".tmrp-data-textarea").value = "";
cachedImportModal.querySelector(".tmrp-file-name").textContent = "No file selected";
cachedImportModal.querySelector(".tmrp-import-file").value = "";
cachedImportModal.querySelector(".tmrp-import-role-name").value = "";
cachedImportModal.querySelector(".tmrp-file-picker").classList.remove("is-dragging");
cachedImportModal.classList.add("is-open");
pauseObserverDuringModal(true);
}
function closeExportModal() {
if (!cachedExportModal) return;
cachedExportModal.classList.remove("is-open");
if (!isAnyPlannerModalOpen()) pauseObserverDuringModal(false);
}
function exportActiveRole() {
const payload = buildRolePayload();
const jsonText = JSON.stringify(payload, null, 2);
if (!cachedExportModal || !cachedExportModal.isConnected) {
cachedExportModal = document.createElement("div");
cachedExportModal.className = "tmrp-data-modal-backdrop";
cachedExportModal.innerHTML = `<div class="tmrp-data-modal" role="dialog" aria-modal="true" aria-label="Export merit build"><div class="tmrp-data-modal-header"><h5>Export Build</h5></div><div class="tmrp-data-modal-body"><p>Copy this JSON or download it as file.</p><textarea class="tmrp-data-textarea" spellcheck="false" readonly wrap="soft"></textarea></div><div class="tmrp-data-modal-actions"><button type="button" class="tmrp-export-copy">Copy</button><button type="button" class="tmrp-export-file">Download</button><button type="button" class="tmrp-export-close">Close</button></div></div>`;
document.body.appendChild(cachedExportModal);
cachedExportModal.addEventListener("click", (e) => {
if (e.target === cachedExportModal) closeExportModal();
});
shieldPlannerInputEvents(cachedExportModal);
cachedExportModal.querySelector(".tmrp-export-close").onclick = closeExportModal;
cachedExportModal.querySelector(".tmrp-export-copy").onclick = async () => {
const text = cachedExportModal.querySelector(".tmrp-data-textarea").value;
try {
await navigator.clipboard.writeText(text);
window.alert("Build JSON copied.");
} catch (_) {
window.alert("Could not copy automatically. Please copy manually.");
}
};
cachedExportModal.querySelector(".tmrp-export-file").onclick = () => {
const text = cachedExportModal.querySelector(".tmrp-data-textarea").value;
const buildName = String(cachedExportModal.dataset.buildName || "build").replace(/[^a-z0-9_-]/gi, "_");
const blob = new Blob([text], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${buildName || "build"}-merit-build.json`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
};
}
cachedExportModal.querySelector(".tmrp-data-textarea").value = jsonText;
cachedExportModal.dataset.buildName = payload.build?.name || "build";
cachedExportModal.classList.add("is-open");
pauseObserverDuringModal(true);
}
function renderPanel() {
const rootHost = document.querySelector("#awards-root");
const fallbackAnchor = document.querySelector("h4, h3, .title-black, .content-title h4");
const panelHost = rootHost || fallbackAnchor?.parentElement || null;
if (!panelHost) return;
if (!cachedPanel || !cachedPanel.isConnected) {
cachedPanel = document.createElement("div");
cachedPanel.className = "tmrp-panel";
cachedPanel.innerHTML = `<label>Build <select class="tmrp-role"></select></label><button class="tmrp-edit-toggle tmrp-icon-btn" title="Open bulk editor" aria-label="Open bulk editor">✎</button><button class="tmrp-new tmrp-icon-btn" title="Create new build" aria-label="Create new build">+</button><button class="tmrp-import tmrp-icon-btn" title="Import build" aria-label="Import build">⇩</button><button class="tmrp-export tmrp-icon-btn" title="Export build" aria-label="Export build">⇧</button><label class="tmrp-filter"><input type="checkbox" class="tmrp-only"> Show only planned merits</label>`;
cachedPanelRoleSelect = cachedPanel.querySelector(".tmrp-role");
cachedPanelOnlyToggle = cachedPanel.querySelector(".tmrp-only");
cachedPanel.querySelector(".tmrp-edit-toggle").addEventListener("click", () => {
openEditModal(false);
});
cachedPanel.querySelector(".tmrp-new").addEventListener("click", createRole);
cachedPanel.querySelector(".tmrp-import").addEventListener("click", openImportModal);
cachedPanel.querySelector(".tmrp-export").addEventListener("click", exportActiveRole);
cachedPanelRoleSelect.addEventListener("change", (e) => {
state.activeRoleId = e.target.value;
saveState();
// Keep this event short so the native selected value paints instantly.
scheduleProcess({ immediate: true });
});
cachedPanelOnlyToggle.addEventListener("change", (e) => { state.showOnlyPrioritized = !!e.target.checked; saveState(); scheduleProcess({ immediate: true }); });
}
// Keep panel mounted at the top of awards root to avoid layout constraints.
if (cachedPanel.parentElement !== panelHost || panelHost.firstElementChild !== cachedPanel) {
panelHost.prepend(cachedPanel);
}
const select = cachedPanelRoleSelect;
const only = cachedPanelOnlyToggle;
const roles = getAllRoles();
const rolesSignature = Object.entries(roles).map(([id, role]) => `${id}:${role.label}`).join("|");
if (rolesSignature !== panelRoleOptionsSignature) {
panelRoleOptionsSignature = rolesSignature;
select.innerHTML = Object.entries(roles).map(([id, role]) => `<option value="${id}">${role.label}</option>`).join("");
}
const desiredRoleId = state.activeRoleId in roles ? state.activeRoleId : "combat";
if (select.value !== desiredRoleId) select.value = desiredRoleId;
if (only.checked !== state.showOnlyPrioritized) only.checked = state.showOnlyPrioritized;
}
function annotateMerits() {
const role = getActiveRole();
const handledCards = [];
const seenMeritKeys = new Set();
for (const card of getMeritCards()) {
// Selector list can match nested wrappers. Handle only first (outer) card.
if (handledCards.some((root) => root.contains(card))) continue;
let meritTitle = card.dataset.tmrpMeritTitle || "";
if (!meritTitle) {
const titleEl = findTitleElement(card);
meritTitle = getMeritTitle(card, titleEl);
}
if (!meritTitle) continue;
const meritKey = normalizeKey(meritTitle);
if (!meritKey || seenMeritKeys.has(meritKey)) continue;
seenMeritKeys.add(meritKey);
handledCards.push(card);
const rule = getRuleForMerit(role, meritTitle);
if (!rule) {
card.classList.toggle("tmrp-hidden", state.showOnlyPrioritized);
if (card.classList.contains("tmrp-priority-row") || card.classList.contains("p-high") || card.classList.contains("p-medium") || card.classList.contains("p-low")) {
card.classList.remove("tmrp-priority-row", "p-high", "p-medium", "p-low");
}
clearTargetRing(card);
continue;
}
const isPrioritized = PRIORITY_ORDER.includes(rule.priority);
const shouldShowWhenFiltered = hasPlannedRuleData(rule);
if (isPrioritized) {
const desiredPriorityClass = getPriorityMarkerClass(rule.priority);
card.classList.add("tmrp-priority-row");
if (!card.classList.contains(desiredPriorityClass)) {
card.classList.remove("p-high", "p-medium", "p-low");
card.classList.add(desiredPriorityClass);
}
} else {
card.classList.remove("p-high", "p-medium", "p-low");
card.classList.remove("tmrp-priority-row");
}
renderTargetRing(card, rule);
card.classList.toggle("tmrp-hidden", state.showOnlyPrioritized && !shouldShowWhenFiltered);
}
}
function processPage() {
if (!isMeritsPage()) {
clearPlannerUiForNonMerits();
return;
}
isProcessingPage = true;
try {
withPerfLabel("processPage", () => {
applyThemeMode();
renderPanel();
renderTableLegend();
annotateMerits();
});
} finally {
isProcessingPage = false;
}
}
function isAnyPlannerModalOpen() {
return !!(
cachedModal?.classList.contains("is-open") ||
cachedImportModal?.classList.contains("is-open") ||
cachedExportModal?.classList.contains("is-open")
);
}
function scheduleProcess(options) {
const immediate = !!(typeof options === "object" ? options.immediate : options);
const force = !!(typeof options === "object" ? options.force : false);
window.clearTimeout(processTimer);
if (!force && isAnyPlannerModalOpen()) return;
if (immediate) {
debugPerf("scheduleProcess immediate");
processTimer = window.setTimeout(processPage, 0);
return;
}
debugPerf("scheduleProcess debounced");
processTimer = window.setTimeout(processPage, RENDER_DEBOUNCE_MS);
}
function installStyles() {
if (document.getElementById("tmrp-style")) return;
const style = document.createElement("style");
style.id = "tmrp-style";
style.textContent = `:root[data-tmrp-theme='dark']{
--tmrp-panel-bg:rgba(0,0,0,.24);
--tmrp-panel-border:rgba(130,130,130,.35);
--tmrp-control-bg:rgba(0,0,0,.35);
--tmrp-control-border:rgba(150,150,150,.4);
--tmrp-control-text:#e4e4e4;
--tmrp-toggle-on-bg:rgba(67,128,85,.5);
--tmrp-toggle-on-border:rgba(120,220,150,.6);
--tmrp-target-fill:#ae7cff;
--tmrp-target-empty:rgba(100,110,125,.45);
--tmrp-priority-high:#ff6f6f;
--tmrp-priority-medium:#ffd45a;
--tmrp-priority-low:#71b8ff;
--tmrp-modal-bg:#1f2228;
--tmrp-modal-border:rgba(150,150,150,.45);
--tmrp-modal-overlay:rgba(0,0,0,.74);
--tmrp-modal-row-bg:rgba(255,255,255,.03);
--tmrp-modal-row-alt-bg:rgba(255,255,255,.06);
--tmrp-option-bg:#2a2f38;
--tmrp-option-text:#f0f0f0;
}
:root[data-tmrp-theme='light']{
--tmrp-panel-bg:rgba(255,255,255,.72);
--tmrp-panel-border:rgba(90,90,90,.25);
--tmrp-control-bg:rgba(255,255,255,.92);
--tmrp-control-border:rgba(90,90,90,.3);
--tmrp-control-text:#252525;
--tmrp-toggle-on-bg:rgba(130,214,150,.55);
--tmrp-toggle-on-border:rgba(40,120,70,.55);
--tmrp-target-fill:#8a5ad4;
--tmrp-target-empty:rgba(120,130,145,.45);
--tmrp-priority-high:#e55353;
--tmrp-priority-medium:#c9a21f;
--tmrp-priority-low:#3b8fdb;
--tmrp-modal-bg:#ffffff;
--tmrp-modal-border:rgba(90,90,90,.35);
--tmrp-modal-overlay:rgba(0,0,0,.55);
--tmrp-modal-row-bg:rgba(0,0,0,.015);
--tmrp-modal-row-alt-bg:rgba(0,0,0,.035);
--tmrp-option-bg:#ffffff;
--tmrp-option-text:#1f1f1f;
}
.tmrp-panel{margin:8px 0;padding:12px 10px;min-height:54px;border:1px solid var(--tmrp-panel-border);background:var(--tmrp-panel-bg);border-radius:6px;display:flex;flex-wrap:wrap;gap:8px;align-items:center;color:inherit}
.tmrp-panel > label{display:flex;align-items:center;gap:6px}
.tmrp-panel button,.tmrp-panel select{height:32px;font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px}
.tmrp-panel select,.tmrp-edit-table select{appearance:auto;-webkit-appearance:auto;-moz-appearance:auto}
.tmrp-panel select option,.tmrp-edit-table select option{background:var(--tmrp-option-bg);color:var(--tmrp-option-text)}
.tmrp-panel button{padding:0 8px;cursor:pointer}
.tmrp-panel .tmrp-icon-btn{min-width:32px;height:32px;padding:0 6px;line-height:1;font-size:13px}
.tmrp-filter{margin-left:8px;font-size:12px}
.tmrp-table-legend{font-size:11px;opacity:.9;padding:4px 10px 6px;color:var(--tmrp-control-text)}
.tmrp-table-legend .tmrp-legend-high{color:var(--tmrp-priority-high);font-weight:700}
.tmrp-table-legend .tmrp-legend-medium{color:var(--tmrp-priority-medium);font-weight:700}
.tmrp-table-legend .tmrp-legend-low{color:var(--tmrp-priority-low);font-weight:700}
.tmrp-table-legend .tmrp-legend-target{color:var(--tmrp-target-fill);font-weight:700}
.tmrp-priority-row.p-high{box-shadow:inset 5px 0 0 var(--tmrp-priority-high)}
.tmrp-priority-row.p-medium{box-shadow:inset 5px 0 0 var(--tmrp-priority-medium)}
.tmrp-priority-row.p-low{box-shadow:inset 5px 0 0 var(--tmrp-priority-low)}
.tmrp-modal-backdrop{position:fixed;inset:0;background:var(--tmrp-modal-overlay);z-index:99999;display:none;align-items:center;justify-content:center;padding:16px}
.tmrp-modal-backdrop.is-open{display:flex}
.tmrp-modal{width:min(940px,95vw);max-height:88vh;overflow:auto;background:var(--tmrp-modal-bg);border:1px solid var(--tmrp-modal-border);border-radius:8px;color:var(--tmrp-control-text);padding:10px}
.tmrp-modal-header h5{margin:0 0 8px 0;font-size:14px}
.tmrp-modal-role-row{display:flex;align-items:center;gap:8px;margin:0 0 8px 0}
.tmrp-modal-role-row label{font-size:12px;min-width:70px}
.tmrp-modal-role-row .tmrp-role-name-input{width:100%;max-width:320px;font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px;padding:4px 6px}
.tmrp-modal-body{max-height:65vh;overflow:auto}
.tmrp-edit-table{width:100%;min-width:0;border-collapse:collapse;font-size:12px}
.tmrp-edit-table th,.tmrp-edit-table td{padding:6px;border-bottom:1px solid var(--tmrp-control-border);text-align:left;color:var(--tmrp-control-text)}
.tmrp-edit-table thead th{background:var(--tmrp-modal-row-alt-bg);position:sticky;top:0;z-index:1}
.tmrp-edit-table tbody tr{background:var(--tmrp-modal-row-bg)}
.tmrp-edit-table tbody tr:nth-child(even){background:var(--tmrp-modal-row-alt-bg)}
.tmrp-edit-table input,.tmrp-edit-table select{width:100%;max-width:110px;box-sizing:border-box;font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px;padding:2px 4px}
.tmrp-edit-table .tmrp-reason-input{max-width:none;min-width:0}
.tmrp-modal-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:10px}
.tmrp-modal-actions button{font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px;padding:4px 10px;cursor:pointer}
.tmrp-data-modal-backdrop{position:fixed;inset:0;background:var(--tmrp-modal-overlay);z-index:100000;display:none;align-items:center;justify-content:center;padding:16px}
.tmrp-data-modal-backdrop.is-open{display:flex}
.tmrp-data-modal{width:min(760px,95vw);max-height:88vh;overflow:auto;background:var(--tmrp-modal-bg);border:1px solid var(--tmrp-modal-border);border-radius:8px;color:var(--tmrp-control-text);padding:10px;box-sizing:border-box}
.tmrp-data-modal-header h5{margin:0 0 8px 0;font-size:14px}
.tmrp-data-modal-body p{margin:0 0 8px 0;font-size:12px}
.tmrp-data-role-row{display:flex;align-items:center;gap:8px;margin:0 0 8px 0}
.tmrp-data-role-row label{font-size:12px;min-width:70px}
.tmrp-data-role-row .tmrp-import-role-name{width:100%;max-width:320px;font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px;padding:4px 6px}
.tmrp-data-textarea{width:100%;min-height:220px;resize:vertical;font-family:Consolas,Monaco,monospace;font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px;padding:6px;box-sizing:border-box;overflow-x:hidden;white-space:pre-wrap;word-break:break-word}
.tmrp-data-tools{display:flex;gap:8px;align-items:center;margin-top:8px}
.tmrp-data-tools .tmrp-import-file{display:none}
.tmrp-data-tools .tmrp-file-picker{width:100%;display:flex;align-items:center;gap:10px;font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-modal-row-bg);border:1px dashed var(--tmrp-control-border);border-radius:6px;padding:10px 12px;cursor:pointer;text-align:left}
.tmrp-data-tools .tmrp-file-picker:hover{background:var(--tmrp-modal-row-alt-bg)}
.tmrp-data-tools .tmrp-file-picker.is-dragging{border-style:solid;border-color:var(--tmrp-target-fill);box-shadow:0 0 0 1px var(--tmrp-target-fill) inset}
.tmrp-data-tools .tmrp-file-picker-icon{font-size:16px;line-height:1}
.tmrp-data-tools .tmrp-file-picker-label{flex:0 0 auto;opacity:.9}
.tmrp-data-tools .tmrp-file-name{margin-left:auto;font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px;padding:3px 8px;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tmrp-data-modal-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:10px}
.tmrp-data-modal-actions button{font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px;padding:4px 10px;cursor:pointer}
.tmrp-hidden{display:none !important}
@media (max-width: 760px){
.tmrp-panel{padding:10px 8px;min-height:0;gap:4px}
.tmrp-panel > label:first-child{display:grid;grid-template-columns:auto 1fr;align-items:center;column-gap:6px;flex:1 1 0;min-width:0}
.tmrp-panel > label:first-child select{min-width:0;width:100%}
.tmrp-panel .tmrp-icon-btn{flex:0 0 28px;min-width:28px;height:28px;padding:0}
.tmrp-filter{order:10;flex:1 0 100%;margin-left:0;font-size:11px}
.tmrp-modal-backdrop{padding:8px}
.tmrp-modal{width:96vw;max-height:94vh;padding:8px}
.tmrp-modal-header h5{font-size:13px}
.tmrp-modal-role-row label{min-width:62px;font-size:11px}
.tmrp-modal-role-row .tmrp-role-name-input{max-width:none;font-size:11px}
.tmrp-modal-body{max-height:70vh}
.tmrp-edit-table{min-width:0;font-size:11px}
.tmrp-edit-table th,.tmrp-edit-table td{padding:5px}
.tmrp-edit-table input,.tmrp-edit-table select{max-width:86px;font-size:11px}
.tmrp-modal-actions{position:sticky;bottom:0;background:var(--tmrp-modal-bg);padding-top:8px;border-top:1px solid var(--tmrp-control-border)}
.tmrp-data-modal-backdrop{padding:8px}
.tmrp-data-modal{width:96vw;max-height:94vh;padding:8px}
.tmrp-data-role-row label{min-width:62px;font-size:11px}
.tmrp-data-role-row .tmrp-import-role-name{max-width:none;font-size:11px}
.tmrp-data-textarea{min-height:180px;font-size:11px}
.tmrp-data-modal-actions{position:sticky;bottom:0;background:var(--tmrp-modal-bg);padding-top:8px;border-top:1px solid var(--tmrp-control-border)}
.tmrp-data-tools{flex-wrap:wrap}
.tmrp-data-tools .tmrp-file-picker{flex-wrap:wrap;gap:6px}
.tmrp-data-tools .tmrp-file-name{max-width:100%;width:100%;margin-left:0}
}`;
document.head.appendChild(style);
}
function startObservers() {
function isPlannerNode(node) {
if (!(node instanceof Element)) return false;
if (node.id === "tmrp-style") return true;
if (node.classList?.contains("tmrp-panel")) return true;
if (node.classList?.contains("tmrp-modal-backdrop")) return true;
if (node.classList?.contains("tmrp-data-modal-backdrop")) return true;
if (node.classList?.contains("tmrp-table-legend")) return true;
return !!node.closest?.(".tmrp-panel,.tmrp-modal-backdrop,.tmrp-data-modal-backdrop,.tmrp-table-legend");
}
function hasExternalMutation(mutations) {
for (const mutation of mutations) {
if (!isPlannerNode(mutation.target)) return true;
for (const node of mutation.addedNodes) {
if (!isPlannerNode(node)) return true;
}
for (const node of mutation.removedNodes) {
if (!isPlannerNode(node)) return true;
}
}
return false;
}
if (mutationObserver) mutationObserver.disconnect();
mutationObserver = new MutationObserver((mutations) => {
if (observerPaused || isAnyPlannerModalOpen()) return;
if (isProcessingPage) return;
if (!hasExternalMutation(mutations)) return;
withPerfLabel("mutationObserver callback", () => {
scheduleProcess();
});
});
mutationObserver.observe(document.body, { childList: true, subtree: true });
window.setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
scheduleProcess({ force: true });
}
}, 700);
}
installStyles();
if (DEBUG_PERF && "PerformanceObserver" in window) {
try {
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
debugPerf(`Long task ${entry.duration.toFixed(1)}ms`, entry.name || "unknown");
}
});
longTaskObserver.observe({ type: "longtask", buffered: true });
debugPerf("debug mode enabled");
} catch (_) {
debugPerf("longtask observer not available");
}
}
processPage();
startObservers();
})();