Adds a box with possible targets to faction page
// ==UserScript==
// @name Torn War Targets
// @namespace https://www.torn.com/factions.php
// @version v1.6.6
// @description Adds a box with possible targets to faction page
// @author Maahly [3893095]
// @match https://www.torn.com/factions.php?step=your*
// @icon https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM.xmlHttpRequest
// @connect api.torn.com
// @connect ffscouter.com
// ==/UserScript==
// Feel free to modify these values
const DEFAULT_MIN_FAIR_FIGHT = 0;
const DEFAULT_MAX_FAIR_FIGHT = 3.5;
const LAST_N_MESSAGES_TO_CHECK_FOR_DIBS = 15;
const CALL_FULFILLMENT_TIMEOUT_MINUTES = 15;
const TARGET_REFRESH_INTERVAL_NOT_IN_WAR_MS = 15000;
const TARGET_REFRESH_INTERVAL_IN_WAR_MS = 5000;
const ENABLE_DEBUG_LOGS = false;
// /////////////////////////////
//
// DO NOT TOUCH BELOW THIS POINT
//
// /////////////////////////////
const FFSCOUTER_KEY_LENGTH = 16;
const MIN_CALL_FRAGMENT_LENGTH = 4;
const FFSCOUTER_API_KEY_STORAGE_KEY = 'ffscouterApiKey';
const CONTENT_ELEMENT_ID = 'war-tagets-content';
const HEADER_ELEMENT_ID = 'war-targets-header';
const HEADER_TITLE_CLASS = 'war-targets-header-title';
const NO_ACTIVE_WAR_MESSAGE = 'NO ONGOING WAR';
const TARGET_STYLE_ID = 'war-targets-style';
const CALL_FULFILLMENT_TIMEOUT_MS = CALL_FULFILLMENT_TIMEOUT_MINUTES * 60 * 1000;
const LAST_UPDATED_INTERVAL_MS = 1000;
const REQUEST_TIMEOUT_MS = 10000;
const FACTION_CHAT_ID_PATTERN = /^faction-\d+$/;
const headerState = {
lastUpdatedAt: null,
lastUpdatedTimer: null,
refreshTimer: null,
warStarted: false,
isCollapsed: true,
noActiveWar: false,
};
const targetState = {
cards: new Map(),
grid: null,
message: null,
settingsPanel: null,
minFairFightInput: null,
maxFairFightInput: null,
onlineFirstInput: null,
calledFragments: new Set(),
calledExactNames: new Set(),
calledBy: new Map(),
calledByExactName: new Map(),
selfCalledFragments: new Set(),
selfCalledExactNames: new Set(),
callEntries: [],
parsedMessages: new Map(),
lastKnownStates: new Map(),
wrapper: null,
latestTargets: [],
minFairFight: DEFAULT_MIN_FAIR_FIGHT,
maxFairFight: DEFAULT_MAX_FAIR_FIGHT,
onlineFirst: false,
};
const chatState = {
listObserver: null,
listElement: null,
};
const statsCache = new Map();
const LOG_PREFIX = '[Torn War Targets]';
const logInfo = (...args) => console.log(LOG_PREFIX, ...args);
const logWarn = (...args) => console.warn(LOG_PREFIX, ...args);
const logError = (...args) => console.error(LOG_PREFIX, ...args);
const logDebug = (...args) => {
if (!ENABLE_DEBUG_LOGS) {
return;
}
console.debug(LOG_PREFIX, ...args);
};
const isFunction = (value) => typeof value === 'function';
const getModernGmApi = () => globalThis.GM ?? null;
const getTornPdaHttpGet = () =>
globalThis.PDA_httpGet ?? globalThis.unsafeWindow?.PDA_httpGet ?? null;
const safeGetValue = (key, fallback = '') => {
if (!isFunction(globalThis.GM_getValue)) {
try {
return globalThis.localStorage?.getItem(key) ?? fallback;
} catch (error) {
logWarn('Failed to read from localStorage.', { key, error });
return fallback;
}
}
try {
return GM_getValue(key, fallback);
} catch (error) {
logWarn('GM_getValue failed.', { key, error });
return fallback;
}
};
const safeSetValue = (key, value) => {
if (!isFunction(globalThis.GM_setValue)) {
try {
globalThis.localStorage?.setItem(key, value);
} catch (error) {
logWarn('Failed to write to localStorage.', { key, error });
}
return;
}
try {
GM_setValue(key, value);
} catch (error) {
logWarn('GM_setValue failed.', { key, error });
}
};
const getAvailabilityStatus = (member) => {
if (!member) {
return 'Offline';
}
return member?.last_update?.status ?? member?.last_action?.status ?? 'Offline';
};
const hasOwn = (value, property) =>
value != null && Object.prototype.hasOwnProperty.call(value, property);
const isCompletedRankedWar = (war) => {
const winner = war?.winner;
if (winner != null && winner !== '') {
return true;
}
const endValue = Number(war?.end);
return Number.isFinite(endValue) && endValue > 0;
};
const toTimestampMs = (value) => {
if (value == null || value === '') {
return null;
}
const numeric = Number(value);
if (Number.isFinite(numeric)) {
if (numeric > 1e12) {
return numeric;
}
if (numeric > 1e9) {
return numeric * 1000;
}
}
const parsedDate = Date.parse(value);
return Number.isNaN(parsedDate) ? null : parsedDate;
};
const getHeaderTitle = ({ noActiveWar = false } = {}) => {
const baseTitle = `My Targets`;
if (!noActiveWar) {
return `${baseTitle} | FF: ${targetState.minFairFight}-${targetState.maxFairFight}`;
}
return `${baseTitle} | ${NO_ACTIVE_WAR_MESSAGE}`;
};
const parseFairFightValue = (value, fallback) => {
if (typeof value === 'string' && value.trim() === '') {
return fallback;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.max(0, Math.round(parsed * 10) / 10);
};
const updateFairFightInputs = () => {
if (targetState.minFairFightInput) {
targetState.minFairFightInput.value = String(targetState.minFairFight);
}
if (targetState.maxFairFightInput) {
targetState.maxFairFightInput.value = String(targetState.maxFairFight);
}
};
const applyFairFightFilter = () => {
const nextMin = parseFairFightValue(
targetState.minFairFightInput?.value,
DEFAULT_MIN_FAIR_FIGHT
);
const nextMax = parseFairFightValue(
targetState.maxFairFightInput?.value,
9999
);
targetState.minFairFight = Math.min(nextMin, nextMax);
targetState.maxFairFight = Math.max(nextMin, nextMax);
setNoActiveWarHeaderState(headerState.noActiveWar);
if (targetState.latestTargets.length > 0) {
renderTargetGrid(targetState.latestTargets);
}
};
const applyOnlineFirstSetting = () => {
targetState.onlineFirst = Boolean(targetState.onlineFirstInput?.checked);
if (targetState.latestTargets.length > 0) {
renderTargetGrid(targetState.latestTargets);
}
};
const setNoActiveWarHeaderState = (isNoActiveWar) => {
const header = document.getElementById(HEADER_ELEMENT_ID);
const headerTitle = header?.querySelector(`.${HEADER_TITLE_CLASS}`);
headerState.noActiveWar = Boolean(isNoActiveWar);
if (headerTitle) {
headerTitle.textContent = getHeaderTitle({ noActiveWar: isNoActiveWar });
}
const lastUpdated = header?.querySelector('.war-targets-last-updated');
if (lastUpdated) {
lastUpdated.style.display = isNoActiveWar ? 'none' : '';
if (!isNoActiveWar && !headerState.lastUpdatedAt) {
lastUpdated.textContent = 'Last updated: --';
}
}
const content = document.getElementById(CONTENT_ELEMENT_ID);
if (content && header) {
const shouldHideContent = headerState.noActiveWar || headerState.isCollapsed;
header.classList.toggle('active', !shouldHideContent);
header.setAttribute('aria-expanded', String(!shouldHideContent));
content.style.display = shouldHideContent ? 'none' : '';
}
};
const toggleCollapsedState = () => {
headerState.isCollapsed = !headerState.isCollapsed;
setNoActiveWarHeaderState(headerState.noActiveWar);
};
const isFederalTarget = (target) => target?.status?.state === 'Federal';
const isTravelingTarget = (target) => target?.status?.state === 'Traveling';
const isAbroadTarget = (target) => target?.status?.state === 'Abroad';
const isAbroadHospitalTarget = (target) => {
if (target?.status?.state !== 'Hospital') {
return false;
}
return /In\s+a\s+.+\s+hospital/i.test(target?.status?.description ?? '');
};
const ensureTargetStyles = () => {
if (document.getElementById(TARGET_STYLE_ID)) {
return;
}
const style = document.createElement('style');
style.id = TARGET_STYLE_ID;
style.textContent = `
#${HEADER_ELEMENT_ID} {
cursor: pointer;
}
.war-targets-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
padding: 12px;
color: #f3f4f6;
font-family: "Inter", "Segoe UI", sans-serif;
}
.war-targets-section-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: #cbd5f5;
margin-bottom: 6px;
}
.war-targets-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px;
}
.war-target-card {
border-radius: 8px;
border: 1px solid #1f2937;
background: #111827;
padding: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
cursor: pointer;
position: relative;
}
.war-target-card.state-okay {
background: #064e3b;
border-color: #047857;
}
.war-target-card.state-hospital {
background: #7f1d1d;
border-color: #dc2626;
}
.war-target-card.is-called {
border-color: #fb7185;
box-shadow: 0 0 0 1px rgba(190, 18, 60, 0.8),
0 2px 6px rgba(190, 18, 60, 0.35);
}
.war-target-card.is-yours {
border-color: #38bdf8;
box-shadow: 0 0 0 1px rgba(14, 116, 144, 0.8),
0 2px 6px rgba(14, 116, 144, 0.35);
}
.war-target-call-button {
position: absolute;
right: 6px;
bottom: 6px;
border: none;
border-radius: 999px;
padding: 4px 8px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
background: rgba(15, 23, 42, 0.9);
color: #e2e8f0;
cursor: pointer;
}
.war-target-call-button:hover {
background: rgba(30, 41, 59, 0.95);
}
.war-target-called-badge {
position: absolute;
top: 6px;
right: 6px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(190, 18, 60, 0.9);
color: #fff1f2;
font-size: 9px;
letter-spacing: 0.16em;
text-transform: uppercase;
display: none;
}
.war-target-called-badge.is-yours {
background: rgba(14, 116, 144, 0.9);
color: #ecfeff;
}
.war-target-name {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 12px;
margin-bottom: 6px;
cursor: pointer;
}
.war-target-status-dot {
width: 8px;
height: 8px;
border-radius: 999px;
}
.war-target-status-dot.online {
background: #22c55e;
}
.war-target-status-dot.idle {
background: #facc15;
}
.war-target-status-dot.offline {
background: #9ca3af;
}
.war-target-meta {
font-size: 11px;
line-height: 1.4;
color: #f9fafb;
cursor: pointer;
}
.war-target-ff {
color: #fde047;
font-weight: 600;
}
.war-target-divider {
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 12px;
margin-top: 12px;
}
.war-targets-header-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.war-targets-last-updated {
font-size: 11px;
color: #cbd5f5;
margin-left: 12px;
padding-right: 6px;
white-space: nowrap;
}
.war-targets-message {
font-size: 12px;
color: #e5e7eb;
}
.war-targets-settings {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border: 1px solid #334155;
border-radius: 6px;
background: #0f172a;
box-sizing: border-box;
width: 100%;
max-width: 100%;
}
.war-targets-settings label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #cbd5f5;
white-space: nowrap;
}
.war-targets-settings input {
width: 56px;
border: 1px solid #475569;
border-radius: 4px;
background: #020617;
color: #f8fafc;
font-size: 11px;
padding: 3px 4px;
}
.war-targets-settings input[type='checkbox'] {
width: auto;
border: 0;
border-radius: 0;
background: transparent;
padding: 0;
accent-color: #38bdf8;
}
.war-targets-api-key-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.war-targets-api-key-input {
min-width: 220px;
width: min(320px, 100%);
border: 1px solid #4b5563;
border-radius: 6px;
padding: 6px 8px;
background: #0f172a;
color: #f8fafc;
font-size: 12px;
}
.war-targets-api-key-button {
border: 1px solid #2563eb;
border-radius: 6px;
padding: 6px 10px;
background: #1d4ed8;
color: #eff6ff;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.war-targets-api-key-button:hover {
background: #1e40af;
}
`;
document.head.appendChild(style);
};
const getHospitalLabel = (untilTimestamp) => {
const untilSeconds = Number(untilTimestamp);
if (!Number.isFinite(untilSeconds) || untilSeconds <= 0) {
return { label: '', remainingSeconds: null };
}
const remainingSeconds = Math.max(0, Math.floor(untilSeconds - Date.now() / 1000));
if (remainingSeconds === 0) {
return { label: '', remainingSeconds };
}
if (remainingSeconds < 60) {
return { label: `${remainingSeconds}s`, remainingSeconds };
}
const hours = Math.floor(remainingSeconds / 3600);
const minutes = Math.floor((remainingSeconds % 3600) / 60);
const parts = [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null]
.filter(Boolean)
.join('');
return { label: parts, remainingSeconds };
};
const findFactionChatElement = (node) => {
if (!(node instanceof Element)) {
return null;
}
if (FACTION_CHAT_ID_PATTERN.test(node.id)) {
return node;
}
const candidates = node.querySelectorAll('[id^="faction-"]');
for (const candidate of candidates) {
if (FACTION_CHAT_ID_PATTERN.test(candidate.id)) {
return candidate;
}
}
return null;
};
const getFactionChatList = () => {
const factionChat = document.querySelector('[id^="faction-"]');
return factionChat?.querySelector('[class^="scrollWrapper__"]') ?? null;
};
const getFactionChatTextarea = () => {
const factionChat = document.querySelector('[id^="faction-"]');
return factionChat?.querySelector('[class^="textarea__"]') ?? null;
};
const startsWithAnyFragment = (targetName, fragments) => {
if (!targetName) {
return false;
}
const lowerName = targetName.toLowerCase();
for (const fragment of fragments) {
if (lowerName.startsWith(fragment)) {
return true;
}
}
return false;
};
const dispatchEnterKey = (element) => {
if (!element) {
return;
}
const eventInit = {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
};
element.dispatchEvent(new KeyboardEvent('keydown', eventInit));
element.dispatchEvent(new KeyboardEvent('keypress', eventInit));
element.dispatchEvent(new KeyboardEvent('keyup', eventInit));
};
const collectLastMessages = (listElement) => {
if (!listElement) {
logDebug('collectLastMessages skipped: missing chat list element.');
return [];
}
const messageContainer = listElement.firstElementChild;
if (!messageContainer) {
logDebug('collectLastMessages skipped: message container is unavailable.');
return [];
}
const messages = [...messageContainer.children]
.slice(-LAST_N_MESSAGES_TO_CHECK_FOR_DIBS)
.map((node) => node.innerText);
logDebug('Collected recent faction chat messages.', {
messageCount: messages.length,
maxTrackedMessages: LAST_N_MESSAGES_TO_CHECK_FOR_DIBS,
});
return messages;
};
const observeChatList = (listElement) => {
if (!listElement || chatState.listElement === listElement) {
if (!listElement) {
logDebug('observeChatList skipped: no list element available yet.');
}
return;
}
if (chatState.listObserver) {
chatState.listObserver.disconnect();
}
chatState.listElement = listElement;
chatState.listObserver = new MutationObserver((mutations) => {
if (!mutations.some((mutation) => mutation.addedNodes.length > 0)) {
return;
}
logDebug('Faction chat list mutation detected.', {
mutationCount: mutations.length,
listChildCount: listElement.childElementCount,
});
updateCalledTargets(collectLastMessages(listElement));
});
chatState.listObserver.observe(listElement, { childList: true, subtree: true });
updateCalledTargets(collectLastMessages(listElement));
};
const observeChatRoot = (chatRoot) => {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (findFactionChatElement(node)) {
logInfo('Faction chat opened.');
const listElement = getFactionChatList();
observeChatList(listElement);
return;
}
}
}
});
observer.observe(chatRoot, { childList: true, subtree: true });
observeChatList(getFactionChatList());
};
const startChatRootObserver = () => {
const chatRoot = document.getElementById('chatRoot');
if (!chatRoot) {
logWarn('Chat root not found yet.');
return false;
}
observeChatRoot(chatRoot);
return true;
};
const waitForChatRoot = () => {
if (startChatRootObserver()) {
return;
}
const bodyObserver = new MutationObserver(() => {
if (startChatRootObserver()) {
bodyObserver.disconnect();
}
});
if (!document.body) {
logError('document.body is unavailable; cannot observe chat root.');
return;
}
bodyObserver.observe(document.body, { childList: true, subtree: true });
};
const getHospitalRemainingSeconds = (untilTimestamp) => {
const untilSeconds = Number(untilTimestamp);
if (!Number.isFinite(untilSeconds) || untilSeconds <= 0) {
return null;
}
const remaining = Math.floor(untilSeconds - Date.now() / 1000);
return Math.max(0, remaining);
};
const isCallEligible = (target) => {
if (target?.status?.state !== 'Hospital') {
return true;
}
const remainingSeconds = getHospitalRemainingSeconds(target?.status?.until);
if (remainingSeconds == null) {
return true;
}
return remainingSeconds <= 20 * 60;
};
const getEffectiveState = (target) => {
const rawState = target?.status?.state ?? 'Unknown';
if (rawState !== 'Hospital') {
return rawState;
}
const remaining = getHospitalRemainingSeconds(target?.status?.until);
return remaining === 0 ? 'Okay' : rawState;
};
const getEffectiveDescription = (target, effectiveState) => {
if (effectiveState === 'Okay' && target?.status?.state === 'Hospital') {
return '';
}
return target?.status?.description ?? '';
};
const formatCallMessage = (target) => {
const targetName = target?.name ?? '';
if (!targetName) {
return '';
}
const effectiveState = getEffectiveState(target);
if (effectiveState !== 'Hospital') {
return targetName;
}
const remainingSeconds = getHospitalRemainingSeconds(target?.status?.until);
if (remainingSeconds == null || remainingSeconds <= 0) {
return targetName;
}
const minutesRemaining = Math.max(1, Math.ceil(remainingSeconds / 60));
return `${targetName} in ${minutesRemaining}`;
};
const getStatusLabel = ({ state, description, hospitalLabel }) => {
if (state === 'Hospital' && hospitalLabel) {
const isAbroadHospital = /In\s+a\s+.+\s+hospital/i.test(description);
return `${isAbroadHospital ? 'Hosp' : 'Hospital'} ${hospitalLabel}`;
}
if (state === 'Traveling' && description.startsWith('Traveling')) {
return description;
}
return state;
};
const updateTargetCard = (target, cardData) => {
if (!cardData?.card) {
return;
}
cardData.targetData = target;
const effectiveState = getEffectiveState(target);
const effectiveDescription = getEffectiveDescription(target, effectiveState);
const { label: hospitalLabel } = getHospitalLabel(target?.status?.until);
const stateClass = (effectiveState ?? 'unknown').toLowerCase();
cardData.card.className = `war-target-card state-${stateClass}`;
const targetId = String(target?.id ?? '');
const targetName = target?.name ?? '';
handleTargetStateTransition(targetId, targetName, effectiveState);
applyCalledState(cardData, targetName);
cardData.card.dataset.targetId = targetId;
const availability = (target?.availability_status ?? 'Offline').toLowerCase();
cardData.statusDot.className = `war-target-status-dot ${availability}`;
cardData.statusDot.title = availability;
cardData.name.textContent = target?.name ?? 'Unknown';
cardData.statusLine.textContent = getStatusLabel({
state: effectiveState,
description: effectiveDescription,
hospitalLabel,
});
cardData.bsLine.textContent = `BS: ${target?.bs_estimate_human ?? 'Unknown'}`;
cardData.ffLine.className = 'war-target-ff';
cardData.ffLine.textContent = `FF: ${target?.fair_fight ?? 'N/A'}`;
updateCalledBadge(cardData, targetName);
updateCallButtonVisibility(cardData, targetName);
};
const renderTargetCard = (target) => {
const card = document.createElement('div');
const cardData = {
card,
name: document.createElement('span'),
statusDot: document.createElement('span'),
statusLine: document.createElement('div'),
bsLine: document.createElement('div'),
ffLine: document.createElement('div'),
calledBadge: document.createElement('div'),
callButton: document.createElement('button'),
};
updateTargetCard(target, cardData);
const targetId = target?.id;
if (targetId) {
card.addEventListener('click', () => {
window.open(
`https://www.torn.com/loader.php?sid=attack&user2ID=${encodeURIComponent(targetId)}`,
'_blank',
'noopener'
);
});
}
const nameRow = document.createElement('div');
nameRow.className = 'war-target-name';
nameRow.appendChild(cardData.statusDot);
nameRow.appendChild(cardData.name);
const meta = document.createElement('div');
meta.className = 'war-target-meta';
meta.appendChild(cardData.statusLine);
meta.appendChild(cardData.bsLine);
meta.appendChild(cardData.ffLine);
cardData.calledBadge.className = 'war-target-called-badge';
cardData.callButton.className = 'war-target-call-button';
cardData.callButton.type = 'button';
cardData.callButton.textContent = 'Call';
cardData.callButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const textarea = getFactionChatTextarea();
if (!textarea) {
logWarn('Cannot send call message: faction chat textarea not found.', {
targetName: cardData?.targetData?.name,
});
window.alert('Open the faction chat first.');
return;
}
const message = formatCallMessage(cardData?.targetData);
if (!message) {
logWarn('Cannot send call message: empty message after formatting.', {
target: cardData?.targetData,
});
return;
}
textarea.value = message;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus();
setTimeout(() => {
dispatchEnterKey(textarea);
}, 0);
});
card.appendChild(cardData.calledBadge);
card.appendChild(cardData.callButton);
card.appendChild(nameRow);
card.appendChild(meta);
return cardData;
};
const isTargetCalled = (targetName) => {
if (!targetName) {
return false;
}
const lowerName = targetName.toLowerCase();
if (targetState.calledExactNames.has(lowerName)) {
return true;
}
return startsWithAnyFragment(targetName, targetState.calledFragments);
};
const getTargetCaller = (targetName) => {
if (!targetName) {
return null;
}
const lowerName = targetName.toLowerCase();
if (targetState.calledByExactName.has(lowerName)) {
return targetState.calledByExactName.get(lowerName) ?? null;
}
for (const fragment of targetState.calledFragments) {
if (lowerName.startsWith(fragment)) {
return targetState.calledBy.get(fragment) ?? null;
}
}
return null;
};
const isTargetCalledBySelf = (targetName) => {
if (!targetName) {
return false;
}
const lowerName = targetName.toLowerCase();
if (targetState.selfCalledExactNames.has(lowerName)) {
return true;
}
return startsWithAnyFragment(targetName, targetState.selfCalledFragments);
};
const formatCallerLabel = (name) => {
if (!name) {
return 'Called';
}
const trimmed = name.trim();
if (trimmed.length <= 10) {
return trimmed;
}
return `${trimmed.slice(0, 10)}…`;
};
const applyCalledState = (cardData, targetName) => {
if (!cardData?.card) {
return;
}
if (!isCallEligible(cardData?.targetData)) {
cardData.card.classList.remove('is-yours');
cardData.card.classList.remove('is-called');
return;
}
const calledBySelf = isTargetCalledBySelf(targetName);
if (calledBySelf) {
cardData.card.classList.add('is-yours');
} else {
cardData.card.classList.remove('is-yours');
}
if (!calledBySelf && isTargetCalled(targetName)) {
cardData.card.classList.add('is-called');
} else {
cardData.card.classList.remove('is-called');
}
};
const updateCalledBadge = (cardData, targetName) => {
if (!cardData?.calledBadge) {
return;
}
if (!isCallEligible(cardData?.targetData)) {
cardData.calledBadge.textContent = 'Called';
cardData.calledBadge.classList.remove('is-yours');
cardData.calledBadge.style.display = 'none';
return;
}
if (isTargetCalledBySelf(targetName)) {
cardData.calledBadge.textContent = 'Yours';
cardData.calledBadge.classList.add('is-yours');
cardData.calledBadge.style.display = 'inline-flex';
return;
}
if (isTargetCalled(targetName)) {
cardData.calledBadge.textContent = formatCallerLabel(
getTargetCaller(targetName)
);
cardData.calledBadge.classList.remove('is-yours');
cardData.calledBadge.style.display = 'inline-flex';
return;
}
cardData.calledBadge.textContent = 'Called';
cardData.calledBadge.classList.remove('is-yours');
cardData.calledBadge.style.display = 'none';
};
const updateCallButtonVisibility = (cardData, targetName) => {
if (!cardData?.callButton) {
return;
}
const shouldShow =
isCallEligible(cardData?.targetData) &&
!isTargetCalled(targetName) &&
!isTargetCalledBySelf(targetName);
cardData.callButton.style.display = shouldShow ? 'inline-flex' : 'none';
};
const handleTargetStateTransition = (targetId, targetName, effectiveState) => {
if (!targetId) {
logDebug('handleTargetStateTransition skipped: target id missing.', {
targetName,
effectiveState,
});
return;
}
const previousState = targetState.lastKnownStates.get(targetId);
targetState.lastKnownStates.set(targetId, effectiveState);
if (previousState !== effectiveState) {
logDebug('Target state transition tracked.', {
targetId,
targetName,
previousState,
effectiveState,
});
}
if (previousState === 'Okay' && effectiveState === 'Hospital') {
if (removeCallsForTargetName(targetName)) {
logDebug('Removed call entries due to Okay -> Hospital transition.', {
targetId,
targetName,
});
rebuildCalledState();
refreshCallStyles();
}
}
};
const normalizeMessageKey = (message) => (message ?? '').trim().toLowerCase();
const parseChatMessageParts = (message) => {
const text = (message ?? '').replace(/\r/g, '').trim();
if (!text) {
logDebug('Skipping chat parse: message text is empty after trim.');
return null;
}
const lines = text
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
logDebug('Skipping chat parse: no non-empty lines remained.', {
originalLength: text.length,
});
return null;
}
const callerMatch = lines[0].match(/^([^:]+):$/);
if (callerMatch && lines.length >= 2) {
logDebug('Parsed chat message with explicit caller prefix.', {
callerName: callerMatch[1].trim(),
messagePart: lines[1],
lineCount: lines.length,
});
return {
callerName: callerMatch[1].trim(),
messagePart: lines[1],
isSelf: false,
};
}
logDebug('Parsed chat message as self/unprefixed call candidate.', {
messagePart: lines[0],
lineCount: lines.length,
});
return {
callerName: '',
messagePart: lines[0],
isSelf: true,
};
};
const extractCallEntry = (message) => {
if (!message) {
logDebug('Skipping call extraction: message is empty.');
return null;
}
const messageParts = parseChatMessageParts(message);
if (!messageParts) {
logDebug('Skipping call extraction: message parts parsing failed.', {
rawMessage: message,
});
return null;
}
const { callerName, messagePart, isSelf } = messageParts;
const match = messagePart.match(/^([a-z0-9_]+)(?:\s+in\s+\d+)?(?:[.!?,])?$/i);
if (!match) {
logDebug('Skipping call extraction: message part did not match fragment pattern.', {
callerName,
messagePart,
isSelf,
});
return null;
}
const fragment = match[1].toLowerCase();
const entry = {
fragment,
callerName,
isSelf,
messageKey: normalizeMessageKey(`${callerName}|${messagePart}`),
};
logDebug('Extracted valid call entry from chat message.', entry);
return entry;
};
const rebuildCalledState = () => {
const fragments = new Set();
const exactNames = new Set();
const calledBy = new Map();
const calledByExactName = new Map();
const selfFragments = new Set();
const selfExactNames = new Set();
targetState.callEntries.forEach((entry) => {
exactNames.add(entry.fragment);
if (entry.isSelf) {
selfExactNames.add(entry.fragment);
if (entry.fragment.length >= MIN_CALL_FRAGMENT_LENGTH) {
selfFragments.add(entry.fragment);
}
return;
}
if (entry.fragment.length >= MIN_CALL_FRAGMENT_LENGTH) {
fragments.add(entry.fragment);
}
if (entry.callerName) {
calledByExactName.set(entry.fragment, entry.callerName);
calledBy.set(entry.fragment, entry.callerName);
}
});
targetState.calledFragments = fragments;
targetState.calledExactNames = exactNames;
targetState.calledBy = calledBy;
targetState.calledByExactName = calledByExactName;
targetState.selfCalledFragments = selfFragments;
targetState.selfCalledExactNames = selfExactNames;
logDebug('Rebuilt called-state indexes.', {
calledFragments: [...fragments],
calledExactNames: [...exactNames],
selfCalledFragments: [...selfFragments],
selfCalledExactNames: [...selfExactNames],
calledBy: [...calledBy.entries()],
calledByExactName: [...calledByExactName.entries()],
});
};
const refreshCallStyles = () => {
targetState.cards.forEach((cardData) => {
const targetName = cardData?.name?.textContent ?? '';
applyCalledState(cardData, targetName);
updateCalledBadge(cardData, targetName);
updateCallButtonVisibility(cardData, targetName);
});
};
const pruneParsedMessages = (now) => {
const originalSize = targetState.parsedMessages.size;
targetState.parsedMessages.forEach((parsedAt, key) => {
if (now - parsedAt > CALL_FULFILLMENT_TIMEOUT_MS) {
targetState.parsedMessages.delete(key);
}
});
while (targetState.parsedMessages.size > LAST_N_MESSAGES_TO_CHECK_FOR_DIBS) {
const oldestKey = targetState.parsedMessages.keys().next().value;
targetState.parsedMessages.delete(oldestKey);
}
if (targetState.parsedMessages.size !== originalSize) {
logDebug('Pruned parsed message cache.', {
before: originalSize,
after: targetState.parsedMessages.size,
timeoutMs: CALL_FULFILLMENT_TIMEOUT_MS,
maxTrackedMessages: LAST_N_MESSAGES_TO_CHECK_FOR_DIBS,
});
}
};
const pruneCallEntries = (now) => {
const originalCount = targetState.callEntries.length;
targetState.callEntries = targetState.callEntries.filter(
(entry) => now - entry.parsedAt <= CALL_FULFILLMENT_TIMEOUT_MS
);
if (targetState.callEntries.length > LAST_N_MESSAGES_TO_CHECK_FOR_DIBS) {
targetState.callEntries.sort((a, b) => a.parsedAt - b.parsedAt);
targetState.callEntries.splice(
0,
targetState.callEntries.length - LAST_N_MESSAGES_TO_CHECK_FOR_DIBS
);
}
if (targetState.callEntries.length !== originalCount) {
logDebug('Pruned call fulfillment entries.', {
before: originalCount,
after: targetState.callEntries.length,
timeoutMs: CALL_FULFILLMENT_TIMEOUT_MS,
maxTrackedMessages: LAST_N_MESSAGES_TO_CHECK_FOR_DIBS,
});
}
};
const removeCallsForTargetName = (targetName) => {
if (!targetName) {
logDebug('removeCallsForTargetName skipped: missing target name.');
return false;
}
const lowerName = targetName.toLowerCase();
const originalLength = targetState.callEntries.length;
targetState.callEntries = targetState.callEntries.filter((entry) => {
if (lowerName === entry.fragment) {
return false;
}
if (entry.fragment.length < MIN_CALL_FRAGMENT_LENGTH) {
return true;
}
return !lowerName.startsWith(entry.fragment);
});
const removed = targetState.callEntries.length !== originalLength;
if (removed) {
logDebug('Removed call entries for target fulfillment target.', {
targetName,
removedCount: originalLength - targetState.callEntries.length,
});
}
return removed;
};
const updateCalledTargets = (messages) => {
const now = Date.now();
logDebug('Starting called-target update cycle.', {
incomingMessageCount: (messages ?? []).length,
existingCallEntries: targetState.callEntries.length,
parsedMessageCacheSize: targetState.parsedMessages.size,
});
(messages ?? []).forEach((message) => {
const entry = extractCallEntry(message);
if (!entry) {
return;
}
if (targetState.parsedMessages.has(entry.messageKey)) {
logDebug('Skipping duplicate call entry based on message key.', {
messageKey: entry.messageKey,
fragment: entry.fragment,
});
return;
}
targetState.parsedMessages.set(entry.messageKey, now);
const hadExistingFragment = targetState.callEntries.some(
(existing) => existing.fragment === entry.fragment
);
targetState.callEntries = targetState.callEntries.filter(
(existing) => existing.fragment !== entry.fragment
);
targetState.callEntries.push({ ...entry, parsedAt: now });
logDebug('Registered/updated call fulfillment entry.', {
fragment: entry.fragment,
callerName: entry.callerName,
isSelf: entry.isSelf,
replacedExistingFragment: hadExistingFragment,
});
});
pruneParsedMessages(now);
pruneCallEntries(now);
rebuildCalledState();
refreshCallStyles();
logDebug('Completed called-target update cycle.', {
totalCallEntries: targetState.callEntries.length,
calledFragmentsCount: targetState.calledFragments.size,
selfCalledFragmentsCount: targetState.selfCalledFragments.size,
});
};
const ensureTargetLayout = () => {
const content = document.getElementById(CONTENT_ELEMENT_ID);
if (!content) {
return null;
}
ensureTargetStyles();
if (!targetState.wrapper) {
content.textContent = '';
targetState.wrapper = document.createElement('div');
targetState.wrapper.className = 'war-targets-wrapper';
targetState.message = document.createElement('div');
targetState.message.className = 'war-targets-message';
targetState.settingsPanel = document.createElement('div');
targetState.settingsPanel.className = 'war-targets-settings';
const minLabel = document.createElement('label');
minLabel.textContent = 'min FF';
targetState.minFairFightInput = document.createElement('input');
targetState.minFairFightInput.type = 'number';
targetState.minFairFightInput.step = '0.1';
targetState.minFairFightInput.min = '0';
minLabel.appendChild(targetState.minFairFightInput);
const maxLabel = document.createElement('label');
maxLabel.textContent = 'max FF';
targetState.maxFairFightInput = document.createElement('input');
targetState.maxFairFightInput.type = 'number';
targetState.maxFairFightInput.step = '0.1';
targetState.maxFairFightInput.min = '0';
maxLabel.appendChild(targetState.maxFairFightInput);
targetState.settingsPanel.appendChild(minLabel);
targetState.settingsPanel.appendChild(maxLabel);
const onlineFirstLabel = document.createElement('label');
onlineFirstLabel.textContent = 'onlines first';
targetState.onlineFirstInput = document.createElement('input');
targetState.onlineFirstInput.type = 'checkbox';
targetState.onlineFirstInput.checked = targetState.onlineFirst;
onlineFirstLabel.appendChild(targetState.onlineFirstInput);
targetState.settingsPanel.appendChild(onlineFirstLabel);
targetState.minFairFightInput.addEventListener('input', applyFairFightFilter);
targetState.maxFairFightInput.addEventListener('input', applyFairFightFilter);
targetState.onlineFirstInput.addEventListener('input', applyOnlineFirstSetting);
updateFairFightInputs();
targetState.grid = document.createElement('div');
targetState.grid.className = 'war-targets-grid';
targetState.wrapper.appendChild(targetState.settingsPanel);
targetState.wrapper.appendChild(targetState.message);
targetState.wrapper.appendChild(targetState.grid);
content.appendChild(targetState.wrapper);
}
return targetState.wrapper;
};
const renderTargetGrid = (targets) => {
if (!ensureTargetLayout()) {
logWarn('Target layout is unavailable; cannot render target grid.');
return;
}
targetState.latestTargets = Array.isArray(targets) ? targets : [];
const visibleTargets = targetState.latestTargets.filter(
(target) => {
const fairFight = Number(target?.fair_fight);
return (
Number.isFinite(fairFight) &&
fairFight >= targetState.minFairFight &&
fairFight <= targetState.maxFairFight &&
!isAbroadHospitalTarget(target)
);
}
);
const sortedTargets = visibleTargets
.map((target, index) => ({ target, index }))
.sort((first, second) => {
if (targetState.onlineFirst) {
const firstOnline =
(first.target?.availability_status ?? '').toLowerCase() ===
'online';
const secondOnline =
(second.target?.availability_status ?? '').toLowerCase() ===
'online';
if (firstOnline !== secondOnline) {
return firstOnline ? -1 : 1;
}
}
const firstState = getEffectiveState(first.target);
const secondState = getEffectiveState(second.target);
const firstGroup =
firstState === 'Okay' ? 0 : firstState === 'Hospital' ? 1 : 2;
const secondGroup =
secondState === 'Okay' ? 0 : secondState === 'Hospital' ? 1 : 2;
if (firstGroup !== secondGroup) {
return firstGroup - secondGroup;
}
if (firstGroup === 0) {
const firstFf = Number(first.target?.fair_fight);
const secondFf = Number(second.target?.fair_fight);
const firstValue = Number.isFinite(firstFf)
? firstFf
: Number.NEGATIVE_INFINITY;
const secondValue = Number.isFinite(secondFf)
? secondFf
: Number.NEGATIVE_INFINITY;
if (firstValue !== secondValue) {
return secondValue - firstValue;
}
}
if (firstGroup === 1) {
const firstSeconds = getHospitalRemainingSeconds(
first.target?.status?.until
);
const secondSeconds = getHospitalRemainingSeconds(
second.target?.status?.until
);
const firstValue =
firstSeconds == null ? Number.POSITIVE_INFINITY : firstSeconds;
const secondValue =
secondSeconds == null ? Number.POSITIVE_INFINITY : secondSeconds;
if (firstValue !== secondValue) {
return firstValue - secondValue;
}
}
return first.index - second.index;
})
.map(({ target }) => target);
if (visibleTargets.length === 0) {
targetState.message.textContent = 'No targets found.';
targetState.message.style.display = 'block';
targetState.grid.style.display = 'none';
return;
}
targetState.message.textContent = '';
targetState.message.style.display = 'none';
targetState.grid.style.display = 'grid';
const seenIds = new Set();
sortedTargets.forEach((target) => {
const targetId = target?.id ?? '';
if (!targetId) {
logWarn('Skipping target without id.', { target });
return;
}
seenIds.add(String(targetId));
let cardData = targetState.cards.get(String(targetId));
if (!cardData) {
cardData = renderTargetCard(target);
targetState.cards.set(String(targetId), cardData);
} else {
updateTargetCard(target, cardData);
}
// Keep DOM order aligned with sorted target order on every refresh.
targetState.grid.appendChild(cardData.card);
});
targetState.cards.forEach((cardData, id) => {
if (!seenIds.has(id)) {
cardData.card.remove();
targetState.cards.delete(id);
}
});
};
const setContentMessage = (message) => {
if (!ensureTargetLayout()) {
logWarn('Target layout is unavailable; cannot set content message.', {
message,
});
return;
}
targetState.message.textContent = message;
targetState.message.style.display = 'block';
targetState.grid.style.display = 'none';
};
const getStoredApiKey = () => {
const storedKey = safeGetValue(FFSCOUTER_API_KEY_STORAGE_KEY, '');
if (typeof storedKey === 'string') {
return storedKey.trim();
}
return '';
};
const setStoredApiKey = (key) => {
safeSetValue(FFSCOUTER_API_KEY_STORAGE_KEY, key);
};
const renderApiKeyMessage = (message, initialValue = '') => {
if (!ensureTargetLayout()) {
return;
}
targetState.message.textContent = '';
const textElement = document.createElement('div');
textElement.textContent = message;
const form = document.createElement('div');
form.className = 'war-targets-api-key-form';
const keyInput = document.createElement('input');
keyInput.className = 'war-targets-api-key-input';
keyInput.type = 'text';
keyInput.maxLength = FFSCOUTER_KEY_LENGTH;
keyInput.placeholder = 'Enter your FFScouter API key';
keyInput.value = initialValue;
const saveButton = document.createElement('button');
saveButton.className = 'war-targets-api-key-button';
saveButton.type = 'button';
saveButton.textContent = 'Save';
const submitApiKey = () => {
const key = keyInput.value.trim();
setStoredApiKey(key);
verifyApiKey(key);
};
saveButton.addEventListener('click', submitApiKey);
keyInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
submitApiKey();
}
});
form.appendChild(keyInput);
form.appendChild(saveButton);
targetState.message.appendChild(textElement);
targetState.message.appendChild(form);
targetState.message.style.display = 'block';
targetState.grid.style.display = 'none';
};
const requestJson = (url) => {
const requestFn = isFunction(globalThis.GM_xmlhttpRequest)
? globalThis.GM_xmlhttpRequest
: isFunction(getModernGmApi()?.xmlHttpRequest)
? getModernGmApi().xmlHttpRequest
: null;
if (requestFn) {
return new Promise((resolve, reject) => {
requestFn({
method: 'GET',
url,
timeout: REQUEST_TIMEOUT_MS,
onload: (response) => {
if (response.status < 200 || response.status >= 300) {
logError('HTTP request failed.', {
url,
status: response.status,
responseText: response.responseText,
});
reject(new Error(`Request failed with status ${response.status}`));
return;
}
try {
resolve(JSON.parse(response.responseText));
} catch (error) {
logError('Failed to parse JSON response.', {
url,
responseText: response.responseText,
error,
});
reject(error);
}
},
onerror: (error) => {
logError('Network request failed.', { url, error });
reject(new Error('Network request failed.'));
},
ontimeout: () => {
logError('Network request timed out.', { url, timeoutMs: REQUEST_TIMEOUT_MS });
reject(new Error('Request timed out.'));
},
});
});
}
const tornPdaHttpGet = getTornPdaHttpGet();
if (isFunction(tornPdaHttpGet)) {
return Promise.resolve(tornPdaHttpGet(url))
.then((response) => {
const text = typeof response === 'string' ? response : response?.responseText;
if (!text) {
logError('TornPDA request returned an empty response.', { url, response });
throw new Error('TornPDA request returned an empty response.');
}
return JSON.parse(text);
})
.catch((error) => {
logError('TornPDA request failed.', { url, error });
throw error;
});
}
return fetch(url, { method: 'GET' })
.then((response) => {
if (!response.ok) {
logError('Fetch request failed.', { url, status: response.status });
throw new Error(`Request failed with status ${response.status}`);
}
return response.json();
})
.catch((error) => {
logError('Fetch request failed unexpectedly.', { url, error });
throw error;
});
};
const fetchFactionInfo = async (key) => {
const data = await requestJson(
`https://api.torn.com/v2/faction?key=${encodeURIComponent(key)}`
);
const id = data?.basic?.id;
const name = data?.basic?.name;
if (!id || !name) {
logError('Faction info response is missing required fields.', { data });
throw new Error('Unable to read faction information.');
}
return { id: String(id), name };
};
const fetchEnemyFaction = async (key, currentFactionId) => {
if (!currentFactionId) {
logError('Missing current faction id while fetching enemy faction.');
throw new Error('Missing faction id.');
}
const data = await requestJson(
`https://api.torn.com/v2/faction/rankedwars?key=${encodeURIComponent(
key
)}&limit=1`
);
const rankedWarsRaw = data?.rankedwars ?? data;
const rankedWars = Array.isArray(rankedWarsRaw)
? rankedWarsRaw
: Object.values(rankedWarsRaw ?? {});
const nowMs = Date.now();
const candidates = rankedWars
.filter((war) => !isCompletedRankedWar(war))
.map((war) => {
const factions = war?.factions ?? [];
const enemyFaction = factions.find(
(faction) => String(faction?.id) !== String(currentFactionId)
);
const startAtMs = toTimestampMs(war?.start);
const isUpcoming = Number.isFinite(startAtMs) && startAtMs > nowMs;
return { enemyFaction, startAtMs, isUpcoming };
})
.filter((entry) => Boolean(entry.enemyFaction?.id));
if (candidates.length === 0) {
logInfo('No active ranked war found for current faction.', {
currentFactionId,
rankedWarCount: rankedWars.length,
});
return null;
}
const ongoingCandidates = candidates.filter((entry) => !entry.isUpcoming);
const upcomingCandidates = candidates.filter((entry) => entry.isUpcoming);
ongoingCandidates.sort((first, second) => {
const firstStart = first.startAtMs ?? -Infinity;
const secondStart = second.startAtMs ?? -Infinity;
return secondStart - firstStart;
});
upcomingCandidates.sort((first, second) => {
const firstStart = first.startAtMs ?? Infinity;
const secondStart = second.startAtMs ?? Infinity;
return firstStart - secondStart;
});
const selected = ongoingCandidates[0] ?? upcomingCandidates[0] ?? candidates[0];
const enemyFaction = selected?.enemyFaction;
if (!enemyFaction?.id) {
logError('Unable to resolve enemy faction from ranked wars response.', {
currentFactionId,
rankedWars,
selected,
});
throw new Error('Unable to resolve enemy faction.');
}
return {
id: String(enemyFaction.id),
name: enemyFaction?.name ?? '',
warStarted: !selected?.isUpcoming,
};
};
const fetchEnemyTargets = async (key, enemyId, { useScouter = true } = {}) => {
if (!enemyId) {
logWarn('Enemy faction id missing; skipping target fetch.');
return [];
}
const membersData = await requestJson(
`https://api.torn.com/v2/faction/${encodeURIComponent(
enemyId
)}/members?key=${encodeURIComponent(key)}`
);
const rawMembers = Array.isArray(membersData?.members)
? membersData.members
: Object.values(membersData?.members ?? {});
const memberIds = rawMembers
.map((member) => member?.id)
.filter((id) => Boolean(id));
if (memberIds.length === 0) {
logWarn('No enemy members found.', { enemyId });
return [];
}
if (useScouter && memberIds.length > 0 && statsCache.size === 0) {
const statsData = await requestJson(
`https://ffscouter.com/api/v1/get-stats?key=${encodeURIComponent(
key
)}&targets=${memberIds.join(',')}`
);
(Array.isArray(statsData) ? statsData : []).forEach((stat) => {
if (stat?.player_id) {
statsCache.set(stat.player_id, stat);
}
});
if (statsCache.size === 0) {
logWarn('FFScouter returned no stats for requested member ids.', {
enemyId,
memberCount: memberIds.length,
});
}
}
return rawMembers
.map((member) => {
const stats = statsCache.get(member?.id);
if (!stats || stats.fair_fight == null) {
return null;
}
return {
...member,
availability_status: getAvailabilityStatus(member),
fair_fight: stats.fair_fight,
bs_estimate_human: stats.bs_estimate_human,
};
})
.filter(
(member) =>
Boolean(member) &&
!isFederalTarget(member) &&
!isTravelingTarget(member) &&
!isAbroadTarget(member)
);
};
const loadTargets = async (key, options = {}) => {
const { showLoading = true, ...requestOptions } = options;
setNoActiveWarHeaderState(false);
if (showLoading) {
setContentMessage('Loading targets...');
}
const factionInfo = await fetchFactionInfo(key);
const enemyInfo = await fetchEnemyFaction(key, factionInfo?.id);
if (!enemyInfo?.id) {
headerState.warStarted = false;
headerState.lastUpdatedAt = null;
setNoActiveWarHeaderState(true);
return;
}
headerState.warStarted = enemyInfo.warStarted === true;
setNoActiveWarHeaderState(false);
const targets = await fetchEnemyTargets(key, enemyInfo?.id, requestOptions);
headerState.lastUpdatedAt = Date.now();
renderTargetGrid(targets);
};
const getTargetRefreshIntervalMs = () =>
headerState.warStarted
? TARGET_REFRESH_INTERVAL_IN_WAR_MS
: TARGET_REFRESH_INTERVAL_NOT_IN_WAR_MS;
const updateLastUpdatedText = () => {
const header = document.getElementById(HEADER_ELEMENT_ID);
const lastUpdated = header?.querySelector('.war-targets-last-updated');
if (!lastUpdated) {
return;
}
if (!headerState.lastUpdatedAt) {
lastUpdated.textContent = 'Last updated: --';
return;
}
const seconds = Math.max(
0,
Math.floor((Date.now() - headerState.lastUpdatedAt) / 1000)
);
lastUpdated.textContent = `Last updated: ${seconds} seconds ago`;
};
const startLastUpdatedTimer = () => {
if (headerState.lastUpdatedTimer) {
clearInterval(headerState.lastUpdatedTimer);
}
headerState.lastUpdatedTimer = setInterval(
updateLastUpdatedText,
LAST_UPDATED_INTERVAL_MS
);
updateLastUpdatedText();
};
const startAutoRefresh = (key) => {
if (headerState.refreshTimer) {
clearTimeout(headerState.refreshTimer);
}
const runRefresh = () => {
loadTargets(key, { useScouter: false, showLoading: false })
.catch((error) => {
logError('Auto-refresh failed.', { error });
setContentMessage('Unable to refresh targets.');
})
.finally(() => {
headerState.refreshTimer = setTimeout(
runRefresh,
getTargetRefreshIntervalMs()
);
});
};
headerState.refreshTimer = setTimeout(runRefresh, getTargetRefreshIntervalMs());
};
const verifyApiKey = (key) => {
if (!key || key.length !== FFSCOUTER_KEY_LENGTH) {
renderApiKeyMessage(
'Invalid API key! Please use the same you use for FFScouter.',
key
);
return;
}
setContentMessage('Validating API key...');
requestJson(
`https://ffscouter.com/api/v1/check-key?key=${encodeURIComponent(key)}`
)
.then((data) => {
if (!data?.is_registered) {
throw new Error('Invalid API key.');
}
setStoredApiKey(key);
return loadTargets(key).then(() => {
startAutoRefresh(key);
startLastUpdatedTimer();
});
})
.catch((error) => {
logError('API key validation failed.', { key, error });
renderApiKeyMessage(
'Unable to validate API key. Please confirm your key and try again.',
key
);
});
};
const getTargetContainer = () => {
const factionMain = document.getElementById('faction-main');
if (!factionMain) {
logWarn('Faction main container not found yet.');
}
return factionMain?.children?.[0]?.children?.[0]?.children?.[0] ?? null;
};
function renderNewElements() {
const targetDiv = getTargetContainer();
if (!targetDiv) {
logWarn('Target container not available; delaying initialization.');
return false;
}
if (document.getElementById(HEADER_ELEMENT_ID)) {
return true;
}
// Add new divs
// Header
const headerDiv = document.createElement('div');
const headerContent = document.createElement('div');
headerContent.className = 'war-targets-header-content';
const headerTitle = document.createElement('span');
headerTitle.className = HEADER_TITLE_CLASS;
headerTitle.textContent = getHeaderTitle();
const lastUpdated = document.createElement('span');
lastUpdated.className = 'war-targets-last-updated';
lastUpdated.textContent = 'Last updated: --';
headerContent.appendChild(headerTitle);
headerContent.appendChild(lastUpdated);
headerDiv.appendChild(headerContent);
headerDiv.className =
'title-black m-top10 tablet active top-round war-targets-header';
headerDiv.id = HEADER_ELEMENT_ID;
headerDiv.setAttribute('aria-expanded', 'true');
headerDiv.addEventListener('click', (event) => {
// Ignore synthetic clicks that can be dispatched by host-page accordion scripts.
if (!event.isTrusted) {
return;
}
toggleCollapsedState();
});
const fifthChild = targetDiv.children[4];
targetDiv.insertBefore(headerDiv, fifthChild);
// Content
const contentDiv = document.createElement('div');
contentDiv.textContent = 'Checking API key...';
contentDiv.className =
'cont-gray10 bottom-round editor-content announcement unreset scrollbar-bright war-targets-content';
contentDiv.id = 'war-tagets-content';
const sixthChild = targetDiv.children[5];
targetDiv.insertBefore(contentDiv, sixthChild);
setNoActiveWarHeaderState(false);
return true;
}
(async function() {
'use strict';
waitForChatRoot();
const initialize = () => {
if (!renderNewElements()) {
logWarn('Render preconditions not met; retrying initialization in 500ms.');
setTimeout(initialize, 500);
return;
}
const storedApiKey = getStoredApiKey();
if (!storedApiKey) {
headerState.isCollapsed = false;
setNoActiveWarHeaderState(headerState.noActiveWar);
}
verifyApiKey(storedApiKey);
};
setTimeout(initialize, 1000);
})();