Dibs, Faction-wide notes, and war management systems for Torn (PC AND TornPDA Support)
// ==UserScript==
// @name TreeDibsMapper
// @namespace http://tampermonkey.net/
// @version 3.12.1
// @description Dibs, Faction-wide notes, and war management systems for Torn (PC AND TornPDA Support)
// @author TreeMapper [3573576]
// @match https://www.torn.com/loader.php?sid=attack&user2ID=*
// @match https://www.torn.com/factions.php*
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @connect api.torn.com
// @connect us-central1-tornuserstracker.cloudfunctions.net
// @connect apiget-codod64xdq-uc.a.run.app
// @connect apipost-codod64xdq-uc.a.run.app
// @connect issueauthtoken-codod64xdq-uc.a.run.app
// @connect identitytoolkit.googleapis.com
// @connect securetoken.googleapis.com
// @connect storage.googleapis.com
// @connect ffscouter.com
// @connect update.greasyfork.org
// @connect greasyfork.org
// ==/UserScript==
/*
Documentation: Torn Faction Dibs and War Management Userscript
For full user's guide: visit - https://www.torn.com/forums.php#/p=threads&f=67&t=16515676&b=0&a=0
This userscript provides a comprehensive dibs, war management, and user notes system
for the game Torn. Authentication uses the Torn API Key
provided by the user for server-side verification.
If you have any issues, send me a console log on discord or in-game.
PDA:
- This script relies on PDA's emulation of standard `GM_` functions and its `###PDA-APIKEY###` placeholder.
*/
(function() {
'use strict';
// ----- Global singleton / multi-injection guard (moved beneath metadata to not break TM/GF parsing) -----
if (window.__TDM_SINGLETON__) {
window.__TDM_SINGLETON__.reloads = (window.__TDM_SINGLETON__.reloads || 0) + 1;
console.info('[TDM] Secondary script load detected; reusing existing UI artifacts. Reload count=', window.__TDM_SINGLETON__.reloads);
return; // abort duplicate initialization
}
(function initTdmSingleton(){
try {
// IndexedDB helpers for storing heavy attack arrays (avoid localStorage quota issues)
const idb = {
dbName: 'tdmRankedWarAttacks',
storeName: 'attacks',
openDB: function() {
return new Promise((resolve, reject) => {
try {
// Prefer an active dibs-record name early to avoid flicker showing 'ID <id>' on load
try {
const dibEntry = Array.isArray(state.dibsData) ? state.dibsData.find(d => d && String(d.opponentId) === String(oppId) && (d.opponentname || d.opponentName)) : null;
if (dibEntry) oppNameFull = String(dibEntry.opponentname || dibEntry.opponentName || '');
} catch(_) {}
const req = indexedDB.open(this.dbName, 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: 'warId' });
}
};
req.onsuccess = (e) => resolve(e.target.result);
req.onerror = (e) => reject(e.target.error || new Error('IndexedDB open failed'));
} catch (err) { reject(err); }
});
},
getAttacks: async function(warId) {
if (!warId) return null;
try {
const db = await this.openDB();
const res = await new Promise((resolve, reject) => {
try {
const tx = db.transaction([this.storeName], 'readonly');
const store = tx.objectStore(this.storeName);
const req = store.get(String(warId));
req.onsuccess = (e) => { const v = e.target.result; if (!v) return resolve({ attacks: null, updatedAt: null, meta: null }); resolve({ attacks: v.attacks || [], updatedAt: v.updatedAt || null, meta: v.meta || null }); };
req.onerror = () => resolve(null);
} catch (err) { resolve(null); }
});
try { db.close(); } catch(_) {}
return res;
} catch (err) { return null; }
},
saveAttacks: async function(warId, attacks, meta = {}) {
if (!warId) return;
try {
const db = await this.openDB();
await new Promise((resolve, reject) => {
try {
const tx = db.transaction([this.storeName], 'readwrite');
const store = tx.objectStore(this.storeName);
store.put({ warId: String(warId), attacks: Array.isArray(attacks) ? attacks : [], updatedAt: Date.now(), meta: meta });
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
} catch (_) { resolve(); }
});
try { db.close(); } catch(_) {}
if (state.debug?.idbLogs) tdmlogger('info', '[idb] saved attacks', { warId, count: Array.isArray(attacks) ? attacks.length : 0 });
} catch(_) { /* noop */ }
},
delAttacks: async function(warId) {
try {
const db = await this.openDB();
await new Promise((resolve) => {
try {
const tx = db.transaction([this.storeName], 'readwrite');
const store = tx.objectStore(this.storeName);
store.delete(String(warId));
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
} catch (_) { resolve(); }
});
if (state.debug?.idbLogs) tdmlogger('info', '[idb] deleted attacks', { warId });
} catch(_) { /* noop */ }
}
};
window.__TDM_SINGLETON__ = { firstLoadAt: Date.now() };
// Polyfill helpers: some host environments (PDA/SVG) expose
// `element.className` as an SVGAnimatedString object which
// doesn't have String methods like `includes`. That causes
// runtime errors when other code calls `el.className.includes(...)`.
// Safely augment the SVGAnimatedString prototype with lightweight
// delegating methods so existing code continues to work.
try {
if (typeof SVGAnimatedString !== 'undefined' && SVGAnimatedString.prototype) {
const sad = SVGAnimatedString.prototype;
if (typeof sad.toString !== 'function') {
sad.toString = function() { try { return String(this && this.baseVal != null ? this.baseVal : ''); } catch(_) { return ''; } };
}
if (typeof sad.valueOf !== 'function') {
sad.valueOf = sad.toString;
}
if (typeof sad.includes !== 'function') {
sad.includes = function(sub, pos) { try { return String(this.baseVal || '').includes(sub, pos); } catch(_) { return false; } };
}
if (typeof sad.indexOf !== 'function') {
sad.indexOf = function(sub, pos) { try { return String(this.baseVal || '').indexOf(sub, pos); } catch(_) { return -1; } };
}
if (typeof sad.startsWith !== 'function') {
sad.startsWith = function(sub, pos) { try { return String(this.baseVal || '').startsWith(sub, pos); } catch(_) { return false; } };
}
}
} catch (e) { /* ignore */}
} catch(_) { /* ignore */ }
})();
// Central configuration
const config = {
VERSION: '3.12.1',
API_GET_URL: 'https://apiget-codod64xdq-uc.a.run.app',
API_POST_URL: 'https://apipost-codod64xdq-uc.a.run.app',
API_HTTP_GET_URL: 'https://us-central1-tornuserstracker.cloudfunctions.net/apiHttpGet',
API_HTTP_POST_URL: 'https://us-central1-tornuserstracker.cloudfunctions.net/apiHttpPost',
FIREBASE: {
projectId: 'tornuserstracker',
apiKey: 'AIzaSyBEWTXThhAF4-gZyax_8pZ_15xn8FhLZaE',
customTokenUrl: 'https://issueauthtoken-codod64xdq-uc.a.run.app'
},
// PDA placeholder: replaced by PDA host with actual key at runtime; stays starting with '#' when not replaced
PDA_API_KEY_PLACEHOLDER: '###PDA-APIKEY###',
REFRESH_INTERVAL_ACTIVE_MS: 10000,
REFRESH_INTERVAL_INACTIVE_MS: 60000,
DEFAULT_FACTION_BUNDLE_REFRESH_MS: 30000,
MIN_GLOBAL_FETCH_INTERVAL_MS: 2000,
// Global cross-tab limiter for getWarStorageUrls to avoid spike-level bursts across pages
GET_WAR_STORAGE_URLS_GLOBAL_MIN_INTERVAL_MS: 30000,
// Client-side cooldown to avoid refetching manifest URLs that backend
// reported as missing recently. Matches server-side cooldown but is
// independent per client instance.
CLIENT_MANIFEST_MISSING_COOLDOWN_MS: 60000,
WAR_STATUS_AND_MANIFEST_MIN_INTERVAL_MS: 60000,
WAR_STATUS_MIN_INTERVAL_MS: 30000,
MANIFEST_BOOTSTRAP_MIN_INTERVAL_MS: 10 * 60 * 1000,
IP_BLOCK_COOLDOWN_MS: 5 * 60 * 1000,
IP_BLOCK_COOLDOWN_JITTER_MS: 15000,
IP_BLOCK_LOG_INTERVAL_MS: 30000,
ACTIVITY_TIMEOUT_MS: 30000,
MIN_FACTION_CACHE_FRESH_MS: 30000,
MIN_DIBS_STATUS_FRESH_MS: 10000,
// Storage V2 flag & adaptive polling flag removed (manifest path now default; legacy polling unified)
GREASYFORK: {
scriptId: '540873',
pageUrl: 'https://greasyfork.org/en/scripts/540873-treedibsmapper',
downloadUrl: 'https://update.greasyfork.org/scripts/540873/TreeDibsMapper.user.js',
updateMetaUrl: 'https://update.greasyfork.org/scripts/540873/TreeDibsMapper.meta.js'
},
customKeyUrl: 'https://www.torn.com/preferences.php#tab=api?step=addNewKey&title=TreeDibsMapper&user=basic,profile,faction,job&faction=rankedwars,members,attacks,attacksfull,basic,chain,chains,positions,warfare,wars&torn=rankedwars,rankedwarreport',
LANDED_TTL_MS: 600000,
TRAVEL_PROMOTE_MS: 60000,
DEFAULT_COLUMN_VISIBILITY: {
rankedWar: { lvl: true, factionIcon: false, members: true, points: true, status: true, attack: true },
membersList: { lvl: true, member: true, memberIcons: true, position: true, days: false, factionIcon: false, status: true, memberIndex: true, dibsDeals: true, notes: true }
},
// Default column widths (percent). These are per-table maps of column-key->percentage.
// Values are used to set width / flex-basis for header and cell elements.
DEFAULT_COLUMN_WIDTHS: {
// Ranked War table
rankedWar: { lvl: 12, members: 40, points: 12, status: 16, attack: 6, factionIcon: 6 },
// Members list table
membersList: { lvl: 6, member: 30, memberIcons: 16, memberIndex: 3, position: 10, days: 8, status: 9, factionIcon: 6, dibsDeals: 10, notes: 10 }
},
// PDA defaults: designed for narrow screens / mobile. Keep visibility similar to PC but with tighter widths.
DEFAULT_COLUMN_VISIBILITY_PDA: {
rankedWar: { lvl: true, factionIcon: false, members: true, points: true, status: true, attack: true },
membersList: { lvl: true, member: true, memberIcons: false, position: false, days: false, factionIcon: false, status: true, memberIndex: true, dibsDeals: true, notes: true }
},
DEFAULT_COLUMN_WIDTHS_PDA: {
rankedWar: { lvl: 6, members: 36, points: 12, status: 15, attack: 9, factionIcon: 4 },
membersList: { lvl: 6, member: 40, memberIcons: 16, memberIndex: 2, position: 12, days: 12, status: 20, factionIcon: 4, dibsDeals: 14, notes: 16 }
},
DEFAULT_SETTINGS: {
showAllRetaliations: false,
chainTimerEnabled: true,
inactivityTimerEnabled: false,
opponentStatusTimerEnabled: true,
apiUsageCounterEnabled: false,
dibsDealsBadgeEnabled: true,
attackModeBadgeEnabled: true,
chainWatcherBadgeEnabled: true,
activityTrackingEnabled: false,
activityCadenceSeconds: 15,
debugOverlay: false
},
CSS: {
colors: {
success: '#4CAF50',
error: '#f44336',
warning: '#ff9800',
info: '#2196F3',
dibsSuccess: '#22c55e',
dibsSuccessHover: '#16a34a',
dibsOther: '#ef4444',
dibsOtherHover: '#dc2626',
dibsInactive: '#2196F3',
dibsInactiveHover: '#1976D2',
noteInactive: 'rgba(33, 150, 243, 0.7)',
noteInactiveHover: 'rgba(33, 150, 243, 0.85)',
noteActive: '#2196F3',
noteActiveHover: '#1976D2',
medDealInactive: '#f97316',
medDealInactiveHover: '#ea580c',
medDealSet: '#22c55e',
medDealSetHover: '#16a34a',
medDealMine: '#a855f7',
medDealMineHover: '#9333ea',
assistButton: '#40004bff',
assistButtonHover: '#35003aff',
modalBg: '#1a1a1a',
modalBorder: '#333',
buttonBg: '#2c2c2c',
mainColor: '#2196F3'
}
}
};
// Safety guard: ensure initial cache shape is always an object to avoid runtime errors
// when other code reads/modifies the cache. This protects the script from
// earlier broken states and future regressions where the initializer might be mutated.
// try { if (!state.rankedWarAttacksCache || typeof state.rankedWarAttacksCache !== 'object') state.rankedWarAttacksCache = {}; } catch(_) {}
// set log levels
// persisted log level is stored via storage key 'logLevel' (default: 'warn')
const logLevels = ['debug', 'info', 'warn', 'error', 'log'];
const ADMIN_ROLE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
//======================================================================
// Terms of Service display / acknowledgment version
const TDM_TOS_VERSION = 1;
const TDM_TOS_ACK_KEY = `tdm_tos_ack_v${TDM_TOS_VERSION}`;
//======================================================================
// Selections must be in the fetch custom API key URL
const REQUIRED_API_KEY_SCOPES = Object.freeze([
'faction.basic',
'faction.members',
'faction.rankedwars',
'faction.warfare',
'faction.wars',
'faction.attacks',
'faction.attacksfull',
'faction.chain',
'faction.chains',
'faction.positions',
'user.basic',
'user.profile',
'user.faction',
'user.job',
'torn.rankedwars',
'torn.rankedwarreport'
]);
const validateApiKeyScopes = (keyInfo) => {
const access = keyInfo?.info?.access || {};
const level = (typeof access.level === 'number') ? access.level : null;
const scopes = [];
const pushScope = (value) => {
if (!value && value !== 0) return;
const normalized = String(value).toLowerCase();
if (!scopes.includes(normalized)) scopes.push(normalized);
};
const scopeList = Array.isArray(access.scopes) ? access.scopes : (Array.isArray(access.scope) ? access.scope : []);
scopeList.forEach(pushScope);
// Support multiple key info shapes.
// Torn sometimes returns top-level `info.selections` while other times
// selections are nested under `info.access.selections`. Accept either
// and process any selection maps we find.
const selectionSources = [];
if (access.selections && typeof access.selections === 'object') selectionSources.push(access.selections);
if (keyInfo?.info?.selections && typeof keyInfo.info.selections === 'object') selectionSources.push(keyInfo.info.selections);
selectionSources.forEach((selMap) => {
Object.entries(selMap).forEach(([root, entries]) => {
if (!root) return;
const base = String(root).toLowerCase();
pushScope(base);
if (Array.isArray(entries)) {
entries.forEach(sel => pushScope(`${base}.${String(sel).toLowerCase()}`));
}
});
});
const hasScope = (target) => {
const normalized = String(target).toLowerCase();
if (scopes.includes(normalized)) return true;
const root = normalized.split('.')[0];
return scopes.includes(root);
};
const missing = REQUIRED_API_KEY_SCOPES.filter(req => !hasScope(req));
const isLimited = typeof level === 'number' && level >= 3;
const ok = missing.length === 0 || isLimited;
return { ok, missing, level, scopes, isLimited };
};
//======================================================================
// 2. STORAGE & UTILITIES
//======================================================================
// Key mapping: legacy keys replaced with new namespaced keys
const _keyMap = Object.freeze({
columnVisibility: 'columnVisibility',
adminFunctionality: 'adminFunctionality',
dataTimestamps: 'dataTimestamps',
'rankedWar.attacksCache': 'rankedWar.attacksCache',
'rankedWar.lastAttacksSource': 'rankedWar.lastAttacksSource',
'rankedWar.lastAttacksMeta': 'rankedWar.lastAttacksMeta',
'rankedWar.summaryCache': 'rankedWar.summaryCache',
'rankedWar.lastSummarySource': 'rankedWar.lastSummarySource',
'rankedWar.lastSummaryMeta': 'rankedWar.lastSummaryMeta',
'cache.factionData': 'cache.factionData',
'fs.medDeals': 'fs.medDeals',
'fs.dibs': 'fs.dibs',
'fs.seenNotificationIds': 'fs.seenNotificationIds'
});
// Consistent prefix for all storage keys
const _namespacedPrefix = 'tdm.';
// Storage helper: always applies prefix, uses new key names
const storage = {
get(key, def) {
const mappedKey = _keyMap[key] || key;
let raw = localStorage.getItem(_namespacedPrefix + mappedKey);
if (raw == null) return def;
if (raw === 'undefined') { try { localStorage.removeItem(_namespacedPrefix + mappedKey); } catch(_) {}; return def; }
if (raw === 'null') return null;
try { return JSON.parse(raw); } catch(_) { return raw; }
},
set(key, value) {
const mappedKey = _keyMap[key] || key;
if (value === undefined) {
try { localStorage.removeItem(_namespacedPrefix + mappedKey); } catch(_) {}
return;
}
let serialized;
let stringifyDuration = 0;
let totalDuration = 0;
const nowFn = (typeof performance !== 'undefined' && typeof performance.now === 'function')
? () => performance.now()
: () => Date.now();
// Storage write — no UI/CSS mutations here (avoid leaking variables into storage/set scope)
try {
if (typeof value === 'string') {
serialized = value;
} else {
const stringifyStart = nowFn();
serialized = JSON.stringify(value);
stringifyDuration = Math.max(0, nowFn() - stringifyStart);
}
const storeStart = nowFn();
localStorage.setItem(_namespacedPrefix + mappedKey, serialized);
totalDuration = Math.max(0, nowFn() - storeStart) + stringifyDuration;
} finally {
if (typeof serialized === 'string') {
try {
const metricsRoot = state.metrics || (state.metrics = {});
const storageStats = metricsRoot.storageWrites || (metricsRoot.storageWrites = {});
const entry = storageStats[mappedKey] || (storageStats[mappedKey] = { count: 0, totalMs: 0, maxMs: 0, lastMs: 0, lastBytes: 0, totalStringifyMs: 0, lastStringifyMs: 0 });
entry.count += 1;
entry.lastMs = totalDuration;
entry.totalMs += totalDuration;
if (totalDuration > entry.maxMs) entry.maxMs = totalDuration;
entry.lastBytes = serialized.length;
entry.totalStringifyMs += stringifyDuration;
entry.lastStringifyMs = stringifyDuration;
} catch(_) { /* metrics are best-effort */ }
}
}
},
remove(key) {
const mappedKey = _keyMap[key] || key;
localStorage.removeItem(_namespacedPrefix + mappedKey);
},
updateStateAndStorage(key, value) { state[key] = value; try { this.set(key, value); } catch(_) {} },
cleanupLegacyKeys() {
// Remove known legacy localStorage keys/prefixes that predate namespacing
try {
const toRemove = [];
for (let i = 0; i < localStorage.length; i++) {
try {
const k = localStorage.key(i);
if (!k) continue;
// Legacy timeline / sampler keys
if (k.startsWith('tdmTimeline') || k.startsWith('tdmTimeline.')) toRemove.push(k);
// Old per-flag live track toggles
if (k.startsWith('liveTrackFlag_')) toRemove.push(k);
// Very old baseline / polling keys
if (k === 'tdmBaselineV1' || k === 'forceActivePolling') toRemove.push(k);
// legacy underscore-prefixed keys
if (k.startsWith('tdm_')) toRemove.push(k);
} catch(_) { /* ignore per-key errors */ }
}
toRemove.forEach(k => { try { localStorage.removeItem(k); } catch(_) {} });
} catch(_) { /* ignore */ }
}
};
const FEATURE_FLAG_STORAGE_KEY = 'featureFlags';
const FEATURE_FLAG_DEFAULTS = Object.freeze({
rankWarEnhancements: {
enabled: true,
adapters: true,
overlay: true,
sorter: true,
favorites: true,
favoritesRail: false,
hospital: false
}
});
const _cloneFeatureFlagDefaults = () => JSON.parse(JSON.stringify(FEATURE_FLAG_DEFAULTS));
const _hydrateFeatureFlags = (target, defaults, stored) => {
if (!defaults || typeof defaults !== 'object') return;
Object.keys(defaults).forEach((key) => {
const defaultValue = defaults[key];
const storedValue = stored && typeof stored === 'object' ? stored[key] : undefined;
if (defaultValue && typeof defaultValue === 'object' && !Array.isArray(defaultValue)) {
if (!target[key] || typeof target[key] !== 'object') target[key] = {};
_hydrateFeatureFlags(target[key], defaultValue, storedValue);
} else {
target[key] = typeof storedValue === 'boolean' ? storedValue : defaultValue;
}
});
};
const featureFlagController = (() => {
const flags = _cloneFeatureFlagDefaults();
_hydrateFeatureFlags(flags, FEATURE_FLAG_DEFAULTS, storage.get(FEATURE_FLAG_STORAGE_KEY, {}));
const persist = () => { try { storage.set(FEATURE_FLAG_STORAGE_KEY, flags); } catch(_) {} };
const readPath = (path) => {
if (!path) return undefined;
return path.split('.').reduce((acc, segment) => {
if (acc && Object.prototype.hasOwnProperty.call(acc, segment)) {
return acc[segment];
}
return undefined;
}, flags);
};
const set = (path, value, opts = {}) => {
if (!path) return false;
const segments = path.split('.');
let cursor = flags;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
if (i === segments.length - 1) {
cursor[segment] = !!value;
} else {
if (!cursor[segment] || typeof cursor[segment] !== 'object') cursor[segment] = {};
cursor = cursor[segment];
}
}
if (opts.persist !== false) persist();
if (!opts.silent) {
try { window.dispatchEvent(new CustomEvent('tdm:featureFlagUpdated', { detail: { path, value: !!value } })); } catch(_) {}
}
return true;
};
const isEnabled = (path) => {
if (!path) return false;
if (path === 'rankWarEnhancements') {
return !!flags.rankWarEnhancements?.enabled;
}
if (path.startsWith('rankWarEnhancements.') && !flags.rankWarEnhancements?.enabled) {
return false;
}
const value = readPath(path);
return typeof value === 'boolean' ? value : false;
};
return { flags, set, isEnabled, persist };
})();
const ADAPTER_MEMO_TTL_MS = 1000;
const adapterMemoController = (() => {
const buckets = new Map();
let lastReset = 0;
return {
get(name) {
const now = Date.now();
if (!lastReset || (now - lastReset) > ADAPTER_MEMO_TTL_MS) {
buckets.forEach(map => map.clear());
lastReset = now;
}
if (!buckets.has(name)) buckets.set(name, new Map());
return buckets.get(name);
},
clear() {
buckets.forEach(map => map.clear());
lastReset = Date.now();
}
};
})();
const normalizeTimestampMs = (value) => {
try {
if (value == null) return null;
if (typeof value === 'number' && Number.isFinite(value)) {
if (value >= 1e12) return Math.floor(value);
if (value >= 1e9) return Math.floor(value * 1000);
if (value > 1e5) return Math.floor(value * 1000);
return null;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
if (/^\d+$/.test(trimmed)) return normalizeTimestampMs(Number(trimmed));
const parsed = Date.parse(trimmed);
return Number.isNaN(parsed) ? null : parsed;
}
if (typeof value === 'object') {
if (value instanceof Date) return value.getTime();
if (typeof value._seconds === 'number') {
const baseMs = value._seconds * 1000;
const extra = typeof value._nanoseconds === 'number' ? Math.floor(value._nanoseconds / 1e6) : 0;
return baseMs + extra;
}
if (typeof value.seconds === 'number') {
const baseMs = value.seconds * 1000;
const extra = typeof value.nanoseconds === 'number' ? Math.floor(value.nanoseconds / 1e6) : 0;
return baseMs + extra;
}
}
return null;
} catch(_) {
return null;
}
};
const coerceNumber = (input) => {
const num = Number(input);
return Number.isFinite(num) ? num : null;
};
const sanitizeString = (input) => {
if (typeof input !== 'string') return null;
const trimmed = input.trim();
return trimmed ? trimmed : null;
};
const readLocalStorageRaw = (key) => {
try { return localStorage.getItem(key); } catch(_) { return null; }
};
const parseJsonSafe = (raw) => {
if (!raw) return null;
try { return JSON.parse(raw); } catch(_) { return null; }
};
const FFSCOUTER_STORAGE_PREFIX = 'ffscouter.player_';
const BSP_ENABLED_STORAGE_KEY = 'tdup.battleStatsPredictor.IsBSPEnabledOnPage_Faction';
const LEVEL_DISPLAY_MODES = ['level','ff','ff-bs','bsp'];
const DEFAULT_LEVEL_DISPLAY_MODE = 'level';
const LEVEL_CELL_LONGPRESS_MS = 500;
const formatBattleStatsValue = (rawValue) => {
try {
const number = Number(rawValue);
if (!Number.isFinite(number)) return '';
const absOriginal = Math.abs(number);
const localized = absOriginal.toLocaleString('en-US');
const parts = localized.split(',');
if (!parts.length) return String(rawValue ?? '');
if (absOriginal < 1000) {
const prefix = number < 0 ? '-' : '';
return `${prefix}${parts[0]}`;
}
let head = parts[0];
const absHead = head.replace('-', '');
const leadingInt = parseInt(absHead, 10);
if (!Number.isNaN(leadingInt) && leadingInt < 10 && parts[1]) {
const decimalsOnly = parts[1].replace(/[^0-9]/g, '');
if (decimalsOnly && decimalsOnly[0] && decimalsOnly[0] !== '0') {
head += `.${decimalsOnly[0]}`;
}
}
const suffixMap = { 2: 'k', 3: 'm', 4: 'b', 5: 't', 6: 'q' };
const suffix = suffixMap[parts.length] || '';
const sign = number < 0 ? '-' : '';
return `${sign}${head}${suffix}`;
} catch(_) {
return String(rawValue ?? '');
}
};
const formatFairFightValue = (rawValue) => {
try {
const num = Number(rawValue);
if (!Number.isFinite(num)) return '';
const abs = Math.abs(num);
if (abs >= 10) return num.toFixed(1).replace(/\.0$/, '');
if (abs >= 1) return num.toFixed(2).replace(/0$/, '').replace(/\.0$/, '');
return num.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
} catch(_) {
return '';
}
};
const normalizeFfRecord = (raw, playerId) => {
if (!raw || typeof raw !== 'object') return null;
const ffValue = coerceNumber(raw.ffValue ?? raw.fairFight ?? raw.fair_fight ?? raw.value ?? raw.ff);
const bsEstimate = coerceNumber(raw.bsEstimate ?? raw.bs_estimate ?? raw.bs_est);
const bsHuman = sanitizeString(raw.bsHuman ?? raw.bs_estimate_human ?? raw.bs_estimate_display) || (bsEstimate != null ? formatBattleStatsValue(bsEstimate) : null);
const lastUpdatedMs = normalizeTimestampMs(raw.lastUpdated ?? raw.last_updated ?? raw.updatedAt ?? raw.updated ?? raw.timestamp ?? null);
if (ffValue == null && bsEstimate == null && !bsHuman) return null;
return {
playerId: String(playerId),
ffValue: ffValue != null ? ffValue : null,
bsEstimate: bsEstimate != null ? bsEstimate : null,
bsHuman: bsHuman || null,
lastUpdatedMs: lastUpdatedMs || null,
source: raw.source || raw._source || 'tdm.ffscouter',
raw
};
};
const normalizeBspRecord = (raw, playerId) => {
if (!raw || typeof raw !== 'object') return null;
const tbs = coerceNumber(raw.TBS ?? raw.TBS_Raw ?? raw.Score ?? raw.Result);
const tbsBalanced = coerceNumber(raw.TBS_Balanced ?? raw.TBSBalanced);
const score = coerceNumber(raw.Score);
if (tbs == null && tbsBalanced == null && score == null) return null;
const timestampMs = normalizeTimestampMs(raw.PredictionDate ?? raw.DateFetched ?? raw.UpdatedAt ?? null);
const subscriptionEndMs = normalizeTimestampMs(raw.SubscriptionEnd ?? null);
return {
playerId: String(playerId),
tbs: tbs != null ? tbs : null,
tbsBalanced: tbsBalanced != null ? tbsBalanced : null,
score: score != null ? score : null,
formattedTbs: tbs != null ? formatBattleStatsValue(tbs) : null,
timestampMs: timestampMs || null,
subscriptionEndMs: subscriptionEndMs || null,
reason: sanitizeString(raw.Reason) || null,
source: 'tdup.battleStatsPredictor',
raw
};
};
// Canonical storage keys for API-key state with legacy fallbacks for migration
const STORAGE_KEY = Object.freeze({
CUSTOM_API_KEY: 'user.customApiKey',
LIMITED_KEY_NOTICE: 'user.flags.limitedKeyNoticeShown',
LEGACY_CUSTOM_API_KEY: 'torn_api_key',
LEGACY_LIMITED_KEY_NOTICE: 'tdmLimitedKeyNoticeShown'
});
const getStoredCustomApiKey = () => {
try {
let raw = storage.get(STORAGE_KEY.CUSTOM_API_KEY, null);
if (typeof raw === 'string') raw = raw.trim();
if (raw) return raw;
} catch (_) { /* noop */ }
try {
// Try the namespaced legacy key first, then fall back to an un-prefixed legacy key
let legacy = storage.get(STORAGE_KEY.LEGACY_CUSTOM_API_KEY, null);
if (!legacy) {
try { legacy = localStorage.getItem(STORAGE_KEY.LEGACY_CUSTOM_API_KEY); } catch(_) { legacy = null; }
}
if (typeof legacy === 'string') legacy = legacy.trim();
else if (legacy != null) legacy = String(legacy).trim();
if (legacy) {
storage.set(STORAGE_KEY.CUSTOM_API_KEY, legacy);
try { tdmlogger('info','[migrateApiKey] migrated legacy torn_api_key -> user.customApiKey'); } catch(_) {}
// Remove both namespaced and bare legacy keys so future lookups are consistent
try { storage.remove(STORAGE_KEY.LEGACY_CUSTOM_API_KEY); } catch(_) {}
try { localStorage.removeItem(STORAGE_KEY.LEGACY_CUSTOM_API_KEY); } catch(_) {}
return legacy;
}
// If there was nothing useful, also ensure bare legacy key removed for hygiene
try { localStorage.removeItem(STORAGE_KEY.LEGACY_CUSTOM_API_KEY); } catch(_) {}
} catch (_) { /* noop */ }
return null;
};
const setStoredCustomApiKey = (value) => {
const trimmed = (typeof value === 'string') ? value.trim() : '';
if (!trimmed) {
try { storage.remove(STORAGE_KEY.CUSTOM_API_KEY); } catch (_) { /* noop */ }
try { storage.remove(STORAGE_KEY.LEGACY_CUSTOM_API_KEY); } catch (_) { /* noop */ }
return null;
}
storage.set(STORAGE_KEY.CUSTOM_API_KEY, trimmed);
// Clear both namespaced and bare legacy keys if present
try { storage.remove(STORAGE_KEY.LEGACY_CUSTOM_API_KEY); } catch (_) { /* noop */ }
try { localStorage.removeItem(STORAGE_KEY.LEGACY_CUSTOM_API_KEY); } catch (_) { /* noop */ }
return trimmed;
};
const clearStoredCustomApiKey = () => {
try { storage.remove(STORAGE_KEY.CUSTOM_API_KEY); } catch (_) { /* noop */ }
// Remove both the namespaced and legacy bare entries
try { storage.remove(STORAGE_KEY.LEGACY_CUSTOM_API_KEY); } catch (_) { /* noop */ }
try { localStorage.removeItem(STORAGE_KEY.LEGACY_CUSTOM_API_KEY); } catch (_) { /* noop */ }
};
// Post-reset verification hook (Phase 1 acceptance criteria #1 & #2)
(function postResetVerify(){
try {
if (!sessionStorage.getItem('post_reset_check')) return;
sessionStorage.removeItem('post_reset_check');
const idbDeleted = sessionStorage.getItem('post_reset_idb_deleted') === '1';
sessionStorage.removeItem('post_reset_idb_deleted');
// Scan localStorage for residual legacy keys
const residual = Object.keys(localStorage).filter(k => (
k.startsWith('tdmTimeline') || k.startsWith('liveTrackFlag_') || k === 'tdmBaselineV1' || k === 'forceActivePolling'
));
const trackingEnabled = !!localStorage.getItem(_namespacedPrefix + 'tdmActivityTrackingEnabled');
const summary = { idbDeleted, residualLegacyKeys: residual, trackingEnabledAfterReset: trackingEnabled };
if (residual.length === 0 && !trackingEnabled) {
try { tdmlogger('info', '[PostReset]', 'Verification PASS', summary); } catch(_) {}
} else {
try { tdmlogger('warn', '[PostReset]', 'Verification WARN', summary); } catch(_) {}
}
} catch(_) { /* silent */ }
})();
const utils = {
// Shared small helpers (consolidate duplicated local helpers)
rawTrim: (s) => (typeof s === 'string' ? s.trim() : ''),
rawHasValue: (s) => { const t = (typeof s === 'string' ? s.trim() : ''); if (!t) return false; if (t === '0') return false; return true; },
fallbackNumIsPositive: (v) => (v != null && Number(v) > 0),
pad2: (n) => String(n).padStart(2, '0'),
formatTimeHMS: (totalSeconds) => {
const hrs = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = Math.floor(totalSeconds % 60);
if (hrs > 0) return `${hrs}:${utils.pad2(mins)}:${utils.pad2(secs)}`;
return `${mins}:${utils.pad2(secs)}`;
},
coerceStorageString: (value, fallback = '') => {
if (value == null) return fallback;
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
return value
.map(v => (v == null ? '' : String(v).trim()))
.filter(Boolean)
.join(',');
}
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return fallback;
},
// ---- Unified Status & Travel Maps (consolidated) START ----
_statusMap: (() => {
// Canonical keys: Okay, Hospital, HospitalAbroad, Travel, Returning, Abroad, Jail
// Each entry: { aliases: [regex|string], events: [eventType strings], priority }
// Priority: higher number wins when multiple match heuristics (HospitalAbroad > Hospital > Returning > Travel > Abroad > Jail > Okay)
const make = (priority, aliases, events=[]) => ({ priority, aliases, events });
return {
// Abroad hospital if description begins with article 'In a' or 'In an'
HospitalAbroad: make(90, [/^\s*in\s+a[n]?\s+/i], ['status:hospitalAbroad']),
// Domestic (Torn City) hospital strings: 'In hospital for ...' or plain 'Hospital'
Hospital: make(80, [/^\s*in\s+hospital\b/i, /^hospital/i], ['status:hospital']),
Returning: make(70, [/^returning to torn from /i, / returning$/i], ['travel:returning']),
Travel: make(60, [/^travell?ing to /i, /^travel(ing)?$/i, / flight to /i], ['travel:depart']),
Abroad: make(50, [/^in\s+[^.]+$/i, / abroad$/i], ['travel:abroad']),
Jail: make(40, [/^in jail/i, /^jail$/i], ['status:jail']),
Okay: make(10, [/^okay$/i, /^active$/i, /^idle$/i], ['status:okay'])
};
})(),
// Travel destination map: canonicalName => { aliases:[regex|string], minutes (deprecated avg), planes:{light_aircraft, airliner, airliner_business, private_jet} }
_travelMap: (() => {
const mk = (base, aliases, planes, adjective, abbr) => ({ minutes: base, aliases, planes, adjective, abbr });
return {
'Mexico': mk(15, [/^mex(?:ico)?$/i], { light_aircraft:18, airliner:26, airliner_business:8, private_jet:13 }, 'Mexican', 'MEX'),
'Cayman Islands': mk(60, [/^cayman$/i, /cayman islands?/i, /\bci\b/i], { light_aircraft:25, airliner:35, airliner_business:11, private_jet:18 }, 'Caymanian', 'CI'),
'Canada': mk(45, [/^can(ad?a)?$/i], { light_aircraft:29, airliner:41, airliner_business:12, private_jet:20 }, 'Canadian', 'CAN'),
'Hawaii': mk(120, [/^hawai/i], { light_aircraft:94, airliner:134, airliner_business:40, private_jet:67 }, 'Hawaiian', 'HI'),
'United Kingdom': mk(300, [/^(uk|united kingdom|london)$/i], { light_aircraft:111, airliner:159, airliner_business:48, private_jet:80 }, 'British', 'UK'),
'Argentina': mk(240, [/^arg(?:entina)?$/i], { light_aircraft:117, airliner:167, airliner_business:50, private_jet:83 }, 'Argentinian', 'ARG'),
'Switzerland': mk(360, [/^swiss|switzerland$/i], { light_aircraft:123, airliner:175, airliner_business:53, private_jet:88 }, 'Swiss', 'SWITZ'),
'Japan': mk(420, [/^jap(?:an)?$/i], { light_aircraft:158, airliner:225, airliner_business:68, private_jet:113 }, 'Japanese', 'JPN'),
'China': mk(420, [/^china$/i, /^chi$/i], { light_aircraft:169, airliner:242, airliner_business:72, private_jet:121 }, 'Chinese', 'CN'),
// UAE: broaden alias patterns to match descriptions like 'In UAE', 'in the UAE', or embedded tokens
'United Arab Emirates': mk(480, [/\buae\b/i, /united arab emir/i, /\bdubai\b/i], { light_aircraft:190, airliner:271, airliner_business:81, private_jet:135 }, 'Emirati', 'UAE'),
'South Africa': mk(540, [/^south africa$/i, /^sa$/i], { light_aircraft:208, airliner:297, airliner_business:89, private_jet:149 }, 'African', 'SA')
};
})(),
// Legacy shims (kept for any residual calls) – prefer buildUnifiedStatusV2 outputs
// Legacy status shims removed. Use buildUnifiedStatusV2 for canonical status records.
parseUnifiedDestination: function(str) {
let raw = (str||'').toString().trim();
if (!raw) return null;
// Strip leading 'In ' or 'In the ' forms which appear in Abroad descriptions
raw = raw.replace(/^in\s+(the\s+)?/i,'').trim();
for (const [canon, meta] of Object.entries(utils._travelMap)) {
for (const ali of meta.aliases) {
if (typeof ali === 'string') { if (raw.toLowerCase().includes(ali.toLowerCase())) return canon; }
else if (ali instanceof RegExp) { if (ali.test(raw)) return canon; }
}
}
return null;
},
// Ensure we upgrade to business class timing if user is eligible but still marked plain airliner.
// Supports both legacy travel record shape (mins/ct0) and unified status v2 (durationMins/startedMs/arrivalMs).
// If eligibility not yet known it will trigger an async check (non-blocking) once per cooldown window.
ensureBusinessUpgrade: function(rec, id, opts={}){
try {
if (!rec || !rec.dest) return; // nothing to do
const allowAsync = opts.async !== false; // default true
// Normalize plane fields across legacy + v2 records.
// If the current record does not include a plane (API omitted it), but the previous
// record indicates an outbound 'airliner', allow the eligibility check to proceed
// using the previous plane type as a hint. Do not overwrite rec.plane here; only
// set rec.plane when applying the business upgrade.
const curPlane = rec.plane || null;
const hintedPlane = (!curPlane && opts && opts.prevRec && opts.prevRec.plane) ? opts.prevRec.plane : null;
const effectivePlane = curPlane || hintedPlane || null;
if (!effectivePlane || effectivePlane !== 'airliner') return; // only upgrade airliner base
// Avoid repeated work: if we've already applied a business upgrade for this user
// and cached that fact, skip further checks. This prevents repeated KV/API checks
// and repeated application logs when the UI re-renders frequently.
state._businessApplied = state._businessApplied || {};
const sid = String(id || '');
// Quick synchronous cross-tab check via localStorage first
try {
const lsKey = 'tdm.business.applied.id_' + sid;
const raw = localStorage.getItem(lsKey);
if (raw) {
try { state._businessApplied[sid] = true; } catch(_) {}
return; // already applied (persisted)
}
} catch(_) {}
// If in-memory already marked, short-circuit
if (state._businessApplied[sid]) return;
// Also attempt to hydrate from async KV in background (non-blocking)
try {
if (typeof ui !== 'undefined' && ui && ui._kv && typeof ui._kv.getItem === 'function') {
ui._kv.getItem('tdm.business.applied.id_' + sid).then(v => {
if (v) {
try { state._businessApplied[sid] = true; } catch(_) {}
}
}).catch(()=>{});
}
} catch(_) {}
const eligible = utils?.business?.isEligibleSync?.(id);
const applyUpgrade = () => {
try {
rec.plane = 'airliner_business';
const newMins = utils.getTravelMinutes(rec.dest, 'airliner_business');
// Determine current duration
const legacyMins = rec.mins != null ? rec.mins : null;
const v2Mins = rec.durationMins != null ? rec.durationMins : null;
const currentMins = v2Mins != null ? v2Mins : legacyMins;
if (newMins && (!currentMins || newMins !== currentMins)) {
if (rec.durationMins != null) rec.durationMins = newMins; // unified record
else rec.mins = newMins; // legacy record
// Recompute arrival/eta only if we have a known start timestamp
const startMs = rec.startedMs || rec.ct0 || null;
if (startMs) {
const newArrival = startMs + newMins*60000;
if (rec.arrivalMs && Math.abs(newArrival - rec.arrivalMs) > 15000) {
rec.arrivalMs = newArrival;
} else if (!rec.arrivalMs) {
rec.arrivalMs = newArrival;
}
}
// High confidence ETA recompute (legacy path)
if (rec.ct0 && rec.confidence === 'HIGH') {
if (!rec.etams) rec.etams = utils.travel.computeEtaMs(rec.ct0, newMins);
rec.inferredmaxetams = 0;
}
try { tdmlogger('info', '[Travel]', 'Business upgrade applied', id, rec.dest, currentMins,'->',newMins); } catch(_) {}
// Mark as applied so we don't repeatedly attempt to re-apply on re-renders
try { state._businessApplied = state._businessApplied || {}; state._businessApplied[sid] = true; } catch(_) {}
// Persist the applied marker for cross-tab/reload suppression
try {
const key = 'tdm.business.applied.id_' + sid;
const payload = { appliedAt: Date.now() };
// Try async KV first (preferred), fall back to localStorage
if (typeof ui !== 'undefined' && ui && ui._kv && typeof ui._kv.setItem === 'function') {
ui._kv.setItem(key, payload).catch(()=>{
try { localStorage.setItem(key, JSON.stringify(payload)); } catch(_){}
});
} else {
try { localStorage.setItem(key, JSON.stringify(payload)); } catch(_){}
}
} catch(_) {}
// Persist upgrade into shared unified status store if present
try { if (state.unifiedStatus && state.unifiedStatus[id] && state.unifiedStatus[id] !== rec) state.unifiedStatus[id] = rec; } catch(_) {}
// Fire a lightweight update event so tooltips/overlay can refresh
try { window.dispatchEvent(new CustomEvent('tdm:unifiedStatusUpdated', { detail: { id, upgradedBusiness: true } })); } catch(_) {}
}
} catch(e) { /* swallow */ }
};
if (eligible) {
applyUpgrade();
} else if (allowAsync && utils?.business?.ensureAsync) {
// Kick off async eligibility determination; upgrade when resolved true.
// When calling back, ensure we still only upgrade if the record effectively
// represents an airliner outbound (i.e., either API plane was 'airliner' or
// previous hint indicated 'airliner'). We re-check rec.plane and hinted prevRec.
utils.business.ensureAsync(id).then(ok => {
try {
const stillEffective = (rec.plane === 'airliner') || (opts && opts.prevRec && opts.prevRec.plane === 'airliner');
if (ok && stillEffective) applyUpgrade();
} catch(_) { /* ignore */ }
}).catch(()=>{});
}
} catch(_){ /* noop */ }
},
travelMinutesFor: function(destCanon) {
return utils._travelMap[destCanon]?.minutes || 0;
},
travelMinutesForPlane: function(destCanon, planeType='light_aircraft') {
const meta = utils._travelMap[destCanon];
if (!meta) return 0;
const p = (meta.planes && meta.planes[planeType]) || null;
return (Number(p)||0) || meta.minutes || 0;
},
// Normalize a variety of legacy/new callers into the member-shaped payload that buildUnifiedStatusV2 expects.
normalizeStatusInputForV2: function(candidate, context = {}) {
if (!candidate && !context?.status) return null;
const ctxId = context.id ?? context.player_id ?? context.user_id ?? context.userID ?? null;
const ctxLast = context.last_action || context.lastAction || null;
const ctxStatus = context.status || null;
let statusObj = null;
let lastAction = ctxLast;
let id = ctxId;
if (candidate && typeof candidate === 'object') {
if (candidate.status || candidate.last_action || candidate.lastAction || candidate.id || candidate.player_id || candidate.user_id || candidate.userID) {
statusObj = candidate.status || ctxStatus;
lastAction = candidate.last_action || candidate.lastAction || lastAction || null;
id = candidate.id ?? candidate.player_id ?? candidate.user_id ?? candidate.userID ?? id ?? null;
}
if (!statusObj && (candidate.state != null || candidate.description != null || candidate.until != null)) {
statusObj = candidate;
}
}
if (!statusObj) {
if (typeof candidate === 'string') {
statusObj = { state: candidate, description: candidate };
} else if (candidate && typeof candidate === 'object') {
statusObj = { ...candidate };
} else if (ctxStatus) {
statusObj = { ...ctxStatus };
}
} else {
statusObj = { ...statusObj };
}
if (!statusObj) return null;
const inferredState = utils.inferStatusStateFromText(statusObj.state) || utils.inferStatusStateFromText(statusObj.description);
if (inferredState) {
statusObj.state = inferredState;
} else if (statusObj.state) {
const canonicalMap = {
traveling: 'Traveling',
travel: 'Traveling',
return: 'Traveling',
returning: 'Traveling',
abroad: 'Abroad',
hospital: 'Hospital',
hospitalabroad: 'Hospital',
jail: 'Jail',
okay: 'Okay',
idle: 'Okay',
active: 'Okay'
};
const lower = String(statusObj.state || '').toLowerCase();
if (canonicalMap[lower]) statusObj.state = canonicalMap[lower];
}
if (!statusObj.description && typeof statusObj.state === 'string') {
statusObj.description = statusObj.state;
}
return {
id,
status: statusObj,
last_action: lastAction
};
},
buildCurrentUnifiedActivityState: () => {
const source = state.unifiedStatus || {};
const out = {};
for (const [id, rec] of Object.entries(source)) {
out[id] = {
id,
dest: rec?.dest,
arrivalMs: rec?.arrivalMs,
startMs: rec?.startedMs || rec?.startMs,
canonical: rec?.canonical || null,
confidence: rec?.confidence,
witness: rec?.witness,
previousPhase: rec?.previousPhase || null,
transient: !!rec?.transient
};
}
return out;
},
isActivityKeepActiveEnabled: () => {
try {
return !!(storage.get('tdmActivityTrackingEnabled', false) && storage.get('tdmActivityTrackWhileIdle', false));
} catch(_) {
return false;
}
},
inferStatusStateFromText: function(text) {
const str = String(text || '').trim().toLowerCase();
if (!str) return '';
if (/\bjail\b/.test(str)) return 'Jail';
if (/returning to torn/.test(str)) return 'Traveling';
if (/travell?ing|flight|depart|en route|en-route|plane/.test(str)) return 'Traveling';
if (/\bhospital\b/.test(str)) return 'Hospital';
if (/\babroad\b/.test(str) || /^\s*in\s+(?!torn\b)[a-z]/.test(str)) return 'Abroad';
if (/okay|active|idle|home|city/.test(str)) return 'Okay';
return '';
},
// Ensure a compact note button (icon or label) exists under a parent row/subrow.
// options: { disabled?:bool, withLabel?:bool }
ensureNoteButton: function(parent, options={}) {
try {
if (!parent) return null;
let btn = parent.querySelector('.note-button');
if (btn) {
if (options.disabled) btn.disabled = true; else btn.disabled = false;
return btn;
}
const hasNote = false; // initial unknown state → inactive style
const cls = 'btn note-button ' + (hasNote ? 'active-note-button' : 'inactive-note-button');
btn = document.createElement('button');
btn.type = 'button';
btn.className = cls;
// visual sizing handled by CSS (applyGeneralStyles)
// Compact option: tightly constrain dimensions so button doesn't grow row height
const compact = !!options.compact;
// If withLabel, show small text; else show icon-only (SVG) to match existing CSS expectations
if (options.withLabel) {
btn.textContent = 'Note';
} else {
if (compact) {
// compact variant: add class and minimal markup; sizing is in CSS
btn.className += ' tdm-note-compact';
btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h13a3 3 0 0 1 3 3v13"/><path d="M14 2v4"/><path d="M6 2v4"/><path d="M4 10h16"/><path d="M8 14h2"/><path d="M8 18h4"/></svg>';
} else {
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h13a3 3 0 0 1 3 3v13"/><path d="M14 2v4"/><path d="M6 2v4"/><path d="M4 10h16"/><path d="M8 14h2"/><path d="M8 18h4"/></svg>';
}
}
btn.title = '';
btn.setAttribute('aria-label','');
if (options.disabled) btn.disabled = true;
parent.appendChild(btn);
return btn;
} catch(_) { return null; }
},
// Update note button active/inactive styling & accessible labels
updateNoteButtonState: function(btn, noteText) {
try {
if (!btn) return;
const txt = (noteText||'').trim();
const has = txt.length > 0;
const want = 'btn note-button ' + (has ? 'active-note-button' : 'inactive-note-button');
if (btn.className !== want) btn.className = want;
// Compact-mode preview: show a single-line truncated text preview
if (btn.classList && btn.classList.contains('tdm-note-compact')) {
try {
if (has) {
const maxLen = 120; // allow longer preview inside doubled width
const preview = txt.length > maxLen ? (txt.slice(0, maxLen - 1) + '\u2026') : txt;
if (btn.textContent !== preview || btn.querySelector('svg')) btn.textContent = preview;
// Visual truncation handled by CSS via .tdm-note-preview
btn.classList.add('tdm-note-preview');
const sv = btn.querySelector('svg'); if (sv) sv.remove();
} else {
try { const sv = btn.querySelector('svg'); if (sv) sv.remove(); } catch(_) {}
if (btn.textContent !== 'Notes') btn.textContent = 'Notes';
btn.classList.remove('tdm-note-preview');
btn.classList.add('tdm-note-empty');
}
} catch(_) {}
if (btn.title !== txt) btn.title = txt;
if (btn.getAttribute('aria-label') !== txt) btn.setAttribute('aria-label', txt);
return;
}
// If this is a text-label button (no SVG icon), update text content to show the note
if (!btn.querySelector('svg')) {
if (has) {
if (btn.textContent !== txt) btn.textContent = txt;
// Let CSS handle multiline/truncation and layout
btn.classList.add('tdm-note-expanded');
} else {
if (btn.textContent !== 'Note') btn.textContent = 'Note';
btn.classList.remove('tdm-note-expanded');
}
}
if (btn.title !== txt) btn.title = txt;
if (btn.getAttribute('aria-label') !== txt) btn.setAttribute('aria-label', txt);
} catch(_) { /* silent */ }
},
// --- Persistence (optional enhancement) ---
saveUnifiedStatusSnapshot: function(){
try {
// Keep unifiedStatus.v1 as a lightweight UI cache only (no embedded phaseHistory)
const snap = { ts: Date.now(), records: state.unifiedStatus };
storage.set('unifiedStatus.v1', snap);
} catch(err){ /* ignore */ }
},
loadUnifiedStatusSnapshot: function(maxAgeMinutes=30){
try {
const snap = storage.get('unifiedStatus.v1');
if (!snap || !snap.records || !snap.ts) return;
const ageMin = (Date.now() - snap.ts)/60000;
if (ageMin > maxAgeMinutes) return; // stale
// Filter out any records whose arrival already passed long ago (>15m)
const filtered = {};
const cutoff = Date.now() - (15*60*1000);
for (const [pid, rec] of Object.entries(snap.records)) {
if (rec.arrivalMs && rec.arrivalMs < cutoff && rec.canonical === 'Travel') continue; // old travel
filtered[pid] = rec;
}
state.unifiedStatus = filtered;
// NOTE: phase history is now persisted in IndexedDB per-player (tdm.phaseHistory.id_<id>).
// Restoration of phaseHistory is handled in ui._restorePersistedTravelMeta which runs on init.
} catch(err){ /* ignore */ }
},
// Attach a single tooltip refresh listener once (idempotent)
ensureUnifiedStatusUiListener: function(){
if (window._tdmUnifiedUiListenerBound) return;
window._tdmUnifiedUiListenerBound = true;
window.addEventListener('tdm:unifiedStatusUpdated', (ev) => {
try {
const detail = ev.detail || {}; const id = detail.id;
if (!id) return;
const sel = `.tdm-travel-eta[data-opp-id="${id}"]`;
const el = document.querySelector(sel);
if (!el) return;
// Pull unified record and rebuild tooltip if in travel phase
const rec = state.unifiedStatus ? state.unifiedStatus[id] : null;
if (!rec) return;
const canon = (rec && (rec.canonical || rec.phase)) || '';
const travelPhases = canon === 'Travel' || canon === 'Returning' || canon === 'Abroad';
if (!travelPhases) return;
const dest = rec.dest || null;
const planeForMins = rec.plane || 'light_aircraft';
const mins = rec.mins || (dest ? utils.getTravelMinutes(dest, planeForMins) : 0) || 0;
if (rec.confidence === 'HIGH' && mins > 0 && rec.etams && el) {
const arrow = rec.isreturn ? '\u2190' : '\u2192';
const destAbbr = dest ? (utils.abbrevDest(dest) || dest.split(/[\s,]/)[0]) : '';
let etaMs = rec.etams;
const remMs = Math.max(0, etaMs - Date.now());
const remMin = Math.ceil(remMs/60000);
const leftLocal = new Date(rec.ct0).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', hour12:false});
const observedMin = Math.max(0, Math.floor((Date.now() - rec.ct0)/60000));
const remainingPart = ` * ETA ${remMin}m`;
const tooltip = `${arrow} ${dest||destAbbr} * ${planeForMins} * Observed ${observedMin}m of ${mins}m${remainingPart} * Since ${leftLocal}`.trim();
if (el.title !== tooltip) el.title = tooltip;
if (/dur\. \?/.test(el.textContent || '') || /dur\.\s+\d/.test(el.textContent||'')) {
let remStr = `${remMin}m`; if (remMin > 60) { const rh=Math.floor(remMin/60); const rm=remMin%60; remStr = rh+ 'h' + (rm? ' ' + rm + 'm':''); }
el.textContent = `${arrow} ${destAbbr} LAND~${remStr}`;
el.classList.remove('tdm-travel-lowconf');
el.classList.add('tdm-travel-conf');
}
}
} catch(err){ /* silent */ }
});
// Removed delegated handler: rely on per-element binding logic which is now maintained
},
// --- Pre-arrival Alert Hooks ---
_arrivalAlerts: [], // { id, playerId|null, minutesBefore, fired:boolean, fn }
registerArrivalAlert: function({ playerId=null, minutesBefore=5, fn }) {
if (typeof fn !== 'function') return null;
const id = 'alrt_'+Math.random().toString(36).slice(2,9);
utils._arrivalAlerts.push({ id, playerId, minutesBefore: Number(minutesBefore)||0, fn, fired:false });
return id;
},
unregisterArrivalAlert: function(id){
utils._arrivalAlerts = utils._arrivalAlerts.filter(a => a.id !== id);
},
_evaluateArrivalAlerts: function(){
if (!utils._arrivalAlerts.length) return;
const now = Date.now();
for (const alert of utils._arrivalAlerts) {
if (alert.fired) continue;
// Scan relevant records (all or specific player)
const recs = Object.values(state.unifiedStatus || {});
for (const r of recs) {
if (alert.playerId && String(r.playerId) !== String(alert.playerId)) continue;
if (r.canonical !== 'Travel' && r.canonical !== 'Returning') continue;
if (!r.arrivalMs) continue;
const msBefore = alert.minutesBefore * 60000;
if (r.arrivalMs - now <= msBefore && r.arrivalMs > now) {
try { alert.fn({ record: r, minutesBefore: alert.minutesBefore, now }); } catch(err){ tdmlogger('warn', '[ArrivalAlert]', 'error', err); }
alert.fired = true;
break;
}
}
}
// Optional cleanup of fired alerts (keep for inspection for now)
},
// Trim `state.tornFactionData` to the N most recently fetched entries (default 20)
trimTornFactionData: function(limit = 5) {
try {
if (!state || !state.tornFactionData || typeof state.tornFactionData !== 'object') return;
const cache = state.tornFactionData;
// Collect entries with fetchedAtMs (default to 0)
const arr = Object.entries(cache).map(([id, entry]) => ({ id, fetchedAt: Number(entry?.fetchedAtMs || 0) || 0 }));
arr.sort((a, b) => b.fetchedAt - a.fetchedAt);
// Also trim unified status periodically (piggyback here)
try { utils.trimUnifiedStatus(60); } catch(_) {}
// Also trim ranked war attacks cache (piggyback here)
try { utils.trimRankedWarAttacksCache(3); } catch(_) {}
if (arr.length <= limit) {
utils.schedulePersistTornFactionData();
return cache;
}
const keep = new Set(arr.slice(0, limit).map(x => String(x.id)));
for (const k of Object.keys(cache)) {
if (!keep.has(String(k))) delete cache[k];
}
utils.schedulePersistTornFactionData();
if (state?.debug?.statusWatch) tdmlogger('info', '[TornFactionData] trimmed to', limit, 'entries');
return cache;
} catch (e) { /* best-effort, non-fatal */ return state.tornFactionData || {}; }
},
// Prune old ranked war attacks from memory (keep only N most recent)
trimRankedWarAttacksCache: function(limit = 3) {
try {
if (!state || !state.rankedWarAttacksCache) return;
const cache = state.rankedWarAttacksCache;
const arr = Object.entries(cache).map(([id, entry]) => ({ id, updatedAt: Number(entry?.updatedAt || 0) || 0 }));
arr.sort((a, b) => b.updatedAt - a.updatedAt);
if (arr.length <= limit) return;
const keep = new Set(arr.slice(0, limit).map(x => String(x.id)));
let removed = 0;
for (const k of Object.keys(cache)) {
if (!keep.has(String(k))) {
delete cache[k];
removed++;
}
}
if (removed > 0) {
persistRankedWarAttacksCache(cache); // Update storage to reflect memory trim
if (state?.debug?.apiLogs) tdmlogger('info', '[RankedWarAttacks] Trimmed', removed, 'old wars from memory');
}
} catch(_) {}
},
// Prune old unified status records to prevent unbounded memory growth
trimUnifiedStatus: function(maxAgeMinutes = 60) {
try {
if (!state.unifiedStatus) return;
const now = Date.now();
const cutoff = now - (maxAgeMinutes * 60 * 1000);
let removed = 0;
for (const [id, rec] of Object.entries(state.unifiedStatus)) {
// Keep if updated recently OR if it's a member of a currently cached faction
if ((rec.updated || 0) < cutoff) {
// Check if user is in any currently cached faction before deleting
// TODO:(Optimization: this check might be expensive, so maybe just rely on timestamp?
// If they are in a cached faction, they would have been updated recently when that faction was fetched.)
delete state.unifiedStatus[id];
removed++;
}
}
if (removed > 0 && state?.debug?.statusWatch) tdmlogger('info', '[UnifiedStatus] Pruned', removed, 'old records');
} catch(_) {}
},
// Emergency memory safety valve: checks for runaway array/object growth and hard-trims if necessary
enforceMemoryLimits: function() {
try {
// 1. Unified Status: Hard cap at 5000 records (approx 1-2MB)
if (state.unifiedStatus) {
const keys = Object.keys(state.unifiedStatus);
if (keys.length > 5000) {
tdmlogger('warn', '[Memory] UnifiedStatus exceeded 5000 records. Hard trimming...');
utils.trimUnifiedStatus(10); // Trim to 10 minutes
// If still too big, random slash
if (Object.keys(state.unifiedStatus).length > 5000) {
state.unifiedStatus = {}; // Nuclear option
tdmlogger('error', '[Memory] UnifiedStatus cleared completely due to overflow.');
}
}
}
// 2. Dibs Data: Hard cap at 2000 entries (unlikely to be reached legitimately)
if (Array.isArray(state.dibsData) && state.dibsData.length > 2000) {
tdmlogger('warn', '[Memory] DibsData exceeded 2000 entries. Truncating...');
state.dibsData = state.dibsData.slice(0, 2000);
}
// 3. Med Deals: Hard cap at 2000 entries
if (state.medDeals && Object.keys(state.medDeals).length > 2000) {
tdmlogger('warn', '[Memory] MedDeals exceeded 2000 entries. Clearing...');
state.medDeals = {};
}
// 4. Metrics: Clear if too large
if (state.metrics && JSON.stringify(state.metrics).length > 50000) {
state.metrics = {};
}
} catch(_) {}
},
schedulePersistTornFactionData: (() => {
let timer = null;
return () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
try { storage.set('tornFactionData', state.tornFactionData || {}); } catch(_) {}
}, 2000);
};
})(),
// Ranked-war meta / snapshot persistence helpers
// Debounced + cross-tab aware: coalesces frequent updates and avoids duplicate writes across tabs
_rwMetaDebounceMs: Number(config.RW_META_DEBOUNCE_MS) || 5000,
_rwMetaTimers: {},
_rwMetaLastHash: {},
_rwMetaLastWriteTs: {},
// Internal cross-tab signal key (uses native localStorage to trigger storage events)
_rwMetaSignalKey: 'tdm.rw_meta_signal',
// compute a stable JSON string for hashing (simple canonicalization)
_rwMetaStringify: function(obj) {
try { return JSON.stringify(obj); } catch (_) { return String(obj || ''); }
},
// Persist ranked war meta for warKey after debounce; avoids writing if unchanged
schedulePersistRankedWarMeta: function(warKey, opts = {}) {
try {
if (!warKey) return;
const key = `tdm.rw_meta_${warKey}`;
const payload = { v: state.rankedWarChangeMeta || {}, ts: Date.now(), warId: state.lastRankWar?.id || null };
const payloadStr = utils._rwMetaStringify(payload.v);
const lastHash = utils._rwMetaLastHash[key] || null;
// If nothing changed and it's been written recently, skip
const now = Date.now();
if (lastHash && lastHash === payloadStr && (now - (utils._rwMetaLastWriteTs[key] || 0) < (opts.forceWriteMs || 15000))) return;
// Clear existing timer
try { if (utils._rwMetaTimers[key]) clearTimeout(utils._rwMetaTimers[key]); } catch(_) {}
// Schedule write with some jitter so concurrent tabs reduce collisions
const delay = utils._rwMetaDebounceMs + (Math.floor(Math.random() * 200) - 100);
utils._rwMetaTimers[key] = setTimeout(() => {
try {
// Compare one more time before writing
const now2 = Date.now();
const current = { v: state.rankedWarChangeMeta || {}, ts: now2, warId: state.lastRankWar?.id || null };
const curStr = utils._rwMetaStringify(current.v);
const last = utils._rwMetaLastHash[key] || null;
if (last && last === curStr && (now2 - (utils._rwMetaLastWriteTs[key]||0) < (opts.forceWriteMs || 15000))) {
// nothing to do
try { delete utils._rwMetaTimers[key]; } catch(_) {}
return;
}
// Write via storage wrapper if available; also emit a native localStorage signal for other tabs
try { storage.set(key, current); } catch(_) { try { localStorage.setItem(key, JSON.stringify(current)); } catch(_) {} }
try { localStorage.setItem(utils._rwMetaSignalKey, JSON.stringify({ key, ts: Date.now(), hash: curStr })); } catch(_) {}
utils._rwMetaLastHash[key] = curStr;
utils._rwMetaLastWriteTs[key] = Date.now();
try { delete utils._rwMetaTimers[key]; } catch(_) {}
} catch(e) { try { delete utils._rwMetaTimers[key]; } catch(_) {} }
}, delay);
} catch(_) {}
},
// Persist ranked war snapshot (debounced, simpler: uses same debounce window)
schedulePersistRankedWarSnapshot: function(warKey, opts = {}) {
try {
if (!warKey) return;
const key = `tdm.rw_snap_${warKey}`;
const payload = state.rankedWarTableSnapshot || {};
const payloadStr = utils._rwMetaStringify(payload);
const lastHash = utils._rwMetaLastHash[key] || null;
const now = Date.now();
if (lastHash && lastHash === payloadStr && (now - (utils._rwMetaLastWriteTs[key] || 0) < (opts.forceWriteMs || 15000))) return;
try { if (utils._rwMetaTimers[key]) clearTimeout(utils._rwMetaTimers[key]); } catch(_) {}
const delay = utils._rwMetaDebounceMs + (Math.floor(Math.random() * 200) - 100);
utils._rwMetaTimers[key] = setTimeout(() => {
try {
const now2 = Date.now();
const current = payload;
const curStr = utils._rwMetaStringify(current);
const last = utils._rwMetaLastHash[key] || null;
if (last && last === curStr && (now2 - (utils._rwMetaLastWriteTs[key]||0) < (opts.forceWriteMs || 15000))) {
try { delete utils._rwMetaTimers[key]; } catch(_) {}
return;
}
try { storage.set(key, current, { warId: state.lastRankWar?.id }); } catch(_) { try { localStorage.setItem(key, JSON.stringify(current)); } catch(_) {} }
try { localStorage.setItem(utils._rwMetaSignalKey, JSON.stringify({ key, ts: Date.now(), hash: curStr })); } catch(_) {}
utils._rwMetaLastHash[key] = curStr;
utils._rwMetaLastWriteTs[key] = Date.now();
try { delete utils._rwMetaTimers[key]; } catch(_) {}
} catch(e) { try { delete utils._rwMetaTimers[key]; } catch(_) {} }
}, delay);
} catch(_) {}
},
// On storage event notify: when another tab writes to a meta or snap key, update our lastHash bookkeeping
_initRwMetaSignalListener: function() {
try {
if (utils._rwMetaSignalListenerBound) return;
utils._rwMetaSignalListenerBound = true;
window.addEventListener('storage', (ev) => {
try {
if (!ev || !ev.key) return;
const sigKey = utils._rwMetaSignalKey;
if (ev.key === sigKey) {
const detail = ev.newValue ? JSON.parse(ev.newValue) : null;
if (!detail || !detail.key) return;
// Update lastWriteTs/hash for the signaled key so we don't re-write unnecessarily
try { utils._rwMetaLastHash[detail.key] = detail.hash || (localStorage.getItem(detail.key) ? JSON.stringify(JSON.parse(localStorage.getItem(detail.key)).v || JSON.parse(localStorage.getItem(detail.key))) : null); } catch(_) {}
try { utils._rwMetaLastWriteTs[detail.key] = Number(detail.ts || Date.now()); } catch(_) {}
// Clear local timers for this key — another tab already wrote
try { if (utils._rwMetaTimers[detail.key]) { clearTimeout(utils._rwMetaTimers[detail.key]); delete utils._rwMetaTimers[detail.key]; } } catch(_) {}
}
} catch(_) {}
});
} catch(_) {}
},
//
// Return a sorted list of cached faction bundle metadata for quick inspection
listCachedFactionBundles: function() {
try {
const cache = state.tornFactionData || {};
const out = Object.entries(cache).map(([id, entry]) => ({
factionId: String(id),
fetchedAtMs: Number(entry?.fetchedAtMs || 0) || 0,
selections: Array.isArray(entry?.selections) ? entry.selections.slice() : (entry?.selections ? [entry.selections] : []),
memberCount: (entry?.data && (entry.data.members || entry.data.member)) ? (Array.isArray(entry.data.members || entry.data.member) ? (entry.data.members || entry.data.member).length : Object.keys(entry.data.members || entry.data.member || {}).length) : 0
}));
out.sort((a,b)=> b.fetchedAtMs - a.fetchedAtMs);
return out;
} catch (_) { return []; }
},
// List cached ranked war meta keys (for inspection)
listCachedRankedWarMeta: function(prefix = 'tdm.rw_meta_') {
try {
const out = [];
for (const k of Object.keys(localStorage || {})) {
if (!String(k || '').startsWith(String(prefix))) continue;
try {
const raw = localStorage.getItem(k);
const obj = raw ? JSON.parse(raw) : null;
out.push({ key: k, ts: Number(obj?.ts || 0) || 0, warId: obj?.warId || null, count: obj?.v && typeof obj.v === 'object' ? Object.keys(obj.v).length : 0 });
} catch(_) { out.push({ key: k, ts: 0 }); }
}
out.sort((a,b)=> b.ts - a.ts);
return out;
} catch(_) { return []; }
},
// API-driven unified status builder (v2) – ignores Awoken, Dormant, Fallen, Federal for canonical events
/*
* Unified Status Record (buildUnifiedStatusV2 output)
* ------------------------------------------------------------------
* Fields:
* playerId? (attached externally when stored)
* rawState Original API status.state
* rawDescription Original API status.description
* canonical Canonical status: Okay | Travel | Returning | Abroad | Hospital | HospitalAbroad | Jail | SelfHosp | LostAttack | LostAttackAbroad
* activity last_action.status or null
* dest Canonical destination (key of _travelMap) when traveling
* plane plane_image_type (e.g. light_aircraft, airliner_business, private_jet) or null
* startedMs Inferred start time (ms) of travel (arrivalMs - duration or reused from previous record)
* arrivalMs Expected arrival (ms) – from API until (sec) or inferred
* durationMins Inferred duration from travel map and plane-specific matrix
* landedTornRecent Within grace window just after landing in Torn
* landedAbroadRecent Within grace window after landing abroad
* landedGrace Generic landed grace boolean
* issues Validation anomalies array (see validateTravelRecord)
* generatedAt Construction timestamp (ms)
*
* Events (emitStatusChangeV2):
* travel:start | travel:complete | travel:eta | travel:destination | travel:plane | travel:duration
* status:hospital | status:jail | activity:change
*
* Diagnostics:
* validateTravelRecord: no-destination | no-duration | duration-mismatch | unknown-destination
* Drift detection logs >90s deviation between stored vs recomputed arrival.
*/
buildUnifiedStatusV2: function(member, prev) {
try {
const normalized = utils.normalizeStatusInputForV2(member, { id: prev?.id });
const rawStatus = normalized?.status || {};
const rawState = rawStatus.state || '';
const rawDesc = rawStatus.description || '';
const rawUntil = rawStatus.until != null ? Number(rawStatus.until) || null : null; // epoch seconds
const plane = rawState === 'Traveling' ? (rawStatus.plane_image_type || null) : null;
const now = Date.now();
const prevRec = prev || null;
// Ignore states we don't track as canonical (treat as Okay baseline unless in travel grace)
// Do not ignore Fallen - it is a valid canonical state we want to display
const ignoreSet = new Set(['Awoken','Dormant','Federal']);
let canonical = 'Okay';
let dest = null;
let isreturn = false;
let startedMs = null;
let arrivalMs = null;
let durationMins = null;
// Legacy etaConfidence removed in favor of unified confidence ladder
let landedGrace = false;
const graceMs = 5*60*1000;
// Parse destination from description for traveling/abroad forms (simple regex then alias map)
const parseDestFromDesc = (desc) => {
if (!desc) return null;
// Return: 'Returning to Torn from X'
let m = desc.match(/^returning to torn from\s+([A-Za-z][A-Za-z \-]{1,40})/i);
if (m && m[1]) {
// Returning flight: dest is Torn, from is m[1]
return { from: m[1].trim(), to: 'Torn' };
}
// Outbound: 'Traveling to X', 'In X', etc.
m = /(travell?ing to|in)\s+([A-Za-z][A-Za-z \-]{1,40})/i.exec(desc);
if (m && m[2]) {
const raw = m[2].trim();
const mapped = utils.parseUnifiedDestination(raw) || null;
// from is Torn, to is mapped or raw
return { from: 'Torn', to: mapped || raw}
}
return null;
};
if (rawState === 'Traveling') {
// Determine outbound vs inbound (returning) by description. Inbound flights are now
// classified as canonical 'Returning' (previously always 'Travel'), so that the overlay
// and activity metrics reflect real-time returning counts instead of only recent landings.
let parsed = parseDestFromDesc(rawDesc);
const inbound = !!(parsed && parsed.to === 'Torn');
if (inbound) {
canonical = 'Returning';
// dest represents the origin we are coming FROM (parsed.from)
dest = utils.parseUnifiedDestination(parsed.from) || parsed.from || (prevRec && prevRec.dest);
isreturn = true;
} else {
canonical = 'Travel';
if (parsed && parsed.to && parsed.to !== 'Torn') {
dest = utils.parseUnifiedDestination(parsed.to) || parsed.to;
} else {
// Reuse previous destination if we remain in a traveling phase
dest = (prevRec && (prevRec.canonical === 'Travel' || prevRec.canonical === 'Returning') ? prevRec.dest : null);
}
}
if (dest) {
durationMins = utils.travelMinutesForPlane(dest, plane || 'light_aircraft') || utils.travelMinutesFor(dest) || null;
if (durationMins) {
// Reuse prior startedMs if still same dest/plane and previously traveling or returning
if (prevRec && (prevRec.canonical === 'Travel' || prevRec.canonical === 'Returning') && prevRec.dest === dest && prevRec.plane === plane && prevRec.startedMs) {
startedMs = prevRec.startedMs;
} else {
if (rawUntil && rawUntil > 0) {
arrivalMs = rawUntil * 1000;
startedMs = arrivalMs - (durationMins*60*1000);
} else {
startedMs = now;
}
}
if (!arrivalMs) arrivalMs = startedMs + (durationMins*60*1000);
}
}
} else if (rawState === 'Abroad') {
canonical = 'Abroad';
const parsed = parseDestFromDesc(rawDesc);
dest = (parsed && parsed.to) || (prevRec && prevRec.dest ? prevRec.dest : null);
// If we recently landed (prev was Travel) adjust returning vs landed flags later
// Extra inference: simple 'In UAE' or 'In <alias>' forms sometimes miss due to short token
if (!dest && /^\s*in\s+([A-Za-z]{2,10})/i.test(rawDesc)) {
const token = rawDesc.replace(/^\s*in\s+/i,'').trim().replace(/^the\s+/i,'').split(/\s+/)[0];
const maybe = utils.parseUnifiedDestination(token);
if (maybe) dest = maybe;
}
} else if (rawState === 'Hospital') {
canonical = 'Hospital';
// Examine description + details (some sources embed attacker outcome text)
const descLower = (rawDesc || '').toLowerCase();
const detailsLower = (normalized?.status?.details || normalized?.status?.detail || member?.status?.details || '').toLowerCase();
// Self-inflicted hospitalization (using a medical item): starts with "Suffering from"
if (/^suffering\s+from\b/i.test(rawDesc || '') && !/^in\s+an?\s+/i.test(rawDesc || '')) {
canonical = 'SelfHosp';
} else if (/^lost\s+to\s+/i.test(detailsLower) && /^in\s+an?\s+/i.test(rawDesc || '')) {
// Lost a fight and is abroad (description begins with In a|an ... )
canonical = 'LostAttackAbroad';
} else if (/^lost\s+to\s+/i.test(detailsLower) && /^in\s+hospital\b/i.test(descLower)) {
// Lost a fight, standard Torn hospital phrasing
canonical = 'LostAttack';
} else {
// If the description explicitly indicates an abroad hospital using
// the strict "In a/an <adjective> hospital for <duration>" form,
// treat that as HospitalAbroad. Do NOT fall back to prevRec.dest
// here to avoid stale travel data causing mislabels.
try {
const hospDest = utils.parseHospitalAbroadDestination(rawDesc);
if (hospDest) {
canonical = 'HospitalAbroad';
dest = hospDest;
}
} catch (_) { /* ignore parse failures */ }
}
} else { canonical = rawState; }
// Returning / landed recent logic
// Landed subtype convenience flags (separate from canonical; consumer can inspect)
let landedTornRecent = false;
let landedAbroadRecent = false;
// If an inbound flight reached its ETA but Torn still reports the player as Abroad,
// treat this as a landed-in-Torn state to avoid showing stale "Abroad <dest>" after arrival.
if (prevRec && prevRec.isreturn && prevRec.arrivalMs && (now - prevRec.arrivalMs) >= 0 && (now - prevRec.arrivalMs) <= graceMs && canonical === 'Abroad') {
canonical = 'Okay';
landedTornRecent = true;
landedGrace = true;
dest = 'Torn';
}
if (prevRec && (prevRec.canonical === 'Travel' || prevRec.canonical === 'Returning') && (canonical !== 'Travel' && canonical !== 'Returning')) {
const withinGrace = !prevRec.arrivalMs || (now - prevRec.arrivalMs) <= graceMs;
if (withinGrace) {
landedGrace = true;
if (!dest && prevRec.dest) dest = prevRec.dest;
const inbound = !!prevRec.isreturn || (prevRec.dest && /^torn\b/i.test(prevRec.dest));
if (inbound) {
landedTornRecent = true;
if (canonical === 'Okay' || canonical === 'Returning') {
canonical = 'Returning';
}
} else {
landedAbroadRecent = true;
}
}
}
// Reconcile legacy landed Returning (post-arrival) without affecting in-flight returning.
// Only run if canonical Returning due to landing (isreturn false) not an active inbound flight.
if (!landedTornRecent && !landedAbroadRecent && canonical === 'Returning' && prevRec && !isreturn) {
if (prevRec.isreturn || (prevRec.dest && /^torn\b/i.test(prevRec.dest))) landedTornRecent = true;
else landedAbroadRecent = true;
}
const rec = {
id: normalized?.id ?? member?.id ?? member?.player_id ?? null,
rawState,
rawDescription: rawDesc || null,
rawUntil: rawUntil,
// Use API-provided plane when present. If API did not provide plane (null/undefined)
// and we have a previous record that contained an outbound plane (airliner/light_aircraft/etc),
// copy it forward. This preserves outbound plane info across brief transitions where Torn
// sometimes omits the plane on intermediate states (e.g., Returning/Abroad).
// Respect explicit API clears: only copy when API plane is null/undefined and prevRec has a non-empty plane.
plane: (plane != null ? plane : (prevRec && prevRec.plane ? prevRec.plane : null)),
canonical,
dest,
isreturn,
startedMs,
arrivalMs,
durationMins,
// etaConfidence removed
landedGrace,
landedTornRecent,
landedAbroadRecent,
activity: (normalized?.last_action?.status || normalized?.lastAction?.status || member?.last_action?.status || member?.lastAction?.status || '').trim(),
// Include member display name to make unified records more useful for UI consumers
name: (normalized?.name || member?.name || member?.username || member?.playername || null) || null,
updated: now,
sourceVersion: 2
};
// Maintain legacy aliases so downstream callers relying on phase continue to function,
// while canonical remains the single source of truth for status classification.
rec.phase = canonical;
rec.statusCanon = canonical;
const prevConf = prevRec?.confidence;
const rank = { LOW:1, MED:2, HIGH:3 };
if (prevConf && rank[prevConf]) {
rec.confidence = prevConf;
} else {
rec.confidence = 'LOW';
}
return rec;
} catch(err) {
tdmlogger('error', '[StatusV2]', 'build error', err);
return null;
}
},
emitStatusChangeV2: function(prev, next) {
try {
if (!next || !next.id) return;
const tracked = new Set(['Travel','Returning','Abroad','Hospital','HospitalAbroad','Jail','Okay','SelfHosp','LostAttack','LostAttackAbroad']);
if (prev && !tracked.has(prev.canonical) && !tracked.has(next.canonical)) return; // both untracked
const changes = {};
if (!prev || prev.canonical !== next.canonical) changes.canonical = true;
if (!prev || prev.dest !== next.dest) changes.dest = true;
if (!prev || prev.plane !== next.plane) changes.plane = true;
if (!prev || prev.arrivalMs !== next.arrivalMs) changes.arrivalMs = true;
if (!prev || prev.durationMins !== next.durationMins) changes.durationMins = true;
if (!prev || prev.activity !== next.activity) changes.activity = true;
if (Object.keys(changes).length === 0) return; // no material delta
const types = [];
if (changes.canonical && next.canonical === 'Travel') types.push('travel:start');
if (prev && prev.canonical === 'Travel' && next.canonical !== 'Travel') types.push('travel:complete');
if (changes.dest) types.push('travel:destination');
if (changes.plane) types.push('travel:plane');
if (changes.arrivalMs) types.push('travel:eta');
if (changes.durationMins) types.push('travel:duration');
if (changes.canonical && /Hospital/.test(next.canonical)) types.push('status:hospital');
if (changes.canonical && next.canonical === 'Jail') types.push('status:jail');
if (changes.activity) types.push('activity:change');
const evt = { id: next.id, ts: Date.now(), prev, next, changes, types };
if (state?.debug?.statusFlow) tdmlogger('info', '[StatusV2][Change]', evt);
state._recentStatusEvents = state._recentStatusEvents || [];
state._recentStatusEvents.push(evt);
if (state._recentStatusEvents.length > 200) state._recentStatusEvents.shift();
// Diagnostics: arrival mismatch (inferred vs recomputed) – simple tolerance check
if (next.canonical === 'Travel' && next.startedMs && next.arrivalMs && next.durationMins) {
const expectedArrival = next.startedMs + next.durationMins*60*1000;
const driftMs = Math.abs(expectedArrival - next.arrivalMs);
if (driftMs > 90000) {
tdmlogger('warn', '[Travel][Drift]', { id: next.id, driftMs, expectedArrival, arrivalMs: next.arrivalMs, durationMins: next.durationMins });
}
}
// Evaluate pre-arrival alerts when travel state/time changes
if (types.some(t => t.startsWith('travel:'))) {
try { utils._evaluateArrivalAlerts(); } catch(_) { /* ignore */ }
}
} catch(err) { /* swallow */ }
},
validateTravelRecord: function(rec) {
try {
if (!rec || rec.canonical !== 'Travel') return null;
const issues = [];
if (!rec.dest) issues.push('no-destination');
if (!rec.durationMins) issues.push('no-duration');
if (rec.startedMs && rec.arrivalMs) {
const calcDur = (rec.arrivalMs - rec.startedMs) / 60000;
if (rec.durationMins && Math.abs(calcDur - rec.durationMins) > 3) issues.push('duration-mismatch');
}
if (rec.dest && !utils._travelMap[rec.dest]) issues.push('unknown-destination');
if (issues.length) {
tdmlogger('debug', '[Travel][Validate]', { id: rec.id, issues, rec });
}
return issues;
} catch(e) { return null; }
},
// ---- Unified Status & Travel Maps (consolidated) END ----
// Short relative time from unix seconds; stable across devices given same timestamp
formatAgoShort: (unixSeconds) => {
try {
const ts = Number(unixSeconds || 0);
if (!Number.isFinite(ts) || ts <= 0) return '';
const diff = Math.max(0, Math.floor(Date.now() / 1000) - ts);
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
} catch(_) { return ''; }
},
// Full relative time with days/hours/minutes/seconds from unix seconds
formatAgoFull: (unixSeconds) => {
try {
const ts = Number(unixSeconds || 0);
if (!Number.isFinite(ts) || ts <= 0) return '';
let diff = Math.max(0, Math.floor(Date.now() / 1000) - ts);
const d = Math.floor(diff / 86400); diff -= d * 86400;
const h = Math.floor(diff / 3600); diff -= h * 3600;
const m = Math.floor(diff / 60); diff -= m * 60;
const s = diff;
const parts = [];
if (d > 0) parts.push(`${d} ${d === 1 ? 'day' : 'days'}`);
if (h > 0 || d > 0) parts.push(`${h} ${h === 1 ? 'hour' : 'hours'}`);
if (m > 0 || h > 0 || d > 0) parts.push(`${m} ${m === 1 ? 'minute' : 'minutes'}`);
parts.push(`${s} ${s === 1 ? 'second' : 'seconds'}`);
return parts.join(' ') + ' ago';
} catch(_) { return ''; }
},
// Central Torn user normalization (legacy or new schema)
normalizeTornUser: (raw) => {
try {
if (!raw || typeof raw !== 'object') return null;
const profile = raw.profile || {};
const faction = raw.faction || {};
const id = raw.player_id || profile.id || profile.player_id || raw.id || null;
if (!id) return null;
return {
id: String(id),
name: raw.name || profile.name || '',
factionId: faction.faction_id || faction.id || profile.faction_id || null,
position: faction.position || null,
status: profile.status || raw.status || null,
last_action: profile.last_action || raw.last_action || null,
faction: faction.id ? { id: faction.faction_id || faction.id, name: faction.name, tag: faction.tag } : null
};
} catch(_) { return null; }
},
debounce: (func, delay) => {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), delay);
};
},
// --- Runtime resource registry helpers (timers, observers, listeners) ---
registerInterval: (id) => {
try {
if (!id) return id;
state._resources.intervals.add(id);
} catch(_) {}
return id;
},
unregisterInterval: (id) => {
try {
if (!id) return;
try { clearInterval(id); } catch(_) {}
state._resources.intervals.delete(id);
} catch(_) {}
},
registerTimeout: (id) => {
try {
if (!id) return id;
state._resources.timeouts.add(id);
} catch(_) {}
return id;
},
unregisterTimeout: (id) => {
try {
if (!id) return;
try { clearTimeout(id); } catch(_) {}
state._resources.timeouts.delete(id);
} catch(_) {}
},
registerObserver: (obs) => {
try { if (!obs) return obs; state._resources.observers.add(obs); } catch(_) {}
return obs;
},
unregisterObserver: (obs) => {
try { if (!obs) return; try { obs.disconnect(); } catch(_) {} state._resources.observers.delete(obs); } catch(_) {}
},
registerWindowListener: (type, handler, opts) => {
try {
if (!type || typeof handler !== 'function') return null;
window.addEventListener(type, handler, opts);
state._resources.windowListeners.push({ type, handler, opts });
return handler;
} catch (_) { return null; }
},
unregisterAllWindowListeners: () => {
try {
for (const l of (state._resources.windowListeners || [])) {
try { window.removeEventListener(l.type, l.handler, l.opts); } catch(_) {}
}
state._resources.windowListeners = [];
} catch(_) {}
},
unregisterWindowListener: (type, handler, opts) => {
try {
if (!type || typeof handler !== 'function') return;
window.removeEventListener(type, handler, opts);
const arr = state._resources.windowListeners || [];
state._resources.windowListeners = arr.filter(l => !(l.type === type && l.handler === handler));
} catch(_) {}
},
// Attach handler on an element while recording it on the element to avoid building
// a strong global registry that would keep DOM nodes alive. `cleanupElementHandlers`
// will remove handlers when the element is taken down.
addElementHandler: (el, event, handler, opts) => {
try {
if (!el || typeof handler !== 'function') return;
el.addEventListener(event, handler, opts);
try { if (!el._tdmHandlers) el._tdmHandlers = []; el._tdmHandlers.push({ event, handler, opts }); el.dataset && (el.dataset.tdmHandled = '1'); } catch(_) {}
} catch(_) {}
},
cleanupElementHandlers: (el) => {
try {
if (!el || !el._tdmHandlers) return;
for (const h of el._tdmHandlers) {
try { el.removeEventListener(h.event, h.handler, h.opts); } catch(_) {}
}
try { el._tdmHandlers = []; } catch(_) {}
} catch(_) {}
},
// Walk known resource collections and clean everything up. This is a best-effort
// attempt to release long-lived references on page unload / hard reset.
cleanupAllResources: () => {
try {
// intervals
for (const id of Array.from(state._resources.intervals || [])) {
try { clearInterval(id); } catch(_) {}
}
state._resources.intervals.clear();
// timeouts
for (const id of Array.from(state._resources.timeouts || [])) {
try { clearTimeout(id); } catch(_) {}
}
state._resources.timeouts.clear();
// observers
for (const obs of Array.from(state._resources.observers || [])) {
try { obs.disconnect(); } catch(_) {}
}
state._resources.observers.clear();
// window listeners
try { utils.unregisterAllWindowListeners(); } catch(_) {}
// best-effort: clear per-element handlers where possible (search for common containers)
try {
const els = document.querySelectorAll('[data-tdm-handled], #tdm-settings-popup, #tdm-attack-container');
els && els.forEach(el => { try { utils.cleanupElementHandlers(el); } catch(_) {} });
} catch(_) {}
} catch(e) { try { tdmlogger('warn', '[cleanupAllResources] error', e); } catch(_) {} }
},
// TODO Review this debugger
// Runtime diagnostics: lightweight snapshot of tracked resources and cache sizes
debugResources: (opts = {}) => {
try {
const out = {
intervals: state._resources.intervals ? state._resources.intervals.size : 0,
timeouts: state._resources.timeouts ? state._resources.timeouts.size : 0,
observers: state._resources.observers ? state._resources.observers.size : 0,
windowListeners: state._resources.windowListeners ? state._resources.windowListeners.length : 0,
ui: {
apiCadenceInfoIntervalId: !!state.ui?.apiCadenceInfoIntervalId,
noteSnapshotInterval: !!state.ui?._noteSnapshotInterval,
},
script: {
mainRefreshIntervalId: !!state.script?.mainRefreshIntervalId,
fetchWatchdogIntervalId: !!state.script?.fetchWatchdogIntervalId,
factionBundleRefreshIntervalId: !!state.script?.factionBundleRefreshIntervalId,
activityTimeoutId: !!state.script?.activityTimeoutId,
},
caches: {
scoreBumpTimers: (state._scoreBumpTimers && typeof state._scoreBumpTimers === 'object') ? Object.keys(state._scoreBumpTimers).length : 0,
phaseHistoryWriteTimers: (handlers && handlers._phaseHistoryWriteTimers && typeof handlers._phaseHistoryWriteTimers === 'object') ? Object.keys(handlers._phaseHistoryWriteTimers).length : 0,
pendingSets: (ui && ui._kv && ui._kv._pendingSets) ? ui._kv._pendingSets.size : (typeof state._pendingSets === 'object' && state._pendingSets?.size ? state._pendingSets.size : 0)
}
};
if (opts.log !== false) {
try { console.info('TDM resources:', out); } catch(_) {}
}
return out;
} catch(_) { return null; }
},
exposeDebugToWindow: () => {
try { window.__TDM_DEBUG = window.__TDM_DEBUG || {}; window.__TDM_DEBUG.resources = utils.debugResources; } catch(_) {}
},
// Fingerprint helpers (canonical) for dibs & medDeals content gating
computeDibsFingerprint: (arr) => {
try {
const cur = Array.isArray(arr) ? arr : [];
const stable = cur.map(d => ({ o:d.opponentId, u:d.userId, a:!!d.dibsActive, t:d.updatedAt||d.updated||0 }))
.sort((a,b)=> (a.o - b.o) || String(a.u).localeCompare(String(b.u)));
const json = JSON.stringify(stable);
// FNV-1a 32-bit
let h = 0x811c9dc5;
for (let i=0;i<json.length;i++){ h^=json.charCodeAt(i); h = (h>>>0)*0x01000193; }
return 'd:' + (h>>>0).toString(36);
} catch(_) { return null; }
},
computeMedDealsFingerprint: (map) => {
try {
const cur = (map && typeof map === 'object') ? map : {};
const stableEntries = Object.entries(cur).map(([id,v]) => [id, !!v?.isMedDeal, v?.medDealForUserId||v?.forUserId||null, (v?.updatedAt?._seconds||v?.updatedAt||0)]);
stableEntries.sort((a,b)=> String(a[0]).localeCompare(String(b[0])));
const json = JSON.stringify(stableEntries);
let h = 0x811c9dc5;
for (let i=0;i<json.length;i++){ h^=json.charCodeAt(i); h = (h>>>0)*0x01000193; }
return 'm:' + (h>>>0).toString(36);
} catch(_) { return null; }
},
// Apply status color classes to an element based on a member.last_action.status
// Returns true if status applied, false if no status info present or on error.
addLastActionStatusColor: (el, member) => {
try {
if (!el || !member || !member.last_action || !member.last_action.status) {
// remove any previous status classes to avoid stale coloring
try { el.classList && el.classList.remove && el.classList.remove('tdm-la-online','tdm-la-idle','tdm-la-offline'); } catch(_) {}
return false;
}
const statusText = String(member.last_action.status || '').toLowerCase();
// normalize classes
try { el.classList.remove('tdm-la-online','tdm-la-idle','tdm-la-offline'); } catch(_) {}
if (statusText === 'online') el.classList.add('tdm-la-online');
else if (statusText === 'idle') el.classList.add('tdm-la-idle');
else el.classList.add('tdm-la-offline');
return true;
} catch(_) { return false; }
},
// Score formatting & anti-flicker helpers
formatScore: (value, scoreType) => {
if (value == null || isNaN(value)) return '0';
if (String(scoreType||'').toLowerCase().includes('r')) {
// r for respect, r, rnc, rnb
const fixed = Number(value).toFixed(2);
return fixed.replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
}
return String(value);
},
formatBattleStats: (rawValue) => formatBattleStatsValue(rawValue),
normalizeTimestampMs: (value) => normalizeTimestampMs(value),
isFeatureEnabled: (path) => {
try { return featureFlagController.isEnabled(path); } catch(_) { return false; }
},
setFeatureFlag: (path, value, opts) => {
try { return featureFlagController.set(path, value, opts); } catch(_) { return false; }
},
readFFScouter: (playerId, opts = {}) => {
try {
if (!playerId) return null;
// Allow if flag is enabled OR if a key is present (implicit enable)
const hasKey = !!storage.get('ffscouterApiKey', null);
if (opts.ignoreFeatureFlag !== true && !featureFlagController.isEnabled('rankWarEnhancements.adapters') && !hasKey) return null;
const sid = String(playerId).trim();
if (!sid) return null;
const memo = adapterMemoController.get('ff');
if (memo.has(sid)) return memo.get(sid);
let record = null;
if (state.ffscouterCache && typeof state.ffscouterCache === 'object' && state.ffscouterCache[sid]) {
record = state.ffscouterCache[sid];
}
if (!record) {
try {
const raw = localStorage.getItem('ffscouterv2-' + sid);
if (raw) {
record = JSON.parse(raw);
if (record) {
state.ffscouterCache = state.ffscouterCache || {};
state.ffscouterCache[sid] = record;
}
}
} catch(_) {}
}
if (!record) {
memo.set(sid, null);
recordAdapterMetric('ff', 'miss');
return null;
}
const normalized = normalizeFfRecord(record, sid);
memo.set(sid, normalized);
recordAdapterMetric('ff', normalized ? 'hit' : 'miss');
return normalized;
} catch (err) {
recordAdapterMetric('ff', 'error', err?.message || 'ffscouter-read-failed');
return null;
}
},
readBSP: (playerId, opts = {}) => {
try {
if (!playerId) return null;
if (opts.ignoreFeatureFlag !== true && !featureFlagController.isEnabled('rankWarEnhancements.adapters')) return null;
const sid = String(playerId).trim();
if (!sid) return null;
if (opts.skipPageFlag !== true) {
const flagRaw = (readLocalStorageRaw(BSP_ENABLED_STORAGE_KEY) || '').toString().toLowerCase();
if (flagRaw !== 'true') return null;
}
const memo = adapterMemoController.get('bsp');
if (memo.has(sid)) return memo.get(sid);
const rawString = readLocalStorageRaw(`tdup.battleStatsPredictor.cache.prediction.${sid}`);
if (!rawString) {
memo.set(sid, null);
recordAdapterMetric('bsp', 'miss');
return null;
}
const parsed = parseJsonSafe(rawString);
if (!parsed) {
memo.set(sid, null);
recordAdapterMetric('bsp', 'error', 'json-parse');
return null;
}
const normalized = normalizeBspRecord(parsed, sid);
memo.set(sid, normalized);
recordAdapterMetric('bsp', normalized ? 'hit' : 'miss');
return normalized;
} catch (err) {
recordAdapterMetric('bsp', 'error', err?.message || 'bsp-read-failed');
return null;
}
},
// Fetch ranked war summary rows (snapshot of per-attacker aggregates) with a tiny in-memory TTL cache
getSummaryRowsCached: async (warId, factionId) => {
try {
if (!warId) return [];
const ttlMs = 1500; // prevent double network within a burst of UI refreshes
const cache = state._summaryCache || (state._summaryCache = {});
const entry = cache[warId];
const now = Date.now();
if (entry && (now - entry.fetchedAt) < ttlMs && Array.isArray(entry.rows)) {
return entry.rows;
}
// Prefer local/storage -> server chain (single helper already implements that)
let rows = [];
try {
rows = await api.getRankedWarSummaryPreferLocal(warId, factionId);
} catch(_) { /* ignore */ }
if (!Array.isArray(rows)) rows = [];
cache[warId] = { rows, fetchedAt: now };
return rows;
} catch(_) { return []; }
},
computeScoreFromRow: (row, scoreType) => {
if (!row) return 0;
if (scoreType === 'Respect') return Number(row.totalRespectGain || 0);
if (scoreType === 'Respect (no chain)') return Number(row.totalRespectGainNoChain || 0);
if (scoreType === 'Respect (no bonus)') return Number(row.totalRespectGainNoBonus || 0);
// For 'Attacks' score-type we now count only "successful" attacks (see totalAttacksSuccessful)
return Number(row.totalAttacksSuccessful ?? row.totalAttacks ?? 0);
},
scores: {
shouldUpdate(prevRaw, nextRaw) {
if (prevRaw == null) return true;
const diff = Math.abs(Number(prevRaw) - Number(nextRaw));
return diff >= 0.005; // ignore micro jitter under half a hundredth
}
},
// TODO Review all calls and verify accuracy
incrementApiCalls: (n = 1) => {
try {
state.session.apiCalls = (state.session.apiCalls || 0) + (Number(n) || 0);
tdmlogger('debug', '[API calls]', [state.session.apiCalls, state.session.apiCallsClient, n]);
try { sessionStorage.setItem('tdm.api_calls', String(state.session.apiCalls)); } catch(_) {}
if (handlers?.debouncedUpdateApiUsageBadge) {
handlers.debouncedUpdateApiUsageBadge();
} else if (ui && typeof ui.updateApiUsageBadge === 'function') {
ui.updateApiUsageBadge();
}
} catch (_) { /* noop */ }
},
incrementClientApiCalls: (n = 1) => {
try {
const add = Number(n) || 0;
state.session.apiCallsClient = (state.session.apiCallsClient || 0) + add;
try { sessionStorage.setItem('tdm.api_calls_client', String(state.session.apiCallsClient)); } catch(_) {}
utils.incrementApiCalls(add);
} catch(_) { /* noop */ }
},
incrementBackendApiCalls: (n = 1) => {
try {
const add = Number(n) || 0;
state.session.apiCallsBackend = (state.session.apiCallsBackend || 0) + add;
try { sessionStorage.setItem('tdm.api_calls_backend', String(state.session.apiCallsBackend)); } catch(_) {}
utils.incrementApiCalls(add);
} catch(_) { /* noop */ }
},
// Semantic version comparison: returns -1 if a<b, 1 if a>b, 0 equal.
// Accepts versions like "1.2.3", "1.2", "1.2.3-beta" (suffix ignored for ordering among base numbers).
compareVersions: (a, b) => {
try {
if (a === b) return 0;
const sanitize = (v) => String(v || '0')
.trim()
.replace(/[^0-9.]/g, '') // drop non-numeric qualifiers
.replace(/\.\.+/g, '.');
const pa = sanitize(a).split('.').map(x => parseInt(x, 10) || 0);
const pb = sanitize(b).split('.').map(x => parseInt(x, 10) || 0);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const na = pa[i] ?? 0;
const nb = pb[i] ?? 0;
if (na > nb) return 1;
if (na < nb) return -1;
}
return 0;
} catch(_) { return 0; }
},
createElement: (tag, attributes = {}, children = []) => {
const element = document.createElement(tag);
Object.entries(attributes).forEach(([key, value]) => {
if (key === 'style' && typeof value === 'object') Object.assign(element.style, value);
else if (key === 'className' || key === 'class') element.className = value;
else if (key === 'innerHTML') element.innerHTML = value;
else if (key === 'textContent') element.textContent = value;
else if (key === 'onclick') utils.addElementHandler(element, 'click', value);
else if (key.startsWith('on') && typeof value === 'function') utils.addElementHandler(element, key.substring(2).toLowerCase(), value);
else if (key === 'dataset' && typeof value === 'object') Object.entries(value).forEach(([dataKey, dataValue]) => element.dataset[dataKey] = dataValue);
else element.setAttribute(key, value);
});
children.forEach(child => {
if (typeof child === 'string') element.appendChild(document.createTextNode(child));
else if (child instanceof Node) element.appendChild(child);
});
return element;
},
buildProfileLink: (id, name, extra = {}) => {
try {
const a = document.createElement('a');
a.href = `/profiles.php?XID=${id}`;
const display = utils.sanitizePlayerName(name, id);
a.textContent = display || String(id);
const cls = ['t-blue'];
if (extra.className) cls.push(extra.className);
a.className = cls.join(' ');
if (extra.title) a.title = extra.title;
return a;
} catch(_) {
const fallback = document.createElement('span');
fallback.textContent = utils.sanitizePlayerName(name, id) || String(id);
return fallback;
}
},
// Normalize names read from the page or external sources.
// - trims whitespace
// - strips trailing separator hyphens like ' -' or '-'
// - treats purely-empty or dash-only names as missing and returns a sensible fallback
// If opponentId is provided we'll return `Opponent ID (<id>)` for missing names.
// TODO is this whats causing issue with BSP names in links?
sanitizePlayerName: (rawName, opponentId = null, { fallbackPrefix = 'Opponent ID' } = {}) => {
try {
let n = String(rawName ?? '').trim();
// If name is empty or a single separator, return fallback
if (!n || /^-+$/i.test(n)) {
return opponentId ? `${fallbackPrefix} (${opponentId})` : '';
}
// Remove a trailing hyphen separator(s) plus whitespace, e.g. 'Alice -' or 'Alice - '
n = n.replace(/[\s\-]+$/g, '').trim();
// If removing left us empty, fallback
if (!n) return opponentId ? `${fallbackPrefix} (${opponentId})` : '';
return n;
} catch (_) { return opponentId ? `${fallbackPrefix} (${opponentId})` : (rawName || ''); }
},
// Extract a cleaned player name from an anchor element while ignoring
// injected BSP stat nodes (e.g., TDup_ColoredStatsInjectionDiv / iconStats).
extractPlayerNameFromAnchor: (anchor) => {
try {
if (!anchor) return '';
// Clone so we can safely remove injected nodes without mutating page DOM
const clone = anchor.cloneNode(true);
// Remove known BSP-injected containers that prefix the name
clone.querySelectorAll('.TDup_ColoredStatsInjectionDiv, .iconStats, [class*="TDup_ColoredStatsInjectionDiv"], [class*="iconStats"]').forEach(n => n.remove());
// Also remove any small meta nodes often found before names (safeguard)
clone.querySelectorAll('[data-bsp], .bsp-stats, .tdm-injected-stat').forEach(n => n.remove());
return (clone.textContent || '').trim();
} catch (_) {
return (anchor.textContent || '').trim();
}
},
// Extract a cleaned player name from a row element (li / .table-row) by
// preferring profile anchors or image alt text and falling back to row text.
extractPlayerNameFromRow: (row) => {
try {
if (!row) return '';
if (row.dataset && row.dataset.tdmMemberName) return String(row.dataset.tdmMemberName).trim();
const link = row.querySelector('a[href*="profiles.php?XID="]');
if (link) return utils.extractPlayerNameFromAnchor(link);
const img = row.querySelector('img[alt]');
if (img && img.alt) return String(img.alt).trim();
return (row.textContent || '').trim();
} catch (_) { return (row && (row.textContent || '') || '').trim(); }
},
getWarById: (warId) => {
try {
const id = String(warId);
const arr = Array.isArray(state.rankWars) ? state.rankWars : (Array.isArray(state.rankWars?.rankedwars) ? state.rankWars.rankedwars : []);
return Array.isArray(arr) ? arr.find(w => String(w?.id) === id) : null;
} catch(_) { return null; }
},
isWarActive: (warId) => {
try {
const w = utils.getWarById(warId) || state.lastRankWar;
const now = Math.floor(Date.now() / 1000);
const start = Number(w?.start || w?.startTime || 0);
const end = Number(w?.end || w?.endTime || 0);
let active = false;
if (!w) {
tdmlogger('debug', '[isWarActive] No war found for warId', warId);
} else if (start && !end) {
active = now >= start;
} else if (start && end) {
active = now >= start && now <= end;
}
return active;
} catch(e) {
tdmlogger('warn', '[isWarActive] Exception', e);
return false;
}
},
// Active or within grace-hours after end
isWarInActiveOrGrace: (warId, graceHours = 6) => {
try {
const w = utils.getWarById(warId) || state.lastRankWar;
if (!w) return false;
const now = Math.floor(Date.now() / 1000);
const start = Number(w.start || w.startTime || 0);
const end = Number(w.end || w.endTime || 0);
if (!start) return false; // no start -> not active
if (now < start) return false; // scheduled in future
if (!end) return true; // active (no end yet)
return now <= end + (graceHours * 3600); // within grace window
} catch(_) { return false; }
},
// Returns status + text for diagnostics and fallback logic
httpGetDetailed: (url) => {
return new Promise((resolve) => {
try {
if (state?.gm?.rD_xmlhttpRequest) {
const ret = state.gm.rD_xmlhttpRequest({
method: 'GET', url,
onload: r => resolve({ status: Number(r.status || 0), text: typeof r.responseText === 'string' ? r.responseText : '' }),
onerror: (e) => resolve({ status: 0, text: '' })
});
if (ret && typeof ret.catch === 'function') ret.catch(() => {});
return;
}
} catch (_) { /* ignore and try fetch */ }
// Fallback to fetch
fetch(url).then(async (res) => {
const text = await res.text().catch(() => '');
resolve({ status: Number(res.status || 0), text });
}).catch(() => resolve({ status: 0, text: '' }));
});
},
perf: {
timers: {},
last: {},
thresholdMs: 1000,
start: function(name) {
this.timers[name] = performance.now();
},
stop: function(name) {
if (this.timers[name]) {
const end = performance.now();
const start = this.timers[name];
delete this.timers[name];
const elapsed = end - start;
this.last[name] = elapsed;
const threshold = typeof this.thresholdMs === 'number' ? this.thresholdMs : 200;
if (elapsed >= threshold) {
tdmlogger('info', '[TDM Perf]', `${name} took ${elapsed.toFixed(2)} ms`);
}
}
},
getLast: function(name) {
return this.last && typeof this.last[name] === 'number' ? this.last[name] : 0;
}
},
isCollectionChanged: (clientTimestamps, masterTimestamps, collectionKey) => {
// Fast skip if backend no longer exposes this collection
const masterTs = masterTimestamps?.[collectionKey];
if (!masterTs) return false;
const clientTs = clientTimestamps?.[collectionKey];
if (!clientTs) return true;
// Compare Firestore Timestamp objects
const masterMillis = masterTs._seconds ? masterTs._seconds * 1000 : (masterTs.toMillis ? masterTs.toMillis() : 0);
const clientMillis = clientTs._seconds ? clientTs._seconds * 1000 : (clientTs.toMillis ? clientTs.toMillis() : 0);
const isChanged = masterMillis > clientMillis;
if (isChanged) {
tdmlogger('info', '[Collection]', {changed: isChanged, master: masterMillis, client: clientMillis});
}
return isChanged;
},
getVisibleOpponentIds: () => {
const ids = new Set();
try {
// Ranked war tables
document.querySelectorAll('.tab-menu-cont .members-list > li a[href*="profiles.php?XID="], .tab-menu-cont .members-cont > li a[href*="profiles.php?XID="]').forEach(a => {
const m = a.href.match(/XID=(\d+)/);
if (m) ids.add(m[1]);
});
// Faction page list
document.querySelectorAll('.f-war-list .table-body a[href*="profiles.php?XID="]').forEach(a => {
const m = a.href.match(/XID=(\d+)/);
if (m) ids.add(m[1]);
});
// Attack page current opponent
const attackId = new URLSearchParams(window.location.search).get('user2ID');
if (attackId) ids.add(String(attackId));
} catch (_) { /* ignore */ }
return Array.from(ids);
},
getClientNoteTimestamps: () => {
const map = {};
try {
for (const [id, note] of Object.entries(state.userNotes || {})) {
const ts = note?.lastEdited?._seconds ? note.lastEdited._seconds * 1000 : (note?.lastEdited?.toMillis ? note.lastEdited.toMillis() : (note?.lastEdited ? new Date(note.lastEdited).getTime() : 0));
map[id] = ts || 0;
}
} catch (_) { /* ignore */ }
return map;
},
// canonicalizeStatus removed. Use buildUnifiedStatusV2 for canonical status records.
getDibsStyleOptions: () => {
const fs = (state.script && state.script.factionSettings) || {};
const dibs = (fs.options && fs.options.dibsStyle) || {};
const defaultStatuses = { Okay: true, Hospital: true, Travel: false, Abroad: false, Jail: false };
const defaultLastAction = { Online: true, Idle: true, Offline: true };
return {
keepTillInactive: dibs.keepTillInactive !== false,
mustRedibAfterSuccess: !!dibs.mustRedibAfterSuccess,
inactivityTimeoutSeconds: parseInt(dibs.inactivityTimeoutSeconds || 300),
// New: if > 0, only allow dibbing Hospital opponents when release time < N minutes
maxHospitalReleaseMinutes: Number.isFinite(Number(dibs.maxHospitalReleaseMinutes)) ? Number(dibs.maxHospitalReleaseMinutes) : 0,
// Opponent status allowance
allowStatuses: { ...defaultStatuses, ...(dibs.allowStatuses || {}) },
// Opponent activity allowance (last_action.status)
allowLastActionStatuses: { ...defaultLastAction, ...(dibs.allowLastActionStatuses || {}) },
// User status allowance
allowedUserStatuses: { ...defaultStatuses, ...(dibs.allowedUserStatuses || {}) },
// Opponent travel removal
removeOnFly: !!dibs.removeOnFly,
// User travel removal
removeWhenUserTravels: !!dibs.removeWhenUserTravels,
// Admin option: bypass dibs style enforcement (prevents automated cleanup/enforcement)
bypassDibStyle: !!dibs.bypassDibStyle,
};
},
/**
* Centralized dibs button state updater.
* Handles suppression, active dibs (yours vs others), policy gating, and async opponent status policy check (optional).
* @param {HTMLButtonElement} btn
* @param {string|number} opponentId
* @param {string} opponentName
* @param {Object} opts
* @param {boolean} [opts.opponentPolicyCheck=false] If true, performs async opponent status allowance check (attack page usage)
*/
updateDibsButton: (btn, opponentId, opponentName, opts = {}) => {
try {
btn.setAttribute('data-opponent-id', opponentId);
if (opponentName) btn.setAttribute('data-opponent-name', opponentName);
} catch(_) {}
const suppress = state.needsSuppression && state.needsSuppression[opponentId];
const activeDibs = Array.isArray(state.dibsData) ? state.dibsData.find(d => d && String(d.opponentId) === String(opponentId) && d.dibsActive) : null;
const canAdmin = state.script?.canAdministerMedDeals && storage.get('adminFunctionality', true) === true;
// Suppression overrides everything (still clickable to show warning)
if (suppress) {
const warn = `Don't dib - Suppress now!`;
const cls = 'btn dibs-btn btn-dibs-disabled';
if (btn.textContent !== warn) btn.textContent = warn;
if (btn.className !== cls) btn.className = cls;
if (btn.disabled !== false) btn.disabled = false;
btn.onclick = () => ui.showMessageBox(warn, 'warning', 4000);
return;
}
if (activeDibs) {
const mine = activeDibs.userId === state.user.tornId;
const txt = mine ? 'YOU Dibbed' : activeDibs.username;
const cls = 'btn dibs-btn ' + (mine ? 'btn-dibs-success-you' : 'btn-dibs-success-other');
if (btn.textContent !== txt) btn.textContent = txt;
if (btn.className !== cls) btn.className = cls;
const dis = !(mine || canAdmin);
if (btn.disabled !== dis) btn.disabled = dis;
// Use a safe resolver in case debounced handlers haven't been initialized yet
const removeHandler = (typeof handlers.debouncedRemoveDibsForTarget === 'function')
? handlers.debouncedRemoveDibsForTarget
: (typeof handlers.removeDibsForTarget === 'function')
? handlers.removeDibsForTarget
: null;
if (removeHandler) btn.onclick = (e) => removeHandler(opponentId, e.currentTarget);
else btn.onclick = (e) => { ui.showMessageBox('Handler unavailable. Please reload the page.', 'warning', 3000); };
return;
}
// Inactive baseline
let cls = 'btn dibs-btn btn-dibs-inactive';
let disabled = false;
const styleOpts = utils.getDibsStyleOptions();
const myCanon = utils.getMyCanonicalStatus();
const policyUserDisabled = styleOpts?.allowedUserStatuses && styleOpts.allowedUserStatuses[myCanon] === false;
if (policyUserDisabled) {
cls = 'btn dibs-btn btn-dibs-disabled';
disabled = false; // remain interactive for message
const msg = `Disabled by policy: Your status (${myCanon})`;
btn.title = msg;
btn.onclick = () => ui.showMessageBox(msg, 'warning', 4000);
} else {
// Resolve the effective dibs handler safely — debounced variants may be initialized later
const effectiveDibsHandler = (typeof handlers.debouncedDibsTarget === 'function')
? handlers.debouncedDibsTarget
: (typeof handlers.dibsTarget === 'function')
? handlers.dibsTarget
: null;
if (canAdmin) {
btn.onclick = (e) => ui.openDibsSetterModal(opponentId, opponentName, e.currentTarget);
} else if (effectiveDibsHandler) {
btn.onclick = (e) => effectiveDibsHandler(opponentId, opponentName, e.currentTarget);
} else {
btn.onclick = (e) => { ui.showMessageBox('Handler unavailable. Please reload the page.', 'warning', 3000); };
}
}
if (btn.textContent !== 'Dibs') btn.textContent = 'Dibs';
if (btn.className !== cls) btn.className = cls;
if (btn.disabled !== disabled) btn.disabled = disabled;
// Optional opponent policy gating (async) - only when initial baseline active (not suppressed/active dibs)
if (opts.opponentPolicyCheck) {
(async () => {
try {
const style = utils.getDibsStyleOptions();
// Fast path: if no allowStatuses overrides, skip
if (!style || !style.allowStatuses) return;
let canonOpp = null;
// Prefer cached faction data
const tf = state.tornFactionData || {};
const oppFactionId = state?.lastOpponentFactionId || state?.warData?.opponentId;
const cachedOpp = oppFactionId ? tf[oppFactionId]?.data : null;
const members = cachedOpp?.members ? (Array.isArray(cachedOpp.members) ? cachedOpp.members : Object.values(cachedOpp.members)) : null;
if (members) {
const m = members.find(m => String(m.id) === String(opponentId));
if (m?.status) canonOpp = utils.buildUnifiedStatusV2(m).canonical;
}
if (!canonOpp) {
const s = await utils.getUserStatus(opponentId);
canonOpp = s?.canonical;
}
if (canonOpp && style.allowStatuses[canonOpp] === false) {
const msg = `Disabled by policy: Opponent status (${canonOpp})`;
if (!btn.classList.contains('btn-dibs-disabled')) {
btn.className = 'btn dibs-btn btn-dibs-disabled';
btn.title = msg;
btn.onclick = () => ui.showMessageBox(msg, 'warning', 2000);
}
}
} catch(_) { /* silent */ }
})();
}
},
/**
* Centralized Med Deal button updater.
* Mirrors dibs helper style; handles inactive, mine, other states with admin gating.
* @param {HTMLButtonElement} btn
* @param {string|number} opponentId
* @param {string} opponentName
*/
updateMedDealButton: (btn, opponentId, opponentName) => {
const warType = state.warData?.warType;
// If the current warData explicitly disables med deals for this war, hide med deal button
if (state.warData?.disableMedDeals === true) {
btn.style.display = 'none';
return;
}
if (warType !== 'Termed War') {
btn.style.display = 'none';
return;
}
btn.style.display = 'inline-flex';
const medDealStatus = state.medDeals?.[opponentId];
const isActive = !!medDealStatus?.isMedDeal;
const mine = isActive && medDealStatus.medDealForUserId === state.user.tornId;
const canAdmin = state.script?.canAdministerMedDeals && storage.get('adminFunctionality', true) === true;
let html, cls, disabled;
if (mine) {
html = 'Remove Deal';
cls = 'btn btn-med-deal-default btn-med-deal-mine';
disabled = false;
btn.onclick = (e) => handlers.debouncedHandleMedDealToggle(opponentId, opponentName, false, state.user.tornId, state.user.tornUsername, e.currentTarget);
} else if (isActive) {
html = `${medDealStatus.medDealForUsername || 'Someone'}`;
cls = 'btn btn-med-deal-default btn-med-deal-set';
disabled = !canAdmin;
btn.onclick = (e) => handlers.debouncedHandleMedDealToggle(opponentId, opponentName, false, medDealStatus.medDealForUserId || opponentId, medDealStatus.medDealForUsername || opponentName, e.currentTarget);
} else {
html = 'Set Deal';
cls = 'btn btn-med-deal-default btn-med-deal-inactive';
disabled = false;
btn.onclick = canAdmin
? (e) => ui.openMedDealSetterModal(opponentId, opponentName, e.currentTarget)
: (e) => handlers.debouncedHandleMedDealToggle(opponentId, opponentName, true, state.user.tornId, state.user.tornUsername, e.currentTarget);
}
if (btn.innerHTML !== html) btn.innerHTML = html;
if (btn.className !== cls) btn.className = cls;
if (btn.disabled !== disabled) btn.disabled = disabled;
},
getUserStatus: async (userId /* string|number|null/undefined = self */) => {
// Cached Torn user status fetcher with small TTL
const id = userId ? String(userId) : String(state.user.tornId || 'self');
const now = Date.now();
const cache = state.session.userStatusCache || (state.session.userStatusCache = {});
const cached = cache[id];
if (cached && (now - cached.fetchedAtMs < 10000)) {
try { if (storage.get('debugDibsEnforce', false)) tdmlogger('debug', '[getUserStatus][cacheHit]', { id, cached }); } catch(_) {}
return cached; // 10s TTL
}
// Single-flight: avoid multiple concurrent Torn API calls for the same user
try {
const pmap = state.session._userStatusPromises || (state.session._userStatusPromises = {});
if (pmap[id]) {
try { if (storage.get('debugDibsEnforce', false)) tdmlogger('debug', '[getUserStatus][waitingOnInFlight]', { id }); } catch(_) {}
return await pmap[id];
}
// create promise placeholder so other callers reuse it
const promise = (async () => {
// Check persistent cache in IDB (legacy v1 gate coerced)
try {
const kv = ui && ui._kv;
if (kv && useV1) {
const key = `tdm.status.id_${id}`;
const raw = await kv.getItem(key);
if (raw) {
const obj = (typeof raw === 'string') ? JSON.parse(raw) : raw;
if (obj && typeof obj.fetchedAtMs === 'number' && (Date.now() - obj.fetchedAtMs) < 10000) {
cache[id] = obj;
try { if (storage.get('debugDibsEnforce', false)) tdmlogger('debug', '[getUserStatus][kvHit][v1]', { id, obj }); } catch(_) {}
return obj;
}
}
}
} catch(_) { /* ignore */ }
// Prefer cached faction members (own or opponent) if available
const tryMemberFromCache = () => {
try {
const tf = state.tornFactionData || {};
const own = tf[state?.user?.factionId]?.data;
const oppId = state?.lastOpponentFactionId || (state?.warData?.opponentId);
const opp = oppId ? tf[oppId]?.data : null;
const getArr = (data) => {
const members = data?.members || data?.member || data?.faction?.members || null;
if (!members) return null;
return Array.isArray(members) ? members : Object.values(members);
};
const inOwn = getArr(own)?.find(m => String(m.id) === String(id));
const inOpp = inOwn ? null : (getArr(opp)?.find(m => String(m.id) === String(id)) || null);
const m = inOwn || inOpp;
if (!m) return null;
const canon = utils.buildUnifiedStatusV2(m).canonical;
const until = Number(m?.status?.until || 0);
const activity = String(m?.last_action?.status || m?.lastAction?.status || '').trim();
const lastActionTs = Number(m?.last_action?.timestamp || m?.lastAction?.timestamp || 0) || undefined;
const factionId = (m.factionId || m.faction_id || m.faction?.faction_id || m.faction?.id || (inOwn ? state.user.factionId : (inOpp ? (state.lastOpponentFactionId || state.warData?.opponentFactionId) : null))) || null;
return { raw: m?.status || {}, canonical: canon, until, activity, lastActionTs, factionId: factionId ? String(factionId) : null, fetchedAtMs: Date.now() };
} catch(_) { return null; }
};
const fromCache = tryMemberFromCache();
if (fromCache) {
cache[id] = fromCache;
return fromCache;
}
// Fallback to Torn user API
try {
const user = await api.getTornUser(state.user.actualTornApiKey, userId ? id : null);
const canon = utils.buildUnifiedStatusV2(user).canonical;
const until = Number(user?.status?.until || 0);
const activity = String(user?.last_action?.status || '').trim();
const factionId = (user?.faction?.faction_id || user?.faction?.id || user?.faction_id || user?.factionId) || null;
const lastActionTs = Number(user?.last_action?.timestamp || 0) || undefined;
const packed = { raw: user?.status || {}, canonical: canon, until, activity, lastActionTs, factionId: factionId ? String(factionId) : null, fetchedAtMs: Date.now() };
cache[id] = packed;
try {
const kv = ui && ui._kv;
if (kv && storage.get('tdmActivityTrackingEnabled', false)) {
try { await kv.setItem(`tdm.status.v2.id_${id}`, packed); } catch(_) {}
}
} catch(_) {}
try { if (storage.get('debugDibsEnforce', false)) tdmlogger('debug', '[getUserStatus][api]', { id, packed }); } catch(_) {}
return packed;
} catch (e) {
let canon = 'Okay', until = 0;
if (!userId) {
const selfMember = (state.factionMembers || []).find(m => String(m.id) === String(state.user.tornId));
if (selfMember?.status) {
canon = utils.buildUnifiedStatusV2(selfMember).canonical;
}
}
const fallbackFactionId = state.user?.factionId || null;
const packed = { raw: {}, canonical: canon, until, activity: undefined, factionId: fallbackFactionId ? String(fallbackFactionId) : null, fetchedAtMs: Date.now() };
cache[id] = packed;
try { if (storage.get('debugDibsEnforce', false)) tdmlogger('debug', '[getUserStatus][fallback]', { id, packed, err: e && e.message }); } catch(_) {}
return packed;
}
})();
pmap[id] = promise;
try {
const result = await promise;
return result;
} finally {
try { delete pmap[id]; } catch(_) {}
}
} catch (err) {
try { if (storage.get('debugDibsEnforce', false)) tdmlogger('debug', '[getUserStatus][outerError]', { id, err: err && err.message }); } catch(_) {}
}
// Check persistent cache in IDB (legacy v1 gate coerced)
try {
const kv = ui && ui._kv;
if (kv && useV1) {
const key = `tdm.status.id_${id}`;
const raw = await kv.getItem(key);
if (raw) {
const obj = (typeof raw === 'string') ? JSON.parse(raw) : raw;
if (obj && typeof obj.fetchedAtMs === 'number' && (now - obj.fetchedAtMs) < 10000) {
cache[id] = obj;
try { if (storage.get('debugDibsEnforce', false)) tdmlogger('debug', '[getUserStatus][kvHit][v1]', { id, obj }); } catch(_) {}
return obj;
}
}
}
} catch(_) { /* ignore */ }
// Prefer cached faction members (own or opponent) if available
const tryMemberFromCache = () => {
try {
const tf = state.tornFactionData || {};
const own = tf[state?.user?.factionId]?.data;
const oppId = state?.lastOpponentFactionId || (state?.warData?.opponentId);
const opp = oppId ? tf[oppId]?.data : null;
const getArr = (data) => {
const members = data?.members || data?.member || data?.faction?.members || null;
if (!members) return null;
return Array.isArray(members) ? members : Object.values(members);
};
const inOwn = getArr(own)?.find(m => String(m.id) === String(id));
const inOpp = inOwn ? null : (getArr(opp)?.find(m => String(m.id) === String(id)) || null);
const m = inOwn || inOpp;
if (!m) return null;
const canon = utils.buildUnifiedStatusV2(m).canonical;
const until = Number(m?.status?.until || 0);
const activity = String(m?.last_action?.status || m?.lastAction?.status || '').trim();
const lastActionTs = Number(m?.last_action?.timestamp || m?.lastAction?.timestamp || 0) || undefined;
// Include factionId (supports both nested faction obj or direct factionId field patterns)
const factionId = (m.factionId || m.faction_id || m.faction?.faction_id || m.faction?.id || (inOwn ? state.user.factionId : (inOpp ? (state.lastOpponentFactionId || state.warData?.opponentFactionId) : null))) || null;
return { raw: m?.status || {}, canonical: canon, until, activity, lastActionTs, factionId: factionId ? String(factionId) : null, fetchedAtMs: now };
} catch(_) { return null; }
};
const fromCache = tryMemberFromCache();
if (fromCache) {
cache[id] = fromCache;
return fromCache;
}
// Fallback to Torn user API
try {
const user = await api.getTornUser(state.user.actualTornApiKey, userId ? id : null);
const canon = utils.buildUnifiedStatusV2(user).canonical;
const until = Number(user?.status?.until || 0);
const activity = String(user?.last_action?.status || '').trim(); // 'Online'|'Idle'|'Offline'
const factionId = (user?.faction?.faction_id || user?.faction?.id || user?.faction_id || user?.factionId) || null;
const lastActionTs = Number(user?.last_action?.timestamp || 0) || undefined;
const packed = { raw: user?.status || {}, canonical: canon, until, activity, lastActionTs, factionId: factionId ? String(factionId) : null, fetchedAtMs: now };
cache[id] = packed;
// Optional: persist latest API-derived status to KV for cross-tab/window durability
try {
const kv = ui && ui._kv;
// Persist v2 status to KV only when activity tracking is enabled (single authoritative toggle)
if (kv && storage.get('tdmActivityTrackingEnabled', false)) {
try { await kv.setItem(`tdm.status.v2.id_${id}`, packed); } catch(_) {}
}
} catch(_) {}
try { if (storage.get('debugDibsEnforce', false)) tdmlogger('debug', '[getUserStatus][api]', { id, packed }); } catch(_) {}
return packed;
} catch (e) {
// Fallback to last known factionMembers/self data if any
let canon = 'Okay', until = 0;
if (!userId) {
const selfMember = (state.factionMembers || []).find(m => String(m.id) === String(state.user.tornId));
if (selfMember?.status) {
canon = utils.buildUnifiedStatusV2(selfMember).canonical;
}
}
const fallbackFactionId = state.user?.factionId || null;
const packed = { raw: {}, canonical: canon, until, activity: undefined, factionId: fallbackFactionId ? String(fallbackFactionId) : null, fetchedAtMs: now };
cache[id] = packed;
// Removed legacy v1 status persistence logic.
try { if (storage.get('debugDibsEnforce', false)) tdmlogger('debug', '[getUserStatus][fallback]', { id, packed, err: e && e.message }); } catch(_) {}
return packed;
}
},
// Fast, synchronous canonical status for the current user (no async calls)
// Order of preference:
// 1) session userStatusCache (freshest cached canonical)
// 2) factionMembers cache entry for self
// 3) fallback 'Okay'
getMyCanonicalStatus: () => {
try {
const selfId = String(state?.user?.tornId || '');
if (selfId) {
const now = Date.now();
// Prefer fresh cached status if available
const cache = state.session?.userStatusCache || null;
const cached = cache && cache[selfId];
const isFresh = cached && (now - (cached.fetchedAtMs || 0) < 10000);
if (isFresh && typeof cached.canonical === 'string' && cached.canonical) {
return cached.canonical;
}
// If cache is stale or missing, trigger a background refresh
(async () => { try { await utils.getUserStatus(selfId); } catch(_) {} })();
// Try factionMembers (own member row) as immediate fallback
try {
const selfMember = (state.factionMembers || []).find(m => String(m.id) === selfId);
if (selfMember?.status) {
return utils.buildUnifiedStatusV2(selfMember).canonical;
}
} catch(_) { /* ignore */ }
// Return stale cache if available
if (cached && typeof cached.canonical === 'string' && cached.canonical) {
return cached.canonical;
}
}
} catch(_) { /* ignore */ }
return 'Okay';
},
getActivityStatusFromRow: (row) => {
try {
if (!row) return null;
// 1) Text markers within typical activity/status containers
const txt = (row.querySelector('.userStatusWrap, .last_action, .lastAction, .activity, .status')?.textContent || '').toLowerCase();
if (txt.includes('online')) return 'Online';
if (txt.includes('idle')) return 'Idle';
if (txt.includes('offline')) return 'Offline';
// 2) Classname hints on the row or descendants
const cls = (row.className || '').toString();
if (/svg_status_online|status[_-]?online|\bonline\b/i.test(cls)) return 'Online';
if (/svg_status_idle|status[_-]?idle|\bidle\b/i.test(cls)) return 'Idle';
if (/svg_status_offline|status[_-]?offline|\boffline\b/i.test(cls)) return 'Offline';
// 3) Check common <use> hrefs that point to status icons
const use = row.querySelector('use[href*="svg_status_"], use[xlink\\:href*="svg_status_"]');
if (use) {
const href = use.getAttribute('href') || use.getAttribute('xlink:href') || '';
if (/svg_status_online/i.test(href)) return 'Online';
if (/svg_status_idle/i.test(href)) return 'Idle';
if (/svg_status_offline/i.test(href)) return 'Offline';
}
// 4) Last resort: inspect outerHTML of first svg
const anySvg = row.querySelector('svg');
if (anySvg) {
const html = anySvg.outerHTML || '';
if (html.includes('svg_status_online')) return 'Online';
if (html.includes('svg_status_idle')) return 'Idle';
if (html.includes('svg_status_offline')) return 'Offline';
}
// 5) Computed style/class hints on a limited set of nodes
const styleNodes = row.querySelectorAll('svg, svg *, use, [class*="status"], [class*="Status"]');
const maxCheck = Math.min(styleNodes.length, 12);
for (let i = 0; i < maxCheck; i++) {
const n = styleNodes[i];
try {
const cs = window.getComputedStyle(n);
const f = (cs && (cs.fill || cs.getPropertyValue('fill'))) || '';
const s = (n.getAttribute && (n.getAttribute('href') || n.getAttribute('xlink:href') || n.getAttribute('class') || '')) || '';
const hay = (f + ' ' + s).toLowerCase();
if (hay.includes('svg_status_online')) return 'Online';
if (hay.includes('svg_status_idle')) return 'Idle';
if (hay.includes('svg_status_offline')) return 'Offline';
} catch(_) { /* noop */ }
}
} catch(_) { /* noop */ }
return null;
},
getStatusTextFromRow: (row) => {
// Status column may be manipulated; prefer preserved original text if present
// Try common selectors, then broader matches, but avoid the activity wrapper
let statusCell = row.querySelector('.status, .status___XXAGt, .statusCol');
if (!statusCell) {
statusCell = row.querySelector('[class*="customStatus"], [class*="statusCol"]');
}
// Avoid picking the activity wrapper
if (statusCell && /userStatusWrap/i.test(statusCell.className)) {
statusCell = null;
}
if (!statusCell) return '';
const orig = statusCell.querySelector('.ffscouter-original');
if (orig && orig.textContent) return orig.textContent.trim();
return (statusCell.textContent || '').trim();
},
// Unified travel equivalence map (single source of truth)
getTravelEquivalence: () => {
try { if (utils._travelEquiv) return utils._travelEquiv; } catch(_) {}
const items = Object.entries(utils._travelMap).map(([name, meta]) => {
const aliasStrings = (meta.aliases||[]).map(a=> (typeof a === 'string') ? a.replace(/\^|\$/g,'') : null).filter(Boolean);
if (meta.adjective) aliasStrings.push(meta.adjective);
if (meta.abbr) aliasStrings.push(meta.abbr);
return {
name,
light_aircraft: meta.planes?.light_aircraft || meta.minutes,
airliner: meta.planes?.airliner || meta.minutes,
airliner_business: meta.planes?.airliner_business || meta.minutes,
private_jet: meta.planes?.private_jet || meta.minutes,
displayAbbr: meta.abbr || (name.match(/\b([A-Z])[A-Za-z]+/g)? name.split(/\s+/).map(w=>w[0]).join('').slice(0,3) : name.slice(0,3)),
adjective: meta.adjective || null,
abbr: meta.abbr || null,
aliases: aliasStrings
};
});
const byName = {}; const namesLower = []; const aliasToNameLower = {}; const aliasToNameUpper = {}; const abbrsLowerSet = new Set();
for (const it of items) {
byName[it.name] = it; namesLower.push(it.name.toLowerCase());
if (it.abbr) abbrsLowerSet.add(it.abbr.toLowerCase());
for (const al of (it.aliases||[])) {
const low = al.toLowerCase();
aliasToNameLower[low] = it.name;
aliasToNameUpper[al] = it.name;
}
}
const equiv = { list: items, byName, namesLower, abbrsLower: Array.from(abbrsLowerSet), aliasToNameLower, aliasToNameUpper };
try { utils._travelEquiv = equiv; } catch(_) {}
return equiv;
},
// Detect hospital-abroad ONLY for strict phrasing:
// "In a <adjective> hospital for <duration>" or "In an <adjective> hospital for <duration>"
// Where <adjective> must exactly match a known travel destination adjective (e.g. Swiss, Caymanian, Japanese).
// This avoids misclassifying flavor text like "Shot in the back by the club boss".
parseHospitalAbroadDestination: (statusStr) => {
try {
if (!statusStr) return null;
const txt = String(statusStr || '').trim();
// Fast reject: must begin with In a / In an (case-insensitive)
if (!/^In\s+an?\s+/i.test(txt)) return null;
// Strict pattern: start -> In a(n) <word> hospital for <number> <timeunit>
// Capture adjective between the article and the word 'hospital'
const m = txt.match(/^In\s+an?\s+([A-Za-z][A-Za-z\-']{1,40})\s+hospital\s+for\s+\d+\s+(?:second|seconds|minute|minutes|hour|hours)\b/i);
if (!m) return null;
const adjective = m[1].toLowerCase();
if (!adjective) return null;
const eq = utils.getTravelEquivalence();
for (const it of eq.list) {
if (!it || !it.adjective) continue;
if (it.adjective.toLowerCase() === adjective) return it.name;
}
return null; // adjective not recognized as a mapped travel destination
} catch (_) { return null; }
},
// Resolve minutes with plane type if available; defaults to light_aircraft for compatibility
getTravelMinutes: (dest, planeType = 'light_aircraft') => {
const eq = utils.getTravelEquivalence();
const it = eq.byName[String(dest || '')];
if (!it) return 0;
// Map unknowns to light_aircraft
const key = (function(pt) {
if (!pt) return 'light_aircraft';
const k = String(pt).toLowerCase();
if (k === 'airliner' || k === 'airliner_business' || k === 'private_jet' || k === 'light_aircraft') return k;
return 'light_aircraft';
})(planeType);
return Number(it[key] || 0) || 0;
},
// Try to resolve a player's base plane type from cached status (does not apply business-class on its own)
getKnownPlaneTypeForId: (id) => {
try {
const sid = String(id || '');
if (!sid) return null;
const cached = state.session?.userStatusCache?.[sid];
const pt = cached?.raw?.plane_image_type || null; // 'airliner' | 'light_aircraft' | 'private_jet'
return pt; // business-class application is contextual (outbound only) and handled elsewhere
} catch(_) { return null; }
},
// Business-class detection & caching (best-effort):
// - isEligibleSync: fast in-memory check only (no IO)
// - ensureAsync: try KV, then Torn API (user job) once per cooldown
business: {
isEligibleSync(id) {
try {
const sid = String(id||'');
if (!sid) return false;
return !!(state.session && state.session.businessClassById && state.session.businessClassById[sid]);
} catch(_) { return false; }
},
async ensureAsync(id) {
try {
const sid = String(id||'');
if (!sid) return false;
state.session = state.session || {};
state.session.businessClassById = state.session.businessClassById || {};
state._businessCheckTs = state._businessCheckTs || {};
const now = Date.now();
const last = state._businessCheckTs[sid] || 0;
// Cooldown 60 minutes between network checks per user
if (now - last < 60*60*1000 && (sid in state.session.businessClassById)) return state.session.businessClassById[sid];
state._businessCheckTs[sid] = now;
// 1) Try KV cache
const kv = ui && ui._kv;
let cached = null;
try { cached = await kv?.getItem(`tdm.business.id_${sid}`); } catch(_) { cached = null; }
if (cached && typeof cached === 'object') {
const b = !!cached.b;
state.session.businessClassById[sid] = b;
return b;
}
tdmlogger('debug', '[BusinessDetect] KV cache miss, making API call for user id:', sid);
// 2) Try Torn API: fetch user job info (best-effort)
const key = state?.user?.actualTornApiKey;
if (!key || !api?.getTornUser) return false;
let data = null;
try { data = await api.getTornUser(key, sid, 'job'); } catch(_) { data = null; }
let eligible = false;
try {
const job = data?.job;
if (job) {
if (job?.type_id === 32 && job?.rating === 10) {
tdmlogger('info', '[BusinessDetect] User has Business Class job:', { id: sid, job });
eligible = true;
} else {
tdmlogger('info', '[BusinessDetect] User job does not qualify for Business Class:', { id: sid, job });
eligible = false;
}
}
} catch(_) { eligible = false; }
try { await kv?.setItem(`tdm.business.id_${sid}`, { b: eligible, ts: now }); } catch(_) {}
state.session.businessClassById[sid] = eligible;
if (storage.get('debugBusinessDetect', false)) {
tdmlogger('info', '[BusinessDetect]', { id: sid, eligible: eligible });
}
return eligible;
} catch(_) { return false; }
}
},
// Unified travel helpers module
travel: {
_unknownLogged: new Set(),
detectType(stateStr, descStr) {
try {
const state = String(stateStr || '');
const desc = String(descStr || '');
const canon = utils.buildUnifiedStatusV2({ state, description: desc }).canonical;
// Returning first
if (/^returning to torn from\s+/i.test(desc)) {
// DEPRECATED legacy returning parser path (superseded by buildUnifiedStatusV2)
return { type: 'returning', dest: utils.parseUnifiedDestination(desc) || null };
}
// Hospital abroad
if (/hospital/i.test(canon || state) && /hospital/i.test(desc)) {
const d = utils.parseHospitalAbroadDestination(desc) || null;
if (d) return { type: 'hosp_abroad', dest: d };
}
// Abroad
if (/^in\s+/i.test(desc) || /abroad/i.test(canon || '')) {
// DEPRECATED legacy abroad parser path
return { type: 'abroad', dest: utils.parseUnifiedDestination(desc) || null };
}
// Traveling
if (/travel/i.test(canon || state) || /^travell?ing to\s+/i.test(desc)) {
// DEPRECATED legacy traveling parser path (spelling unified to 'traveling')
return { type: 'traveling', dest: utils.parseUnifiedDestination(desc) || null };
}
} catch(_) {}
return { type: null, dest: null };
},
getMinutes(dest) { return utils.getTravelMinutes(dest); },
logUnknownDestination(rawDesc) {
try {
const key = String(rawDesc || '').trim().toLowerCase();
if (!key) return;
if (this._unknownLogged.has(key)) return;
this._unknownLogged.add(key);
tdmlogger('warn', '[Travel] Unknown destination in status:', rawDesc);
} catch(_) { /* noop */ }
},
computeEtaMs(leftMs, minutes) {
const mins = Number(minutes) || 0;
if (!Number.isFinite(leftMs) || !Number.isFinite(mins) || mins <= 0) return 0;
return leftMs + mins * 60000;
},
formatTravelLine(leftMs, minutes, statusDescription) {
try {
// [left time] ETA [localtime] -status.description
const leftLocal = new Date(leftMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const etaMs = this.computeEtaMs(leftMs, minutes);
const etaLocal = etaMs ? new Date(etaMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
const desc = String(statusDescription || '').trim();
const line = etaLocal ? `[${leftLocal}] ETA ${etaLocal} -${desc}` : (desc || '');
try { tdmlogger('info', '[Travel][formatTravelLine]', { leftMs, minutes, statusDescription: desc, etaMs, line }); } catch(_) { /* noop */ }
return line;
} catch(_) { return String(statusDescription || '').trim(); }
}
},
// Destination and status/activity abbreviations for compact UI labels
abbrevDest: (dest) => {
const eq = utils.getTravelEquivalence();
const key = String(dest || '');
let meta = eq.byName[key];
if (!meta) {
// If caller passed an alias or an abbreviation (e.g. 'UAE' or 'mex'),
// resolve it to the canonical name and return that canonical abbr.
try {
const mapped = eq.aliasToNameLower && eq.aliasToNameLower[key.toLowerCase()];
if (mapped) meta = eq.byName[mapped];
} catch(_) { /* ignore */ }
}
if (!meta) return '';
return meta.abbr || meta.displayAbbr || '';
},
abbrevStatus: (canon) => {
const c = String(canon || '').toLowerCase();
if (c === 'okay') return 'Ok';
if (c === 'hospitalabroad') return 'Hosp*';
if (c === 'hospital') return 'Hosp';
if (c === 'travel') return 'Trav';
if (c === 'abroad') return 'Abrd';
if (c === 'jail') return 'Jail';
// Fallback: title-case then trim to 6 chars max to avoid overflow
const t = (canon || '').trim();
return t.length > 6 ? t.slice(0, 6) : t;
},
abbrevActivity: (activity) => {
const a = String(activity || '').toLowerCase();
if (a === 'online') return 'On';
if (a === 'offline') return 'Off';
if (a === 'idle') return 'Idle';
const t = (activity || '').trim();
return t.length > 6 ? t.slice(0, 6) : t;
},
// Extract the per-row points for an opponent in the ranked war table
// Stores decimals precisely (if present) but callers can truncate for display.
// Heuristics:
// - Prefer specific points class (.points___* or .points)
// - Support decimal numbers (e.g. 12.5)
// - If multiple numbers are present and a '/' exists (e.g. "12 / 34"), take the first.
// Otherwise take the last numeric token (common for labels like "Pts: 12.5 (curr)").
getPointsFromRow: (row) => {
try {
if (!row) return null;
let el = row.querySelector('.points___TQbnu, .points');
if (!el) return null;
const raw = (el.textContent || '').trim();
if (!raw) return null;
const matches = raw.match(/-?\d+(?:\.\d+)?/g);
if (!matches || !matches.length) return null;
let chosen;
if (matches.length === 1) {
chosen = matches[0];
} else {
chosen = raw.includes('/') ? matches[0] : matches[matches.length - 1];
}
const val = parseFloat(chosen);
if (!Number.isFinite(val)) return null;
if (val > 1e7) return null; // sanity guard against concatenation artifacts
if (state?.debug?.rowLogs) {
// PointsParse verbose logging gated separately to avoid flooding row logs.
try { tdmlogger('debug', '[PointsParse]', { raw, matches, chosen, val }); } catch(_) { /* noop */ }
}
return val;
} catch(_) { return null; }
},
// --- Ranked war scoreboard helpers ---
// Build a stable key for the currently shown war based on the two faction names in the rank box
// This avoids relying on URL (Torn does not expose the war id in hash) and prevents cross-war state bleed
getCurrentWarPageKey: () => {
try {
const rankBox = state.dom.rankBox || document.querySelector('.rankBox___OzP3D');
if (!rankBox) return null;
const oppName = rankBox.querySelector('.nameWp___EX6gT .opponentFactionName___vhESM')?.textContent?.trim() || '';
const curName = rankBox.querySelector('.nameWp___EX6gT .currentFactionName___eq7n8')?.textContent?.trim() || '';
if (!oppName && !curName) return null;
const norm = (s) => s.toLowerCase().replace(/\s+/g,' ').trim();
// Sort for stability regardless of left/right placement
const a = norm(oppName), b = norm(curName);
const [k1, k2] = a < b ? [a,b] : [b,a];
return `${k1}__vs__${k2}`;
} catch(_) { return null; }
},
// Extract faction id from a factions.php profile href
parseFactionIdFromHref: (href) => {
try {
if (!href) return null;
const m = String(href).match(/[?&]ID=(\d+)/i);
return m ? m[1] : null;
} catch(_) { return null; }
},
// Parse a string or array into a sanitized, de-duplicated list of faction ids (strings).
parseFactionIdList: (raw, meta) => {
const result = new Set();
const recordInvalid = (token) => {
if (!meta) return;
meta.invalidTokens = meta.invalidTokens || [];
meta.invalidTokens.push(String(token || '').trim());
};
const recordValid = () => {
if (!meta) return;
meta.validTokensSeen = (meta.validTokensSeen || 0) + 1;
};
const add = (value) => {
const v = String(value ?? '').trim();
if (!v) return;
if (!/^[0-9]+$/.test(v)) {
recordInvalid(value);
return;
}
recordValid();
result.add(v);
};
if (Array.isArray(raw)) {
raw.forEach(add);
} else if (typeof raw === 'string') {
raw.split(/[\s,;]+/).forEach(add);
}
if (meta) {
meta.hadInvalid = Array.isArray(meta.invalidTokens) && meta.invalidTokens.length > 0;
meta.duplicateCount = (meta.validTokensSeen || 0) - result.size;
if (meta.duplicateCount < 0) meta.duplicateCount = 0;
}
return Array.from(result);
},
// Read both visible faction IDs from the ranked war rank box (left/right)
getVisibleRankedWarFactionIds: () => {
try {
const box = state.dom.rankBox || document.querySelector('.rankBox___OzP3D');
if (!box) return { leftId: null, rightId: null, ids: [] };
const leftNode = box.querySelector('.nameWp___EX6gT .opponentFactionName___vhESM');
const rightNode = box.querySelector('.nameWp___EX6gT .currentFactionName___eq7n8');
const leftA = (leftNode && (leftNode.closest('a') || leftNode.querySelector('a'))) || leftNode;
const rightA = (rightNode && (rightNode.closest('a') || rightNode.querySelector('a'))) || rightNode;
const leftId = utils.parseFactionIdFromHref(leftA && leftA.href);
const rightId = utils.parseFactionIdFromHref(rightA && rightA.href);
const ids = [leftId, rightId].filter(Boolean);
return { leftId: leftId || null, rightId: rightId || null, ids: Array.from(new Set(ids)) };
} catch(_) {
return { leftId: null, rightId: null, ids: [] };
}
},
pageWarIncludesOurFaction: () => {
try {
const key = utils.getCurrentWarPageKey?.();
if (!key) return false;
const ourName = (state.factionPull?.name || '').toLowerCase().trim();
return ourName && key.includes(ourName.toLowerCase());
} catch(_) { return false; }
},
readRankBoxScores: () => {
try {
const root = state.dom.rankBox || document.querySelector('.rankBox___OzP3D');
let opp = 0, our = 0;
if (root) {
const left = root.querySelector('.statsBox___zH9Ai .scoreBlock___Pr3xV .left.scoreText___uVRQm');
const right = root.querySelector('.statsBox___zH9Ai .scoreBlock___Pr3xV .right.scoreText___uVRQm');
const oppNode = root.querySelector('.statsBox___zH9Ai .scoreBlock___Pr3xV .scoreText___uVRQm.opponentFaction___HmQpL') || left;
const ourNode = root.querySelector('.statsBox___zH9Ai .scoreBlock___Pr3xV .scoreText___uVRQm.currentFaction___Omz6o') || right;
opp = parseInt((oppNode?.textContent || '0').replace(/[\s,]/g,''), 10) || 0;
our = parseInt((ourNode?.textContent || '0').replace(/[\s,]/g,''), 10) || 0;
}
// Additional fallback: read first row points from left/right tables
if (!opp || !our) {
try {
const leftTable = (state.dom.rankwarContainer || document).querySelector('.tab-menu-cont.left .members-list, .tab-menu-cont.left .members-cont');
const rightTable = (state.dom.rankwarContainer || document).querySelector('.tab-menu-cont.right .members-list, .tab-menu-cont.right .members-cont');
const leftFirst = leftTable?.querySelector(':scope > li .points___TQbnu, :scope > .table-body > li .points___TQbnu');
const rightFirst = rightTable?.querySelector(':scope > li .points___TQbnu, :scope > .table-body > li .points___TQbnu');
const lVal = parseInt((leftFirst?.textContent || '').replace(/[\s,]/g,''), 10);
const rVal = parseInt((rightFirst?.textContent || '').replace(/[\s,]/g,''), 10);
// Heuristic: the larger side likely equals the current faction or opponent depending on labels; we keep them as opp/our only if both parsed
if (Number.isFinite(lVal) && Number.isFinite(rVal)) {
// Map to opp/our by role if rankBox labels exist; else leave as-is (will still detect deltas)
if (!opp) opp = lVal;
if (!our) our = rVal;
}
} catch(_) { /* ignore */ }
}
return { opp, our };
} catch(_) { return null; }
}
};
//======================================================================
// 3. STATE MANAGEMENT
//======================================================================
// Initialize state keys from localStorage or use default values
const bootstrapNowMs = Date.now();
const cachedUserPosition = storage.get('LastUserPosition', null);
const cachedAdminFlagRaw = storage.get('CanAdministerMedDeals', false);
const cachedAdminFlag = cachedAdminFlagRaw === true;
const cachedAdminTsRaw = Number(storage.get('CanAdministerMedDealsTs', 0)) || 0;
const cachedAdminFresh = cachedAdminFlag && cachedAdminTsRaw > 0 && (bootstrapNowMs - cachedAdminTsRaw) < ADMIN_ROLE_CACHE_TTL_MS;
if (cachedAdminFlag && !cachedAdminFresh && cachedAdminTsRaw > 0) {
try { storage.set('CanAdministerMedDeals', false); storage.remove('CanAdministerMedDealsTs'); } catch (_) { /* cleanup best-effort */ }
}
const state = {
featureFlags: featureFlagController.flags,
featureFlagController,
dibsData: storage.get('dibsData', []),
userScore: storage.get('userScore', null),
warData: storage.get('warData', { warType: 'War Type Not Set' }),
rankWars: storage.get('rankWars', []),
lastRankWar: storage.get('lastRankWar', { id: 1, start: 0, end: 0, target: 42069, winner: null, factions: [{ id: 41419, name: "Neon Cartel", score: 7620, chain: 666 }] }),
lastOpponentFactionId: storage.get('lastOpponentFactionId', 0),
lastOpponentFactionName: storage.get('lastOpponentFactionName', 'Not Pulled'),
medDeals: storage.get('medDeals', {}),
userNotes: storage.get('userNotes', {}),
factionMembers: storage.get('factionMembers', []),
factionPull: storage.get('factionPull', {id: 0, name: 'nofactionpulled', members: 0 }),
dibsNotifications: storage.get('dibsNotifications', []),
unauthorizedAttacks: storage.get('unauthorizedAttacks', []),
retaliationOpportunities: storage.get('retaliationOpportunities', {}),
unifiedStatus: {},
// Snapshot and change tracking for ranked war table alerts
rankedWarTableSnapshot: {},
rankedWarChangeMeta: {}, // { [opponentId]: { statusChangedAtMs?: number, lastStatus?: string, lastActivity?: string } }
// Global ranked war score tracking (opponent vs current faction)
rankedWarScoreSnapshot: { opp: 0, our: 0 },
// Opponents that currently need immediate suppression (retal, score spike, or online)
needsSuppression: {}, // { [opponentId]: { reason: 'retal'|'score'|'online', ts: number } }
_autoUndibLastTs: {},
dataTimestamps: storage.get('dataTimestamps', {}), // --- Timestamp-based polling ---
ffscouterCache: storage.get('ffscouterCache', {}),
user: (() => {
const defaults = {
tornId: null,
tornUsername: '',
tornUserObject: null,
actualTornApiKey: null,
actualTornApiKeyAccess: 0,
hasReachedScoreCap: false,
factionId: null,
apiKeySource: 'none',
keyValidation: null,
keyInfoCache: null,
keyValidatedAt: null,
apiKeyUiMessage: null,
factionAPIAccess: false
};
const stored = storage.get('user', defaults);
if (!stored || typeof stored !== 'object') return { ...defaults };
return { ...defaults, ...stored };
})(),
auth: storage.get('firebaseAuthSession', null),
page: { url: new URL(window.location.href), isFactionProfilePage: false, isMyFactionPrivatePage: false, isMyFactionProfilePage: false, isMyFactionYourInfoTab: false, isRankedWarPage: false, isFactionPage: false, isMyFactionPage: false, isAttackPage: false },
dom: { factionListContainer: null, customControlsContainer: null, rankwarContainer: null, rankwarmembersWrap: null, rankwarfactionTables: null, rankBox: null },
script: { currentUserPosition: cachedUserPosition, canAdministerMedDeals: cachedAdminFresh, lastActivityTime: Date.now(), isWindowActive: true, currentRefreshInterval: config.REFRESH_INTERVAL_ACTIVE_MS, mainRefreshIntervalId: null, activityTimeoutId: null, mutationObserver: null, hasProcessedRankedWarTables: false, hasProcessedFactionList: false, factionBundleRefreshIntervalId: null, factionBundleRefreshMs: null, lightPingIntervalId: null, fetchWatchdogIntervalId: null, idleTrackingOverride: false, apiTransport: 'unknown', useHttpEndpoints: false },
ui: { retalNotificationActive: false, retalNotificationElement: null, retalTimerIntervals: [], noteModal: null, noteTextarea: null, currentNoteTornID: null, currentNoteTornUsername: null, currentNoteButtonElement: null, setterModal: null, setterList: null, setterSearchInput: null, currentOpponentId: null, currentOpponentName: null, currentButtonElement: null, currentSetterType: null, unauthorizedAttacksModal: null, currentWarAttacksModal: null, chainTimerEl: null, chainTimerValueEl: null, chainTimerIntervalId: null, chainFallback: { lastFetch: 0, timeoutEpoch: 0 }, inactivityTimerEl: null, inactivityTimerValueEl: null, inactivityTimerIntervalId: null, opponentStatusEl: null, opponentStatusValueEl: null, opponentStatusIntervalId: null, opponentStatusCache: { lastFetch: 0, untilEpoch: 0, text: '', opponentId: null }, apiUsageEl: null, apiUsageValueEl: null, apiUsageDetailEl: null, attackModeEl: null, attackModeValueEl: null, activityTickerIntervalId: null, badgeDockEl: null, badgeDockToggleEl: null, badgeDockItemsEl: null, badgeDockActionsEl: null, badgeDockCollapsedKey: 'tdmBadgeDockCollapsed_v1', debugOverlayMinimizedKey: 'liveTrackDebugOverlayMinimized', levelDisplayModeKey: 'ui.levelDisplayMode', levelDisplayMode: null, levelCellLongPressMs: LEVEL_CELL_LONGPRESS_MS, debugOverlayToggleEl: null, debugOverlayMinimizeEl: null, debugOverlayMinimized: storage.get('liveTrackDebugOverlayMinimized', false), userScoreBadgeEl: null, factionScoreBadgeEl: null, dibsDealsBadgeEl: null , chainWatcherIntervalId: null },
gm: { rD_xmlhttpRequest: null, rD_registerMenuCommand: null },
session: { apiCalls: 0, apiCallsClient: 0, apiCallsBackend: 0, userStatusCache: {}, lastEnforcementMs: 0, nonActiveWarFetchedOnce: {}, factionApi: { hasFactionAccess: null, allowAttacksSelection: null }, selectionsPerFaction: {} },
// Debug/logging & observer throttling
debug: {
rowLogs: storage.get('debugRowLogs', false),
statusWatch: storage.get('debugStatusWatch', false),
pointsParseLogs: storage.get('debugPointsParseLogs', false),
adoptionInfo: storage.get('debugAdoptionInfo', false),
statusCanon: storage.get('debugStatusCanon', false),
// New: cadence/api logs to help diagnose focus/visibility gated refresh behavior
cadence: storage.get('debugCadence', false),
apiLogs: storage.get('debugApiLogs', false),
// New: IndexedDB perf logs toggle (can also be overridden by elapsed threshold)
idbLogs: storage.get('debugIdbLogs', false)
},
// Client setting: if true, while the user has any active dibs, we always send a fresh heartbeat timestamp
passiveActivityHeartbeatEnabled: storage.get('passiveActivityHeartbeatEnabled', true),
_statusWatchLastLogs: {},
// Cached ranked war summaries keyed by warId: { [warId]: { fingerprint, updatedAt, summary, source?, etag?, lastModified?, summaryUrl? } }
rankedWarSummaryCache: storage.get('rankedWarSummaryCache', {}),
// Cached ranked war attacks keyed by warId: { [warId]: { manifestUrl, attacksUrl, lastSeq, etags: { manifest?: string, [seq]: string }, lastModified?: string, attacks: Array } }
// NOTE: attack arrays are kept in-memory only to avoid exceeding localStorage quotas.
// persistedRankedWarAttacksCache contains a minimized form saved into storage (no heavy 'attacks' arrays).
// NOTE: we must not reference `state` here because `state` is being created
// in this object literal — referencing it would trigger a TDZ ReferenceError.
// Use the persisted form from storage for initial load; in-memory caches
// will be used/updated at runtime.
rankedWarAttacksCache: storage.get('rankedWarAttacksCache', {}),
// Last summary source used when presenting the Ranked War Summary modal: 'local' | 'storage200' | 'storage304' | 'server' | null
rankedWarLastSummarySource: storage.get('rankedWarLastSummarySource', null),
// Lightweight meta about the last summary, for debugging/verification (etag, lastModified, counts)
rankedWarLastSummaryMeta: storage.get('rankedWarLastSummaryMeta', {}),
// Attacks provenance tracking for the war summary modal badge/logs
rankedWarLastAttacksSource: storage.get('rankedWarLastAttacksSource', null),
rankedWarLastAttacksMeta: storage.get('rankedWarLastAttacksMeta', {}),
// Cached Torn faction bundles by factionId
tornFactionData: storage.get('tornFactionData', {}), // { [factionId]: { data, fetchedAtMs, selections: string[] } }
// Per-war single-flight guard for ranked war fetches
_warFetchInFlight: {},
// Persisted content fingerprints (loaded once; kept minimal)
_fingerprints: storage.get('fingerprints', { dibs: null, medDeals: null })
,
// Central resource registry for timers/observers/listeners added at runtime.
// Stored here to keep existing patterns of putting app-global runtime state on `state`.
_resources: {
intervals: new Set(), // holds interval ids
timeouts: new Set(), // holds timeout ids
observers: new Set(), // MutationObserver instances
windowListeners: [], // { type, handler, opts }
// intentionally avoid a strong global map of DOM elements -> handlers to prevent retaining
// elements; instead, add handlers using `utils.addElementHandler(el, event, handler)`
// which records handlers directly on the element under a small `_tdmHandlers` array so
// they can be cleared when the element is removed.
}
};
// Validate/migrate persisted userScore: accept new shape { warId, v }
try {
const rawUserScore = storage.get('userScore', null);
if (rawUserScore && typeof rawUserScore === 'object') {
// New format: { warId, v }
if (Object.prototype.hasOwnProperty.call(rawUserScore, 'warId') && Object.prototype.hasOwnProperty.call(rawUserScore, 'v')) {
const storedWarId = rawUserScore.warId;
const currentWarId = state.lastRankWar?.id || null;
if (storedWarId == null || currentWarId == null || String(storedWarId) !== String(currentWarId)) {
// Different war (or unknown current war) — remove stale cache
try { storage.remove('userScore'); } catch(_) {}
state.userScore = null;
} else {
// Hydrate lightweight value
state.userScore = rawUserScore.v || null;
}
} else {
try { storage.remove('userScore'); } catch(_) {}
state.userScore = null;
}
}
} catch(_) { /* best-effort migration; ignore errors */ }
const sanitizeLevelDisplayMode = (value) => (LEVEL_DISPLAY_MODES.includes(value) ? value : DEFAULT_LEVEL_DISPLAY_MODE);
try {
const persistedLevelMode = storage.get(state.ui.levelDisplayModeKey, DEFAULT_LEVEL_DISPLAY_MODE);
state.ui.levelDisplayMode = sanitizeLevelDisplayMode(persistedLevelMode);
} catch(_) {
state.ui.levelDisplayMode = DEFAULT_LEVEL_DISPLAY_MODE;
}
const recordAdapterMetric = (source, status, detail) => {
try {
const metricsRoot = state.metrics || (state.metrics = {});
const adapters = metricsRoot.rankWarAdapters || (metricsRoot.rankWarAdapters = {});
const bucket = adapters[source] || (adapters[source] = { hit: 0, miss: 0, error: 0 });
bucket[status] = (bucket[status] || 0) + 1;
bucket.lastStatus = status;
bucket.lastDetail = detail || null;
bucket.updatedAt = Date.now();
} catch(_) {
/* metrics collection is best-effort */
}
};
// Persist a minimized attacks cache to storage to avoid localStorage quota issues.
// Debounced to prevent rapid synchronous writes during heavy attack polling.
const schedulePersistRankedWarAttacksCache = utils.debounce((cache) => {
try {
const persisted = {};
for (const [wid, entry] of Object.entries(cache || {})) {
// Shallow copy to avoid mutating in-memory array
const c = { ...entry };
// Never persist full attacks array to localStorage
if (Array.isArray(c.attacks)) {
// Keep small fingerprint/length for diagnostics but remove heavy payload
c.attacksCount = c.attacks.length;
delete c.attacks;
}
persisted[wid] = c;
}
storage.set('rankedWarAttacksCache', persisted);
} catch (e) {
// Best-effort only; avoid throwing
}
}, 2000);
function persistRankedWarAttacksCache(cache) {
schedulePersistRankedWarAttacksCache(cache);
}
// Debug: log persisted fingerprints at startup for verification (only if debugging enabled & any value present)
try {
if ((state.debug?.apiLogs || state.debug?.cadence) && state._fingerprints && (state._fingerprints.dibs || state._fingerprints.medDeals)) {
tdmlogger('debug', '[Startup] Loaded persisted fingerprints', { dibs: state._fingerprints.dibs, medDeals: state._fingerprints.medDeals });
}
} catch(_) { /* noop */ }
// Startup recompute & consistency check (silently heals drift)
(function fingerprintStartupConsistency(){
try {
const recomputedD = utils.computeDibsFingerprint(state.dibsData);
const recomputedM = utils.computeMedDealsFingerprint(state.medDeals);
let healed = false; let warnings = [];
if (recomputedD && state._fingerprints?.dibs && state._fingerprints.dibs !== recomputedD) {
warnings.push({ type:'dibs', persisted: state._fingerprints.dibs, recomputed: recomputedD });
state._fingerprints.dibs = recomputedD; healed = true;
} else if (!state._fingerprints?.dibs && recomputedD) {
state._fingerprints.dibs = recomputedD; healed = true;
}
if (recomputedM && state._fingerprints?.medDeals && state._fingerprints.medDeals !== recomputedM) {
warnings.push({ type:'medDeals', persisted: state._fingerprints.medDeals, recomputed: recomputedM });
state._fingerprints.medDeals = recomputedM; healed = true;
} else if (!state._fingerprints?.medDeals && recomputedM) {
state._fingerprints.medDeals = recomputedM; healed = true;
}
if (healed) { try { storage.set('fingerprints', state._fingerprints); } catch(_) {} }
if (warnings.length && (state.debug?.apiLogs || state.debug?.cadence)) {
tdmlogger('warn', '[Startup] Fingerprint drift healed', warnings);
}
} catch(_) { /* ignore */ }
})();
// ================================================================
// Event Bus (lightweight) for decoupled UI & data updates
// ================================================================
const events = (function(){
const listeners = new Map(); // evt -> Set
return {
on(evt, fn) { if (!listeners.has(evt)) listeners.set(evt, new Set()); listeners.get(evt).add(fn); return () => listeners.get(evt)?.delete(fn); },
once(evt, fn) { const off = this.on(evt, (...a)=>{ try { fn(...a); } finally { off(); } }); return off; },
off(evt, fn) { listeners.get(evt)?.delete(fn); },
emit(evt, payload) { const set = listeners.get(evt); if (!set || !set.size) return; [...set].forEach(fn => { try { fn(payload); } catch(e){ /* swallow */ } }); }
};
})();
state.events = events;
const applyAdminCapability = ({ position, computedFlag, source = 'backend', refreshTimestamp } = {}) => {
const canonicalPosition = (() => {
if (typeof position === 'string' && position.trim().length > 0) return position.trim();
if (typeof state.script.currentUserPosition === 'string' && state.script.currentUserPosition.trim().length > 0) return state.script.currentUserPosition.trim();
return position || state.script.currentUserPosition || null;
})();
const prevFlag = !!state.script.canAdministerMedDeals;
const prevPosition = state.script.currentUserPosition || null;
const cachedTs = Number(storage.get('CanAdministerMedDealsTs', 0)) || 0;
const cachedFlag = storage.get('CanAdministerMedDeals', false) === true;
const nowMs = Date.now();
let nextFlag = !!computedFlag;
let nextTs = cachedTs > 0 ? cachedTs : 0;
const shouldRefreshTs = typeof refreshTimestamp === 'boolean' ? refreshTimestamp : !!computedFlag;
if (nextFlag) {
if (shouldRefreshTs || cachedTs <= 0) {
nextTs = nowMs;
}
} else if ((prevFlag || cachedFlag) && cachedTs > 0 && (nowMs - cachedTs) < ADMIN_ROLE_CACHE_TTL_MS) {
nextFlag = true;
nextTs = cachedTs;
} else {
nextTs = 0;
}
const normalizedPosition = (typeof canonicalPosition === 'string' && canonicalPosition.trim().length) ? canonicalPosition.trim() : null;
const positionChanged = normalizedPosition !== prevPosition;
state.script.currentUserPosition = normalizedPosition;
state.script.canAdministerMedDeals = nextFlag;
try { storage.set('LastUserPosition', normalizedPosition); } catch (_) {}
try { storage.set('CanAdministerMedDeals', nextFlag); } catch (_) {}
if (nextFlag && nextTs > 0) {
try { storage.set('CanAdministerMedDealsTs', nextTs); } catch (_) {}
} else {
try { storage.remove('CanAdministerMedDealsTs'); } catch (_) {}
}
if (nextFlag !== prevFlag || positionChanged) {
try { state.events.emit('script:admin-permissions-updated', { canAdmin: nextFlag, position: normalizedPosition, source, grantedAt: nextTs }); } catch (_) {}
}
};
const firebaseAuth = (() => {
const SESSION_KEY = 'firebaseAuthSession';
const firebaseCfg = config.FIREBASE || {};
const API_KEY = firebaseCfg.apiKey || '';
const CUSTOM_TOKEN_URL = firebaseCfg.customTokenUrl || '';
const SIGN_IN_ENDPOINT = API_KEY ? `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${API_KEY}` : null;
const REFRESH_ENDPOINT = API_KEY ? `https://securetoken.googleapis.com/v1/token?key=${API_KEY}` : null;
const initialSession = (state.auth && typeof state.auth === 'object') ? { ...state.auth } : null;
let session = (initialSession && initialSession.idToken && initialSession.refreshToken) ? initialSession : null;
if (!session) {
state.auth = null;
try { storage.remove(SESSION_KEY); } catch(_) {}
}
const now = () => Date.now();
const keySnippet = (key) => {
if (!key || typeof key !== 'string') return null;
return key.length <= 8 ? key : `${key.slice(0, 4)}:${key.slice(-4)}`;
};
const setSession = (next) => {
session = next ? { ...next } : null;
state.auth = session;
if (session) storage.set(SESSION_KEY, session);
else storage.remove(SESSION_KEY);
try { state.events.emit('auth:updated', session); } catch(_) {}
};
const clearSession = (reason) => {
if (reason) {
try { tdmlogger('warn', `[Auth] session cleared: ${reason}`); } catch(_) {}
}
setSession(null);
};
const computeExpiresAt = (expiresInSeconds) => {
const secs = Number(expiresInSeconds) || 0;
const buffer = 120; // refresh a bit early
return now() + Math.max(0, secs - buffer) * 1000;
};
const ensureRequestClient = () => state?.gm?.rD_xmlhttpRequest || null;
const requestRaw = ({ url, method = 'POST', headers = {}, body = '', expectJson = true }) => {
const client = ensureRequestClient();
return new Promise((resolve, reject) => {
const handleSuccess = async (status, text) => {
if (status >= 400) {
return reject(new Error(`HTTP ${status}: ${String(text || '').slice(0, 180)}`));
}
if (!expectJson) return resolve(text);
if (!text) return resolve({});
try { return resolve(JSON.parse(text)); }
catch (err) { return reject(new Error(`Invalid JSON from ${url}: ${err.message}`)); }
};
if (!client) {
const opts = { method, headers, body };
fetch(url, opts).then(async (res) => {
const text = await res.text().catch(() => '');
handleSuccess(Number(res.status || 0), text);
}).catch(err => reject(new Error(err?.message || 'Network error')));
return;
}
client({
method,
url,
headers,
data: body,
onload: (response) => {
const status = Number(response?.status || 0);
const text = typeof response?.responseText === 'string' ? response.responseText : '';
handleSuccess(status, text);
},
onerror: (error) => reject(new Error(error?.status ? `Request failed: ${error.status}` : 'Request failed'))
});
});
};
const postJson = (url, data) => {
const headers = { 'Content-Type': 'application/json' };
return requestRaw({ url, headers, body: JSON.stringify(data || {}), expectJson: true });
};
const postForm = (url, data) => {
const params = new URLSearchParams();
Object.entries(data || {}).forEach(([k, v]) => {
if (typeof v !== 'undefined' && v !== null) params.append(k, v);
});
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
return requestRaw({ url, headers, body: params.toString(), expectJson: true });
};
const mintCustomToken = async ({ tornApiKey, tornId, factionId, version }) => {
if (!CUSTOM_TOKEN_URL) throw new Error('Custom auth endpoint not configured.');
const response = await postJson(CUSTOM_TOKEN_URL, { tornApiKey, tornId, factionId, version });
if (!response || !response.customToken) throw new Error('Custom token missing in response.');
return response;
};
const exchangeCustomToken = async (customToken) => {
if (!SIGN_IN_ENDPOINT) throw new Error('Firebase API key missing.');
const response = await postJson(SIGN_IN_ENDPOINT, { token: customToken, returnSecureToken: true });
if (!response || !response.idToken || !response.refreshToken) throw new Error('Failed to exchange Firebase custom token.');
return response;
};
const refreshIdToken = async (refreshToken) => {
if (!REFRESH_ENDPOINT) throw new Error('Firebase refresh endpoint unavailable.');
const response = await postForm(REFRESH_ENDPOINT, { grant_type: 'refresh_token', refresh_token: refreshToken });
if (!response || !response.id_token || !response.refresh_token) throw new Error('Failed to refresh Firebase token.');
const refreshed = {
idToken: response.id_token,
refreshToken: response.refresh_token,
expiresAt: computeExpiresAt(response.expires_in),
keySnippet: session?.keySnippet || null,
keyHash: session?.keyHash || null,
tornId: response.user_id || session?.tornId || null,
factionId: session?.factionId || null
};
setSession(refreshed);
return refreshed;
};
const signIn = async ({ tornApiKey, tornId, factionId, version } = {}) => {
if (!tornApiKey || !tornId) throw new Error('Missing Torn API details for sign-in.');
const snippet = keySnippet(tornApiKey);
if (session && session.keySnippet === snippet) {
try {
const idToken = await ensureIdToken({ allowAutoSignIn: false });
if (idToken) return session;
} catch (_) { /* proceed to full sign-in */ }
}
const minted = await mintCustomToken({ tornApiKey, tornId, factionId, version });
const exchanged = await exchangeCustomToken(minted.customToken);
const nextSession = {
idToken: exchanged.idToken,
refreshToken: exchanged.refreshToken,
expiresAt: computeExpiresAt(exchanged.expiresIn),
keySnippet: snippet,
keyHash: minted.keyHash || null,
tornId: minted?.user?.tornId || tornId,
factionId: minted?.user?.factionId || factionId || null
};
setSession(nextSession);
return nextSession;
};
const ensureIdToken = async ({ allowAutoSignIn = false } = {}) => {
if (session && session.idToken && session.expiresAt && (now() + 60000) < session.expiresAt) {
return session.idToken;
}
if (session && session.refreshToken) {
try {
const refreshed = await refreshIdToken(session.refreshToken);
return refreshed.idToken;
} catch (error) {
if (api.isVersionUpdateError?.(error)) {
// Version toast already surfaced via API helper; stop initialization here.
return false;
}
ui.showMessageBox(`API Key Error: ${error.message}. Please check your key.`, "error");
}
}
if (allowAutoSignIn && state.user?.actualTornApiKey && state.user?.tornId) {
const signedIn = await signIn({
tornApiKey: state.user.actualTornApiKey,
tornId: state.user.tornId,
factionId: state.user.factionId,
version: config.VERSION
});
return signedIn.idToken;
}
return null;
};
return { signIn, ensureIdToken, clearSession };
})();
// ================================================================
// Reactive wrappers for dibsData and medDeals to automatically emit
// ================================================================
// Debounced storage helpers
const schedulePersistDibsData = utils.debounce((data) => {
try { storage.set('dibsData', data); } catch(_) {}
}, 1000);
const schedulePersistMedDeals = utils.debounce((data) => {
try { storage.set('medDeals', data); } catch(_) {}
}, 1000);
function setDibsData(next, meta={}) {
if (!Array.isArray(next)) next = [];
const prevFp = state._fingerprints?.dibs || null;
state.dibsData = next;
schedulePersistDibsData(next);
// If the mutation is authoritative (local create/update/remove) and a new computed fingerprint supplied, persist it
if (meta && meta.fingerprint) {
try {
state._fingerprints = state._fingerprints || {};
state._fingerprints.dibs = meta.fingerprint;
storage.set('fingerprints', state._fingerprints);
} catch(_) {}
}
if (meta?.fingerprint && meta.fingerprint !== prevFp) {
try {
state._fingerprintsMeta = state._fingerprintsMeta || {}; state._fingerprintsMeta.dibsChangedAt = Date.now();
document.dispatchEvent(new CustomEvent('tdm:dibsFingerprintChanged', { detail: { fingerprint: meta.fingerprint, previous: prevFp } }));
} catch(_) {}
}
events.emit('dibs:update', { data: next, meta });
// Opportunistic badge refresh (debounced via orchestrator if heavy churn later)
try { ui.updateDibsDealsBadge?.(); } catch(_) {}
try { ui.updateDebugOverlayFingerprints?.(); } catch(_) {}
}
function patchDibs(updater, meta={}) {
try {
const cur = Array.isArray(state.dibsData) ? state.dibsData.slice() : [];
const result = updater(cur) || cur;
// Auto-compute fingerprint if not provided
if (!meta.fingerprint) {
try { meta.fingerprint = utils.computeDibsFingerprint(result); } catch(_) {}
}
setDibsData(result, meta);
} catch(e) { /* noop */ }
}
function setMedDeals(next, meta={}) {
if (!next || typeof next !== 'object') next = {};
const prevFp = state._fingerprints?.medDeals || null;
// Debug logging for setMedDeals
if (state.debug?.apiLogs) {
const prevCount = Object.keys(state.medDeals || {}).length;
const nextCount = Object.keys(next || {}).length;
tdmlogger('debug', '[setMedDeals] Called with', {
dataCount: nextCount,
fingerprint: meta?.fingerprint,
prevFp
});
// Debug for critical transitions
if (prevCount === 0 && nextCount > 0) {
tdmlogger('debug', '[setMedDeals] Empty to non-empty transition', {
prevCount,
nextCount,
fingerprint: meta.fingerprint,
source: meta.source || 'backend'
});
}
}
state.medDeals = next;
schedulePersistMedDeals(next);
if (meta && typeof meta.fingerprint !== 'undefined') {
try {
state._fingerprints = state._fingerprints || {};
state._fingerprints.medDeals = meta.fingerprint;
storage.set('fingerprints', state._fingerprints);
} catch(_) {}
}
if (meta?.fingerprint && meta.fingerprint !== prevFp) {
try {
state._fingerprintsMeta = state._fingerprintsMeta || {}; state._fingerprintsMeta.medDealsChangedAt = Date.now();
document.dispatchEvent(new CustomEvent('tdm:medDealsFingerprintChanged', { detail: { fingerprint: meta.fingerprint, previous: prevFp } }));
} catch(_) {}
}
events.emit('medDeals:update', { data: next, meta });
try { ui.updateDibsDealsBadge?.(); } catch(_) {}
try { ui.updateDebugOverlayFingerprints?.(); } catch(_) {}
}
function patchMedDeals(updater, meta={}) {
try {
const cur = (state.medDeals && typeof state.medDeals === 'object') ? { ...state.medDeals } : {};
const result = updater(cur) || cur;
if (!meta.fingerprint) {
try { meta.fingerprint = utils.computeMedDealsFingerprint(result); } catch(_) {}
}
setMedDeals(result, meta);
} catch(e) { /* noop */ }
}
// Expose mutation helpers (non-breaking; future code can migrate to use these)
state._mutate = Object.assign(state._mutate || {}, { setDibsData, patchDibs, setMedDeals, patchMedDeals });
// Example consumer: prewire badge & overlay updates when listeners first attach
events.on('dibs:update', (p)=>{ if (state.debug?.apiLogs) console.debug('[TDM events] dibs:update', p?.meta); });
events.on('medDeals:update', (p)=>{ if (state.debug?.apiLogs) console.debug('[TDM events] medDeals:update', p?.meta); });
const scheduleUnifiedStatusSnapshotSave = utils.debounce(() => {
try {utils.saveUnifiedStatusSnapshot();} catch (_) { /* ignored */ }
}, 500);
//======================================================================
// 4. API MODULE
//======================================================================
// Ranked War Storage V2 Notes:
// - Fast bootstrap via recent window (window JSON) or full (snapshot+delta) from backend callable getRankedWarBundle.
// - api.fetchRankedWarAttacksV2Enhanced caches attacks and marks entry.v2; old chunk aggregation paths will be phased out.
// - config.ENABLE_STORAGE_V2 must be true to attempt bootstrap; fallback logic remains for legacy mode.
// - To rollback: set ENABLE_STORAGE_V2=false and remove fetchRankedWarAttacksV2Enhanced usage in getRankedWarAttacksSmart.
const rateLimitMeta = { lastLogMs: 0, lastSkipLogMs: 0 };
const api = {
_markIpRateLimited(context = {}) {
const now = Date.now();
const baseMs = Number(config.IP_BLOCK_COOLDOWN_MS) || 300000;
const jitterMax = Number(config.IP_BLOCK_COOLDOWN_JITTER_MS) || 0;
const jitter = jitterMax > 0 ? Math.floor(Math.random() * Math.max(1, jitterMax)) : 0;
const until = now + baseMs + jitter;
try {
state.script = state.script || {};
const prev = Number(state.script.ipRateLimitUntilMs) || 0;
if (!prev || until > prev) {
state.script.ipRateLimitUntilMs = until;
}
state.script.ipRateLimitLastReason = context.reason || context.code || context.message || null;
state.script.ipRateLimitLastAction = context.action || null;
state.script.ipRateLimitLastSeenAt = now;
state.script.lastFactionRefreshSkipReason = 'ip-blocked';
} catch(_) { /* ignore state issues */ }
const logInterval = Number(config.IP_BLOCK_LOG_INTERVAL_MS) || 30000;
if (!Number.isFinite(rateLimitMeta.lastLogMs) || (now - rateLimitMeta.lastLogMs) >= logInterval) {
rateLimitMeta.lastLogMs = now;
const remainingSec = Math.max(0, Math.round((until - now) / 1000));
try {
tdmlogger('warn', `[Cadence] Backend IP rate limit detected; pausing API calls for ~${remainingSec}s${context.action ? ` (action=${context.action})` : ''}.`);
} catch(_) { /* logging best-effort */ }
}
try { ui.updateApiCadenceInfo?.(); } catch(_) { /* UI update best-effort */ }
},
isIpRateLimited() {
try {
state.script = state.script || {};
const until = Number(state.script.ipRateLimitUntilMs) || 0;
if (!until) return false;
if (Date.now() >= until) {
state.script.ipRateLimitUntilMs = 0;
state.script.ipRateLimitLastReason = null;
state.script.ipRateLimitLastAction = null;
return false;
}
return true;
} catch(_) {
return false;
}
},
getIpRateLimitRemainingMs() {
try {
state.script = state.script || {};
const until = Number(state.script.ipRateLimitUntilMs) || 0;
if (!until) return 0;
return Math.max(0, until - Date.now());
} catch(_) {
return 0;
}
},
verifyFFScouterKey: (key) => {
return new Promise((resolve) => {
if (!key) return resolve({ ok: false, message: 'No key provided' });
if (!state.gm.rD_xmlhttpRequest) return resolve({ ok: false, message: 'Script not fully initialized' });
const url = `https://ffscouter.com/api/v1/check-key?key=${encodeURIComponent(key)}`;
tdmlogger('info', `[FFScouter Verify] Verifying key at ${url}`);
try {
state.gm.rD_xmlhttpRequest({
method: 'GET',
url: url,
timeout: 10000,
onload: (resp) => {
if (resp.status === 429) {
resolve({ ok: false, message: 'Rate limited (429) - Please wait' });
return;
}
try {
const json = JSON.parse(resp.responseText);
if (json && json.is_registered === true) {
resolve({ ok: true, message: 'Key verified' });
} else {
try { tdmlogger('warn', '[FFScouter Verify] Key rejected', json); } catch(_) {}
resolve({ ok: false, message: 'Key invalid or not registered' });
}
} catch (e) {
try { tdmlogger('error', '[FFScouter Verify] Invalid response', { error: e.message, responseText: resp.responseText, status: resp.status }); } catch(_) {}
resolve({ ok: false, message: 'Invalid response from FFScouter' });
}
},
onerror: (err) => {
try { console.error('[TDM] FFScouter verify error:', err); } catch(_) {}
resolve({ ok: false, message: 'Network error checking key' });
},
ontimeout: () => {
resolve({ ok: false, message: 'Request timed out' });
}
});
} catch (e) {
resolve({ ok: false, message: 'Request failed: ' + e.message });
}
});
},
fetchFFScouterStats: (playerIds) => {
// tdmlogger('info', `[FFScouter Fetch] Requested stats for ${Array.isArray(playerIds) ? playerIds.length : '1'} players`);
return new Promise((resolve) => {
const key = storage.get('ffscouterApiKey', null);
if (!key) return resolve({ ok: false, message: 'No API key' });
if (!playerIds || !playerIds.length) return resolve({ ok: true, data: {} });
// Initialize pending and attempted sets
state.ffscouterPending = state.ffscouterPending || new Set();
state.ffscouterAttempted = state.ffscouterAttempted || new Set();
// Filter out IDs we've fetched recently (e.g. last 5 minutes) to avoid spamming
const now = Date.now();
const idsToFetch = [];
const ids = Array.isArray(playerIds) ? playerIds : [playerIds];
ids.forEach(id => {
const sid = String(id);
// Skip if currently pending or already attempted this session
if (state.ffscouterPending.has(sid) || state.ffscouterAttempted.has(sid)) return;
let cached = state.ffscouterCache?.[sid];
// If not in memory, check storage (using new shared key format)
if (!cached) {
try {
const raw = localStorage.getItem('ffscouterv2-' + sid);
if (raw) {
cached = JSON.parse(raw);
if (cached) {
state.ffscouterCache = state.ffscouterCache || {};
state.ffscouterCache[sid] = cached;
}
}
} catch(_) {}
}
// Fetch if not cached or expired
// Check 'expiry' (new format) or fallback to 5 min TTL for old format
const isExpired = cached ? (cached.expiry ? (now > cached.expiry) : ((now - (cached.lastUpdated || 0)) > 300000)) : true;
if (!cached || isExpired) {
idsToFetch.push(sid);
state.ffscouterPending.add(sid); // Mark as pending immediately
state.ffscouterAttempted.add(sid); // Mark as attempted to prevent retry loops
}
});
if (!idsToFetch.length) return resolve({ ok: true, count: 0, message: 'All cached, pending, or attempted' });
// Chunk into batches of 200 to avoid URL length issues or 400s
const chunks = [];
while (idsToFetch.length) {
chunks.push(idsToFetch.splice(0, 200));
}
let totalProcessed = 0;
// Process chunks sequentially
const processChunk = async (chunk) => {
return new Promise(resolveChunk => {
const cleanup = () => {
chunk.forEach(id => state.ffscouterPending.delete(String(id)));
resolveChunk();
};
try {
// Don't encode the commas, some APIs prefer literal commas for lists
const url = `https://ffscouter.com/api/v1/get-stats?key=${encodeURIComponent(key)}&targets=${chunk.join(',')}`;
tdmlogger('info', `[FFScouter Fetch] Fetching stats for ${chunk.length} players`);
state.gm.rD_xmlhttpRequest({
method: 'GET',
url: url,
timeout: 10000,
onload: (resp) => {
try {
const json = JSON.parse(resp.responseText);
const nowMs = Date.now();
// Handle array response (current API behavior)
if (Array.isArray(json)) {
state.ffscouterCache = state.ffscouterCache || {};
json.forEach(data => {
const pid = String(data.player_id);
const record = {
value: data.fair_fight,
last_updated: Math.floor(nowMs / 1000),
expiry: nowMs + 3600000, // 1 hour expiry
bs_estimate: data.bs_estimate,
bs_estimate_human: data.bs_estimate_human
};
state.ffscouterCache[pid] = record;
try {
localStorage.setItem('ffscouterv2-' + pid, JSON.stringify(record));
} catch(_) {}
});
totalProcessed += json.length;
tdmlogger('info', `[FFScouter Fetch] Successfully cached ${json.length} players`);
}
// Handle legacy/alternative object response
else if (json && json.success) {
const results = json.data || {};
state.ffscouterCache = state.ffscouterCache || {};
Object.entries(results).forEach(([pid, data]) => {
const record = {
value: data.ff || data.fair_fight,
last_updated: Math.floor(nowMs / 1000),
expiry: nowMs + 3600000, // 1 hour expiry
bs_estimate: data.estimate || data.bs_estimate,
bs_estimate_human: data.estimate_text || data.bs_estimate_human
};
state.ffscouterCache[pid] = record;
try {
localStorage.setItem('ffscouterv2-' + pid, JSON.stringify(record));
} catch(_) {}
});
totalProcessed += Object.keys(results).length;
} else {
tdmlogger('warn', `[FFScouter Fetch] API Error: ${json?.message || 'Unknown error'}`, json);
}
} catch (e) {
tdmlogger('error', `[FFScouter Fetch] Parse error: ${e.message}`, { response: resp.responseText });
}
cleanup();
},
onerror: () => {
tdmlogger('warn', '[FFScouter Fetch] Network error');
cleanup();
},
ontimeout: () => {
tdmlogger('warn', '[FFScouter Fetch] Timeout');
cleanup();
}
});
} catch (e) { cleanup(); }
});
};
// Execute all chunks
(async () => {
for (const chunk of chunks) {
await processChunk(chunk);
}
// Trigger UI refresh if we got new data
if (totalProcessed > 0) {
try { ui.queueLevelOverlayRefresh({ reason: 'ffscouter-update' }); } catch(_) {}
}
resolve({ ok: true, count: totalProcessed });
})();
});
},
_shouldBailDueToIpRateLimit(contextLabel) {
if (!this.isIpRateLimited()) return false;
const now = Date.now();
const logInterval = Number(config.IP_BLOCK_LOG_INTERVAL_MS) || 30000;
if (!Number.isFinite(rateLimitMeta.lastSkipLogMs) || (now - rateLimitMeta.lastSkipLogMs) >= logInterval) {
rateLimitMeta.lastSkipLogMs = now;
const remainingSec = Math.max(0, Math.round(this.getIpRateLimitRemainingMs() / 1000));
try {
tdmlogger('info', `[Cadence] Skip ${contextLabel || 'API call'}; backend IP block cool-down ~${remainingSec}s remaining.`);
} catch(_) { /* logging best-effort */ }
}
return true;
},
_maybeHandleRateLimitError(error, context = {}) {
if (!error) return false;
const status = Number(error.status || error.statusCode || error.httpStatus || 0);
const codeRaw = error.code || error.status;
const code = typeof codeRaw === 'string' ? codeRaw.toLowerCase() : '';
const msg = typeof error.message === 'string' ? error.message.toLowerCase() : '';
const isResourceExhausted = code === 'resource-exhausted' || code === 'resource_exhausted';
const isHttp429 = status === 429;
const mentionsIpLimit = msg.includes('too many requests') && msg.includes('ip');
if (isResourceExhausted || isHttp429 || mentionsIpLimit) {
this._markIpRateLimited({ ...context, code: codeRaw || status || null, message: error.message || null });
error.isIpRateLimited = true;
return true;
}
return false;
},
isVersionUpdateError: (err) => {
if (!err) return false;
const safeLower = (value) => (typeof value === 'string' ? value.toLowerCase() : '');
const code = safeLower(err.code || err.status || '');
const reasonCandidates = [
err.reason,
err.errorReason,
err.error_reason,
err.details?.reason,
err.details?.errorReason
];
const reason = safeLower(reasonCandidates.find((r) => typeof r === 'string' && r) || '');
const message = typeof err.message === 'string' ? err.message : '';
const messageHasVersionHint = /userscript is outdated|update required|client version|new version/i.test(message);
const reasonHasVersionHint = /outdated|version|update/.test(reason);
const hasExplicitVersion = !!(err.minVersion || err.requiredVersion || err.minimumVersion);
if (hasExplicitVersion || reason === 'client_version_outdated') return true;
if (messageHasVersionHint || reasonHasVersionHint) return true;
if ((code === 'failed-precondition' || code === 'failed_precondition')) {
// Only treat failed-precondition as version-related when paired with explicit hints.
const updateFlag = err.updateRequired === true || err.clientVersionOutdated === true;
const detailsFlag = err.details && (err.details.updateRequired === true || err.details.clientVersionOutdated === true);
return Boolean(updateFlag || detailsFlag);
}
return false;
},
_extractVersionFromMessage: (message) => {
if (!message) return null;
const text = String(message);
const strict = text.match(/version\s+([0-9]+(?:\.[0-9]+){1,3})/i);
if (strict && strict[1]) return strict[1].trim();
const loose = text.match(/([0-9]+\.[0-9]+\.[0-9]+)/);
if (loose && loose[1]) return loose[1].trim();
return null;
},
_markUpdateAvailable: (candidateVersion) => {
if (!candidateVersion) return;
try {
if (!state.script) state.script = {};
const prev = state.script.updateAvailableLatestVersion || null;
if (!prev || utils.compareVersions(prev, candidateVersion) < 0) {
state.script.updateAvailableLatestVersion = candidateVersion;
try { storage.set('lastKnownLatestVersion', candidateVersion); } catch(_) { /* noop */ }
}
} catch(_) {
try { state.script.updateAvailableLatestVersion = candidateVersion; } catch(__) { /* noop */ }
}
try { ui.updateSettingsButtonUpdateState(); } catch(_) { /* noop */ }
},
_handleVersionOutdatedError: (err, context = {}) => {
if (!api.isVersionUpdateError(err)) return false;
try { state.script = state.script || {}; } catch(_) { /* noop */ }
const minVersion = err.minVersion || err.requiredVersion || err.minimumVersion || api._extractVersionFromMessage(err.message);
if (minVersion) {
api._markUpdateAvailable(minVersion);
} else if (!state.script.updateAvailableLatestVersion) {
// Ensure the settings button still reflects update-needed state even without explicit version detail
try {
const fallbackVersion = `${config.VERSION || '0.0.0'}.999`;
state.script.updateAvailableLatestVersion = fallbackVersion;
ui.updateSettingsButtonUpdateState();
} catch(_) { /* noop */ }
}
const updateUrl = err.updatePageUrl || err.updateUrl || config.GREASYFORK?.pageUrl || config.GREASYFORK?.downloadUrl;
if (updateUrl) {
try { state.script.updateAvailableLatestVersionUrl = updateUrl; } catch(_) { /* noop */ }
}
if (!state.script.versionBlockShown) {
state.script.versionBlockShown = true;
const minLabel = minVersion || 'latest';
const baseMessage = err.message && err.message.length < 160 ? err.message : `Your TreeDibsMapper userscript is out of date.`;
const prompt = `${baseMessage}\nUpdate required: v${minLabel} or newer.${updateUrl ? `\nClick to open update: ${updateUrl}` : ''}`;
try {
ui.showMessageBox(prompt, 'error', 15000, updateUrl ? () => {
try { window.open(updateUrl, '_blank', 'noopener'); } catch(_) { /* noop */ }
} : null);
} catch(_) { /* noop */ }
}
return true;
},
// Build a safe selection list for Torn faction endpoint (always exclude 'attacks' on client)
buildSafeFactionSelections: (wantList, forFactionId) => {
try {
const desired = (wantList || []).map(s => String(s).trim()).filter(Boolean);
const filtered = desired.filter(s => s !== '');
// Unconditionally exclude 'attacks' to avoid permission errors and reduce churn
const pruned = filtered.filter(s => s.toLowerCase() !== 'attacks');
// Track per-faction last requested selections
try {
state.session.selectionsPerFaction = state.session.selectionsPerFaction || {};
state.session.selectionsPerFaction[String(forFactionId||'self')] = pruned.slice();
} catch(_) {}
return pruned;
} catch(_) {
return (wantList || []).filter(Boolean);
}
},
_call: async (method, url, action, params = {}) => {
if (!state.user.tornId) {
throw new Error(`User context missing for Firebase ${method}.`);
}
const defaultFaction = state?.user?.factionId ? { factionId: state.user.factionId } : {};
let idToken = null;
try {
idToken = await firebaseAuth.ensureIdToken({ allowAutoSignIn: true });
} catch (authError) {
try { tdmlogger('warn', `[Auth] ensureIdToken failed: ${authError.message}`); } catch(_) {}
}
if (!idToken) {
throw new Error('Unable to obtain Firebase ID token. Please reauthenticate.');
}
const payload = { action, tornId: state.user.tornId, version: config.VERSION, clientTimestamps: state.dataTimestamps, ...defaultFaction, ...params };
const requestBody = { data: payload };
const headers = { 'Content-Type': 'application/json' };
headers.Authorization = `Bearer ${idToken}`;
return await new Promise((resolve, reject) => {
const rejectWith = (error) => {
try { api._maybeHandleRateLimitError(error, { action, params, method, url }); } catch(_) { /* noop */ }
try { api._handleVersionOutdatedError?.(error, { action, params, method, url }); } catch(_) { /* noop */ }
reject(error);
};
// Perform request with retries on transient errors (401 handled by refreshing idToken).
const MAX_ATTEMPTS = 4;
let attempt = 0;
const transientStatusCodes = new Set([408, 429, 500, 502, 503, 504]);
const doRequest = async () => {
attempt += 1;
// Ensure Authorization header is current
headers.Authorization = `Bearer ${idToken}`;
const ret = state.gm.rD_xmlhttpRequest({
method: 'POST',
url,
headers,
data: JSON.stringify(requestBody),
onload: async function(response) {
const raw = typeof response?.responseText === 'string' ? response.responseText : '';
const statusCode = Number(response?.status || 0);
// If we received HTTP 401 and we haven't retried yet, attempt to refresh the token and retry.
if (statusCode === 401 && attempt < MAX_ATTEMPTS) {
try {
const newToken = await firebaseAuth.ensureIdToken({ allowAutoSignIn: true });
if (newToken && newToken !== idToken) {
idToken = newToken;
// try again immediately
return doRequest();
}
} catch (e) {
// fallthrough to parsing/error handling below
}
}
// If we received a transient HTTP error (timeout/rate-limit/server error), retry with backoff
if (transientStatusCodes.has(statusCode) && attempt < MAX_ATTEMPTS) {
try {
const baseMs = 250;
const backoff = Math.pow(2, attempt - 1) * baseMs;
const jitter = Math.floor(Math.random() * 200);
await new Promise(r => setTimeout(r, backoff + jitter));
return doRequest();
} catch (e) {
// fallthrough to parse/reject below
}
}
// If there is no body, treat as error
const isHttpError = statusCode >= 400;
if (!raw || raw.trim() === '') {
return rejectWith(new Error(`Empty or invalid response from server (${method}).`));
}
try {
const payload = JSON.parse(raw);
try {
const _metaCalls = payload?.result?.meta?.userKeyApiCalls ?? payload?.meta?.userKeyApiCalls;
const add = Number(_metaCalls) || 0;
if (add > 0) utils.incrementBackendApiCalls(add);
} catch (_) { /* ignore */ }
if (payload?.result?.status === 'success' && 'data' in payload.result) return resolve(payload.result.data);
if (payload?.status === 'success' && 'data' in payload) return resolve(payload.data);
const fbError = payload?.error || payload?.result?.error || payload?.data?.error;
if (fbError) {
const err = new Error(fbError.message || 'Firebase error');
if (fbError.status) err.code = fbError.status;
if (typeof fbError.httpStatus === 'number') err.status = fbError.httpStatus;
if (typeof fbError.statusCode === 'number' && typeof err.status === 'undefined') err.status = fbError.statusCode;
try {
if (fbError.details && typeof fbError.details === 'object') {
Object.assign(err, fbError.details);
if (fbError.details.tornError) err.tornError = fbError.details.tornError;
if (typeof fbError.details.tornErrorCode !== 'undefined') err.tornErrorCode = fbError.details.tornErrorCode;
if (typeof fbError.details.tornErrorMessage !== 'undefined') err.tornErrorMessage = fbError.details.tornErrorMessage;
if (!err.tornErrorMessage && fbError.details.tornError && fbError.details.tornError.error) {
err.tornErrorMessage = fbError.details.tornError.error;
}
}
} catch(_) { /* noop */ }
if (typeof err.tornErrorCode !== 'undefined' && err.tornErrorMessage) err.message = `Torn API error ${err.tornErrorCode}: ${err.tornErrorMessage}`;
else if (err.tornErrorMessage) err.message = `Torn API error: ${err.tornErrorMessage}`;
return rejectWith(err);
}
if (payload?.result && 'data' in payload.result) return resolve(payload.result.data);
if (isHttpError) {
const err = new Error(`Request failed (${statusCode}): ${raw.slice(0, 200)}`);
if (typeof statusCode === 'number') { err.status = statusCode; err.httpStatus = statusCode; }
return rejectWith(err);
}
return resolve(payload);
} catch (e) {
return rejectWith(new Error(`Failed to parse Firebase API response: ${e.message}`));
}
},
onerror: async (error) => {
// Treat network errors as transient and retry with backoff when possible
if (attempt < MAX_ATTEMPTS) {
try {
const baseMs = 250;
const backoff = Math.pow(2, attempt - 1) * baseMs;
const jitter = Math.floor(Math.random() * 200);
await new Promise(r => setTimeout(r, backoff + jitter));
return doRequest();
} catch (_) { /* fallthrough to reject below */ }
}
const err = new Error(`Firebase API request failed: Status ${error?.status || 'Unknown'}`);
if (error && typeof error.status !== 'undefined') err.status = error.status;
rejectWith(err);
}
});
if (ret && typeof ret.catch === 'function') ret.catch(() => {});
};
// Kick off first request
try { doRequest(); } catch (e) { rejectWith(e); }
});
},
// Cross-tab GET coalescing: use BroadcastChannel when available, fallback to localStorage notify/cache.
// This reduces duplicate identical GET requests from multiple open pages/tabs.
_crossTabGetHelper: (function(){
const bcSupported = typeof BroadcastChannel === 'function';
const bc = bcSupported ? new BroadcastChannel('tdm:crossget') : null;
const TAB_ID = Math.random().toString(36).slice(2);
const pending = new Map();
const CACHE_PREFIX = 'tdm:crossget:cache:';
const WAIT_MS = 120; // wait briefly for another tab to respond
const CACHE_TTL_MS = Number((window.__TDM_CROSSGET_TTL_MS__) || 5000);
function makeKey(action, params) {
try {
// Normalize param keys order for stable key
const p = params && typeof params === 'object' && !Array.isArray(params) ? params : {};
const ordered = {};
Object.keys(p).sort().forEach(k => { ordered[k] = p[k]; });
return `${action}|${JSON.stringify(ordered)}`;
} catch (e) { return `${action}|${String(params)}`; }
}
function invalidateKey(action, params) {
try {
const exact = makeKey(action, params || {});
const prefix = action + '|';
// remove exact and prefix matches from localStorage
try {
for (let i = localStorage.length - 1; i >= 0; i--) {
const k = localStorage.key(i);
if (!k) continue;
if (k.indexOf(CACHE_PREFIX) !== 0) continue;
const bare = k.slice(CACHE_PREFIX.length);
if (bare === exact || bare.indexOf(prefix) === 0) {
try { localStorage.removeItem(k); } catch(_){}
}
}
} catch(_){}
// broadcast invalidation
if (bc) {
try { bc.postMessage({ type: 'tdm:GET:invalidate', action, key: exact, sender: TAB_ID }); } catch(_) {}
} else {
try { localStorage.setItem(CACHE_PREFIX + 'invalidate:' + Date.now(), JSON.stringify({ action, key: exact, ts: Date.now() })); } catch(_) {}
}
} catch (_) {}
}
// Global invalidation listener: clear local cache entries when peers broadcast invalidation
if (bc) {
try {
bc.addEventListener('message', (ev) => {
try {
const msg = ev?.data;
if (!msg) return;
if (msg.type === 'tdm:GET:invalidate') {
const action = msg.action;
const key = msg.key;
try {
for (let i = localStorage.length - 1; i >= 0; i--) {
const k = localStorage.key(i);
if (!k) continue;
if (k.indexOf(CACHE_PREFIX) !== 0) continue;
const bare = k.slice(CACHE_PREFIX.length);
if (bare === key || (action && bare.indexOf(action + '|') === 0)) {
try { localStorage.removeItem(k); } catch(_){}
}
}
} catch(_){}
}
} catch(_){}
});
} catch(_){}
}
// Listen for localStorage-based invalidation fallback
try {
window.addEventListener('storage', (ev) => {
try {
if (!ev || !ev.key) return;
if (ev.key.indexOf(CACHE_PREFIX + 'invalidate:') !== 0) return;
const raw = ev.newValue;
if (!raw) return;
let msg = null;
try { msg = JSON.parse(raw); } catch(_) { msg = null; }
const action = msg?.action || null;
const key = msg?.key || null;
try {
for (let i = localStorage.length - 1; i >= 0; i--) {
const k = localStorage.key(i);
if (!k) continue;
if (k.indexOf(CACHE_PREFIX) !== 0) continue;
const bare = k.slice(CACHE_PREFIX.length);
if (key && bare === key) { try { localStorage.removeItem(k); } catch(_){} }
else if (action && bare.indexOf(action + '|') === 0) { try { localStorage.removeItem(k); } catch(_){} }
}
} catch(_){}
} catch(_){}
});
} catch(_){}
const crossTabGet = async function crossTabGet(action, params, performFn) {
const key = makeKey(action, params || {});
if (pending.has(key)) return pending.get(key);
// Check local cache first
try {
const raw = localStorage.getItem(CACHE_PREFIX + key);
if (raw) {
const obj = JSON.parse(raw);
if (obj && (Date.now() - (obj.ts || 0) < CACHE_TTL_MS)) {
return Promise.resolve(obj.data);
}
}
} catch (_) {}
const p = new Promise((resolve, reject) => {
let resolved = false;
const onMessage = (ev) => {
try {
const msg = ev?.data;
if (!msg || msg.type !== 'tdm:GET:response' || msg.key !== key) return;
if (msg.sender === TAB_ID) return; // ignore our own
resolved = true;
resolve(msg.data);
cleanup();
} catch (e) { /* ignore */ }
};
const onStorage = (ev) => {
try {
if (ev.key !== (CACHE_PREFIX + key) || !ev.newValue) return;
const obj = JSON.parse(ev.newValue);
if (obj && (Date.now() - (obj.ts || 0) < CACHE_TTL_MS)) {
resolved = true;
resolve(obj.data);
cleanup();
}
} catch (_) {}
};
function cleanup() {
try { if (bc) bc.removeEventListener('message', onMessage); } catch(_){}
try { window.removeEventListener('storage', onStorage); } catch(_){}
pending.delete(key);
}
if (bc) bc.addEventListener('message', onMessage);
window.addEventListener('storage', onStorage);
// After a short wait, if no other tab answered, perform the request ourselves
const timer = setTimeout(async () => {
if (resolved) return;
try {
const result = await performFn();
try {
const cacheObj = { ts: Date.now(), data: result };
try { localStorage.setItem(CACHE_PREFIX + key, JSON.stringify(cacheObj)); } catch(_){}
if (bc) {
try { bc.postMessage({ type: 'tdm:GET:response', key, data: result, sender: TAB_ID }); } catch(_){}
}
} catch(_){}
if (!resolved) {
resolved = true;
resolve(result);
cleanup();
}
} catch (err) {
if (!resolved) {
resolved = true;
reject(err);
cleanup();
}
}
}, WAIT_MS);
});
// expose invalidate helper on the returned function
try { p.invalidate = invalidateKey; } catch(_){}
pending.set(key, p);
return p;
};
try { crossTabGet.invalidate = invalidateKey; } catch(_){}
return crossTabGet;
})(),
get: function(action, params = {}) { return this._crossTabGetHelper(action, params, () => this._call('GET', config.API_GET_URL, action, params)); },
post: async function(action, data = {}) {
const res = await this._call('POST', config.API_POST_URL, action, data);
try {
// best-effort invalidate related GET cache entries across tabs
try {
if (this._crossTabGetHelper && typeof this._crossTabGetHelper === 'function') {
// prefer explicit invalidate function if present
if (typeof this._crossTabGetHelper.invalidate === 'function') {
try { this._crossTabGetHelper.invalidate(action, data); } catch(_) {}
} else if (typeof this._crossTabGetHelper === 'function' && typeof this._crossTabGetHelper.invalidateKey === 'function') {
try { this._crossTabGetHelper.invalidateKey(action, data); } catch(_) {}
}
}
} catch(_) {}
} catch(_) {}
return res;
},
// remove soon?
getGlobalData: async (params = {}) => {
if (!state.user.tornId) {
throw new Error('User context missing for getGlobalData.');
}
let idToken = null;
try {
idToken = await firebaseAuth.ensureIdToken({ allowAutoSignIn: true });
} catch (authError) {
try { tdmlogger('warn', `[Auth] ensureIdToken failed: ${authError.message}`); } catch(_) {}
}
if (!idToken) {
throw new Error('Unable to obtain Firebase ID token. Please reauthenticate.');
}
// --- Differential polling: send client timestamps ---
const payload = {
tornId: state.user.tornId,
clientTimestamps: state.dataTimestamps,
...params
};
const headers = { 'Content-Type': 'application/json' };
headers.Authorization = `Bearer ${idToken}`;
return await new Promise((resolve, reject) => {
const ret = state.gm.rD_xmlhttpRequest({
method: 'POST',
url: 'https://getglobaldata-codod64xdq-uc.a.run.app',
headers,
data: JSON.stringify({ data: payload }),
onload: function(response) {
if (!response || typeof response.responseText !== 'string' || response.responseText.trim() === '') {
return reject(new Error('Empty or invalid response from getGlobalData endpoint.'));
}
try {
const jsonResponse = JSON.parse(response.responseText);
if (jsonResponse.error) {
const error = new Error(jsonResponse.error.message || 'Unknown getGlobalData error');
if (jsonResponse.error.details) Object.assign(error, jsonResponse.error.details);
reject(error);
} else if (jsonResponse.result) {
// Session API usage counter (backend-reported, user-key only)
try {
const add = Number(jsonResponse?.result?.meta?.userKeyApiCalls || 0);
if (add > 0) utils.incrementBackendApiCalls(add);
} catch (_) { /* ignore */ }
// --- Persist masterTimestamps after each fetch ---
if (jsonResponse.result.masterTimestamps) {
state.dataTimestamps = jsonResponse.result.masterTimestamps;
storage.set('dataTimestamps', state.dataTimestamps);
}
// --- Update only changed collections in state ---
// --- Support full backend response structure ---
const firebaseCollections = [
'dibsData',
'userNotes',
'medDeals',
'dibsNotifications',
'unauthorizedAttacks'
];
const tornApiCollections = [
'rankWars',
'warData',
'retaliationOpportunities'
];
const actionsCollections = [
'attackerLastAction',
'unauthorizedAttacks'
];
// Update firebase collections
if (jsonResponse.result.firebase && typeof jsonResponse.result.firebase === 'object') {
for (const key of firebaseCollections) {
if (jsonResponse.result.firebase.hasOwnProperty(key) && jsonResponse.result.firebase[key] !== null && jsonResponse.result.firebase[key] !== undefined) {
storage.updateStateAndStorage(key, jsonResponse.result.firebase[key]);
}
}
}
// Update tornApi collections
if (jsonResponse.result.tornApi && typeof jsonResponse.result.tornApi === 'object') {
for (const key of tornApiCollections) {
if (Object.prototype.hasOwnProperty.call(jsonResponse.result.tornApi, key) && jsonResponse.result.tornApi[key] !== null && jsonResponse.result.tornApi[key] !== undefined) {
storage.updateStateAndStorage(key, jsonResponse.result.tornApi[key]);
}
}
}
// Update actions collections
if (jsonResponse.result.actions && typeof jsonResponse.result.actions === 'object') {
for (const key of actionsCollections) {
if (jsonResponse.result.actions.hasOwnProperty(key) && jsonResponse.result.actions[key] !== null && jsonResponse.result.actions[key] !== undefined) {
storage.updateStateAndStorage(key, jsonResponse.result.actions[key]);
}
}
}
resolve(jsonResponse.result);
} else {
resolve(jsonResponse);
}
} catch (e) {
reject(new Error('Failed to parse getGlobalData response: ' + e.message));
}
},
onerror: (error) => reject(new Error('getGlobalData request failed: Status ' + (error.status || 'Unknown')))
});
if (ret && typeof ret.catch === 'function') ret.catch(() => {});
});
},
// Smart fetch for ranked war summary using Cloud Storage JSON with ETag; falls back to Firebase meta+reads
getRankedWarSummarySmart: async (rankedWarId, factionId) => {
utils.perf.start('getRankedWarSummarySmart');
try {
const warId = String(rankedWarId);
const cacheKey = warId; // per-war cache
const clientEntry = state.rankedWarSummaryCache?.[cacheKey] || {};
// Ensure we have a storage URL
let summaryUrl = clientEntry.summaryUrl;
if (!summaryUrl) {
// Attempt lazy materialization first time we notice absence
// Prefer cheap GET; only force ensure when explicitly needed
await api.ensureWarArtifactsSafe(warId, factionId).catch(() => null);
const urls = await api.getWarStorageUrls(warId, factionId, { ifNoneMatch: clientEntry.etag || null });
summaryUrl = urls?.summaryUrl || null;
if (summaryUrl) {
clientEntry.summaryUrl = summaryUrl;
state.rankedWarSummaryCache[cacheKey] = { ...clientEntry };
storage.set('rankedWarSummaryCache', state.rankedWarSummaryCache);
}
}
// Try Cloud Storage first when URL is known
if (summaryUrl) {
let { status, json, etag, lastModified } = await api.fetchStorageJson(summaryUrl, { etag: clientEntry.etag, ifModifiedSince: clientEntry.lastModified });
if (status === 200 && Array.isArray(json)) {
const next = { ...clientEntry, summaryUrl, etag: etag || null, lastModified: lastModified || null, updatedAt: Date.now(), summary: json, source: 'storage200' };
// summary persisted unconditionally (metrics pruned)
state.rankedWarSummaryCache[cacheKey] = next;
storage.set('rankedWarSummaryCache', state.rankedWarSummaryCache);
try {
state.rankedWarLastSummarySource = 'storage200';
state.rankedWarLastSummaryMeta = { source: 'storage', etag: etag || null, lastModified: lastModified || null, count: Array.isArray(json) ? json.length : 0, url: summaryUrl };
storage.set('rankedWarLastSummarySource', state.rankedWarLastSummarySource);
storage.set('rankedWarLastSummaryMeta', state.rankedWarLastSummaryMeta);
} catch(_) { /* noop */ }
tdmlogger('debug', `getRankedWarSummarySmart: 200 OK for war ${warId}, fetched fresh summary (${Array.isArray(json) ? json.length : 0} entries).`);
utils.perf.stop('getRankedWarSummarySmart');
return next.summary;
}
if (status === 304 && Array.isArray(clientEntry.summary)) {
try {
state.rankedWarLastSummarySource = 'storage304';
state.rankedWarLastSummaryMeta = { source: 'storage', etag: clientEntry.etag || null, lastModified: clientEntry.lastModified || null, count: Array.isArray(clientEntry.summary) ? clientEntry.summary.length : 0, url: summaryUrl };
storage.set('rankedWarLastSummarySource', state.rankedWarLastSummarySource);
storage.set('rankedWarLastSummaryMeta', state.rankedWarLastSummaryMeta);
} catch(_) { /* noop */ }
tdmlogger('debug', `getRankedWarSummarySmart: 304 Not Modified for war ${warId}, using cached summary (${Array.isArray(clientEntry.summary) ? clientEntry.summary.length : 0} entries).`);
utils.perf.stop('getRankedWarSummarySmart');
return clientEntry.summary;
}
// If 304 but we don't have a cached summary yet, force a one-time bootstrap without conditional headers
if (status === 304 && !Array.isArray(clientEntry.summary)) {
const forced = await api.fetchStorageJson(summaryUrl, {});
if (forced.status === 200 && Array.isArray(forced.json)) {
const next = { ...clientEntry, summaryUrl, etag: forced.etag || null, lastModified: forced.lastModified || null, updatedAt: Date.now(), summary: forced.json, source: 'storage200' };
// forced summary persisted (metrics pruned)
state.rankedWarSummaryCache[cacheKey] = next;
storage.set('rankedWarSummaryCache', state.rankedWarSummaryCache);
try { state.rankedWarLastSummarySource = 'storage200'; state.rankedWarLastSummaryMeta = { source: 'storage', etag: forced.etag || null, lastModified: forced.lastModified || null, count: next.summary.length, url: summaryUrl }; storage.set('rankedWarLastSummarySource', state.rankedWarLastSummarySource); storage.set('rankedWarLastSummaryMeta', state.rankedWarLastSummaryMeta); } catch(_) {}
tdmlogger('debug', `getRankedWarSummarySmart: Forced 200 OK for war ${warId}, fetched fresh summary (${Array.isArray(forced.json) ? forced.json.length : 0} entries).`);
utils.perf.stop('getRankedWarSummarySmart');
return next.summary;
}
}
if (status === 404) {
// Trigger a forced ensure so missing historical summaries get materialized server-side
await api.ensureWarArtifactsSafe(warId, factionId, { force: true }).catch(() => null);
try { await new Promise(r => setTimeout(r, 400)); } catch(_) {}
try {
const urls2 = await api.getWarStorageUrls(warId, factionId, { ifNoneMatch: clientEntry.etag || null }).catch(() => null);
if (urls2?.summaryUrl) {
summaryUrl = urls2.summaryUrl;
clientEntry.summaryUrl = summaryUrl;
state.rankedWarSummaryCache[cacheKey] = { ...clientEntry };
storage.set('rankedWarSummaryCache', state.rankedWarSummaryCache);
const again = await api.fetchStorageJson(summaryUrl, { etag: clientEntry.etag, ifModifiedSince: clientEntry.lastModified });
if (again.status === 200 && Array.isArray(again.json)) {
const next = { ...clientEntry, summaryUrl, etag: again.etag || null, lastModified: again.lastModified || null, updatedAt: Date.now(), summary: again.json, source: 'storage200' };
// retry summary persisted (metrics pruned)
state.rankedWarSummaryCache[cacheKey] = next;
storage.set('rankedWarSummaryCache', state.rankedWarSummaryCache);
try { state.rankedWarLastSummarySource = 'storage200'; state.rankedWarLastSummaryMeta = { source: 'storage', etag: again.etag || null, lastModified: again.lastModified || null, count: next.summary.length, url: summaryUrl }; storage.set('rankedWarLastSummarySource', state.rankedWarLastSummarySource); storage.set('rankedWarLastSummaryMeta', state.rankedWarLastSummaryMeta); } catch(_) {}
tdmlogger('debug', `getRankedWarSummarySmart: Retry 200 OK for war ${warId}, fetched fresh summary (${Array.isArray(again.json) ? again.json.length : 0} entries).`);
utils.perf.stop('getRankedWarSummarySmart');
return next.summary;
}
}
tdmlogger('info', `getRankedWarSummarySmart: 404 Not Found for war ${warId}, attempting lazy materialization and retry.`);
} catch(_) { /* ignore retry errors */ }
}
// If 404 or other error, fall through to Firebase path
}
// No Firestore fallback: return cached summary if present else empty list
if (Array.isArray(clientEntry.summary)) {
tdmlogger('debug', `getRankedWarSummarySmart: Falling back to cached summary for war ${warId} (${clientEntry.summary.length} entries).`);
utils.perf.stop('getRankedWarSummarySmart');
return clientEntry.summary;
}
tdmlogger('info', `getRankedWarSummarySmart: No summary URL available for war ${warId}, no cached summary present.`);
utils.perf.stop('getRankedWarSummarySmart');
return [];
} catch (e) {
// Fallback to last cached if available
const warId = String(rankedWarId);
const clientEntry = state.rankedWarSummaryCache?.[warId];
if (clientEntry && Array.isArray(clientEntry.summary)) {
tdmlogger('debug', `getRankedWarSummarySmart: Falling back to cached summary for war ${warId} (${clientEntry.summary.length} entries).`, e);
utils.perf.stop('getRankedWarSummarySmart');
return clientEntry.summary;
}
utils.perf.stop('getRankedWarSummarySmart');
throw e;
}
},
// Smart fetch for ranked war attacks using Cloud Storage manifest+chunked JSON. Returns aggregated attacks array.
// options: { onDemand?: boolean } — when not in active war, only fetch if onDemand or not yet fetched once this page load
getRankedWarAttacksSmart: async (rankedWarId, factionId, options = {}) => {
// If V2 manifest path available, prefer that
try {
// Short-circuit manifest fetch when a fresh finalized final JSON is cached locally to avoid repeated conditional GETs
try {
const warId = String(rankedWarId);
const cache = state.rankedWarAttacksCache || (state.rankedWarAttacksCache = {});
const entry = cache[warId] || {};
const now = Date.now();
const isNonActive = !((utils.isWarActive && utils.isWarActive(warId)) || false);
// Use cached attacks if present and not forced to refresh and the client-side backoff allows it
if (!options.forceManifest && Array.isArray(entry.attacks) && entry.attacks.length > 0 && isNonActive) {
const freshMs = (entry.updatedAt && (now - entry.updatedAt) < 60_000); // 1 minute freshness
const backoffOk = entry.nextAllowedFetchMs ? (now < entry.nextAllowedFetchMs) : false;
if (freshMs || backoffOk || !!entry.finalized) {
try { tdmlogger('debug', `getRankedWarAttacksSmart: using cached final attacks and skipping manifest fetch for war ${warId}`, { count: entry.attacks.length }); } catch(_) {}
return entry.attacks;
}
}
} catch(_) { /* swallow cache-inspection errors and fall through */ }
const manifestRes = await api.fetchWarManifestV2(rankedWarId, factionId, { force: options.forceManifest });
if (manifestRes && manifestRes.status !== 'error' && manifestRes.status !== 'missing') {
// If this war is not active, prefer the final export (if available) to avoid assembling empty snapshot/delta parts
try {
const warId = String(rankedWarId);
const activeNow = utils.isWarActive ? utils.isWarActive(warId) : false;
if (!activeNow) {
const cache = state.rankedWarAttacksCache || (state.rankedWarAttacksCache = {});
let entry = cache[warId] || {};
if (!entry.attacksUrl) {
// Try to populate urls quickly
try {
const candidateIfNone = (entry && typeof entry.lastSeq === 'number') ? String(entry.lastSeq) : (entry && (entry.manifestEtag || entry.storageSummaryEtag || entry.storageAttacksEtag)) || null;
const urls = await api.getWarStorageUrls(warId, factionId, { ifNoneMatch: candidateIfNone }).catch(()=>null);
if (urls) { entry = cache[warId] = { ...(entry||{}), manifestUrl: urls.manifestUrl||entry.manifestUrl, attacksUrl: urls.attacksUrl||entry.attacksUrl, lastSeq: Number(urls.latestChunkSeq||entry.lastSeq||0) }; persistRankedWarAttacksCache(cache); }
} catch(_) {}
}
if (entry.attacksUrl) {
try {
const { status: fStatus, json: fJson } = await api.fetchStorageJson(entry.attacksUrl, {});
if (fStatus === 200 && Array.isArray(fJson) && fJson.length > 0) {
entry.attacks = fJson; entry.finalized = true; entry.updatedAt = Date.now(); cache[warId] = entry;
try { await idb.saveAttacks(warId, entry.attacks); } catch(_) {}
persistRankedWarAttacksCache(cache);
try { state.rankedWarLastAttacksSource = 'storage-final-200'; state.rankedWarLastAttacksMeta = { source: 'storage-final', count: fJson.length, url: entry.attacksUrl }; storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource); storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta); tdmlogger('info', `getRankedWarAttacksSmart: loaded final attacks for war ${warId} (fast-path)`, { count: fJson.length, url: entry.attacksUrl }); } catch(_) {}
return entry.attacks;
}
} catch(_) { /* ignore final fetch failures and fall through to assembly */ }
}
}
} catch(_) {}
const assembled = await api.assembleAttacksFromV2(rankedWarId, factionId, options);
if (Array.isArray(assembled) && assembled.length) return assembled;
}
} catch(_) { /* fall back to legacy path below */ }
const warId = String(rankedWarId);
const cache = state.rankedWarAttacksCache || (state.rankedWarAttacksCache = {});
let entry = cache[warId] || {};
try { tdmlogger('debug', `getRankedWarAttacksSmart: start war=${warId} active=${utils.isWarActive(warId)} lastSeq=${entry.lastSeq||0} finalized=${!!entry.finalized}`); } catch(_) {}
// Non-active war gating: only one fetch per page load unless onDemand=true
const active = utils.isWarActive(warId);
const onceMap = state.session.nonActiveWarFetchedOnce || (state.session.nonActiveWarFetchedOnce = {});
// If this war is finalized (we've confirmed final file) and it's not active, return cached attacks only if non-empty.
// This prevents returning an empty array when the manifest signals finalization but the final file hasn't been fetched yet.
if (!active && entry.finalized && Array.isArray(entry.attacks) && entry.attacks.length > 0) {
return entry.attacks;
}
// If finalized marker present but we have no cached attacks, try one immediate final-file fetch
if (!active && entry.finalized && Array.isArray(entry.attacks) && entry.attacks.length === 0) {
try {
tdmlogger('warn', `[WarAttacks] finalized marker present but no cached attacks; attempting direct final fetch`, { warId, manifestUrl: entry.manifestUrl, attacksUrl: entry.attacksUrl });
} catch(_) {}
if (entry.attacksUrl) {
try {
const { status: fStatus, json: fJson } = await api.fetchStorageJson(entry.attacksUrl, {});
if (fStatus === 200 && Array.isArray(fJson) && fJson.length > 0) {
entry.attacks = fJson;
try { await idb.saveAttacks(warId, entry.attacks); } catch(_) {}
entry.finalized = true;
entry.updatedAt = Date.now();
cache[warId] = entry; persistRankedWarAttacksCache(cache);
try {
state.rankedWarLastAttacksSource = 'storage-final-200';
state.rankedWarLastAttacksMeta = { source: 'storage-final', count: fJson.length, url: entry.attacksUrl };
storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource);
storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta);
tdmlogger('info', `getRankedWarAttacksSmart: loaded final attacks for war ${warId} during finalized-recovery`, { count: fJson.length, url: entry.attacksUrl });
} catch(_) {}
return entry.attacks;
} else {
try { tdmlogger('warn', '[WarAttacks] final fetch during recovery returned no data', { warId, status: fStatus }); } catch(_) {}
}
} catch (e) {
try { tdmlogger('warn', '[WarAttacks] final fetch during recovery failed', { warId, err: e && e.message }); } catch(_) {}
}
}
}
if (!active && onceMap[warId] && !options.onDemand) {
return entry.attacks || [];
}
// Single-flight guard to avoid duplicate concurrent calls/logs
if (state._warFetchInFlight[warId]) {
try { await state._warFetchInFlight[warId]; } catch(_) {}
return (state.rankedWarAttacksCache?.[warId]?.attacks) || entry.attacks || [];
}
let resolveSf; const sfPromise = new Promise(res => resolveSf = res);
state._warFetchInFlight[warId] = sfPromise;
try {
// Ensure we have storage URLs
if (!entry.manifestUrl || !entry.attacksUrl) {
let urls = await api.getWarStorageUrls(warId, factionId, { ifNoneMatch: (entry && typeof entry.lastSeq === 'number') ? String(entry.lastSeq) : (entry && (entry.manifestEtag || entry.storageSummaryEtag || entry.storageAttacksEtag)) || null }).catch(() => null);
if (!urls) {
// Attempt lazy materialization then retry lookup once
await api.ensureWarArtifactsSafe(warId, factionId).catch(() => null);
urls = await api.getWarStorageUrls(warId, factionId, { ifNoneMatch: (entry && typeof entry.lastSeq === 'number') ? String(entry.lastSeq) : (entry && (entry.manifestEtag || entry.storageSummaryEtag || entry.storageAttacksEtag)) || null }).catch(() => null);
}
if (urls) {
entry.manifestUrl = urls.manifestUrl || entry.manifestUrl || null;
entry.attacksUrl = urls.attacksUrl || entry.attacksUrl || null; // final export (used when war ends)
entry.lastSeq = typeof entry.lastSeq === 'number' ? entry.lastSeq : Number(urls.latestChunkSeq || 0);
cache[warId] = entry; persistRankedWarAttacksCache(cache);
try { tdmlogger('info', `getRankedWarAttacksSmart: obtained storage urls for war ${warId}`, { manifestUrl: entry.manifestUrl, attacksUrl: entry.attacksUrl, lastSeq: entry.lastSeq }); } catch(_) {}
// Reset attacks provenance when URLs refresh
try {
state.rankedWarLastAttacksSource = null;
state.rankedWarLastAttacksMeta = {};
storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource);
storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta);
} catch(_) { /* noop */ }
} else {
try { tdmlogger('debug', '[TDM] War attacks: failed to get storage URLs from backend', { warId, factionId }); } catch(_) { /* noop */ }
}
}
// If war is not active and we have a final attacksUrl, prefer it before attempting any manifest/segments to avoid 404 noise
if (!active && entry.attacksUrl && !entry.finalized) {
const { status, json } = await api.fetchStorageJson(entry.attacksUrl, {});
if (status === 200 && Array.isArray(json)) {
entry.attacks = json;
try { await idb.saveAttacks(warId, entry.attacks); } catch(_) {}
entry.lastSeq = Number.isFinite(entry.lastSeq) ? entry.lastSeq : 0;
entry.finalized = true;
// Long backoff (12h jittered) because final won't change
try { entry.nextAllowedFetchMs = Date.now() + Math.floor(12 * 60 * 60 * 1000 * (1 + (Math.random() * 2 - 1) * 0.2)); } catch(_) {}
cache[warId] = entry; persistRankedWarAttacksCache(cache);
try {
state.rankedWarLastAttacksSource = 'storage-final-200';
state.rankedWarLastAttacksMeta = { source: 'storage-final', count: json.length, url: entry.attacksUrl };
storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource);
storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta);
tdmlogger('debug', '[War attacks source: storage final (200)]', state.rankedWarLastAttacksMeta);
} catch(_) { /* noop */ }
try { tdmlogger('info', `getRankedWarAttacksSmart: loaded final attacks for war ${warId}`, { count: Array.isArray(json)?json.length:0, url: entry.attacksUrl }); } catch(_) {}
return entry.attacks;
}
}
// If no manifestUrl, as a fallback try final attacksUrl (only valid after war end)
if (!entry.manifestUrl && entry.attacksUrl) {
const { status, json } = await api.fetchStorageJson(entry.attacksUrl, {});
if (status === 200 && Array.isArray(json)) {
entry.attacks = json;
try { await idb.saveAttacks(warId, entry.attacks); } catch(_) {}
entry.lastSeq = Number.isFinite(entry.lastSeq) ? entry.lastSeq : 0;
// Mark as finalized to avoid future manifest attempts
entry.finalized = true;
// Long backoff since final file won't change; 12h jittered
try { entry.nextAllowedFetchMs = Date.now() + Math.floor(12 * 60 * 60 * 1000 * (1 + (Math.random() * 2 - 1) * 0.2)); } catch(_) {}
cache[warId] = entry; persistRankedWarAttacksCache(cache);
try {
state.rankedWarLastAttacksSource = 'storage-final-200';
state.rankedWarLastAttacksMeta = { source: 'storage-final', count: json.length, url: entry.attacksUrl };
storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource);
storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta);
tdmlogger('debug', '[War attacks source: storage final (200)]', state.rankedWarLastAttacksMeta);
} catch(_) { /* noop */ }
return entry.attacks;
} else {
try { tdmlogger('warn', 'War attacks: final file fetch failed', { url: entry.attacksUrl, status }); } catch(_) { /* noop */ }
}
}
if (!entry.manifestUrl) {
try { tdmlogger('warn', 'War attacks: no manifest URL available; returning cached attacks', { warId, hasCached: Array.isArray(entry.attacks) }); } catch(_) { /* noop */ }
return entry.attacks || [];
}
// Jittered throttle to avoid herd effects across many clients
try {
const nowMs = Date.now();
if (entry.nextAllowedFetchMs && nowMs < entry.nextAllowedFetchMs) {
return entry.attacks || [];
}
} catch(_) { /* ignore throttle read errors */ }
// Fetch manifest with conditional headers
// Decide if we should fetch manifest now; if not, fast return cached attacks
const now0 = Date.now();
if (!api.shouldFetchManifest(entry, options?.reason || null, active, now0)) {
return entry.attacks || [];
}
try { tdmlogger('debug', `getRankedWarAttacksSmart: fetching manifest for war ${warId}`, { manifestUrl: entry.manifestUrl, etag: entry.etags?.manifest, lastModified: entry.lastModified }); } catch(_) {}
let { status: mStatus, json: mJson, etag: mEtag, lastModified: mLm } = await api.fetchStorageJson(entry.manifestUrl, { etag: entry.etags?.manifest, ifModifiedSince: entry.lastModified });
let didUpdate = false;
if (mStatus === 200 && mJson && typeof mJson === 'object') {
entry.etags = entry.etags || {};
entry.etags.manifest = mEtag || entry.etags.manifest || null;
entry.lastModified = mLm || entry.lastModified || null;
entry.lastManifestFetchMs = Date.now();
// Capture enriched fields
if (typeof mJson.warStart !== 'undefined') entry.warStart = mJson.warStart;
if (typeof mJson.warEnd !== 'undefined') entry.warEnd = mJson.warEnd;
if (typeof mJson.storageAttacksComplete !== 'undefined') entry.storageAttacksComplete = !!mJson.storageAttacksComplete;
if (typeof mJson.storageAttacksThrough !== 'undefined') entry.storageAttacksThrough = mJson.storageAttacksThrough;
if (typeof mJson.storageSummaryLastWriteAt !== 'undefined') entry.storageSummaryLastWriteAt = mJson.storageSummaryLastWriteAt;
if (typeof mJson.storageAttacksLastWriteAt !== 'undefined') entry.storageAttacksLastWriteAt = mJson.storageAttacksLastWriteAt;
try {
state.rankedWarLastAttacksSource = 'manifest-200';
state.rankedWarLastAttacksMeta = { source: 'manifest', etag: mEtag || null, lastModified: mLm || null, attacksSeq: Number((mJson.latestSeq != null ? mJson.latestSeq : mJson.attacksSeq) || 0), summaryEtag: mJson.summaryEtag || null, manifestUrl: entry.manifestUrl };
storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource);
storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta);
tdmlogger('info', 'War attacks source: manifest (200)', state.rankedWarLastAttacksMeta);
} catch(_) { /* noop */ }
const latestSeq = Number((mJson.latestSeq != null ? mJson.latestSeq : mJson.attacksSeq) || 0);
const fromSeq = Number(entry.lastSeq || 0);
// Build new chunks list
const want = [];
for (let seq = fromSeq + 1; seq <= latestSeq; seq++) want.push(seq);
// Fetch each wanted chunk (immutable) and append
if (want.length > 0 && entry.attacksUrl) {
const base = entry.attacksUrl.replace(/\.json$/i, '');
const loaded = [];
for (const seq of want) {
const url = `${base}_${seq}.json`;
const { status, json, etag } = await api.fetchStorageJson(url, {});
if (status === 200 && Array.isArray(json)) {
// Append and record
if (!entry.attacks) entry.attacks = [];
// Basic de-dupe by attack id if present
const had = new Set(entry.attacks.map(a => a?.attackId || a?.id || a?.timestamp));
for (const a of json) { const key = a?.attackId || a?.id || a?.timestamp; if (!had.has(key)) entry.attacks.push(a); }
entry.etags[String(seq)] = etag || null;
loaded.push(seq);
try {
state.rankedWarLastAttacksSource = 'chunk-200';
state.rankedWarLastAttacksMeta = { source: 'chunk', seq, count: json.length, url };
storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource);
storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta);
tdmlogger('info', 'War attacks source: chunk (200)', state.rankedWarLastAttacksMeta);
} catch(_) { /* noop */ }
} else {
try { tdmlogger('warn', 'War attacks: chunk fetch failed', { seq, url, status }); } catch(_) { /* noop */ }
}
}
// Trim to last 4000 attacks to bound memory
if (entry.attacks && entry.attacks.length > 4000) entry.attacks = entry.attacks.slice(-4000);
if (loaded.length > 0) entry.lastSeq = Math.max(...loaded, fromSeq);
cache[warId] = entry; persistRankedWarAttacksCache(cache);
if (loaded.length > 0) didUpdate = true;
}
// Optionally refresh summary if manifest indicates change
if (mJson.summaryEtag && state.rankedWarSummaryCache?.[warId]?.etag !== mJson.summaryEtag) {
try { await api.getRankedWarSummarySmart(warId, factionId); } catch(_) { /* ignore */ }
}
}
if (mStatus === 304) {
try {
state.rankedWarLastAttacksSource = 'manifest-304';
state.rankedWarLastAttacksMeta = { source: 'manifest', etag: entry.etags?.manifest || null, lastModified: entry.lastModified || null, manifestUrl: entry.manifestUrl };
storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource);
storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta);
tdmlogger('debug', 'War attacks source: manifest (304/not modified)', state.rankedWarLastAttacksMeta);
} catch(_) { /* noop */ }
entry.lastManifestFetchMs = Date.now();
// Build a lightweight manifest object from cached entry fields so callers can operate
// without requiring a subsequent full manifest body fetch. This helps when fetch returns 304.
try {
const pseudo = {};
pseudo.manifestVersion = entry.v2Enabled ? 2 : 1;
pseudo.latestSeq = Number(entry.lastSeq || 0);
pseudo.attacksSeq = Number(entry.lastSeq || 0);
if (typeof entry.warStart !== 'undefined') pseudo.warStart = entry.warStart;
if (typeof entry.warEnd !== 'undefined') pseudo.warEnd = entry.warEnd;
if (entry.windowUrl) pseudo.window = { url: entry.windowUrl, fromSeq: Number(entry.windowFromSeq||0), toSeq: Number(entry.windowToSeq||0), count: Number(entry.windowCount||0) };
if (entry.snapshotUrl) pseudo.snapshot = { url: entry.snapshotUrl, seq: Number(entry.lastSnapshotSeq||0) };
if (entry.deltaUrl) pseudo.delta = { url: entry.deltaUrl };
if (entry.attacksUrl) pseudo.final = { url: entry.attacksUrl };
pseudo.finalized = !!entry.finalized;
return { status: 'not-modified', manifest: pseudo };
} catch(_) { /* ignore pseudo-build errors and fall through */ }
}
if (mStatus === 404) {
// Attempt ensure then retry manifest once
await api.ensureWarArtifactsSafe(warId, factionId).catch(() => null);
try { await new Promise(r => setTimeout(r, 400)); } catch(_) {}
try {
const urls2 = await api.getWarStorageUrls(warId, factionId, { ifNoneMatch: (entry && typeof entry.lastSeq === 'number') ? String(entry.lastSeq) : (entry && (entry.manifestEtag || entry.storageSummaryEtag || entry.storageAttacksEtag)) || null }).catch(() => null);
if (urls2?.manifestUrl && urls2.manifestUrl !== entry.manifestUrl) {
entry.manifestUrl = urls2.manifestUrl; cache[warId] = entry; persistRankedWarAttacksCache(cache);
({ status: mStatus, json: mJson, etag: mEtag, lastModified: mLm } = await api.fetchStorageJson(entry.manifestUrl, { etag: entry.etags?.manifest, ifModifiedSince: entry.lastModified }));
if (mStatus === 200 && mJson && typeof mJson === 'object') {
entry.etags = entry.etags || {}; entry.etags.manifest = mEtag || entry.etags.manifest || null; entry.lastModified = mLm || entry.lastModified || null;
}
}
} catch(_) { /* ignore retry errors */ }
}
if (mStatus !== 200 && mStatus !== 304) {
try { tdmlogger('warn', 'War attacks: manifest fetch failed', { url: entry.manifestUrl, status: mStatus }); } catch(_) { /* noop */ }
// Fallback: if manifest is missing (e.g., only final export exists), try the final attacks JSON
if (entry.attacksUrl && (mStatus === 404 || (mStatus >= 400 && mStatus < 600))) {
try {
const { status: fStatus, json: fJson } = await api.fetchStorageJson(entry.attacksUrl, {});
if (fStatus === 200 && Array.isArray(fJson)) {
entry.attacks = fJson;
try { await idb.saveAttacks(warId, entry.attacks); } catch(_) {}
entry.lastSeq = Number.isFinite(entry.lastSeq) ? entry.lastSeq : 0;
// Mark as finalized and avoid manifest re-tries
entry.finalized = true;
entry.manifestUrl = null;
// Long backoff since final file won't change; 12h jittered
try { entry.nextAllowedFetchMs = Date.now() + Math.floor(12 * 60 * 60 * 1000 * (1 + (Math.random() * 2 - 1) * 0.2)); } catch(_) {}
cache[warId] = entry; persistRankedWarAttacksCache(cache);
try {
state.rankedWarLastAttacksSource = 'storage-final-200';
state.rankedWarLastAttacksMeta = { source: 'storage-final', count: fJson.length, url: entry.attacksUrl };
storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource);
storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta);
tdmlogger('info', 'War attacks source: storage final (200)', state.rankedWarLastAttacksMeta);
} catch(_) { /* noop */ }
return entry.attacks;
} else {
try { tdmlogger('warn', 'War attacks: final fallback fetch failed', { url: entry.attacksUrl, status: fStatus }); } catch(_) { /* noop */ }
}
} catch(_) { /* ignore */ }
}
}
// Set next allowed fetch with jitter/backoff: shorter after updates, longer when idle
try {
const jitter = (ms, pct = 0.2) => Math.floor(ms * (1 + (Math.random() * 2 - 1) * pct));
// If war not active, lengthen idle backoff to reduce background traffic
const phaseActive = active;
const finalized = !!entry.storageAttacksComplete && !!entry.warEnd && entry.warEnd > 0;
let baseMs;
if (phaseActive) baseMs = didUpdate ? 7000 : 25000;
else if (finalized) baseMs = 300000; // 5m when finalized
else baseMs = 180000; // inactive pre-start
entry.nextAllowedFetchMs = Date.now() + jitter(baseMs, 0.2);
if (didUpdate) entry.updatedAt = Date.now();
cache[warId] = entry; persistRankedWarAttacksCache(cache);
} catch(_) { /* ignore throttle write errors */ }
// Release single-flight
resolveSf(); delete state._warFetchInFlight[warId];
// If 304, no changes; just return current
return entry.attacks || [];
} finally {
try { if (!utils.isWarActive(warId)) { const map = state.session.nonActiveWarFetchedOnce || (state.session.nonActiveWarFetchedOnce = {}); map[warId] = true; } } catch(_) {}
try { resolveSf(); } catch(_) {}
delete state._warFetchInFlight[warId];
}
},
// Choose the freshest source between local aggregation and server summary
getRankedWarSummaryFreshest: async (rankedWarId, factionId) => {
const warId = String(rankedWarId);
// PATCH: Prefer cheap server summary first to avoid expensive local attack aggregation
// This trades potential <2min latency for massive bandwidth/CPU savings.
try {
const smartSummary = await api.getRankedWarSummarySmart(warId, factionId);
if (Array.isArray(smartSummary) && smartSummary.length > 0) {
return smartSummary;
}
} catch(_) {/* swallow and fall through to full freshness check */ }
// Only poll attacks/manifest automatically during active wars; for inactive wars rely on cached/finalized data unless UI triggers onDemand
try { if (utils.isWarActive(warId)) { await api.getRankedWarAttacksSmart(warId, factionId, { onDemand: false }); } } catch(_) {}
const attacksEntry = state.rankedWarAttacksCache?.[warId] || {};
const summaryEntry = state.rankedWarSummaryCache?.[warId] || {};
const parseLm = (lm) => {
if (!lm) return 0;
if (typeof lm === 'number') return lm;
const t = Date.parse(lm); return Number.isFinite(t) ? t : 0;
};
const localTs = Math.max(parseLm(attacksEntry.lastModified), Number(attacksEntry.updatedAt || 0));
const serverTs = Math.max(parseLm(summaryEntry.lastModified), Number(summaryEntry.updatedAt || 0));
if (localTs && localTs > serverTs) {
try {
const local = await api.getRankedWarSummaryLocal(warId, factionId);
if (Array.isArray(local) && local.length > 0) {
// Stamp provenance when local is chosen
try {
state.rankedWarLastSummarySource = 'local';
state.rankedWarLastSummaryMeta = {
source: 'local',
attacksCount: Array.isArray(attacksEntry.attacks) ? attacksEntry.attacks.length : 0,
lastSeq: Number(attacksEntry.lastSeq || 0),
lastModified: attacksEntry.lastModified || null,
};
storage.set('rankedWarLastSummarySource', state.rankedWarLastSummarySource);
storage.set('rankedWarLastSummaryMeta', state.rankedWarLastSummaryMeta);
// console.info('[TDM] War summary source: local-attacks', state.rankedWarLastSummaryMeta); // silenced to reduce noise
} catch(_) { /* noop */ }
return local;
}
} catch(_) {}
}
return api.getRankedWarSummarySmart(warId, factionId);
},
// Unified user fetch that normalizes both legacy (player_id etc.) and new v2 (profile,faction) shapes
getTornUser: (apiKey, id = null, selections = 'faction,profile') => {
if (!apiKey) return Promise.reject(new Error("No API key provided"));
const apiUrl = `https://api.torn.com/v2/user/${id ? id + '/' : ''}?selections=${selections}&key=${apiKey}&comment=TDM_FEgTU×tamp=${Math.floor(Date.now()/1000)}`;
return new Promise((resolve, reject) => {
const ret = state.gm.rD_xmlhttpRequest({
method: "GET",
url: apiUrl,
onload: res => {
try {
const data = JSON.parse(res.responseText);
utils.incrementClientApiCalls(1);
if (data.error) {
const err = new Error(data.error?.error || 'Torn API error');
err.tornError = data.error;
err.tornErrorCode = data.error?.code;
err.tornErrorMessage = data.error?.error;
if (typeof err.tornErrorCode !== 'undefined' && err.tornErrorMessage) {
err.message = `Torn API error ${err.tornErrorCode}: ${err.tornErrorMessage}`;
} else if (err.tornErrorMessage) {
err.message = `Torn API error: ${err.tornErrorMessage}`;
}
return reject(err);
}
// Normalize shape
let normalized = data;
try {
const hasProfile = data && data.profile && typeof data.profile === 'object';
const hasFaction = data && data.faction && typeof data.faction === 'object';
if (hasProfile) {
const p = data.profile;
normalized = {
profile: {
id: p.id || p.player_id || p.playerId,
name: p.name,
level: p.level,
rank: p.rank,
title: p.title,
age: p.age,
signed_up: p.signed_up || p.signup || p.signedup,
faction_id: p.faction_id || p.factionId || (data.faction?.id),
honor_id: p.honor_id || p.honorId || p.honor,
property: p.property || { id: p.property_id, name: p.property },
image: p.image || p.profile_image,
gender: p.gender,
revivable: typeof p.revivable === 'boolean' ? p.revivable : (p.revivable === 1),
role: p.role,
status: p.status || data.status || {},
spouse: p.spouse || p.married || null,
awards: p.awards,
friends: p.friends,
enemies: p.enemies,
forum_posts: p.forum_posts || p.forumposts,
karma: p.karma,
last_action: p.last_action || data.last_action || {},
life: p.life || data.life || {},
},
faction: hasFaction ? {
id: data.faction.id || data.faction.faction_id,
name: data.faction.name || data.faction.faction_name,
tag: data.faction.tag || data.faction.faction_tag,
tag_image: data.faction.tag_image || data.faction.faction_tag_image,
position: data.faction.position,
days_in_faction: data.faction.days_in_faction
} : (p.faction_id ? { id: p.faction_id } : null)
};
} else if (data.player_id) {
// Legacy flat shape -> wrap into profile
normalized = {
profile: {
id: data.player_id,
name: data.name,
level: data.level,
rank: data.rank,
title: data.title,
age: data.age,
signed_up: data.signed_up || data.signup,
faction_id: data.faction?.faction_id,
honor_id: data.honor_id || data.honor,
property: data.property ? { id: data.property_id, name: data.property } : undefined,
image: data.profile_image,
gender: data.gender,
revivable: data.revivable === 1 || data.revivable === true,
role: data.role,
status: data.status || {},
spouse: data.spouse || data.married || null,
awards: data.awards,
friends: data.friends,
enemies: data.enemies,
forum_posts: data.forum_posts,
karma: data.karma,
last_action: data.last_action || {},
life: data.life || {},
},
faction: data.faction ? {
id: data.faction.faction_id,
name: data.faction.faction_name,
tag: data.faction.faction_tag,
tag_image: data.faction.faction_tag_image,
position: data.faction.position,
days_in_faction: data.faction.days_in_faction
} : null
};
}
} catch(_) { /* swallow normalization issues */ }
resolve(normalized);
} catch (e) { reject(new Error("Invalid JSON from Torn API")); }
},
onerror: () => reject(new Error("Torn API request failed"))
});
if (ret && typeof ret.catch === 'function') ret.catch(() => {});
});
},
// Centralized: update all unified status records from tornFactionData using V2 model
updateAllUnifiedStatusRecordsFromFactionData: function() {
try {
const allFactions = state.tornFactionData || {};
state.unifiedStatus = state.unifiedStatus || {};
for (const [factionId, entry] of Object.entries(allFactions)) {
const membersObj = entry?.data?.members || entry?.data?.faction?.members || null;
const members = membersObj ? (Array.isArray(membersObj) ? membersObj : Object.values(membersObj)) : [];
for (const m of members) {
try {
const id = String(m?.id || m?.player_id || m?.user_id || m?.userID || '');
if (!id) continue;
const prevRec = state.unifiedStatus[id] || null;
const nextRec = utils.buildUnifiedStatusV2(m, prevRec);
if (nextRec) {
state.unifiedStatus[id] = nextRec;
utils.emitStatusChangeV2(prevRec, nextRec);
}
} catch(_) { /* per-member swallow */ }
}
}
scheduleUnifiedStatusSnapshotSave();
} catch(e) { if (state?.debug?.statusWatch) tdmlogger('warn', '[StatusV2] updateAllUnifiedStatusRecordsFromFactionData error', e); }
},
getTornFaction: async function(apiKey, selections = '', factionIdParam = null, options = {}) {
try {
const factionId = factionIdParam || state?.user?.factionId;
if (!factionId) return null;
const want = api.buildSafeFactionSelections(String(selections || '')
.split(',')
.map(s => s.trim())
.filter(Boolean), factionId);
// Merge with existing cached selections to avoid re-calling for already-fetched fields
const cache = state.tornFactionData || (state.tornFactionData = {});
const entry = cache[factionId];
const now = Date.now();
const isFresh = entry && (now - (entry.fetchedAtMs || 0) < config.MIN_FACTION_CACHE_FRESH_MS);
// If data is fresh and includes all requested selections, return it
if (isFresh && entry?.data) {
const have = new Set(entry.selections || []);
const allIncluded = want.every(s => have.has(s));
if (allIncluded) {
try {
state.script.lastFactionRefreshSkipReason = 'fresh-cache';
// tdmlogger('debug', '[getTornFaction] Skip fetch: fresh-cache (<=10s) for selections', selections, 'factionId=', factionId||'default');
ui.updateApiCadenceInfo?.();
} catch(_) {}
return entry.data;
}
}
// Determine selections to request: union of want and already cached
const merged = new Set([...(entry?.selections || []), ...want]);
const mergedList = Array.from(merged);
// If fresh but missing some fields, we still wait until freshness expires unless we never had data
if (isFresh && entry?.data) {
// Return current data immediately; schedule a background refresh for missing fields
(async () => {
try {
const urlBg = `https://api.torn.com/v2/faction/${factionId}?selections=${mergedList.join(',')}&key=${apiKey}&sort=DESC&comment=TDM_BEgTF×tamp=${Math.floor(Date.now()/1000)}`;
const resBg = await fetch(urlBg);
const jsonBg = await resBg.json();
utils.incrementClientApiCalls(1);
if (!jsonBg.error) {
cache[factionId] = { data: jsonBg, fetchedAtMs: Date.now(), selections: mergedList };
// Trim and persist cached faction bundles to keep memory/storage bounded
try { utils.trimTornFactionData?.(20); } catch(_) { utils.schedulePersistTornFactionData(); }
// Update chain fallback timing from background chain data if present
try {
const chain = jsonBg?.chain;
// Only update chainFallback if this fetch corresponds to OUR faction (avoid opponent flicker)
if (chain && typeof chain === 'object' && String(factionId) === String(state?.user?.factionId)) {
// Chain timeout in Torn v2 may be a remaining-seconds counter (small number) not an epoch.
(function(){
try {
const nowSec = Math.floor(Date.now()/1000);
const current = Number(chain.current)||0;
const raw = Number(chain.timeout)||0; // could be seconds remaining OR epoch
let timeoutEpoch = 0;
if (raw > 0) {
// Heuristic: treat as epoch if it already looks like a unix timestamp (> 1B).
timeoutEpoch = raw > 1_000_000_000 ? raw : (nowSec + raw);
}
state.ui.chainFallback.current = current;
state.ui.chainFallback.timeoutEpoch = timeoutEpoch;
// Preserve chain end epoch if provided for fallback display
const endEpoch = Number(chain.end)||0;
if (endEpoch > 0) state.ui.chainFallback.endEpoch = endEpoch;
} catch(_) { /* ignore */ }
})();
}
} catch(_) {}
if (!options.skipUnifiedUpdate) {
try { api.updateAllUnifiedStatusRecordsFromFactionData(); } catch(_) {}
}
try {
state.script.lastFactionRefreshBackgroundMs = Date.now();
tdmlogger('debug', '[getTornFaction] Background update applied for faction', factionId, 'selections=', mergedList);
ui.updateApiCadenceInfo?.();
} catch(_) {}
} else {
// Log background errors to help debug "Traveling" issues
try { tdmlogger('warn', '[getTornFaction] Background fetch API error', jsonBg.error); } catch(_) {}
}
} catch (e) {
// Catch all background errors to prevent unhandled rejections
try { tdmlogger('warn', '[getTornFaction] Background fetch exception', e); } catch(_) {}
}
})();
try {
state.script.lastFactionRefreshSkipReason = 'fresh-partial-bg';
tdmlogger('debug', '[getTornFaction] Partial fresh detected; background refresh scheduled for missing selections');
ui.updateApiCadenceInfo?.();
} catch(_) {}
return entry.data;
}
// Fetch merged bundle now
const url = `https://api.torn.com/v2/faction/${factionId}?selections=${mergedList.join(',')}&key=${apiKey}&sort=DESC&comment=TDM_BEgTF2×tamp=${Math.floor(Date.now()/1000)}`;
let response = await fetch(url);
let data = await response.json();
utils.incrementClientApiCalls(1);
if (data.error) {
const err = new Error(data.error?.error || 'Torn API error');
err.tornError = data.error;
err.tornErrorCode = data.error?.code;
err.tornErrorMessage = data.error?.error;
if (typeof err.tornErrorCode !== 'undefined' && err.tornErrorMessage) {
err.message = `Torn API error ${err.tornErrorCode}: ${err.tornErrorMessage}`;
} else if (err.tornErrorMessage) {
err.message = `Torn API error: ${err.tornErrorMessage}`;
}
throw err;
}
cache[factionId] = { data, fetchedAtMs: now, selections: mergedList };
// Trim and persist cached faction bundles to keep memory/storage bounded
try { utils.trimTornFactionData?.(20); } catch(_) { utils.schedulePersistTornFactionData(); }
// Update chain fallback timing from fresh fetch if chain data present
try {
const chain = data?.chain;
// Only update chainFallback if this is OUR faction's data (prevent swapping with opponent faction chain)
if (chain && typeof chain === 'object' && String(factionId) === String(state?.user?.factionId)) {
(function(){
try {
const nowSec = Math.floor(Date.now()/1000);
const current = Number(chain.current)||0;
const raw = Number(chain.timeout)||0;
let timeoutEpoch = 0;
if (raw > 0) timeoutEpoch = raw > 1_000_000_000 ? raw : (nowSec + raw);
state.ui.chainFallback.current = current;
state.ui.chainFallback.timeoutEpoch = timeoutEpoch;
const endEpoch = Number(chain.end)||0;
if (endEpoch > 0) state.ui.chainFallback.endEpoch = endEpoch;
} catch(_) { /* noop */ }
})();
}
} catch(_) {}
// Centralized: update all unified status records from tornFactionData (V2)
if (!options.skipUnifiedUpdate) {
try { api.updateAllUnifiedStatusRecordsFromFactionData(); } catch(_) {}
}
try {
state.script.lastFactionRefreshFetchMs = Date.now();
state.script.lastFactionRefreshSkipReason = null;
// tdmlogger('debug', '[getTornFaction] Fresh fetch completed for selections', selections, 'factionId=', factionId||'default');
ui.updateApiCadenceInfo?.();
} catch(_) {}
// (Removed: in-memory status cache update; now handled by unified status record update)
return data;
} catch (error) {
tdmlogger('error', '[getTornFaction] Error fetching faction data:', error);
return null;
}
},
getKeyInfo:async function(apiKey) {
try{
const kv = ui && ui._kv;
const cacheKey = 'torn_api_key';
const now = Date.now();
// Try IndexedDB cache first
try {
const cached = await kv?.getItem(cacheKey);
if (cached && typeof cached === 'object') {
const { data: cachedData, ts } = cached;
if (cachedData && ts && (now - ts < 60 * 60 * 1000)) {
// Use cached result and update state flags
try {
const access = cachedData?.info?.access;
state.session.factionApi = state.session.factionApi || {};
state.user.factionAPIAccess = !!(access && access.faction);
if (typeof access?.level === 'number') state.user.actualTornApiKeyAccess = access.level;
storage.updateStateAndStorage('user', state.user);
} catch(_) {}
return cachedData;
}
}
} catch(_) { /* ignore cache read issues */ }
const url = `https://api.torn.com/v2/key/info?key=${apiKey}&comment=TDMKey`; // removed ×tamp=${Math.floor(Date.now()/1000)}
const response = await fetch(url);
const data = await response.json();
utils.incrementClientApiCalls(1);
if (data.error) {
const err = new Error(data.error?.error || 'Torn API error');
err.tornError = data.error;
err.tornErrorCode = data.error?.code;
err.tornErrorMessage = data.error?.error;
if (typeof err.tornErrorCode !== 'undefined' && err.tornErrorMessage) {
err.message = `Torn API error ${err.tornErrorCode}: ${err.tornErrorMessage}`;
} else if (err.tornErrorMessage) {
err.message = `Torn API error: ${err.tornErrorMessage}`;
}
throw err;
}
// Persist to IndexedDB with 1h TTL
try { await kv?.setItem(cacheKey, { data, ts: now }); } catch(_) {}
// Track access level and faction access scope and expose factionAPIAccess
try {
state.session.factionApi = state.session.factionApi || {};
const access = data?.info?.access;
if (access && typeof access.level === 'number') {
state.user.actualTornApiKeyAccess = access.level;
}
state.user.factionAPIAccess = !!(access && access.faction);
storage.updateStateAndStorage('user', state.user);
// Maintain legacy flags for any consumers
const scopes = access?.scopes || access?.scope || [];
const scopesStr = Array.isArray(scopes) ? scopes.map(s=>String(s).toLowerCase()) : [];
if (scopesStr.length > 0) {
const hasFaction = scopesStr.some(s => s.startsWith('factions'));
const hasFactionAttacks = scopesStr.includes('factions.attacks');
state.session.factionApi.hasFactionAccess = !!hasFaction;
if (!hasFactionAttacks) state.session.factionApi.allowAttacksSelection = false;
}
} catch(_) { /* non-fatal */ }
return data;
} catch (error) {
tdmlogger('error', '[getKeyInfo] Error fetching key info:', error);
return null;
}
},
// Bundle refresh for both factions (ours and opponent) with union selections; respects freshness
refreshFactionBundles: async function(options = {}) {
if (options === true) {
options = { force: true };
} else if (!options || typeof options !== 'object') {
options = {};
}
// Ensure throttle state exists immediately to prevent access errors
state._factionBundleThrottle = state._factionBundleThrottle || { lastCall: 0, lastIds: [], skipped: 0, lastSkipLog: 0, lastSourceCall: {} };
const key = state.user.actualTornApiKey;
if (!key) return;
const nowThrottle = Date.now();
// Reuse existing cadence concepts: use MIN_GLOBAL_FETCH_INTERVAL_MS as a hard floor, and the
// current factionBundleRefreshMs (user adjustable) as the primary pacing reference.
const userCadence = state.script?.factionBundleRefreshMs || config.DEFAULT_FACTION_BUNDLE_REFRESH_MS;
// Allow some mid-interval opportunistic calls (e.g. focus regain) but never closer than MIN_GLOBAL_FETCH_INTERVAL_MS.
// We permit at most one extra call between scheduled ticks -> choose floor = MIN_GLOBAL_FETCH_INTERVAL_MS
const minIntervalMs = Math.max(config.MIN_GLOBAL_FETCH_INTERVAL_MS || 2000, 0);
state._factionBundleThrottle = state._factionBundleThrottle || { lastCall: 0, lastIds: [], skipped: 0, lastSkipLog: 0 };
state._factionBundleThrottle.lastSourceCall = state._factionBundleThrottle.lastSourceCall || {};
// Allow force bypass with options.force (used sparingly for manual user refresh actions)
if (!options.force) {
if (state._factionBundleThrottle.lastCall && (nowThrottle - state._factionBundleThrottle.lastCall) < minIntervalMs) {
state._factionBundleThrottle.skipped++;
try {
state.script.lastFactionRefreshSkipReason = `throttled:${nowThrottle - state._factionBundleThrottle.lastCall}ms<${minIntervalMs}`;
if ((nowThrottle - state._factionBundleThrottle.lastSkipLog) > 4000) {
state._factionBundleThrottle.lastSkipLog = nowThrottle;
tdmlogger('debug', '[FactionBundles] SKIP (throttled)', state.script.lastFactionRefreshSkipReason);
}
ui.updateApiCadenceInfo?.();
} catch(_) {}
return; // hard skip
}
}
// Soft guard against overlapping concurrent executions
if (state._factionBundleThrottle.inFlight) {
tdmlogger('debug', '[FactionBundles] SKIP (inFlight)');
state.script.lastFactionRefreshSkipReason = 'inflight';
return;
}
// Exclude 'attacks' client-side; retaliation and attacks are provided by backend/storage
const ourSel = options.ourSelections || 'basic,members,rankedwars,chain';
const oppSel = options.oppSelections || 'basic,members,rankedwars,chain';
const ourId = state.user.factionId || null;
const oppId = state.lastOpponentFactionId || state?.warData?.opponentId || null;
// If explicit page faction ids were passed in, use them; otherwise try to detect from rank box when not on our own war
let idsToFetch = Array.isArray(options.pageFactionIds) ? options.pageFactionIds.filter(Boolean) : null;
if (!idsToFetch || idsToFetch.length === 0) {
try {
const vis = utils.getVisibleRankedWarFactionIds?.();
if (vis && Array.isArray(vis.ids) && vis.ids.length > 0) {
idsToFetch = vis.ids.slice();
}
} catch(_) { /* noop */ }
}
// Fallback to our/opponent ids if we couldn't detect from page
if (!idsToFetch || idsToFetch.length === 0) {
idsToFetch = [];
}
const extraPollRaw = storage.get('tdmExtraFactionPolls', '');
const extraPollIds = utils.parseFactionIdList(extraPollRaw);
try { state.script.additionalFactionPolls = extraPollIds.slice(); } catch(_) { /* noop */ }
const warId = state.lastRankWar?.id ? String(state.lastRankWar.id) : null;
const warActive = warId ? !!(utils.isWarActive?.(warId)) : false;
// Detect pre-war (announced but not started)
const warPre = (() => {
if (!warId) return false;
const w = utils.getWarById?.(warId) || state.lastRankWar;
if (!w) return false;
const now = Math.floor(Date.now() / 1000);
const start = Number(w.start || w.startTime || 0);
// Pre-war if start is in future
return start && now < start;
})();
const oppIdStr = oppId ? String(oppId) : null;
const shouldPollOpponent = !!(oppIdStr && (warActive || warPre || extraPollIds.includes(oppIdStr)));
try {
state.script.lastOpponentPollActive = shouldPollOpponent;
state.script.lastOpponentPollWarActive = warActive;
let reason = 'none';
if (shouldPollOpponent) {
if (warActive) reason = 'war-active';
else if (warPre) reason = 'war-pre';
else reason = 'forced';
} else if (oppIdStr) {
reason = 'paused';
}
state.script.lastOpponentPollReason = reason;
if (!oppIdStr) state.script.lastOpponentPollAt = null;
} catch(_) { /* noop */ }
const candidateIds = new Set();
if (Array.isArray(idsToFetch)) {
idsToFetch.forEach(id => {
const val = String(id || '').trim();
if (!val) return;
// Always poll explicitly requested IDs (e.g. visible on page), ignoring opponent poll restrictions
candidateIds.add(val);
});
}
if (ourId) {
candidateIds.add(String(ourId));
}
if (shouldPollOpponent && oppIdStr) {
candidateIds.add(oppIdStr);
}
extraPollIds.forEach(id => candidateIds.add(id));
if (candidateIds.size === 0) return;
idsToFetch = Array.from(candidateIds);
// Mark attempt timestamp and details for diagnostics
try {
state.script.lastFactionRefreshAttemptMs = Date.now();
state.script.lastFactionRefreshAttemptIds = (idsToFetch || []).slice();
state.script.lastFactionRefreshSkipReason = null; // clear until proven otherwise
tdmlogger('debug', '[FactionBundles] Attempt refresh, ids=', idsToFetch);
ui.updateApiCadenceInfo?.();
} catch(_) { /* ignore */ }
// Mark as inFlight for concurrency guard
state._factionBundleThrottle.inFlight = true;
let anyFetched = false;
try {
// Fetch bundles, using ourSel for our own faction and oppSel for others
for (const id of idsToFetch) {
const sel = (ourId && String(id) === String(ourId)) ? ourSel : oppSel;
try {
// tdmlogger('debug', '[FactionBundles] getTornFaction begin id=', id, 'sel=', sel);
await api.getTornFaction(key, sel, id);
if (shouldPollOpponent && oppIdStr && String(id) === oppIdStr) {
try { state.script.lastOpponentPollAt = Date.now(); } catch(_) { /* noop */ }
}
anyFetched = true;
// tdmlogger('debug', '[FactionBundles] getTornFaction id=', id, 'sel=', sel);
} catch(e) {
tdmlogger('error', '[FactionBundles] fetch error id=', id, e?.message||e);
}
}
} finally {
const throttleState = state._factionBundleThrottle || (state._factionBundleThrottle = { lastCall: 0, lastIds: [], skipped: 0, lastSkipLog: 0, lastSourceCall: {} });
throttleState.inFlight = false;
const stamp = Date.now();
if (anyFetched) {
throttleState.lastCall = stamp;
throttleState.lastIds = idsToFetch.slice();
}
const sourceKey = options.source || 'default';
throttleState.lastSourceCall[sourceKey] = stamp;
}
},
// Get public URLs for storage-hosted ranked war JSON assets
// Consolidated war status fetch (phase-driven cadence) – lightweight doc read via callable.
getWarStatus: async (rankedWarId, factionId, opts = {}) => {
if (api._shouldBailDueToIpRateLimit('getWarStatus')) return null;
const warId = String(rankedWarId || state.lastRankWar?.id || '');
if (!warId) return null;
const throttleState = state._warStatusThrottle || (state._warStatusThrottle = { lastCall: 0, lastStatus: null, lastPromise: null, inFlight: false });
const minInterval = Number.isFinite(opts.minIntervalMs) ? Number(opts.minIntervalMs) : (config.WAR_STATUS_MIN_INTERVAL_MS || 30000);
const now = Date.now();
const source = typeof opts.source === 'string' && opts.source ? opts.source : 'unspecified';
if (!opts.force) {
if (throttleState.inFlight && throttleState.lastPromise) {
return throttleState.lastPromise;
}
if (throttleState.lastCall && (now - throttleState.lastCall) < minInterval) {
if (state.debug.cadence) {
tdmlogger('debug', '[Cadence] getWarStatus throttled', { warId, source, ageMs: now - throttleState.lastCall, minInterval });
}
return throttleState.lastStatus;
}
}
const fetchPromise = (async () => {
throttleState.inFlight = true;
try {
tdmlogger('debug', '[Cadence] getWarStatus fetching', { warId, source, ensureArtifacts: !!opts.ensureArtifacts });
const res = await api.get('getWarStatus', { rankedWarId: warId, factionId, ensureArtifacts: !!opts.ensureArtifacts, source });
const fetchedAt = Date.now();
const statusPayload = (res && typeof res.status === 'object') ? res.status : ((res && res.status) || null);
throttleState.lastCall = fetchedAt;
throttleState.lastStatus = statusPayload;
state.script.lastWarStatusFetchMs = fetchedAt;
if (statusPayload) {
state._warStatusCache = { data: statusPayload, fetchedAt };
}
return statusPayload;
} catch(e) {
if (state.debug.cadence) tdmlogger('warn', '[Cadence] getWarStatus failed', e?.message||e);
return null;
} finally {
throttleState.inFlight = false;
throttleState.lastPromise = null;
}
})();
throttleState.lastPromise = fetchPromise;
return fetchPromise;
},
// Combined status + manifest fetch to coalesce two reads when we are cold booting.
getWarStatusAndManifest: async (rankedWarId, factionId, opts = {}) => {
if (api._shouldBailDueToIpRateLimit('getWarStatusAndManifest')) return null;
const warId = String(rankedWarId || state.lastRankWar?.id || '');
if (!warId) return null;
const throttleState = state._warStatusAndManifestThrottle || (state._warStatusAndManifestThrottle = { lastCall: 0, lastResult: null, lastPromise: null, inFlight: false });
const minInterval = Number.isFinite(opts.minIntervalMs) ? Number(opts.minIntervalMs) : (config.WAR_STATUS_AND_MANIFEST_MIN_INTERVAL_MS || 60000);
const now = Date.now();
const source = typeof opts.source === 'string' && opts.source ? opts.source : 'unspecified';
if (!opts.force) {
if (throttleState.inFlight && throttleState.lastPromise) {
return throttleState.lastPromise;
}
if (throttleState.lastCall && (now - throttleState.lastCall) < minInterval) {
if (state.debug.cadence) {
tdmlogger('debug', '[Cadence] getWarStatusAndManifest throttled', { warId, source, ageMs: now - throttleState.lastCall, minInterval });
}
return throttleState.lastResult || null;
}
}
const fetchPromise = (async () => {
throttleState.inFlight = true;
try {
tdmlogger('debug', '[Cadence] getWarStatusAndManifest fetching', { warId, source, ensureArtifacts: !!opts.ensureArtifacts });
const res = await api.get('getWarStatusAndManifest', { rankedWarId: warId, factionId, ensureArtifacts: !!opts.ensureArtifacts, source });
const fetchedAt = Date.now();
throttleState.lastCall = fetchedAt;
throttleState.lastResult = res;
state.script.lastWarStatusFetchMs = fetchedAt;
if (res && res.status) {
state._warStatusCache = { data: res.status, fetchedAt };
}
if (res && res.manifestPointer && typeof res.manifestPointer === 'object') {
try {
const cache = state.rankedWarAttacksCache || (state.rankedWarAttacksCache = {});
const entry = cache[warId] || (cache[warId] = { warId });
const p = res.manifestPointer;
const urls = p.urls || {};
const seqs = p.seqs || {};
const etags = p.etags || {};
if (urls.window) entry.windowUrl = urls.window;
if (urls.snapshot) entry.snapshotUrl = urls.snapshot;
if (urls.delta) entry.deltaUrl = urls.delta;
if (urls.attacks) entry.attacksUrl = urls.attacks;
if (urls.final) entry.attacksUrl = urls.final;
if (Number.isFinite(seqs.latestSeq)) entry.lastSeq = seqs.latestSeq;
if (Number.isFinite(seqs.attacksSeq) && !entry.lastSeq) entry.lastSeq = seqs.attacksSeq;
if (!entry.lastSeq && Number.isFinite(etags.attacksSeq)) entry.lastSeq = etags.attacksSeq;
if (typeof p.warStart !== 'undefined') entry.warStart = p.warStart;
if (typeof p.warEnd !== 'undefined') entry.warEnd = p.warEnd;
if (p.manifestFingerprint) entry._lastManifestFingerprint = p.manifestFingerprint;
if (typeof seqs.windowFromSeq !== 'undefined') entry.windowFromSeq = seqs.windowFromSeq;
if (typeof seqs.windowToSeq !== 'undefined') entry.windowToSeq = seqs.windowToSeq;
if (typeof seqs.lastSnapshotSeq !== 'undefined') entry.lastSnapshotSeq = seqs.lastSnapshotSeq;
if (typeof p.windowCount !== 'undefined') entry.windowCount = p.windowCount;
entry.v2Enabled = (p.storageMode === 'v2') || (p.manifestVersion === 2) || !!urls.window || !!urls.delta;
cache[warId] = entry; persistRankedWarAttacksCache(cache);
} catch(_) {}
}
if (res && res.manifest && typeof res.manifest === 'object') {
try {
const cache = state.rankedWarAttacksCache || (state.rankedWarAttacksCache = {});
const entry = cache[warId] || (cache[warId] = { warId });
const m = res.manifest;
if (m.window && m.window.url) entry.windowUrl = m.window.url;
if (m.snapshot && m.snapshot.url) entry.snapshotUrl = m.snapshot.url;
if (m.delta && m.delta.url) entry.deltaUrl = m.delta.url;
if (m.final && m.final.url) entry.attacksUrl = m.final.url;
if (Number.isFinite(m.latestSeq)) entry.lastSeq = m.latestSeq;
if (Number.isFinite(m.attacksSeq) && !entry.lastSeq) entry.lastSeq = m.attacksSeq;
if (m.warStart) entry.warStart = m.warStart;
if (m.warEnd) entry.warEnd = m.warEnd;
if (m.manifestFingerprint) entry._lastManifestFingerprint = m.manifestFingerprint;
if (m.window) { entry.windowFromSeq = m.window.fromSeq; entry.windowToSeq = m.window.toSeq; entry.windowCount = m.window.count; }
if (m.snapshot) entry.lastSnapshotSeq = m.snapshot.seq;
entry.v2Enabled = m.storageMode === 'v2' || m.manifestVersion === 2 || !!m.window || !!m.delta;
cache[warId] = entry; persistRankedWarAttacksCache(cache);
} catch(_) {}
}
return res;
} catch(e) {
if (state.debug.cadence) tdmlogger('warn', '[Cadence] getWarStatusAndManifest failed', e?.message||e);
return null;
} finally {
throttleState.inFlight = false;
throttleState.lastPromise = null;
}
})();
throttleState.lastPromise = fetchPromise;
return fetchPromise;
},
getWarStorageUrls: async (rankedWarId, factionId, opts = {}) => {
if (api._shouldBailDueToIpRateLimit('getWarStorageUrls')) return null;
const warId = String(rankedWarId || '');
if (!warId) return null;
// Per-war throttle/dedupe state
const throttleMap = state._warStorageUrlsThrottle || (state._warStorageUrlsThrottle = {});
const throttleState = throttleMap[warId] || (throttleMap[warId] = { lastCall: 0, lastResult: null, lastPromise: null, inFlight: false });
const minInterval = Number.isFinite(opts.minIntervalMs) ? Number(opts.minIntervalMs) : (config.GET_WAR_STORAGE_URLS_MIN_INTERVAL_MS || 30000);
// Respect a lightweight global (cross-tab) burst limiter so multiple tabs won't spam the backend.
const globalMinInterval = Number.isFinite(opts.globalMinIntervalMs) ? Number(opts.globalMinIntervalMs) : (config.GET_WAR_STORAGE_URLS_GLOBAL_MIN_INTERVAL_MS || 30000);
const now = Date.now();
// Fast-path: return in-flight promise or a recent cached result unless caller forces a fresh fetch
if (!opts.force) {
if (throttleState.inFlight && throttleState.lastPromise) return throttleState.lastPromise;
if (throttleState.lastCall && (now - throttleState.lastCall) < minInterval) {
if (state.debug?.cadence) tdmlogger('debug', '[Cadence] getWarStorageUrls throttled', { warId, ageMs: now - throttleState.lastCall, minInterval });
return throttleState.lastResult;
}
}
// Global cross-tab rate limit (1-per-interval) when not explicitly forced.
try {
const globalKey = 'getWarStorageUrlsGlobalLastCallMs';
const lastGlobalMs = Number(storage.get(globalKey, 0) || 0);
if (!opts.force && !opts.ensure && lastGlobalMs && (now - lastGlobalMs) < globalMinInterval) {
if (state.debug?.cadence) tdmlogger('debug', '[Cadence] getWarStorageUrls globally throttled', { warId, ageMs: now - lastGlobalMs, globalMinInterval });
// Prefer returning last in-memory result, otherwise return a synthesized entry if available.
if (throttleState.lastResult) return throttleState.lastResult;
const cachedShort = cache[warId] ? { summaryUrl: cache[warId].manifestUrl || cache[warId].summaryUrl || null, attacksUrl: cache[warId].attacksUrl || null, manifestUrl: cache[warId].manifestUrl || null, latestChunkSeq: cache[warId].lastSeq || 0 } : null;
return cachedShort;
}
} catch(_) { /* best-effort global throttle guard */ }
// Allow short-circuit if we already have cached urls & same latest-seq (light fingerprint)
const cache = state.rankedWarAttacksCache || (state.rankedWarAttacksCache = {});
const entry = cache[warId];
if (!opts.force && entry && typeof entry.lastSeq === 'number' && throttleState.lastResult && typeof throttleState.lastResult.latestChunkSeq === 'number') {
if (throttleState.lastResult.latestChunkSeq === entry.lastSeq) {
if (state.debug?.cadence) tdmlogger('debug', '[Cadence] getWarStorageUrls short-circuited via lastSeq fingerprint', { warId, lastSeq: entry.lastSeq });
return throttleState.lastResult;
}
}
// Perform the fetch, with in-flight dedupe
const fetchPromise = (async () => {
throttleState.inFlight = true;
try {
// Build a client-side conditional token when possible so server can return lightweight not-modified
// responses. Priority: explicit opts.ifNoneMatch -> entry.lastSeq -> entry.manifestEtag -> summary/attacks etags -> throttleState.lastResult.latestChunkSeq
const candidateIfNone = opts.ifNoneMatch || (entry && (typeof entry.lastSeq === 'number' ? String(entry.lastSeq) : (entry.manifestEtag || entry.storageSummaryEtag || entry.storageAttacksEtag || null))) || (throttleState.lastResult && typeof throttleState.lastResult.latestChunkSeq === 'number' ? String(throttleState.lastResult.latestChunkSeq) : null);
const queryParams = { rankedWarId: warId, factionId, ensure: opts.ensure };
if (candidateIfNone) queryParams.ifNoneMatch = candidateIfNone;
const res = await api.get('getWarStorageUrls', queryParams);
const fetchedAt = Date.now();
throttleState.lastCall = fetchedAt;
// Server may respond with { status: 'not-modified' } when our ifNoneMatch matched.
if (res && res.status === 'not-modified') {
// Prefer previous lastResult; if none, synthesize a small result from our cached entry
const synthesized = throttleState.lastResult || (entry ? ({ summaryUrl: entry.summaryUrl || entry.manifestUrl || null, attacksUrl: entry.attacksUrl || null, manifestUrl: entry.manifestUrl || null, latestChunkSeq: entry.lastSeq || 0, summaryEtag: entry.storageSummaryEtag || null, attacksEtag: entry.storageAttacksEtag || null, manifestEtag: entry.manifestEtag || null }) : null);
throttleState.lastResult = synthesized;
// lastCall already updated
return synthesized;
}
// Always record the server response (may indicate manifestMissing)
throttleState.lastResult = res || null;
// Update cross-tab last-call timestamp on successful fetch (even if server returned not-modified)
try {
const globalKey = 'getWarStorageUrlsGlobalLastCallMs';
storage.set(globalKey, Date.now());
} catch(_) { /* best-effort */ }
// Update local cache where appropriate for callers that rely on entry fields
try {
if (throttleState.lastResult && entry) {
if (typeof throttleState.lastResult.latestChunkSeq === 'number') entry.lastSeq = Number(throttleState.lastResult.latestChunkSeq || entry.lastSeq || 0);
if (!entry.manifestUrl && throttleState.lastResult.manifestUrl) entry.manifestUrl = throttleState.lastResult.manifestUrl;
if (!entry.attacksUrl && throttleState.lastResult.attacksUrl) entry.attacksUrl = throttleState.lastResult.attacksUrl;
// Persist any returned etags so future conditional checks can use them
if (typeof throttleState.lastResult.summaryEtag === 'string') entry.storageSummaryEtag = throttleState.lastResult.summaryEtag;
if (typeof throttleState.lastResult.attacksEtag === 'string') entry.storageAttacksEtag = throttleState.lastResult.attacksEtag;
if (typeof throttleState.lastResult.manifestEtag === 'string') entry.manifestEtag = throttleState.lastResult.manifestEtag;
// Honor server hint that manifest is missing and avoid retries for a cooldown
if (throttleState.lastResult.manifestMissing) {
try {
const until = Date.now() + (config.CLIENT_MANIFEST_MISSING_COOLDOWN_MS || 60000);
entry.manifestMissingUntil = until;
if (state.debug?.cadence) tdmlogger('debug', '[Cadence] server reported manifest missing; setting client cooldown', { warId, until });
} catch(_) { /* swallow */ }
}
persistRankedWarAttacksCache(cache);
}
} catch (_) { /* non-fatal cache persistence */ }
return throttleState.lastResult;
} catch (e) {
if (state.debug?.cadence) tdmlogger('warn', '[Cadence] getWarStorageUrls failed', e?.message || e);
return null;
} finally {
throttleState.inFlight = false;
throttleState.lastPromise = null;
}
})();
throttleState.lastPromise = fetchPromise;
return fetchPromise;
},
// Raw Firestore attacks (full list) retained ONLY for deep debugging; not used in normal manifest flow.
getRankedWarAttacksFromFirestore: async (rankedWarId, factionId) => {
const warId = String(rankedWarId);
try {
const data = await api.get('getRankedWarAttacksFromFirestore', { rankedWarId: warId, factionId });
if (data && Array.isArray(data)) return data;
// Some server responses may wrap in { items: [] }
if (data && Array.isArray(data.items)) return data.items;
return [];
} catch(e) {
tdmlogger('warn', '[TDM] getRankedWarAttacksFromFirestore failed', e?.message||e);
return [];
}
},
// Fetch manifest (V2) with caching and ETag handling. Updates cache entry fields.
fetchWarManifestV2: async (rankedWarId, factionId, opts = {}) => {
const warId = String(rankedWarId);
const cache = state.rankedWarAttacksCache || (state.rankedWarAttacksCache = {});
const entry = cache[warId] || (cache[warId] = { warId });
// If we recently observed the server reporting the manifest as missing,
// avoid repeated probes until the client-side cooldown expires.
if (entry.manifestMissingUntil && Date.now() < entry.manifestMissingUntil) {
if (state.debug?.cadence) tdmlogger('debug', `fetchWarManifestV2: manifest suppressed by client cooldown for war ${warId}`, { until: entry.manifestMissingUntil });
return { status: 'missing', manifestMissing: true };
}
if (!entry.manifestUrl) {
// Populate URLs if absent
try {
let urls = await api.getWarStorageUrls(warId, factionId, { ifNoneMatch: (entry && entry.manifestEtag) ? entry.manifestEtag : ((entry && typeof entry.lastSeq === 'number') ? String(entry.lastSeq) : null) }).catch(() => null);
// If server explicitly reports manifestMissing, set a client cooldown and skip ensure attempts
if (urls && urls.manifestMissing) {
entry.manifestMissingUntil = Date.now() + (config.CLIENT_MANIFEST_MISSING_COOLDOWN_MS || 60000);
cache[warId] = entry; persistRankedWarAttacksCache(cache);
if (state.debug?.cadence) tdmlogger('debug', `fetchWarManifestV2: server reported manifestMissing for war ${warId}`, { cooldownMs: config.CLIENT_MANIFEST_MISSING_COOLDOWN_MS });
return { status: 'missing', manifestMissing: true };
}
if (!urls) { await api.ensureWarArtifactsSafe(warId, factionId, { force: true }).catch(()=>null); urls = await api.getWarStorageUrls(warId, factionId, { ifNoneMatch: (entry && entry.manifestEtag) ? entry.manifestEtag : ((entry && typeof entry.lastSeq === 'number') ? String(entry.lastSeq) : null) }).catch(()=>null); }
if (urls?.manifestUrl) entry.manifestUrl = urls.manifestUrl;
if (urls?.attacksUrl) entry.attacksUrl = urls.attacksUrl;
// Write early so that localStorage shows initial manifest/attacks URLs even before first 200 manifest apply
cache[warId] = entry; persistRankedWarAttacksCache(cache); // debug duplicate cache write removed
} catch(_) { /* noop */ }
}
if (!entry.manifestUrl) return { status: 'missing' };
// Use GM-based fetch (fetchStorageJson) to bypass site CSP. Maintain prior return semantics.
const etag = entry.manifestEtag && !opts.force ? entry.manifestEtag : null;
let result;
try {
result = await api.fetchStorageJson(entry.manifestUrl, { etag });
try { tdmlogger('debug', `fetchWarManifestV2: fetched manifest for war ${warId}`, { url: entry.manifestUrl, status: result?.status, etag: result?.etag }); } catch(_) {}
} catch(e) {
return { status: 'error', error: e.message };
}
if (!result || typeof result.status === 'undefined') return { status: 'error', error: 'no-response' };
if (result.status === 304) {
// If we already have manifest-derived URLs, treat as not-modified; ensure v2 stays enabled.
entry.v2Enabled = true; entry.v2 = true;
// Edge case: If no window/snapshot/delta URLs recorded yet and no attacks cached, force a refetch without ETag once.
const missingStructure = !entry.windowUrl && !entry.snapshotUrl && !entry.deltaUrl && !entry.finalized;
const noAttacksYet = !Array.isArray(entry.attacks) || entry.attacks.length === 0;
if (missingStructure && noAttacksYet && !opts._forcedOnce) {
try {
const forceRes = await api.fetchWarManifestV2(rankedWarId, factionId, { force: true, _forcedOnce: true });
return forceRes.status === 'ok' ? forceRes : { status: 'not-modified', manifest: null };
} catch(_) { /* swallow */ }
}
return { status: 'not-modified', manifest: null };
}
if (result.status === 404) return { status: 'missing' };
if (result.status < 200 || result.status >= 300) return { status: 'error', code: result.status };
let manifest = result.json;
if (!manifest || typeof manifest !== 'object') return { status: 'error', error: 'bad-json' };
// Fingerprint short-circuit (before heavy apply) if unchanged
const mfp = manifest.manifestFingerprint || manifest.manifestFP || null;
// Short-circuit if fingerprint unchanged AND we already populated core URL fields
if (mfp && entry._lastManifestFingerprint === mfp) {
return { status: 'not-modified', manifest: null };
}
if (result.etag) entry.manifestEtag = result.etag;
// Persist important fields
entry.v2Enabled = true; entry.v2 = true;
if (manifest.window) {
entry.windowFromSeq = manifest.window.fromSeq;
entry.windowToSeq = manifest.window.toSeq;
entry.windowCount = manifest.window.count;
if (manifest.window.url) entry.windowUrl = manifest.window.url;
}
if (manifest.snapshot) {
entry.lastSnapshotSeq = manifest.snapshot.seq;
if (manifest.snapshot.url) entry.snapshotUrl = manifest.snapshot.url;
}
if (manifest.delta && manifest.delta.url) entry.deltaUrl = manifest.delta.url;
if (manifest.final && manifest.final.url) entry.attacksUrl = manifest.final.url;
// Persist latest known sequence number for gating window vs snapshot path
if (typeof manifest.attacksSeq !== 'undefined') entry.lastSeq = manifest.attacksSeq;
// Manifest v2 uses latestSeq; fall back to this when attacksSeq is absent
if (typeof manifest.latestSeq !== 'undefined' && (!Number.isFinite(entry.lastSeq) || Number(entry.lastSeq) === 0)) {
entry.lastSeq = manifest.latestSeq;
}
if (typeof manifest.warStart !== 'undefined') entry.warStart = manifest.warStart;
if (typeof manifest.warEnd !== 'undefined') entry.warEnd = manifest.warEnd;
if (manifest.finalized) entry.finalized = true;
entry.updatedAt = Date.now();
cache[warId] = entry; persistRankedWarAttacksCache(cache); // debug duplicate cache write removed
// Track last manifest fingerprint locally (lightweight, no metrics object)
if (mfp) entry._lastManifestFingerprint = mfp;
return { status: 'ok', manifest };
},
// Decide whether to fetch window or snapshot+delta and assemble full attack list
assembleAttacksFromV2: async (rankedWarId, factionId, opts = {}) => {
const warId = String(rankedWarId);
const cache = state.rankedWarAttacksCache || (state.rankedWarAttacksCache = {});
let entry = cache[warId];
if (!entry) {
// Cache entry missing (possibly manual localStorage clear) – create minimal shell then force manifest fetch to populate URLs
entry = cache[warId] = { warId, createdAt: Date.now() };
try { await api.fetchWarManifestV2(warId, factionId, { force: true }); entry = cache[warId] || entry; } catch(_) { /* noop */ }
}
if (!entry.v2Enabled) {
// Attempt one forced manifest refresh to enable V2 artifacts before bailing
try { await api.fetchWarManifestV2(warId, factionId, { force: true }); entry = cache[warId] || entry; } catch(_) { /* noop */ }
if (!entry.v2Enabled) return entry.attacks || [];
}
// Short-circuit: if finalized and we already have a local final attacks cache, return it immediately
try {
if (entry.finalized && Array.isArray(entry.attacks) && entry.attacks.length > 0) {
try { tdmlogger('debug', `assembleAttacksFromV2: final cache present, short-circuit for war=${warId}`, { count: entry.attacks.length, url: entry.attacksUrl }); } catch(_) {}
return entry.attacks;
}
} catch(_) {}
try { tdmlogger('debug', `assembleAttacksFromV2: start war=${warId}`, { lastSeq: entry.lastSeq||0, windowCount: entry.windowCount||0, snapshotSeq: entry.lastSnapshotSeq||0, attacksLen: Array.isArray(entry.attacks)?entry.attacks.length:0, v2Enabled: !!entry.v2Enabled }); } catch(_) {}
// Prefer window aggressively during active wars or when we have no cached attacks yet
const lastSeq = Number(entry.lastSeq || 0);
const windowCount = Number(entry.windowCount || 0);
const active = (() => { try { return !!utils.isWarActive(warId); } catch(_) { return false; } })();
const haveExisting = Array.isArray(entry.attacks) && entry.attacks.length > 0;
const snapshotSeq = Number(entry.lastSnapshotSeq || 0);
const windowFromSeq = Number(entry.windowFromSeq || 0);
const windowToSeq = Number(entry.windowToSeq || 0);
// Decide whether snapshot+delta should be preferred.
// Heuristics:
// - snapshot covers almost entire history (snapshotSeq >= lastSeq - 5)
// - OR snapshot exists and history length (snapshotSeq) is much larger than windowCount (meaning we want full history)
// - OR we already have a partial (window-only) cached set whose length is clearly below latest sequence
const historyMuchLargerThanWindow = snapshotSeq > 0 && windowCount > 0 && (snapshotSeq - windowCount) > 200;
const snapshotNearTip = snapshotSeq > 0 && lastSeq > 0 && (snapshotSeq >= (lastSeq - 5));
const cachedLikelyTruncated = haveExisting && lastSeq > 0 && entry.attacks.length + 50 < lastSeq; // truncated if attacks << seq
// Additional heuristic: if windowCount equals cached attacks length and < lastSeq by a material margin, treat as truncated
const windowClearlyTruncated = windowCount > 0 && lastSeq > 0 && windowCount < lastSeq && (!haveExisting || (haveExisting && entry.attacks.length === windowCount && windowCount + 25 < lastSeq));
// Extra heuristics: snapshotSeq greater than lastSeq is suspicious (manifest inconsistency) — prefer snapshot
const snapshotAheadOfLast = snapshotSeq > 0 && lastSeq > 0 && snapshotSeq > lastSeq;
// Treat canonical small window sizes (e.g., 800) as potentially truncated when lastSeq is larger
const suspiciousWindowSize = windowCount > 0 && (windowCount === 800 || windowCount < 1000) && lastSeq > windowCount + 25;
let shouldPreferSnapshot = (snapshotNearTip || historyMuchLargerThanWindow || cachedLikelyTruncated || windowClearlyTruncated || snapshotAheadOfLast || suspiciousWindowSize);
// Record explicit reasons for diagnostics
const reasonFlags = { snapshotNearTip, historyMuchLargerThanWindow, cachedLikelyTruncated, windowClearlyTruncated, snapshotAheadOfLast, suspiciousWindowSize };
// Record a decision object for diagnostics to help debug why window vs snapshot is chosen
try {
const decision = {
lastSeq, windowCount, snapshotSeq, windowFromSeq, windowToSeq,
active, haveExisting, entryAttacksLen: Array.isArray(entry.attacks) ? entry.attacks.length : 0,
reasonFlags
};
entry.fetchDecision = entry.fetchDecision || {};
entry.fetchDecision.lastComputed = decision;
cache[warId] = entry; persistRankedWarAttacksCache(cache);
try { tdmlogger('info', '[TDM][ManifestDecision] computed', decision); } catch(_) {}
} catch(_) { /* swallow diagnostics errors */ }
// If caller explicitly forced window bootstrap, honor that one time
if (opts.forceWindowBootstrap) shouldPreferSnapshot = false;
// Base window decision (fast path for very early war)
let useWindow = !!entry.windowUrl && windowCount > 0 && (active || !haveExisting);
if (shouldPreferSnapshot && snapshotSeq > 0 && entry.snapshotUrl) {
useWindow = false;
}
// Stamp planned fetch path for diagnostics / overlay
try { entry.fetchPathPlanned = useWindow ? 'window' : 'snapshot-delta'; cache[warId] = entry; persistRankedWarAttacksCache(cache); } catch(_) {}
if (!useWindow && opts.forceWindowBootstrap && entry.windowUrl) {
// Caller explicitly requested a window bootstrap (e.g., summary recovery path)
useWindow = true;
}
const etags = entry.etags || (entry.etags = {});
// GM-based conditional JSON fetch honoring ETag
const conditionalGet = async (url, key, force = false) => {
if (!url) return { changed:false, data:null };
try {
const { status, json, etag } = await api.fetchStorageJson(url, force ? {} : { etag: etags[key] });
if (status === 304) return { changed:false, data:null };
if (status >= 200 && status < 300 && Array.isArray(json)) {
if (etag) etags[key] = etag;
return { changed:true, data: json };
}
return { changed:false, data:null };
} catch(_) { return { changed:false, data:null }; }
};
if (useWindow) {
// If heuristics strongly recommend snapshot, skip window fetch and fall through
if (shouldPreferSnapshot && entry.snapshotUrl) {
try { tdmlogger('info', '[TDM] Overriding window fetch due to strong snapshot preference', { warId, reasonFlags }); } catch(_) {}
} else {
let { changed, data } = await conditionalGet(entry.windowUrl, 'window');
// 'forced' must be visible to later metadata stamping when we prefer to bootstrap
let forced;
// If server says 304 but we don't have any attacks yet, force a one-time bootstrap fetch without ETag
if (!changed && (!haveExisting || !Array.isArray(entry.attacks) || entry.attacks.length === 0)) {
forced = await conditionalGet(entry.windowUrl, 'window', true);
if (forced.changed && Array.isArray(forced.data)) { changed = true; data = forced.data; }
}
// If we got the window but it's clearly truncated (length far below lastSeq), immediately attempt snapshot+delta upgrade once
if (changed && Array.isArray(data) && lastSeq > 0 && data.length + 25 < lastSeq && entry.snapshotUrl) {
try { tdmlogger('info', '[TDM] Window fetch appears truncated; attempting immediate snapshot+delta upgrade'); } catch(_) {}
// Store window first for reference
entry.attacks = data; entry.updatedAt = Date.now(); cache[warId] = entry; try { await idb.saveAttacks(warId, entry.attacks); } catch(_) {} persistRankedWarAttacksCache(cache);
try { state.rankedWarLastAttacksSource = 'window-truncated'; state.rankedWarLastAttacksMeta = { source: 'window', count: data.length, windowCount, lastSeq, url: entry.windowUrl, truncated: true }; storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource); storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta); } catch(_) {}
// Recursively invoke without forcing window to allow snapshot path
return await api.assembleAttacksFromV2(warId, factionId, { forceWindowBootstrap: false, _recursedFromTruncated: true });
}
if (changed && Array.isArray(data)) {
// set merged attacks then add decision metadata and persist once
entry.attacks = data;
entry.updatedAt = Date.now();
try {
entry.fetchDecision = entry.fetchDecision || {};
entry.fetchDecision.snapshotFetch = entry.fetchDecision.snapshotFetch || {};
entry.fetchDecision.snapshotFetch.forcedBootstrap = true;
entry.fetchDecision.snapshotFetch.changed = !!forced.changed;
entry.fetchDecision.snapshotFetch.fetchedCount = Array.isArray(forced.data) ? forced.data.length : 0;
entry.fetchDecision.deltaFetch = { tried: true, forced: !!shouldPreferSnapshot, changed: !!changed, fetchedCount: Array.isArray(data) ? data.length : 0 };
entry.fetchDecision.deltaFetch.forcedBootstrap = true;
entry.fetchDecision.deltaFetch.changed = !!forced.changed;
entry.fetchDecision.deltaFetch.fetchedCount = Array.isArray(forced.data) ? forced.data.length : 0;
} catch (_){ /* noop - best-effort metadata */ }
cache[warId] = entry;
persistRankedWarAttacksCache(cache);
// snapshotDirect diagnostic not applicable in window fetch branch (no 'resp' object)
// Stamp provenance for visibility
try {
state.rankedWarLastAttacksSource = 'window-200';
state.rankedWarLastAttacksMeta = { source: 'window', count: Array.isArray(entry.attacks) ? entry.attacks.length : 0, windowCount, lastSeq, url: entry.windowUrl };
storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource);
storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta);
} catch(_) { /* noop */ }
} else {
// No change; if we have cached, keep it and stamp meta
try {
state.rankedWarLastAttacksSource = 'window-304';
state.rankedWarLastAttacksMeta = { source: 'window', count: Array.isArray(entry.attacks) ? entry.attacks.length : 0, windowCount, lastSeq, url: entry.windowUrl };
storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource);
storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta);
} catch(_) { /* noop */ }
}
return entry.attacks || [];
}
}
// snapshot + delta path
const switchingFromWindow = entry.fetchPathPlanned === 'snapshot-delta' && Array.isArray(entry.attacks) && entry.attacks.length === windowCount && windowCount > 0 && snapshotSeq > 0;
const existing = Array.isArray(entry.attacks) ? entry.attacks : [];
const parts = [];
if (entry.snapshotUrl) {
// If heuristics prefer snapshot, force fetch the snapshot even when existing data present
let { changed, data } = await conditionalGet(entry.snapshotUrl, 'snapshot', !!shouldPreferSnapshot);
try {
entry.fetchDecision = entry.fetchDecision || {};
entry.fetchDecision.snapshotFetch = { tried: true, forced: !!shouldPreferSnapshot, changed: !!changed, fetchedCount: Array.isArray(data) ? data.length : (data ? (typeof data === 'object' ? Object.keys(data).length : 0) : 0) };
cache[warId] = entry; persistRankedWarAttacksCache(cache);
} catch(_) {}
if (!changed && existing.length === 0) {
// bootstrap if first contact returned 304
const forced = await conditionalGet(entry.snapshotUrl, 'snapshot', true);
if (forced.changed) { changed = true; data = forced.data; }
try {
entry.fetchDecision.snapshotFetch.forcedBootstrap = true;
entry.fetchDecision.snapshotFetch.changed = !!forced.changed;
entry.fetchDecision.snapshotFetch.fetchedCount = Array.isArray(forced.data) ? forced.data.length : 0;
cache[warId] = entry;
persistRankedWarAttacksCache(cache);
} catch (_) { }
}
if (changed && Array.isArray(data)) parts.push(...data);
else if (!changed) parts.push(...existing.filter(a => Number(a.seq||0) <= Number(entry.lastSnapshotSeq||0)));
}
if (entry.deltaUrl) {
// Force delta fetch when snapshot pref is present to ensure we get latest deltas
let { changed, data } = await conditionalGet(entry.deltaUrl, 'delta', !!shouldPreferSnapshot);
try { entry.fetchDecision = entry.fetchDecision || {}; entry.fetchDecision.deltaFetch = { tried:true, forced:!!shouldPreferSnapshot, changed:!!changed, fetchedCount: Array.isArray(data)?data.length:0 }; cache[warId]=entry; persistRankedWarAttacksCache(cache); } catch(_){}
if (!changed && existing.length === 0) {
// bootstrap if first contact returned 304
const forced = await conditionalGet(entry.deltaUrl, 'delta', true);
if (forced.changed) { changed = true; data = forced.data; }
try { entry.fetchDecision.deltaFetch.forcedBootstrap = true; entry.fetchDecision.deltaFetch.changed = !!forced.changed; entry.fetchDecision.deltaFetch.fetchedCount = Array.isArray(forced.data)?forced.data.length:0; cache[warId]=entry; persistRankedWarAttacksCache(cache); } catch(_){}
}
if (changed && Array.isArray(data)) parts.push(...data);
else if (!changed) parts.push(...existing.filter(a => Number(a.seq||0) > Number(entry.lastSnapshotSeq||0)));
}
// Deduplicate & sort
const dedupeAndSort = (items) => {
const seen = new Set();
const out = [];
for (const a of items) {
const s = Number(a.seq || a.attackSeq || a.attack_id || a.attackId || 0);
if (!s || seen.has(s)) continue; seen.add(s); out.push(a);
}
out.sort((a,b) => Number(a.seq||0)-Number(b.seq||0));
return out;
};
let merged = dedupeAndSort(parts);
// Tolerance: if dedupe produced nothing but we fetched a snapshot (parts populated),
// try to assign provisional seq numbers (using lastSnapshotSeq when available) and
// re-run dedupe/sort. This prevents falling back to the window when the snapshot
// payload lacks explicit sequence fields but is otherwise valid.
if ((!merged || merged.length === 0) && Array.isArray(parts) && parts.length > 0) {
try {
const anySeq = parts.some(p => {
return Boolean(Number(p.seq || p.attackSeq || p.attack_id || p.attackId || 0));
});
if (!anySeq) {
// Determine an offset for provisional seq assignment
const snapBase = Number(entry.lastSnapshotSeq || snapshotSeq || 0) || 0;
// If snapshot seq looks valid and parts length plausible, compute offset
let offset = snapBase - parts.length;
if (!Number.isFinite(offset) || offset < 0) offset = 0;
// Sort parts by timestamp as a stable deterministic order
parts.sort((a,b) => {
const ta = Number(a.seq || a.ended || a.started || 0) || 0;
const tb = Number(b.seq || b.ended || b.started || 0) || 0;
return ta - tb;
});
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (!Number(p.seq || p.attackSeq || p.attack_id || p.attackId || 0)) {
// assign provisional seq
try { p.seq = offset + i + 1; } catch(_) { p.seq = (offset + i + 1); }
}
}
// Re-run dedupe/sort with provisional seqs
merged = dedupeAndSort(parts);
}
} catch(_) { /* best-effort; fall through to existing fallback */ }
}
// Normalize a boolean stealth flag for downstream use
try {
for (const a of merged) {
if (a && typeof a.isStealth === 'undefined' && typeof a.is_stealthed !== 'undefined') {
a.isStealth = !!a.is_stealthed;
}
}
} catch(_) { /* noop */ }
// If merged is empty but window is available, try a forced window bootstrap once to avoid blank UI during active wars
if ((!merged || merged.length === 0) && entry.windowUrl) {
const forcedWin = await conditionalGet(entry.windowUrl, 'window', true);
if (forcedWin.changed && Array.isArray(forcedWin.data) && forcedWin.data.length > 0) {
entry.attacks = forcedWin.data; entry.updatedAt = Date.now(); try { await idb.saveAttacks(warId, entry.attacks); } catch(_) {}
cache[warId] = entry; persistRankedWarAttacksCache(cache);
try {
state.rankedWarLastAttacksSource = 'window-200';
state.rankedWarLastAttacksMeta = { source: 'window', count: entry.attacks.length, windowCount, lastSeq, url: entry.windowUrl };
storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource);
storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta);
} catch(_) { /* noop */ }
return entry.attacks;
}
}
// If merged is empty, attempt explicit snapshot fetch as a last-resort diagnostic/fix
if ((!merged || merged.length === 0) && entry.snapshotUrl) {
try {
const resp = await api.fetchStorageJson(entry.snapshotUrl, {});
try { entry.fetchDecision = entry.fetchDecision || {}; entry.fetchDecision.snapshotDirect = { status: resp.status, isArray: Array.isArray(resp.json), count: Array.isArray(resp.json)?resp.json.length:0 }; cache[warId]=entry; persistRankedWarAttacksCache(cache); } catch(_){}
if (resp.status === 200 && Array.isArray(resp.json) && resp.json.length > 0) {
// persist attacks captured from the direct snapshot fetch as a single write
entry.attacks = resp.json;
try { await idb.saveAttacks(warId, entry.attacks); } catch(_) {}
entry.updatedAt = Date.now();
cache[warId] = entry;
persistRankedWarAttacksCache(cache);
try { state.rankedWarLastAttacksSource = 'snapshot-direct-200'; state.rankedWarLastAttacksMeta = { source: 'snapshot', count: entry.attacks.length, snapshotSeq: entry.lastSnapshotSeq, url: entry.snapshotUrl }; storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource); storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta); } catch(_){}
return entry.attacks;
}
} catch(e) {
try { tdmlogger('warn', '[TDM] direct snapshot fetch failed', { warId, err: (e && e.message) || e }); } catch(_){}
}
}
try { tdmlogger('info', `assembleAttacksFromV2: assembled merged attacks for war ${warId}`, { mergedLen: Array.isArray(merged)?merged.length:0, partsCount: Array.isArray(parts)?parts.length:0, fetchPathPlanned: entry.fetchPathPlanned }); } catch(_) {}
// Only overwrite cached attacks when we actually produced a non-empty merged result.
if (Array.isArray(merged) && merged.length > 0) {
entry.attacks = merged; entry.updatedAt = Date.now(); try { await idb.saveAttacks(warId, entry.attacks); } catch(_) {}
try {
if (switchingFromWindow) {
state.rankedWarLastAttacksSource = 'snapshot-delta-switch';
state.rankedWarLastAttacksMeta = { source: 'snapshot-delta', merged: merged.length, snapshotSeq, lastSeq, windowCount, windowFromSeq, windowToSeq };
storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource);
storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta);
} else {
state.rankedWarLastAttacksSource = 'snapshot-delta';
state.rankedWarLastAttacksMeta = { source: 'snapshot-delta', merged: merged.length, snapshotSeq, lastSeq };
storage.set('rankedWarLastAttacksSource', state.rankedWarLastAttacksSource);
storage.set('rankedWarLastAttacksMeta', state.rankedWarLastAttacksMeta);
}
} catch(_) { /* noop */ }
} else {
try { tdmlogger('debug', `assembleAttacksFromV2: merged result empty; preserving existing cache for war=${warId}`, { existingCount: Array.isArray(entry.attacks)?entry.attacks.length:0, lastSeq, snapshotSeq, windowCount }); } catch(_) {}
}
cache[warId] = entry; persistRankedWarAttacksCache(cache);
return merged;
},
// Admin trigger: force backend to rebuild full summary (forceFull=true). Returns true on success.
forceFullWarSummaryRebuild: async (rankedWarId, factionId) => {
try {
// New path via api.post action
try {
const res = await api.post('forceRankedWarSummaryRebuild', { rankedWarId, factionId, forceFull: true });
tdmlogger('info', '[TDM] Forced full summary rebuild invoked (api.post)', res);
return true;
} catch(primaryErr) {
tdmlogger('warn', '[TDM] api.post forceRankedWarSummaryRebuild failed, attempting legacy callable fallback', primaryErr);
if (state.firebase?.functions?.httpsCallable) {
try {
const fn = state.firebase.functions.httpsCallable('triggerRankedWarSummaryRebuild');
const legacy = await fn({ rankedWarId, factionId, forceFull: true });
tdmlogger('info', '[TDM] Forced full summary rebuild via legacy callable', legacy?.data || legacy);
return true;
} catch(fallbackErr) {
tdmlogger('error', '[TDM] forceFullWarSummaryRebuild callable fallback failed', fallbackErr);
}
}
throw primaryErr;
}
} catch(e){ tdmlogger('warn', '[TDM] forceFullWarSummaryRebuild failed', e); }
return false;
},
// Dev: force backend to pull latest attacks (manifest refresh + attempt snapshot + delta) via callable (if implemented).
forceBackendWarAttacksRefresh: async (rankedWarId, factionId) => {
try {
try {
const res = await api.post('forceWarAttacksRefresh', { rankedWarId, factionId });
tdmlogger('info', '[TDM] Forced backend war attacks refresh invoked (api.post)', res);
return true;
} catch(primaryErr) {
tdmlogger('warn', '[TDM] api.post forceWarAttacksRefresh failed, attempting legacy callable fallback', primaryErr);
if (state.firebase?.functions?.httpsCallable) {
try {
const fn = state.firebase.functions.httpsCallable('triggerRankedWarAttacksRefresh');
const legacy = await fn({ rankedWarId, factionId, force: true });
tdmlogger('info', '[TDM] Forced backend war attacks refresh via legacy callable', legacy?.data || legacy);
return true;
} catch(fallbackErr) {
tdmlogger('error', '[TDM] forceBackendWarAttacksRefresh callable fallback failed', fallbackErr);
}
}
throw primaryErr;
}
} catch(e){ tdmlogger('warn', '[TDM] forceBackendWarAttacksRefresh failed', e); }
return false;
},
// Integrity checker: evaluate local attack coverage vs manifest sequence metrics.
verifyWarAttackIntegrity: (rankedWarId) => {
const warId = String(rankedWarId);
const entry = state.rankedWarAttacksCache?.[warId];
if (!entry) return { ok:false, reason:'no_cache' };
const attacks = Array.isArray(entry.attacks) ? entry.attacks : [];
const seqs = attacks.map(a => Number(a.seq||a.attackSeq||0)).filter(n=>n>0).sort((a,b)=>a-b);
const first = seqs[0] || null;
const last = seqs.length ? seqs[seqs.length-1] : null;
const gaps = [];
for (let i=1;i<seqs.length;i++){ const prev=seqs[i-1]; const cur=seqs[i]; if (cur>prev+1) { gaps.push([prev+1, cur-1]); if (gaps.length>10) break; } }
const latestSeq = Number(entry.lastSeq||0);
const coveragePct = latestSeq>0? Number(((attacks.length/latestSeq)*100).toFixed(2)) : null;
return { ok:true, attacks:attacks.length, first, last, latestSeq, coveragePct, gapCount:gaps.length, sampleGaps: gaps.slice(0,5), fetchPath: entry.fetchPathPlanned };
},
// Compute ranked war summary locally from cached attacks (no backend reads)
// Returns array of rows. Fields align with server summary shape where possible:
// [{ attackerId, attackerName, attackerFactionId, attackerFactionName, totalAttacks, wins, losses, stalemates, totalRespectGain, totalRespectGainNoChain, respect, lastTs }]
getRankedWarSummaryLocal: async (rankedWarId, factionId) => {
const warId = String(rankedWarId);
// Try summary.json first
let summaryRows = [];
// Prefer summary cache ETag for conditional check when available; fallback to attacks cache lastSeq
const summaryCache = state.rankedWarSummaryCache?.[warId];
const fallbackSeq = state.rankedWarAttacksCache?.[warId]?.lastSeq;
const summaryIfNone = summaryCache?.etag ? summaryCache.etag : (typeof fallbackSeq === 'number' ? String(fallbackSeq) : null);
let urls = await api.getWarStorageUrls(warId, factionId, { ifNoneMatch: summaryIfNone }).catch(() => null);
if (urls && urls.summaryUrl) {
// FIX: Pass etag to fetchStorageJson to enable 304 Not Modified
const { status, json, etag, lastModified } = await api.fetchStorageJson(urls.summaryUrl, { etag: summaryCache?.etag });
// Handle 304 Not Modified: use cached summary if available
if (status === 304 && summaryCache && Array.isArray(summaryCache.summary)) {
summaryRows = summaryCache.summary;
// Update timestamp on cache hit
summaryCache.updatedAt = Date.now();
state.rankedWarSummaryCache = state.rankedWarSummaryCache || {};
state.rankedWarSummaryCache[warId] = summaryCache;
storage.set('rankedWarSummaryCache', state.rankedWarSummaryCache);
// Stamp provenance for 304
try {
state.rankedWarLastSummarySource = 'summary-json-304';
state.rankedWarLastSummaryMeta = { source: 'summary-json-304', count: summaryRows.length, url: urls.summaryUrl };
storage.set('rankedWarLastSummarySource', state.rankedWarLastSummarySource);
storage.set('rankedWarLastSummaryMeta', state.rankedWarLastSummaryMeta);
} catch(_) {}
return summaryRows;
}
if (status === 200 && (Array.isArray(json) || (json && Array.isArray(json.items)))) {
if (Array.isArray(json)) {
summaryRows = json;
} else if (json && Array.isArray(json.items)) {
summaryRows = json.items;
try { state.rankedWarLastSummaryMeta = state.rankedWarLastSummaryMeta || {}; state.rankedWarLastSummaryMeta.scoreBleed = json.scoreBleed || null; } catch(_) {}
} else {
summaryRows = [];
}
// Cache the result for future 304s
if (summaryRows.length > 0) {
const nextCache = {
warId,
summaryUrl: urls.summaryUrl,
etag: etag || null,
lastModified: lastModified || null,
updatedAt: Date.now(),
summary: summaryRows,
source: 'storage200'
};
state.rankedWarSummaryCache = state.rankedWarSummaryCache || {};
state.rankedWarSummaryCache[warId] = nextCache;
storage.set('rankedWarSummaryCache', state.rankedWarSummaryCache);
}
let provenanceStamped = false;
try {
state.rankedWarLastSummarySource = 'summary-json';
state.rankedWarLastSummaryMeta = { source: 'summary-json', count: summaryRows.length, url: urls.summaryUrl };
storage.set('rankedWarLastSummarySource', state.rankedWarLastSummarySource);
storage.set('rankedWarLastSummaryMeta', state.rankedWarLastSummaryMeta);
provenanceStamped = true;
} catch(_) { /* noop */ }
// Sanity fallback: if every attacker has zero totalRespectGain but we have (or can fetch) window attacks
try {
const allZero = summaryRows.length > 0 && summaryRows.every(r => !Number(r.totalRespectGain));
if (allZero) {
// Attempt forced window bootstrap to verify if local attacks show positive gains
try { await api.assembleAttacksFromV2(warId, factionId, { forceWindowBootstrap: true }); } catch(_) { /* ignore */ }
const cacheAttacks = state.rankedWarAttacksCache?.[warId]?.attacks || [];
const anyPositive = cacheAttacks.some(a => Number(a.respect_gain || a.respect || 0) > 0);
if (anyPositive) {
// We'll ignore summary.json and aggregate locally instead to surface real gains
summaryRows = [];
}
}
} catch(_) { /* ignore */ }
if (summaryRows.length > 0) {
// Keep summary (either had non-zero gains or no local positive evidence)
return summaryRows;
} else if (provenanceStamped) {
// Overriding summary due to zero-gain anomaly; update provenance note later when local aggregation completes
try {
state.rankedWarLastSummaryMeta.zeroGainAnomaly = true;
} catch(_) { /* noop */ }
}
}
}
// Fallback: aggregate attacks
const cache = state.rankedWarAttacksCache?.[warId];
let attacks = Array.isArray(cache?.attacks) ? cache.attacks : [];
// If in-memory cache lacked attacks (not persisted to localStorage by design), try IndexedDB
if ((!Array.isArray(attacks) || attacks.length === 0)) {
try {
const idbRes = await idb.getAttacks(warId);
if (idbRes && Array.isArray(idbRes.attacks) && idbRes.attacks.length > 0) {
attacks = idbRes.attacks;
// Rehydrate in-memory state for faster subsequent access and preserve IDB freshness
try {
state.rankedWarAttacksCache = state.rankedWarAttacksCache || {};
const current = state.rankedWarAttacksCache[warId] || {};
const next = { ...(current||{}), attacks };
if (!next.lastModified && idbRes.updatedAt) next.lastModified = idbRes.updatedAt;
if (!next.updatedAt && idbRes.updatedAt) next.updatedAt = idbRes.updatedAt;
state.rankedWarAttacksCache[warId] = next;
} catch(_) {}
}
} catch(_) { /* noop */ }
}
// Auto-upgrade: if we are still on window path and clearly truncated vs latestSeq, attempt snapshot+delta assembly.
try {
if (cache && Number(cache.lastSeq||0) > 0 && attacks.length + 50 < Number(cache.lastSeq||0) && cache.fetchPathPlanned === 'window') {
tdmlogger('info', '[TDM] Local summary detected truncated window; triggering snapshot+delta fetch');
try { await api.assembleAttacksFromV2(warId, factionId, { forceWindowBootstrap: false }); } catch(_) {}
// Re-evaluate attacks after upgrade
const upgraded = state.rankedWarAttacksCache?.[warId]?.attacks || attacks;
if (upgraded !== attacks) {
attacks.length = 0; // mutate existing reference if used further down (safeguard)
for (const a of upgraded) attacks.push(a);
}
}
} catch(_) { /* noop */ }
// Aggregate per attacker
const per = new Map();
for (const a of attacks) {
if (!a) continue;
// Handle stealth attacks in ranked wars: attacker may be null but war modifier indicates war context
try {
const isRankedContext = (a && (a.is_ranked_war === true || Number(a?.modifiers?.war || 0) === 2));
const hasNoAttacker = !(a?.attackerId || a?.attacker_id || (a?.attacker && (a.attacker.id || a.attacker)));
const isStealth = !!(a?.is_stealthed || a?.isStealthed || a?.isStealth);
if (isRankedContext && hasNoAttacker && isStealth) {
const ourFactionId = String(state?.user?.factionId || '');
const oppFactionId = String(state?.warData?.opponentId || state?.lastOpponentFactionId || '');
const defFac = String(a?.defenderFactionId || a?.defender?.faction?.id || a?.defender_faction_id || '');
// Infer attacking faction: if defender is ours, then attacker is opponent; if defender is opponent, attacker is ours
let inferredAttackerFaction = '';
if (ourFactionId && defFac === ourFactionId) inferredAttackerFaction = oppFactionId || 'opponent';
else if (oppFactionId && defFac === oppFactionId) inferredAttackerFaction = ourFactionId || 'ours';
a.attackerId = `someone:${inferredAttackerFaction || 'unknown'}`;
a.attackerName = 'Someone (stealth)';
if (inferredAttackerFaction) a.attackerFactionId = inferredAttackerFaction;
if (typeof a.isStealth === 'undefined') a.isStealth = true;
}
} catch(_) { /* noop */ }
const attackerId = String(a.attackerId || a.attacker_id || a.attacker?.id || '');
if (!attackerId) continue;
let row = per.get(attackerId);
if (!row) {
row = {
attackerId,
attackerName: a.attackerName || a.attacker.name || '',
attackerFactionId: a.attackerFactionId || a.attacker.faction?.id || null,
attackerFactionName: a.attackerFactionName || a.attacker.faction?.name || '',
totalAttacks: 0,
// Successful attack counter (counts only outcomes that should be considered "scoring" for 'Attacks' caps)
totalAttacksSuccessful: 0,
wins: 0,
losses: 0,
stalemates: 0,
// Keep original 'respect' for compatibility, but also expose server-like field names
respect: 0,
totalRespectGain: 0,
totalRespectGainNoChain: 0,
totalRespectLoss: 0,
lastTs: 0,
// Compatibility alias used in some consumers
factionId: null
};
per.set(attackerId, row);
}
row.totalAttacks += 1;
// Count success-only attacks (Mugged, Attacked, Hospitalized, Arrested, Bounty)
try {
const outcome = String(a.result || a.outcome || '').toLowerCase();
const successRE = /(?:mug|attack|hospital|arrest|bounty)/i;
if (successRE.test(outcome)) row.totalAttacksSuccessful += 1;
} catch(_) {}
const res = (a.result || a.outcome || '').toLowerCase();
const r = Number(a.respect || a.respect_gain || 0);
const hasPositiveGain = Number.isFinite(r) && r > 0;
// New win classification: treat any positive respect gain as a win unless explicitly a loss keyword.
if ((/hospital|mug|arrest|leave/.test(res)) || hasPositiveGain) {
row.wins += 1;
} else if (/lost|escape/.test(res)) {
row.losses += 1;
} else {
row.stalemates += 1; // includes timeouts/cancels/unknowns
}
if (Number.isFinite(r)) {
row.respect += r;
row.totalRespectGain += r;
row.totalRespectGainNoChain += r; // chain-agnostic mirror
}
// Respect lost accumulates only when our faction is the defender in this event
try {
const ourFactionId = String(state?.user?.factionId || '');
const defFacId = String(a?.defenderFactionId || a?.defender?.faction?.id || a?.defender_faction_id || '');
if (ourFactionId && defFacId === ourFactionId) {
const loss = Number(a?.respect_loss || a?.respectLoss || 0);
if (Number.isFinite(loss) && loss > 0) {
row.totalRespectLoss = (row.totalRespectLoss || 0) + loss;
}
}
} catch(_) { /* noop */ }
const ts = Number(a.ended || a.timestamp || 0);
if (ts > row.lastTs) row.lastTs = ts;
// Keep aliases in sync
row.factionId = row.attackerFactionId;
row.attackerFaction = row.attackerFactionId;
}
const rows = Array.from(per.values());
// Stable sort: highest respect then most recent
rows.sort((x, y) => (y.respect - x.respect) || (y.lastTs - x.lastTs));
// Provenance
try {
state.rankedWarLastSummarySource = 'local-attacks';
state.rankedWarLastSummaryMeta = {
source: 'local-attacks',
attacksCount: attacks.length,
lastSeq: Number(cache?.lastSeq || 0),
lastModified: cache?.lastModified || null,
};
storage.set('rankedWarLastSummarySource', state.rankedWarLastSummarySource);
storage.set('rankedWarLastSummaryMeta', state.rankedWarLastSummaryMeta);
} catch(_) {}
return rows;
},
// Prefer local aggregation during active wars; fallback to storage/server summary
getRankedWarSummaryPreferLocal: async (rankedWarId, factionId) => {
try {
utils.perf.start('getRankedWarSummaryPreferLocal');
// Recovery: if attacks cache was manually cleared (e.g., user deleted localStorage key) ensure we attempt a bootstrap
try {
const warId = String(rankedWarId);
const cacheEntry = state.rankedWarAttacksCache?.[warId];
const needsBootstrap = !cacheEntry || !Array.isArray(cacheEntry.attacks) || cacheEntry.attacks.length === 0;
if (needsBootstrap) {
// Force manifest fetch (ignore 304) to repopulate URLs, then attempt window path if available
await api.fetchWarManifestV2(warId, factionId, { force: true }).catch(()=>null);
// Attempt a one-shot assemble; this will choose window if possible
try { await api.assembleAttacksFromV2(warId, factionId, { forceWindowBootstrap: true }); } catch(_) { /* ignore */ }
}
} catch(_) { /* swallow bootstrap errors */ }
const local = await api.getRankedWarSummaryLocal(rankedWarId, factionId);
if (Array.isArray(local) && local.length > 0) {
// Stamp provenance when local is chosen
try {
const warId = String(rankedWarId);
const cache = state.rankedWarAttacksCache?.[warId] || {};
state.rankedWarLastSummarySource = 'local';
state.rankedWarLastSummaryMeta = {
source: 'local',
attacksCount: Array.isArray(cache.attacks) ? cache.attacks.length : 0,
lastSeq: Number(cache.lastSeq || 0),
lastModified: cache.lastModified || null,
};
storage.set('rankedWarLastSummarySource', state.rankedWarLastSummarySource);
storage.set('rankedWarLastSummaryMeta', state.rankedWarLastSummaryMeta);
// console.info('[TDM] War summary source: local-attacks', state.rankedWarLastSummaryMeta); // silenced to reduce noise
} catch(_) { /* noop */ }
utils.perf.stop('getRankedWarSummaryPreferLocal');
return local;
}
} catch(_) { /* ignore */ }
utils.perf.stop('getRankedWarSummaryPreferLocal');
return api.getRankedWarSummarySmart(rankedWarId, factionId);
},
// Fetch JSON from a public URL with optional ETag conditional; returns { status, json, etag, lastModified }
fetchStorageJson: (url, opts = {}) => {
const headers = {};
if (opts && opts.etag) headers['If-None-Match'] = opts.etag;
if (opts && opts.ifModifiedSince) headers['If-Modified-Since'] = opts.ifModifiedSince;
return new Promise((resolve) => {
try {
const ret = state.gm.rD_xmlhttpRequest({
method: 'GET', url, headers,
onload: (resp) => {
try {
const status = Number(resp.status || 0);
const rawHeaders = String(resp.responseHeaders || '');
const hdrs = {};
rawHeaders.split(/\r?\n/).forEach(line => {
const idx = line.indexOf(':');
if (idx > 0) {
const k = line.slice(0, idx).trim().toLowerCase();
const v = line.slice(idx + 1).trim();
hdrs[k] = v;
}
});
const etag = hdrs['etag'] || null;
const lastModified = hdrs['last-modified'] || null;
if (status === 304) {
resolve({ status, json: null, etag, lastModified });
return;
}
if (status >= 200 && status < 300) {
let json = [];
try { json = JSON.parse(resp.responseText || '[]'); } catch(_) { json = []; }
resolve({ status, json, etag, lastModified });
return;
}
resolve({ status, json: null, etag, lastModified });
} catch(_) { resolve({ status: 0, json: null, etag: null, lastModified: null }); }
},
onerror: () => resolve({ status: 0, json: null, etag: null, lastModified: null })
});
if (ret && typeof ret.catch === 'function') ret.catch(() => {});
} catch(_) { resolve({ status: 0, json: null, etag: null, lastModified: null }); }
});
},
// Decide if we should attempt a manifest fetch based on phase / last activity.
shouldFetchManifest: (entry, reason, active, nowMs) => {
try {
// Always fetch if reason explicitly score-change
if (reason === 'score-change') return true;
// Respect nextAllowedFetchMs throttle if set
if (entry.nextAllowedManifestMs && nowMs < entry.nextAllowedManifestMs) return false;
const lastSeq = Number(entry.lastSeq || 0);
const haveSummary = !!(state.rankedWarSummaryCache?.[entry.warId]?.etag);
const warStart = Number(entry.warStart || 0);
const warEnd = Number(entry.warEnd || 0);
// PRE phase (warStart in future or 0 && !active)
if (!active && (!warStart || warStart > (Date.now()/1000)) && lastSeq === 0 && !haveSummary) {
// Only every ~10 minutes (600k ms)
return (nowMs - (entry.lastManifestFetchMs||0)) > 600000;
}
// ENDED phase: if finalized and no new seqs expected, poll rarely
if (!active && warEnd > 0 && entry.finalized) {
return (nowMs - (entry.lastManifestFetchMs||0)) > 300000; // 5m
}
// ACTIVE base interval 25s; tighten to 7s if we saw new seq recently (<30s)
const recentUpdate = entry.updatedAt && (nowMs - entry.updatedAt) < 30000;
const base = recentUpdate ? 7000 : 25000;
return (nowMs - (entry.lastManifestFetchMs||0)) > base;
} catch(_) { return true; }
},
// Idempotent request to backend to lazily materialize storage artifacts for a war
ensureWarArtifacts: async (rankedWarId, factionId) => {
if (api._shouldBailDueToIpRateLimit('ensureWarArtifacts')) return null;
try {
const warId = String(rankedWarId);
state._ensureWarArtifactsMs = state._ensureWarArtifactsMs || {};
const now = Date.now();
// 15s client throttle mirrors backend guard to avoid stampedes
if (state._ensureWarArtifactsMs[warId] && now - state._ensureWarArtifactsMs[warId] < 15000) {
return null;
}
state._ensureWarArtifactsMs[warId] = now;
const res = await api.get('ensureWarArtifacts', { rankedWarId: warId, factionId });
state.rankedWarEnsureMeta = state.rankedWarEnsureMeta || {};
state.rankedWarEnsureMeta[warId] = { ts: now, ok: true, res: (res || null) };
return res;
} catch (e) {
try {
state.rankedWarEnsureMeta = state.rankedWarEnsureMeta || {};
state.rankedWarEnsureMeta[String(rankedWarId)] = { ts: Date.now(), ok: false, error: e?.message || String(e) };
} catch(_) { /* noop */ }
return null;
}
},
// Safe helper: prefer GET (cheap) and only call ensure when explicitly forced. Returns { storage } or null.
ensureWarArtifactsSafe: async (rankedWarId, factionId, opts = {}) => {
if (api._shouldBailDueToIpRateLimit('ensureWarArtifactsSafe')) return null;
const warId = String(rankedWarId);
const force = !!opts.force || !!opts.forceEnsure;
// Try cheap GET first
try {
const urls = await api.getWarStorageUrls(warId, factionId).catch(() => null);
if (urls && (urls.summaryUrl || urls.manifestUrl || urls.attacksUrl)) return { storage: urls, fromGet: true };
} catch(_) { /* ignore and allow optional ensure below */ }
if (!force) return null;
// If forced, call ensure (still throttled client-side by ensureWarArtifacts implementation)
try {
await api.ensureWarArtifacts(warId, factionId);
const after = await api.getWarStorageUrls(warId, factionId, { ensure: true }).catch(()=>null);
if (after && (after.summaryUrl || after.manifestUrl || after.attacksUrl)) return { storage: after, fromEnsure: true };
} catch(_) {}
return null;
}
};
//======================================================================
// 5. UI MODULE
//======================================================================
const ui = {
_formatStatus(rec, meta) {
let label = (rec.canonical || rec.status || '—').trim();
// Default: 0 means no explicit sub-rank override (fall back to lexical heuristics)
let subRank = 0;
let remainingText = '';
let sortVal = 0;
const now = Date.now();
// Resolve 'until' timestamp (normalize to ms)
let untilMs = 0;
// Prefer meta.hospitalUntil if available (often more fresh/specific for hospital)
// Also accept `rec.rawUntil` (API seconds) and `rec.arrivalMs` (ms) as common sources.
let rawUntil = (meta && meta.hospitalUntil) || rec.until || rec.rawUntil;
if (rawUntil) {
// Heuristic: if < 1e12 (year 2001 in ms), assume seconds (Torn API uses seconds)
if (rawUntil < 1000000000000) untilMs = rawUntil * 1000;
else untilMs = rawUntil;
}
// Helper to extract destination from description
const extractDest = (desc, prefix) => {
if (!desc) return '';
// Capture until end of string or opening parenthesis (start of duration/time)
const regex = new RegExp(`${prefix}\\s+(.+?)(?:$|\\()`, 'i');
const match = desc.match(regex);
return match ? match[1].trim() : '';
};
// Resolve destination from description, status, or meta
const resolveDest = (desc, prefix) => {
let d = extractDest(desc, prefix);
if (!d) d = extractDest(rec.status, prefix);
if (!d && meta && meta.dest) d = meta.dest;
if (!d && rec.dest) d = rec.dest;
return d;
};
// Travel Logic
if (label === 'Traveling' || label === 'Returning' || label === 'Travel') {
const isReturn = label === 'Returning';
const desc = rec.description || '';
const dest = resolveDest(desc, isReturn ? 'from' : 'to');
const abbr = utils.abbrevDest(dest) || dest;
const arrow = isReturn ? '←' : '→';
// Duration logic
if (untilMs > now) {
const diff = Math.ceil((untilMs - now) / 1000);
sortVal = diff;
const h = Math.floor(diff / 3600);
const m = Math.floor((diff % 3600) / 60);
remainingText = ` dur. ${h}h ${m}m`;
}
label = `${arrow} ${abbr}`;
// Sub-ranks follow the ranked-war heuristic (higher = more urgent for sorting)
// Travel: high priority so it sorts above Abroad/Hospital.
if (isReturn) subRank = 40; else subRank = 45; // Traveling/Returning
}
else if (label === 'Abroad') {
const desc = rec.description || '';
const dest = resolveDest(desc, 'In');
const abbr = utils.abbrevDest(dest) || dest;
label = `In ${abbr}`;
subRank = 50;
}
else if (label === 'HospitalAbroad') {
const desc = rec.description || '';
const dest = resolveDest(desc, 'in');
const abbr = utils.abbrevDest(dest) || dest;
label = `${abbr}`;
// Hospital abroad should sort highly (second only to in-hospital)
subRank = 90;
if (untilMs > now) {
const diff = Math.ceil((untilMs - now) / 1000);
sortVal = diff;
if (diff > 0) { remainingText = ` ${utils.formatTimeHMS(diff)}`; }
}
}
else if (label === 'Hospital') {
// Hospital should be considered the highest-priority status for sorting
subRank = 100;
if (untilMs > now) {
const diff = Math.ceil((untilMs - now) / 1000);
sortVal = diff;
if (diff > 0) { label = `${utils.formatTimeHMS(diff)}`; }
}
}
else if (label === 'Okay') {
subRank = 15;
}
return { label, remainingText, sortVal, subRank };
},
_refreshStatusTimes() {
try {
const unified = state.unifiedStatus || {};
// Streamlined selectors to target only content rows and avoid headers
const cells = document.querySelectorAll(
'.table-body > li .status, ' + // Faction/Members list rows
'.tab-menu-cont ul > li .status' // Ranked war rows
);
cells.forEach(cell => {
const row = cell.closest('li') || cell.closest('.table-row');
if (!row) return;
// Strict Header Guards (though selectors should prevent this)
if (cell.closest('.table-header')) return;
if (cell.classList.contains('tdm-rank-sort-header')) return;
if (cell.closest('.tdm-rank-sort-header')) return;
let id = row.dataset.id || row.dataset.tdmOpponentId || row.dataset.opponentId;
if (!id) {
const link = row.querySelector('a[href*="XID="]');
if (link) {
const m = link.href.match(/XID=(\d+)/);
if (m) id = m[1];
}
}
if (!id) return;
const rec = unified[id];
if (!rec) return;
const meta = state.rankedWarChangeMeta ? state.rankedWarChangeMeta[id] : null;
const { label, remainingText, sortVal, subRank } = ui._formatStatus(rec, meta);
// Preserve inner structure (colors) by targeting the first child if present
const target = cell.firstElementChild || cell;
let disp = (label || '') + (remainingText || '');
if ((target.textContent || '') !== disp || cell.dataset.tdmPhase !== (rec.canonical || rec.phase) || cell.dataset.tdmConf !== (rec.confidence || '')) {
target.textContent = disp;
cell.dataset.tdmPhase = rec.canonical || rec.phase || '';
cell.dataset.tdmConf = rec.confidence || '';
}
cell.dataset.sortValue = sortVal;
if (subRank > 0) cell.dataset.subRank = subRank;
else delete cell.dataset.subRank;
});
} catch(_) {}
},
_getFFColor(value) {
let r, g, b;
if (value <= 1) {
r = 0x28; g = 0x28; b = 0xc6;
} else if (value <= 3) {
const t = (value - 1) / 2;
r = 0x28;
g = Math.round(0x28 + (0xc6 - 0x28) * t);
b = Math.round(0xc6 - (0xc6 - 0x28) * t);
} else if (value <= 5) {
const t = (value - 3) / 2;
r = Math.round(0x28 + (0xc6 - 0x28) * t);
g = Math.round(0xc6 - (0xc6 - 0x28) * t);
b = 0x28;
} else {
r = 0xc6; g = 0x28; b = 0x28;
}
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
},
_getContrastColor(hex) {
if (!hex) return 'black';
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const brightness = r * 0.299 + g * 0.587 + b * 0.114;
return brightness > 126 ? 'black' : 'white';
},
// Remove native title tooltips on touch devices (PDA) to prevent sticky popups
_sanitizeTouchTooltips(root=document) {
try {
const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
if (!isTouch) return;
root.querySelectorAll('[title]').forEach(el => {
if (!el.getAttribute) return;
const t = el.getAttribute('title');
if (!t) return;
// Preserve semantics
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', t);
el.removeAttribute('title');
});
} catch(_) {}
},
_coerceLevelDisplayMode(mode) {
return LEVEL_DISPLAY_MODES.includes(mode) ? mode : DEFAULT_LEVEL_DISPLAY_MODE;
},
isLevelOverlayEnabled(opts = {}) {
if (opts.ignoreFeatureFlag === true) return true;
try { return utils.isFeatureEnabled('rankWarEnhancements.overlay'); } catch(_) { return false; }
},
getLevelDisplayMode(opts = {}) {
if (!ui.isLevelOverlayEnabled(opts)) return DEFAULT_LEVEL_DISPLAY_MODE;
const stored = state.ui?.levelDisplayMode;
return ui._coerceLevelDisplayMode(stored);
},
setLevelDisplayMode(mode, opts = {}) {
const resolved = ui._coerceLevelDisplayMode(mode);
const prev = ui._coerceLevelDisplayMode(state.ui?.levelDisplayMode);
state.ui.levelDisplayMode = resolved;
if (!opts.skipPersist) {
try { storage.set(state.ui.levelDisplayModeKey, resolved); } catch(_) {}
}
if (opts.log !== false && prev !== resolved) {
try { tdmlogger('info', '[LevelOverlay] mode change', { prev, next: resolved, source: opts.source || 'user' }); } catch(_) {}
}
if ((prev !== resolved || opts.forceRefresh) && opts.skipRefresh !== true) {
try {
if (typeof ui.queueLevelOverlayRefresh === 'function') {
ui.queueLevelOverlayRefresh({ reason: 'mode-change' });
}
} catch(_) { /* noop */ }
}
return resolved;
},
cycleLevelDisplayMode(opts = {}) {
const current = ui.getLevelDisplayMode({ ignoreFeatureFlag: opts.ignoreFeatureFlag });
const idx = LEVEL_DISPLAY_MODES.indexOf(current);
const next = LEVEL_DISPLAY_MODES[(idx + 1) % LEVEL_DISPLAY_MODES.length];
return ui.setLevelDisplayMode(next, opts);
},
_levelOverlayRefresh: {
_pending: false,
_last: 0,
_minIntervalMs: 180,
_scopes: null,
schedule(opts = {}) {
if (opts && opts.scope instanceof Node) {
this._scopes = this._scopes || new Set();
this._scopes.add(opts.scope);
}
if (opts && Array.isArray(opts.scopes)) {
this._scopes = this._scopes || new Set();
opts.scopes.forEach(scope => {
if (scope instanceof Node) this._scopes.add(scope);
});
}
if (this._pending) return;
const now = Date.now();
const wait = Math.max(0, this._minIntervalMs - (now - (this._last || 0)));
this._pending = true;
setTimeout(() => {
this._pending = false;
this._last = Date.now();
let scopes = null;
if (this._scopes && this._scopes.size) {
scopes = Array.from(this._scopes);
this._scopes.clear();
}
try {
ui._withRankedWarSortPause(() => ui.updateAllLevelCells({ scopes }), 'level-overlay-refresh');
} catch (err) {
try { tdmlogger('warn', '[LevelOverlay] refresh failed', err); } catch(_) {}
}
}, wait);
}
},
queueLevelOverlayRefresh(opts = {}) {
if (!ui.isLevelOverlayEnabled(opts)) {
if (opts.forceReset) {
try {
ui._withRankedWarSortPause(() => ui.updateAllLevelCells({ forceReset: true, ignoreFeatureFlag: true }), 'level-overlay-reset');
} catch(_) {}
}
return;
}
ui._levelOverlayRefresh.schedule(opts);
},
_collectLevelCellRoots(opts = {}) {
const seen = new Set();
const add = (node) => {
if (!node || typeof node.querySelectorAll !== 'function') return;
if (seen.has(node)) return;
seen.add(node);
};
if (opts.scope) add(opts.scope);
if (Array.isArray(opts.scopes)) opts.scopes.forEach(add);
const tables = state.dom?.rankwarfactionTables;
if (tables && tables.length) Array.from(tables).forEach(add);
if (state.dom?.rankwarContainer) add(state.dom.rankwarContainer);
if (state.dom?.factionListContainer) add(state.dom.factionListContainer);
if (!seen.size) add(document);
return Array.from(seen);
},
_gatherLevelCells(root) {
try {
return Array.from(root.querySelectorAll('.level, .lvl')).filter(cell => cell.closest('.members-list > li, .members-cont > li, .table-body > li, .table-body > .table-row'));
} catch(_) {
return [];
}
},
_readLevelCellMeta(cell) {
const meta = { level: null, text: '' };
if (!cell) return meta;
try {
const rawText = (cell.textContent || '').trim();
meta.text = rawText;
const parsed = parseInt(rawText.replace(/[^0-9]/g,''), 10);
// If we've previously recorded an original level, prefer that and do not overwrite it.
if (cell && cell.dataset && cell.dataset.tdmOrigLevel) {
const orig = Number(cell.dataset.tdmOrigLevel);
if (Number.isFinite(orig)) {
meta.level = orig;
}
} else if (Number.isFinite(parsed)) {
meta.level = parsed;
if (meta.level != null && cell && cell.dataset) cell.dataset.tdmOrigLevel = String(meta.level);
}
if (rawText && !cell.dataset.tdmOrigText) cell.dataset.tdmOrigText = rawText;
} catch(_) {}
return meta;
},
_extractPlayerIdFromRow(row) {
try {
if (!row) return null;
const datasetId = row.dataset?.tdmOpponentId || row.dataset?.opponentId || row.dataset?.playerId || row.dataset?.id || null;
if (datasetId) {
if (row.dataset) row.dataset.tdmOpponentId = datasetId;
return String(datasetId);
}
const link = row.querySelector('a[href*="XID="]');
if (link && link.href) {
const match = link.href.match(/[?&]XID=(\d+)/i);
if (match && match[1]) {
if (row.dataset) row.dataset.tdmOpponentId = match[1];
return match[1];
}
}
return null;
} catch(_) { return null; }
},
_computeLevelDisplay({ mode, level, ff, bsp }) {
const fallbackDisplay = level != null ? String(level) : '—';
const result = { display: fallbackDisplay, sortValue: Number.isFinite(level) ? level : null, overlayActive: mode !== 'level' };
if (mode === 'ff') {
const display = ff && ff.ffValue != null ? (formatFairFightValue(ff.ffValue) || String(ff.ffValue)) : '—';
result.display = display;
result.sortValue = (ff && ff.ffValue != null) ? Number(ff.ffValue) : Number.NEGATIVE_INFINITY;
return result;
}
if (mode === 'ff-bs') {
const display = (ff && (ff.bsHuman || (ff.bsEstimate != null ? utils.formatBattleStats(ff.bsEstimate) : null))) || '—';
result.display = display;
result.sortValue = (ff && ff.bsEstimate != null) ? Number(ff.bsEstimate) : Number.NEGATIVE_INFINITY;
return result;
}
if (mode === 'bsp') {
const display = (bsp && (bsp.formattedTbs || (bsp.tbs != null ? utils.formatBattleStats(bsp.tbs) : null) || (bsp.score != null ? utils.formatBattleStats(bsp.score) : null))) || '—';
result.display = display;
result.sortValue = (bsp && bsp.tbs != null) ? Number(bsp.tbs) : ((bsp && bsp.score != null) ? Number(bsp.score) : Number.NEGATIVE_INFINITY);
return result;
}
result.overlayActive = false;
return result;
},
_formatTooltipAge(ms) {
try {
if (!ms) return null;
const seconds = Math.floor(ms / 1000);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return utils.formatAgoShort(seconds);
} catch(_) { return null; }
},
_buildLevelTooltipText({ level, ff, bsp }) {
try {
const lines = [];
if (Number.isFinite(level)) lines.push(`Lvl: ${level}`);
if (ff) {
const parts = [];
if (ff.ffValue != null) {
const ffDisplay = formatFairFightValue(ff.ffValue) || String(ff.ffValue);
parts.push(`FF: ${ffDisplay}`);
}
const bsText = ff.bsHuman || (ff.bsEstimate != null ? utils.formatBattleStats(ff.bsEstimate) : null);
if (bsText) {
parts.push(`BS: ${bsText}`);
}
if (parts.length > 0) {
const age = ui._formatTooltipAge(ff.lastUpdatedMs);
if (age) parts.push(`(${age})`);
lines.push(parts.join(' '));
}
}
if (bsp) {
const parts = [];
const bspText = bsp.formattedTbs || (bsp.tbs != null ? utils.formatBattleStats(bsp.tbs) : null) || (bsp.score != null ? utils.formatBattleStats(bsp.score) : null);
if (bspText) {
parts.push(`BSP: ${bspText}`);
}
if (bsp.tbsBalanced != null) {
if (parts.length > 0) parts.push('-');
parts.push(utils.formatBattleStats(bsp.tbsBalanced));
}
if (parts.length > 0) {
const age = ui._formatTooltipAge(bsp.timestampMs);
if (age) parts.push(`(${age})`);
lines.push(parts.join(' '));
}
}
return lines.length ? lines.join('\n') : 'Level stats unavailable';
} catch(_) {
return 'Level stats unavailable';
}
},
_ensureLevelCellHandlers(cell) {
if (!cell || cell._tdmLevelBound) return;
const longPressMs = state.ui?.levelCellLongPressMs || LEVEL_CELL_LONGPRESS_MS;
const clearTimer = () => {
if (cell._tdmLevelTouchTimer) {
clearTimeout(cell._tdmLevelTouchTimer);
cell._tdmLevelTouchTimer = null;
}
};
const resetSuppression = (delayed = false) => {
const clear = () => {
if (cell.dataset) {
delete cell.dataset.tdmLongPressActive;
delete cell.dataset.tdmSuppressClick;
}
};
if (delayed) setTimeout(clear, 220); else clear();
};
const handleClick = (event) => {
if (!ui.isLevelOverlayEnabled()) return;
if (cell.dataset?.tdmSuppressClick === '1') {
event.preventDefault();
event.stopPropagation();
resetSuppression(true);
return;
}
ui.cycleLevelDisplayMode({ source: 'level-cell' });
};
const handlePointerDown = (event) => {
if (!ui.isLevelOverlayEnabled()) return;
if (event.pointerType !== 'touch' && event.pointerType !== 'pen') return;
clearTimer();
resetSuppression();
cell._tdmLevelTouchTimer = setTimeout(() => {
if (cell.dataset) {
cell.dataset.tdmLongPressActive = '1';
cell.dataset.tdmSuppressClick = '1';
}
}, longPressMs);
};
const handlePointerEnd = () => {
if (cell.dataset?.tdmLongPressActive === '1') {
resetSuppression(true);
} else {
resetSuppression();
}
clearTimer();
};
cell.addEventListener('click', handleClick);
cell.addEventListener('pointerdown', handlePointerDown);
cell.addEventListener('pointerup', handlePointerEnd);
cell.addEventListener('pointercancel', handlePointerEnd);
cell.addEventListener('pointerleave', handlePointerEnd);
cell._tdmLevelBound = true;
},
_teardownLevelCell(cell) {
if (!cell) return;
cell.classList.add('tdm-level-cell');
cell.classList.remove('tdm-level-cell--overlay');
if (cell.dataset) {
const origText = cell.dataset.tdmOrigText || cell.dataset.tdmDisplay;
if (origText && cell.textContent !== origText) cell.textContent = origText;
delete cell.dataset.tdmDisplay;
delete cell.dataset.tdmLevelMode;
}
try { cell.removeAttribute('data-sort-value'); } catch(_) {}
try { cell.style && cell.style.removeProperty('--tdm-level-overlay-color'); } catch(_) {}
},
_renderLevelCell(cell, mode) {
try {
const row = cell.closest('li, .table-row, tr');
const meta = ui._readLevelCellMeta(cell);
const playerId = ui._extractPlayerIdFromRow(row);
const ff = playerId ? utils.readFFScouter(playerId) : null;
const bsp = playerId ? utils.readBSP(playerId) : null;
const display = ui._computeLevelDisplay({ mode, level: meta.level, ff, bsp });
const originalText = (cell.dataset && cell.dataset.tdmOrigText) ? cell.dataset.tdmOrigText : (meta.text || '');
const resolvedText = display.overlayActive ? (display.display || '—') : (originalText || display.display || '—');
cell.classList.add('tdm-level-cell');
ui._ensureLevelCellHandlers(cell);
let indicatorText = '';
if (display.overlayActive) {
if (mode === 'ff') indicatorText = 'FF';
else if (mode === 'ff-bs') indicatorText = 'FFBS';
else if (mode === 'bsp') indicatorText = 'BSP';
}
let needsUpdate = false;
if (cell.childNodes.length === 0) needsUpdate = true;
else if (cell.childNodes[0].nodeType === 3 && cell.childNodes[0].nodeValue !== resolvedText) needsUpdate = true;
else if (indicatorText && (!cell.childNodes[1] || cell.childNodes[1].textContent !== indicatorText)) needsUpdate = true;
else if (!indicatorText && cell.childNodes.length > 1) needsUpdate = true;
if (needsUpdate) {
cell.textContent = '';
cell.appendChild(document.createTextNode(resolvedText));
if (indicatorText) {
const ind = document.createElement('span');
ind.className = 'tdm-level-indicator';
ind.textContent = indicatorText;
cell.appendChild(ind);
}
}
if (display.overlayActive) {
const newVal = display.display || '—';
if (cell.dataset.tdmDisplay !== newVal) cell.dataset.tdmDisplay = newVal;
cell.classList.add('tdm-level-cell--overlay');
} else {
cell.classList.remove('tdm-level-cell--overlay');
if (cell.dataset && 'tdmDisplay' in cell.dataset) delete cell.dataset.tdmDisplay;
}
// Apply FF Scouter coloring logic
// We use ff.ffValue because normalizeFfRecord returns { ffValue: ... }
// We apply this in 'level' mode as well if data is available, to replicate FF Scouter behavior
const shouldColor = (mode === 'level' || mode === 'ff' || mode === 'ff-bs') && ff && Number.isFinite(ff.ffValue);
if (shouldColor) {
const hexColor = ui._getFFColor(ff.ffValue);
const textColor = ui._getContrastColor(hexColor);
// Convert to RGBA for 50% transparency
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
const rgbaColor = `rgba(${r}, ${g}, ${b}, 0.5)`;
cell.style.backgroundColor = rgbaColor;
cell.style.color = textColor;
cell.style.setProperty('--tdm-level-overlay-color', textColor);
} else {
// Reset inline styles if not coloring
cell.style.backgroundColor = '';
cell.style.color = '';
if (display.overlayActive) {
if (cell.style && !cell.style.getPropertyValue('--tdm-level-overlay-color')) {
try {
const color = window.getComputedStyle(cell).color;
if (color) cell.style.setProperty('--tdm-level-overlay-color', color);
} catch(_) {}
}
} else {
try { cell.style.removeProperty('--tdm-level-overlay-color'); } catch(_) {}
}
}
if (display.sortValue != null && Number.isFinite(display.sortValue)) {
const sv = String(display.sortValue);
if (cell.getAttribute('data-sort-value') !== sv) cell.setAttribute('data-sort-value', sv);
} else {
if (cell.hasAttribute('data-sort-value')) cell.removeAttribute('data-sort-value');
}
if (cell.dataset) {
const pid = playerId || '';
if (cell.dataset.playerId !== pid) cell.dataset.playerId = pid;
if (cell.dataset.tdmLevelMode !== mode) cell.dataset.tdmLevelMode = mode;
}
const origLevel = (cell && cell.dataset && cell.dataset.tdmOrigLevel) ? Number(cell.dataset.tdmOrigLevel) : null;
const tooltip = ui._buildLevelTooltipText({ level: (meta.level != null ? meta.level : origLevel), ff, bsp });
if (tooltip) {
if (cell.title !== tooltip) cell.title = tooltip;
const aria = cell.getAttribute('aria-label');
if (aria !== tooltip) cell.setAttribute('aria-label', tooltip);
}
} catch(_) { /* non-fatal */ }
},
_updateLevelHeaderLabels(mode, overlayEnabled) {
try {
const labelMap = { level: 'Level', ff: 'FF', 'ff-bs': 'FF BS', bsp: 'BSP' };
const label = labelMap[mode] || 'Level';
const selectors = [
'.tab-menu-cont .members-cont .white-grad .level',
'.tab-menu-cont .members-cont .white-grad .lvl',
'.f-war-list .table-header .level',
'.f-war-list .table-header .lvl',
'.table-header .level',
'.table-header .lvl'
];
selectors.forEach(selector => {
document.querySelectorAll(selector).forEach(header => {
if (!header.dataset.tdmOrigLabel) {
header.dataset.tdmOrigLabel = (header.textContent || 'Level').trim();
}
const desiredHTML = 'Lvl/BS';
// Non-destructive text update to preserve sort icons
let textUpdated = false;
// 1. Try direct text nodes
for (const node of header.childNodes) {
if (node.nodeType === 3 && node.nodeValue.trim()) {
// Replace plain text node with an inline span that preserves the sort icon sibling
const span = document.createElement('span');
span.innerHTML = desiredHTML;
header.replaceChild(span, node);
textUpdated = true;
break;
}
}
// 2. Try nested div (common in Torn headers: div > div(text) + div(icon))
if (!textUpdated) {
const textDiv = Array.from(header.children).find(c => !c.className.includes('sortIcon'));
if (textDiv) {
if (textDiv.innerHTML !== desiredHTML) textDiv.innerHTML = desiredHTML;
textUpdated = true;
}
}
// 3. Fallback: if empty or just text, set content. If icon exists but no text node found, prepend text.
if (!textUpdated) {
const icon = header.querySelector('[class*="sortIcon"]');
if (icon) {
const span = document.createElement('span');
span.innerHTML = desiredHTML;
header.insertBefore(span, icon);
} else {
header.innerHTML = desiredHTML;
}
}
// Ensure sort icon exists
let icon = header.querySelector('[class*="sortIcon"]');
if (!icon) {
// Try to clone from a sibling header
const sibling = header.parentElement ? header.parentElement.querySelector('[class*="sortIcon"]') : null;
if (sibling) {
icon = sibling.cloneNode(true);
icon.className = sibling.className.replace(/activeIcon\S*/g, '').replace(/asc\S*/g, '').replace(/desc\S*/g, '');
header.appendChild(icon);
} else {
// Create generic if no sibling found (using classes from user report as best guess)
icon = document.createElement('div');
icon.className = 'sortIcon___SmuX8';
header.appendChild(icon);
}
}
if (overlayEnabled) {
if (header.dataset.tdmLevelMode !== label) {
header.dataset.tdmLevelMode = label;
}
} else {
if ('tdmLevelMode' in header.dataset) {
delete header.dataset.tdmLevelMode;
}
}
const tip = `Level/BS column (${label}) – click any cell to cycle`;
if (header.title !== tip) header.title = tip;
if (header.getAttribute('aria-label') !== tip) header.setAttribute('aria-label', tip);
if (header.getAttribute('data-tdm-overlay-level-header') !== '1') {
header.setAttribute('data-tdm-overlay-level-header', '1');
}
});
});
} catch(_) { /* noop */ }
},
_updateSortIcons(table, activeField, direction) {
try {
if (!table) return;
const headers = table.querySelectorAll('.table-header > *, .white-grad > *');
headers.forEach(header => {
const field = header.dataset.tdmSortKey || ui._detectRankedWarSortField(header);
const icon = header.querySelector('[class*="sortIcon"]');
if (!icon) return;
// Remove active/direction classes
const classes = Array.from(icon.classList);
const activeClass = classes.find(c => c.startsWith('activeIcon'));
const ascClass = classes.find(c => c.startsWith('asc'));
const descClass = classes.find(c => c.startsWith('desc'));
if (activeClass) icon.classList.remove(activeClass);
if (ascClass) icon.classList.remove(ascClass);
if (descClass) icon.classList.remove(descClass);
if (field === activeField) {
const activeCls = 'activeIcon___pGiua';
const ascCls = 'asc___e08kZ';
const descCls = 'desc___S5bx1';
icon.classList.add(activeCls);
icon.classList.add(direction === 'asc' ? ascCls : descCls);
}
});
} catch(_) { /* noop */ }
},
updateAllLevelCells(opts = {}) {
try {
const overlayEnabled = opts.forceReset ? false : ui.isLevelOverlayEnabled({ ignoreFeatureFlag: opts.ignoreFeatureFlag });
const mode = overlayEnabled ? ui.getLevelDisplayMode({ ignoreFeatureFlag: true }) : DEFAULT_LEVEL_DISPLAY_MODE;
const roots = ui._collectLevelCellRoots(opts);
roots.forEach(root => {
ui._gatherLevelCells(root).forEach(cell => {
if (overlayEnabled) {
state.ui = state.ui || {};
state.ui._overlaySortPaused = true;
ui._renderLevelCell(cell, mode);
} else {
ui._teardownLevelCell(cell);
}
});
});
ui._updateLevelHeaderLabels(mode, overlayEnabled);
if (overlayEnabled && ui._isRankWarOverlaySortActive()) {
try { ui._applyRankedWarOverlaySort({ toggle: false, direction: state.ui.rankWarOverlaySortDir, reason: 'overlay-render' }); } catch(_) {}
}
if (overlayEnabled && state.ui) delete state.ui._overlaySortPaused;
if (!overlayEnabled) {
try {
ui._collectMembersListTables().forEach(table => { if (table?.dataset) delete table.dataset.tdmOverlaySorted; });
document.querySelectorAll('.f-war-list.members-list .table-header .lvl, .f-war-list.members-list .table-header .level').forEach(header => {
if (header.dataset) delete header.dataset.tdmOverlaySortDir;
});
ui._collectRankedWarTables().forEach(table => { if (table?.dataset) delete table.dataset.tdmOverlaySorted; });
document.querySelectorAll('.tab-menu-cont .white-grad .lvl, .tab-menu-cont .white-grad .level, .tab-menu-cont .table-header .lvl, .tab-menu-cont .table-header .level').forEach(header => {
if (header.dataset) delete header.dataset.tdmOverlaySortDir;
});
if (state?.ui && 'rankWarOverlaySortDir' in state.ui) delete state.ui.rankWarOverlaySortDir;
} catch(_) { /* optional cleanup */ }
}
if (ui._areRankedWarFavoritesEnabled()) {
const shouldRepinFavorites = true;
if (shouldRepinFavorites) {
try { ui._pinFavoritesInAllVisibleTables(); } catch(_) { /* noop */ }
}
}
} catch(_) { /* noop */ }
},
_isRankWarOverlaySortActive() {
try {
if (state.ui?._overlaySortPaused) return false;
return typeof state.ui?.rankWarOverlaySortDir === 'string' && state.ui.rankWarOverlaySortDir.length > 0;
} catch(_) { return false; }
},
_disableRankedWarOverlaySort(opts = {}) {
try {
if (state?.ui && 'rankWarOverlaySortDir' in state.ui) delete state.ui.rankWarOverlaySortDir;
const tables = opts.table ? [opts.table] : ui._collectRankedWarTables(opts.scope);
tables.forEach(table => {
if (!table) return;
if (table.dataset) delete table.dataset.tdmOverlaySorted;
table.querySelectorAll('.white-grad .level, .white-grad .lvl, .table-header .level, .table-header .lvl').forEach(header => {
if (header?.dataset) delete header.dataset.tdmOverlaySortDir;
});
});
try { ui.requestRankedWarFavoriteRepin?.({ delays: [0, 160, 400] }); } catch(_) {}
} catch(_) { /* noop */ }
},
_collectMembersListTables(container) {
try {
const set = new Set();
const roots = [];
if (container && typeof container.querySelectorAll === 'function') {
roots.push(...container.querySelectorAll('.f-war-list.members-list'));
if (container.matches && container.matches('.f-war-list.members-list')) roots.push(container);
}
if (!roots.length) roots.push(...document.querySelectorAll('.f-war-list.members-list'));
roots.forEach(node => { if (node && !set.has(node)) { set.add(node); } });
return Array.from(set);
} catch(_) {
return [];
}
},
_applyMembersListOverlaySort(table, opts = {}) {
try {
if (!table) return false;
if (!ui.isLevelOverlayEnabled() || ui.getLevelDisplayMode({ ignoreFeatureFlag: true }) === 'level') return false;
const body = table.querySelector('.table-body');
if (!body) return false;
const rows = Array.from(body.querySelectorAll(':scope > li.table-row'));
if (!rows.length) return false;
const mode = ui.getLevelDisplayMode({ ignoreFeatureFlag: true });
const entries = [];
rows.forEach((row, index) => {
const cell = row.querySelector('.level, .lvl');
if (!cell) return;
const meta = ui._readLevelCellMeta(cell);
const playerId = ui._extractPlayerIdFromRow(row);
const ff = playerId ? utils.readFFScouter(playerId) : null;
const bsp = playerId ? utils.readBSP(playerId) : null;
const computed = ui._computeLevelDisplay({ mode, level: meta.level, ff, bsp });
const sortable = Number.isFinite(computed.sortValue) ? computed.sortValue : Number.NEGATIVE_INFINITY;
const fallback = Number.isFinite(meta.level) ? meta.level : Number.NEGATIVE_INFINITY;
entries.push({ row, sortable, fallback, index });
});
if (!entries.length) return false;
const header = table.querySelector('.table-header .lvl, .table-header .level');
let direction = header?.dataset?.tdmOverlaySortDir || 'desc';
if (typeof opts.direction === 'string') direction = opts.direction;
else if (opts.toggle !== false) direction = direction === 'asc' ? 'desc' : 'asc';
if (header?.dataset) header.dataset.tdmOverlaySortDir = direction;
// Update sort icons
ui._updateSortIcons(table, 'level', direction);
const multiplier = direction === 'asc' ? 1 : -1;
entries.sort((a, b) => {
const av = Number.isFinite(a.sortable) ? a.sortable : a.fallback;
const bv = Number.isFinite(b.sortable) ? b.sortable : b.fallback;
if (!Number.isFinite(av) && !Number.isFinite(bv)) return a.index - b.index;
if (!Number.isFinite(av)) return 1;
if (!Number.isFinite(bv)) return -1;
if (av === bv) return a.index - b.index;
return (av - bv) * multiplier;
});
const frag = document.createDocumentFragment();
entries.forEach(entry => frag.appendChild(entry.row));
body.appendChild(frag);
try { ui._pinFavoritesInFactionList(table); } catch(_) {}
if (table.dataset) table.dataset.tdmOverlaySorted = '1';
return true;
} catch(_) {
return false;
}
},
_ensureMembersListOverlaySortHandlers(container) {
try {
const tables = ui._collectMembersListTables(container);
if (!tables.length) return;
tables.forEach(table => {
const headers = table.querySelectorAll('.table-header .lvl, .table-header .level');
headers.forEach(header => {
if (!header || header._tdmOverlaySortBound) return;
const handler = (event) => {
if (!ui.isLevelOverlayEnabled() || ui.getLevelDisplayMode({ ignoreFeatureFlag: true }) === 'level') return;
const applied = ui._applyMembersListOverlaySort(table, { toggle: true });
if (applied) {
event.preventDefault();
event.stopPropagation();
if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
}
};
header.addEventListener('click', handler, true);
header._tdmOverlaySortBound = true;
});
});
} catch(_) { /* noop */ }
},
_collectRankedWarTables(container) {
try {
const roots = [];
if (container && typeof container.querySelectorAll === 'function') {
container.querySelectorAll('.tab-menu-cont').forEach(node => roots.push(node));
if (container.matches && container.matches('.tab-menu-cont')) roots.push(container);
}
if (!roots.length && state.dom?.rankwarfactionTables) {
state.dom.rankwarfactionTables.forEach(node => roots.push(node));
}
if (!roots.length) document.querySelectorAll('.tab-menu-cont').forEach(node => roots.push(node));
const seen = new Set();
return roots.filter(node => {
if (!node || seen.has(node)) return false;
seen.add(node);
return true;
});
} catch(_) {
return [];
}
},
_resolveRankedWarListEl(table) {
if (!table) return null;
const primary = table.querySelector('.members-list, .members-cont, ul.members-list, ul.members-cont');
if (!primary) return null;
if (primary.matches('ul')) return primary;
const scoped = primary.querySelector(':scope > ul');
if (scoped) return scoped;
const nested = primary.querySelector('ul');
return nested || primary;
},
_applyRankedWarOverlaySort(opts = {}) {
try {
if (!ui.isLevelOverlayEnabled() || ui.getLevelDisplayMode({ ignoreFeatureFlag: true }) === 'level') return false;
const tables = ui._collectRankedWarTables();
if (!tables.length) return false;
const mode = ui.getLevelDisplayMode({ ignoreFeatureFlag: true });
if (!state.ui) state.ui = {};
const prior = (opts.originHeader?.dataset?.tdmOverlaySortDir) || state.ui.rankWarOverlaySortDir;
let direction = prior || 'desc';
if (typeof opts.direction === 'string' && opts.direction) direction = opts.direction;
else if (opts.toggle !== false) direction = prior ? (prior === 'asc' ? 'desc' : 'asc') : 'desc';
state.ui.rankWarOverlaySortDir = direction;
let mutated = false;
tables.forEach(table => {
if (ui._isRankedWarCustomSortActive(table)) {
if (table.dataset.tdmSortField !== 'level' && opts.toggle !== true) {
if (table?.dataset) delete table.dataset.tdmOverlaySorted;
return;
}
} else if (opts.toggle !== true) {
const pref = ui._loadRankedWarSortPreference(table);
if (pref && pref.field && pref.field !== 'level') {
ui._refreshRankedWarSortForTable(table, { reason: 'overlay-restore-pref' });
return;
}
}
const sorted = ui._applyRankedWarCustomSort({ table, field: 'level', direction, reason: 'overlay', mode });
if (sorted) mutated = true;
});
return mutated;
} catch(_) {
return false;
}
},
_ensureRankedWarOverlaySortHandlers(container) {
try {
const tables = ui._collectRankedWarTables(container);
if (!tables.length) return;
tables.forEach(table => {
const headers = table.querySelectorAll('.white-grad .level, .white-grad .lvl, .table-header .level, .table-header .lvl');
headers.forEach(header => {
if (!header || header._tdmOverlaySortBound) return;
const handler = (event) => {
if (!ui.isLevelOverlayEnabled() || ui.getLevelDisplayMode({ ignoreFeatureFlag: true }) === 'level') return;
event.preventDefault();
event.stopPropagation();
if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
ui._applyRankedWarOverlaySort({ originHeader: header, toggle: true });
};
header.addEventListener('click', handler, true);
header._tdmOverlaySortBound = true;
if (header.dataset) header.dataset.tdmOverlayLevelHeader = '1';
});
const resetCells = table.querySelectorAll('.white-grad > *, .white-grad .table-cell, .white-grad li, .table-header > *, .table-header .table-cell, .table-header li');
resetCells.forEach(cell => {
if (!cell) return;
if (cell.matches('[data-tdm-overlay-level-header="1"], .level, .lvl')) return;
if (cell._tdmOverlayResetBound) return;
const disableHandler = () => {
if (!ui.isLevelOverlayEnabled() || ui.getLevelDisplayMode({ ignoreFeatureFlag: true }) === 'level') return;
if (!ui._isRankWarOverlaySortActive()) return;
ui._disableRankedWarOverlaySort({ table });
try { ui.requestRankedWarFavoriteRepin?.(); } catch(_) {}
};
['pointerdown', 'click'].forEach(evtType => {
try { cell.addEventListener(evtType, disableHandler, true); } catch(_) {}
});
cell._tdmOverlayResetBound = true;
});
});
} catch(_) { /* noop */ }
},
_ensureRankedWarSortHandlers(container) {
try {
const tables = ui._collectRankedWarTables(container);
if (!tables.length) return;
tables.forEach(table => {
// Fix: Selector must match div headers in ranked war tables (direct children of .white-grad)
const headers = table.querySelectorAll('.white-grad > div, .white-grad .table-header > *, .table-header > *');
headers.forEach(header => {
if (!header || header._tdmRankSortBound) return;
const field = ui._detectRankedWarSortField(header);
if (!field) return;
header.dataset.tdmSortKey = field;
const handler = (event) => {
try {
if (event?.type === 'keydown') {
const key = event.key || event.code || '';
if (key && key !== 'Enter' && key !== ' ' && key !== 'Spacebar') return;
}
if (event?.type === 'click' && event.button && event.button !== 0) return;
// If clicking the Level header (which has overlay enabled), ignore this handler
// The overlay handler will take care of it
if (header.dataset?.tdmOverlayLevelHeader === '1') {
const overlayActive = ui.isLevelOverlayEnabled() && ui.getLevelDisplayMode({ ignoreFeatureFlag: true }) !== 'level';
if (overlayActive) return;
}
event?.preventDefault?.();
event?.stopPropagation?.();
if (typeof event?.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
// Disable overlay sort if active, since we are sorting another column
if (ui._isRankWarOverlaySortActive()) {
ui._disableRankedWarOverlaySort({ table });
}
const sortField = header.dataset?.tdmSortKey || field;
if (!sortField) return;
const sortDir = ui._toggleRankedWarSortDirection(table, sortField);
// Sync sort to all ranked war tables
const allTables = ui._collectRankedWarTables();
allTables.forEach(t => {
ui._refreshRankedWarSortForTable(t, { field: sortField, direction: sortDir, reason: 'header-click' });
});
} catch(_) { /* non-fatal */ }
};
header.addEventListener('click', handler, true);
header.addEventListener('keydown', handler, true);
header._tdmRankSortBound = true;
header.classList.add('tdm-rank-sort-header');
});
ui._ensureRankedWarSortObserver(table);
});
} catch(_) { /* noop */ }
},
_toggleRankedWarSortDirection(table, field) {
try {
if (!table || !field) return 'desc';
const priorField = table.dataset?.tdmSortField;
const priorDir = table.dataset?.tdmSortDir;
if (priorField === field) {
return priorDir === 'asc' ? 'desc' : 'asc';
}
return ui._defaultRankedWarSortDirection(field);
} catch(_) {
return 'desc';
}
},
_defaultRankedWarSortDirection(field) {
switch (field) {
case 'member':
case 'status':
return 'asc';
default:
return 'desc';
}
},
_defaultRankedWarSortField() {
return null;
},
_ensureRankedWarDefaultSort(table) {
try {
if (!table) return;
const activeField = table.dataset?.tdmSortField;
if (activeField) return;
ui._refreshRankedWarSortForTable(table, { reason: 'default' });
} catch(_) { /* noop */ }
},
_isRankedWarCustomSortActive(table) {
try {
if (!table) return false;
return typeof table.dataset?.tdmSortField === 'string' && table.dataset.tdmSortField.length > 0;
} catch(_) {
return false;
}
},
_areRankedWarFavoritesEnabled() {
try {
if (featureFlagController?.isEnabled?.('rankWarEnhancements.favorites') === false) return false;
return true;
} catch(_) {
return true;
}
},
_isRankedWarSortDebugEnabled() { return false; },
_isRankedWarSortPaused() {
try {
return (state?.ui?._rankWarSortPauseDepth || 0) > 0;
} catch(_) {
return false;
}
},
_getRankedWarSortPreferenceStore() {
try {
if (!state.ui) state.ui = {};
if (!(state.ui._rankWarSortPrefs instanceof Map)) {
state.ui._rankWarSortPrefs = new Map();
}
return state.ui._rankWarSortPrefs;
} catch(_) {
return new Map();
}
},
_rankedWarSortPreferenceKey(table) {
try {
if (!table) return null;
// Stability fix: Prefer side-based key to ensure persistence across DOM updates where factionId might be delayed
const isLeft = table.classList?.contains('left');
const isRight = table.classList?.contains('right');
if (isLeft || isRight) {
const key = `side:${isLeft ? 'left' : 'right'}`;
if (table.dataset) table.dataset.tdmSortPrefKey = key;
return key;
}
const factionId = ui._resolveRankedWarTableFactionId?.(table);
if (factionId != null) return `faction:${String(factionId)}`;
if (table.dataset?.factionId) return `faction:${String(table.dataset.factionId)}`;
if (table.dataset?.tdmSortPrefKey) return table.dataset.tdmSortPrefKey;
if (!state.ui) state.ui = {};
const nextId = (state.ui._rankWarSortPrefCounter = (state.ui._rankWarSortPrefCounter || 0) + 1);
const fallbackKey = `table:${nextId}`;
if (table.dataset) table.dataset.tdmSortPrefKey = fallbackKey;
return fallbackKey;
} catch(_) {
return null;
}
},
_loadRankedWarSortPreference(table) {
try {
const key = ui._rankedWarSortPreferenceKey(table);
if (!key) return null;
const store = ui._getRankedWarSortPreferenceStore();
return store.get(key) || null;
} catch(_) {
return null;
}
},
_storeRankedWarSortPreference(table, field, direction) {
try {
if (!field) return;
const key = ui._rankedWarSortPreferenceKey(table);
if (!key) return;
const store = ui._getRankedWarSortPreferenceStore();
store.set(key, { field, direction: direction === 'asc' ? 'asc' : 'desc' });
} catch(_) { /* noop */ }
},
_withRankedWarSortPause(fn, reason = 'unspecified') {
if (typeof fn !== 'function') return;
if (!state.ui) state.ui = {};
const scheduleRelease = () => {
try {
const runRelease = () => {
if (!state.ui) state.ui = {};
const next = Math.max(0, (state.ui._rankWarSortPauseDepth || 1) - 1);
state.ui._rankWarSortPauseDepth = next;
};
setTimeout(runRelease, 0); // allow MutationObserver microtasks to flush before resuming
} catch(_) { /* noop */ }
};
try {
const nextDepth = (state.ui._rankWarSortPauseDepth || 0) + 1;
state.ui._rankWarSortPauseDepth = nextDepth;
const result = fn();
if (result && typeof result.then === 'function') {
return result.finally(() => scheduleRelease());
}
scheduleRelease();
return result;
} catch(err) {
scheduleRelease();
throw err;
}
},
_logRankedWarSort() {},
_refreshRankedWarSortForTable(table, opts = {}) {
try {
if (!table) return;
const reason = opts.reason || 'refresh';
let field = opts.field;
let dir = opts.direction;
if (!field) field = table.dataset?.tdmSortField;
if (!dir) dir = table.dataset?.tdmSortDir;
if (!field) {
const pref = ui._loadRankedWarSortPreference(table);
if (pref?.field) {
field = pref.field;
if (!dir && pref.direction) dir = pref.direction;
}
}
if (!field) field = ui._defaultRankedWarSortField();
if (!dir) dir = ui._defaultRankedWarSortDirection(field);
// Fix: If sorting by a non-level field, disable the overlay sort enforcement to prevent fighting
if (field !== 'level' && state.ui && state.ui.rankWarOverlaySortDir) {
delete state.ui.rankWarOverlaySortDir;
}
ui._withRankedWarSortPause(() => {
ui._applyRankedWarCustomSort({ table, field, direction: dir, reason });
ui._updateSortIcons(table, field, dir);
if (ui._areRankedWarFavoritesEnabled()) {
try {
const factionId = ui._resolveRankedWarTableFactionId(table);
ui._pinFavoritesInTable(table, factionId);
} catch(_) { /* noop */ }
}
}, `refresh:${reason}`);
} catch(_) { /* noop */ }
},
_scheduleRankedWarSortSync(table, reason = 'unspecified', delayMs = 40) {
try {
if (!table) return;
if (ui._isRankedWarSortPaused()) return;
if (table._tdmSortSyncHandle) {
clearTimeout(table._tdmSortSyncHandle);
}
// Use dynamic delay to avoid thrashing when external scripts inject many nodes
table._tdmSortSyncHandle = setTimeout(() => {
if (ui._isRankedWarSortPaused()) {
table._tdmSortSyncHandle = null;
return;
}
try { ui._refreshRankedWarSortForTable(table, { reason }); } catch(_) { /* noop */ }
table._tdmSortSyncHandle = null;
}, Math.max(10, Number(delayMs) || 40));
} catch(_) { /* noop */ }
},
_ensureRankedWarSortObserver(table) {
try {
if (!table || table._tdmSortObserver) return;
const list = ui._resolveRankedWarListEl(table);
if (!list) return;
const observer = new MutationObserver(mutations => {
try {
if (!Array.isArray(mutations)) return;
const changed = mutations.some(m => m && m.type === 'childList');
if (!changed) return;
if (ui._isRankedWarSortPaused()) return;
// Count added nodes across mutation records to detect external-script bursts
const addedCount = mutations.reduce((sum, m) => sum + (m.addedNodes ? m.addedNodes.length : 0), 0);
// If many nodes are being added (likely from another userscript), increase debounce
const delay = addedCount > 8 ? Math.min(600, 40 + addedCount * 20) : 40;
ui._scheduleRankedWarSortSync(table, 'observer-childList', delay);
} catch(_) { /* noop */ }
});
observer.observe(list, { childList: true });
table._tdmSortObserver = observer;
} catch(_) { /* noop */ }
},
_detectRankedWarSortField(header) {
try {
if (!header) return null;
const direct = header.dataset?.tdmSortKey
|| header.dataset?.sortField
|| header.dataset?.column
|| header.dataset?.columnId
|| header.dataset?.columnName;
if (direct) {
const normalized = ui._normalizeRankedWarSortField(direct);
if (normalized) return normalized;
}
const classes = Array.from(header.classList || []);
for (const cls of classes) {
const normalized = ui._normalizeRankedWarSortField(cls);
if (normalized) return normalized;
}
const text = header.textContent ? header.textContent.trim().toLowerCase() : '';
if (!text) return null;
if (text.includes('level') || text.includes('lv')) return 'level';
if (text.includes('member')) return 'member';
if (text.includes('status')) return 'status';
if (text.includes('points')) return 'points';
return null;
} catch(_) {
return null;
}
},
_normalizeRankedWarSortField(value) {
if (!value) return null;
const normalized = String(value).toLowerCase();
if (normalized === 'lvl' || normalized === 'level' || normalized === 'bs') return 'level';
if (normalized === 'member' || normalized === 'members' || normalized === 'name') return 'member';
if (normalized === 'status' || normalized === 'statuses') return 'status';
if (normalized === 'points' || normalized === 'point' || normalized === 'score') return 'points';
return null;
},
_buildRankedWarRowMeta(row, ctx = {}) {
const meta = {
playerId: null,
playerIdNum: Number.NaN,
name: '',
nameSort: '',
overlaySort: Number.NEGATIVE_INFINITY,
level: Number.NEGATIVE_INFINITY,
statusText: '',
statusRank: 0,
points: Number.NEGATIVE_INFINITY,
isFavorite: false
};
try {
if (!row) return meta;
const playerId = ui._extractPlayerIdFromRow(row);
if (playerId) {
meta.playerId = String(playerId);
const pidNum = Number(playerId);
if (Number.isFinite(pidNum)) meta.playerIdNum = pidNum;
}
meta.isFavorite = row?.dataset?.tdmFavorite === '1';
const nameNode = row.querySelector('.member a[href*="profiles.php"], .member span, .member');
const rawName = nameNode ? utils.sanitizePlayerName?.(nameNode.textContent || '', meta.playerId) || nameNode.textContent || '' : '';
meta.name = rawName ? rawName.trim() : '';
meta.nameSort = meta.name.toLowerCase();
const levelCell = row.querySelector('.level, .lvl');
const overlayMode = ctx.mode || ui.getLevelDisplayMode?.({ ignoreFeatureFlag: true }) || 'level';
if (levelCell) {
const cellMeta = ui._readLevelCellMeta(levelCell) || {};
if (Number.isFinite(cellMeta.level)) meta.level = cellMeta.level;
const ff = meta.playerId ? utils.readFFScouter?.(meta.playerId) : null;
const bsp = meta.playerId ? utils.readBSP?.(meta.playerId) : null;
const computed = ui._computeLevelDisplay?.({ mode: overlayMode, level: cellMeta.level, ff, bsp });
if (computed && Number.isFinite(computed.sortValue)) meta.overlaySort = computed.sortValue;
}
const parseNumeric = (selectors) => {
try {
for (const sel of selectors) {
const node = typeof sel === 'string' ? row.querySelector(sel) : null;
if (!node) continue;
const dataVal = node.getAttribute('data-value');
const raw = dataVal != null ? dataVal : (node.textContent || '');
if (!raw) continue;
const cleaned = raw.replace(/[^0-9.\-]/g, '');
if (!cleaned) continue;
const num = Number(cleaned);
if (Number.isFinite(num)) return num;
}
} catch(_) { /* noop */ }
return Number.NaN;
};
meta.points = parseNumeric(['.points', '[data-points]']);
const statusNode = row.querySelector('.status');
meta.statusText = statusNode ? statusNode.textContent?.trim() || '' : '';
if (statusNode && statusNode.dataset.sortValue) {
meta.statusTime = parseInt(statusNode.dataset.sortValue, 10);
} else {
meta.statusTime = 0;
}
if (statusNode && statusNode.dataset.subRank) {
meta.statusRank = parseInt(statusNode.dataset.subRank, 90);
} else {
const status = meta.statusText.toLowerCase();
// Ensure hospital-related statuses get top priority. Check 'hospital' before 'abroad'
if (status.includes('hospital')) meta.statusRank = 100;
else if (status.includes('abroad')) meta.statusRank = 90;
else if (status.includes('travel')) meta.statusRank = 40;
else if (status.includes('jail')) meta.statusRank = 70;
else if (status.includes('okay')) meta.statusRank = 80;
else meta.statusRank = 0;
}
} catch(_) { /* noop */ }
return meta;
},
_compareRankedWarSortValues(field, aMeta, bMeta, direction) {
const asc = direction === 'asc' ? 1 : -1;
const compareNumber = (av, bv) => {
const aValid = Number.isFinite(av);
const bValid = Number.isFinite(bv);
if (aValid && bValid) {
if (av !== bv) return (av - bv) * asc;
return 0;
}
if (aValid && !bValid) return -1 * asc;
if (!aValid && bValid) return 1 * asc;
return 0;
};
switch (field) {
case 'member': {
const cmp = aMeta.nameSort.localeCompare(bMeta.nameSort);
if (cmp !== 0) return cmp * asc;
break;
}
case 'status': {
const rankCmp = compareNumber(aMeta.statusRank, bMeta.statusRank);
if (rankCmp !== 0) return rankCmp;
const timeCmp = compareNumber(aMeta.statusTime, bMeta.statusTime);
if (timeCmp !== 0) return timeCmp;
const textCmp = aMeta.statusText.localeCompare(bMeta.statusText);
if (textCmp !== 0) return textCmp * asc;
break;
}
case 'points':
return compareNumber(aMeta.points, bMeta.points);
case 'level':
default: {
const av = Number.isFinite(aMeta.overlaySort) ? aMeta.overlaySort : aMeta.level;
const bv = Number.isFinite(bMeta.overlaySort) ? bMeta.overlaySort : bMeta.level;
const levelCmp = compareNumber(av, bv);
if (levelCmp !== 0) return levelCmp;
break;
}
}
return 0;
},
_applyRankedWarCustomSort(opts = {}) {
try {
const { table, field, direction, mode, reason = 'unspecified' } = opts;
if (!table || !field) return false;
const list = ui._resolveRankedWarListEl(table);
if (!list) return false;
const rowSelector = ':scope > li, :scope > .table-row';
const rows = Array.from(list.querySelectorAll(rowSelector));
if (!rows.length) return false;
let factionId = ui._resolveRankedWarTableFactionId(table);
if (table.dataset) {
if (factionId != null) table.dataset.factionId = String(factionId);
else if (table.dataset.factionId) factionId = table.dataset.factionId;
}
const favoritesEnabled = ui._areRankedWarFavoritesEnabled();
const favSet = favoritesEnabled && factionId != null ? new Set(Object.keys(ui.getFavoritesForFaction(factionId) || {})) : new Set();
const actualDir = direction === 'asc' ? 'asc' : 'desc';
if (favoritesEnabled) {
rows.forEach(row => {
try {
const pid = ui._extractPlayerIdFromRow(row);
const isFav = pid && favSet.has(String(pid));
ui._setFavoriteRowHighlight(row, !!isFav);
} catch(_) { /* noop */ }
});
}
ui._storeRankedWarSortPreference(table, field, actualDir);
const entries = rows.map((row, index) => ({
row,
index,
meta: ui._buildRankedWarRowMeta(row, { mode, index })
}));
const isEntryFavorite = (entry) => entry?.row?.dataset?.tdmFavorite === '1';
const originalOrder = rows.slice();
entries.sort((a, b) => {
if (favoritesEnabled) {
const favA = isEntryFavorite(a);
const favB = isEntryFavorite(b);
if (favA !== favB) return favA ? -1 : 1;
}
const cmp = ui._compareRankedWarSortValues(field, a.meta, b.meta, actualDir);
if (cmp !== 0) return cmp;
return a.index - b.index;
});
const reordered = entries.some((entry, idx) => entry.row !== originalOrder[idx]);
if (reordered) {
const frag = document.createDocumentFragment();
entries.forEach(entry => frag.appendChild(entry.row));
list.appendChild(frag);
}
if (table.dataset) {
table.dataset.tdmSortField = field;
table.dataset.tdmSortDir = actualDir;
}
ui._updateRankedWarHeaderSortIndicators(table, field, actualDir);
return reordered;
} catch(_) {
return false;
}
},
_updateRankedWarHeaderSortIndicators(table, field, direction) {
try {
if (!table) return;
// Fix: Selector must match div headers in ranked war tables
const headers = table.querySelectorAll('.white-grad > div, .white-grad .table-header > *, .table-header > *');
headers.forEach(header => {
const key = header?.dataset?.tdmSortKey || ui._detectRankedWarSortField(header);
if (!key) return;
header.classList.remove('tdm-sort-asc', 'tdm-sort-desc', 'tdm-sort-active');
if (key === field) {
header.classList.add('tdm-sort-active');
header.classList.add(direction === 'asc' ? 'tdm-sort-asc' : 'tdm-sort-desc');
if (header.dataset) header.dataset.tdmSortDir = direction;
} else if (header.dataset) {
delete header.dataset.tdmSortDir;
}
});
} catch(_) { /* noop */ }
},
_refreshRankedWarSortForFaction(factionId) {
try {
const tables = ui._collectRankedWarTables();
if (!tables.length) return;
tables.forEach(table => {
const tableFactionId = ui._resolveRankedWarTableFactionId(table);
if (factionId != null && tableFactionId != null && String(tableFactionId) !== String(factionId)) return;
ui._refreshRankedWarSortForTable(table);
});
} catch(_) { /* noop */ }
},
_refreshRankedWarSortForAll() {
try {
ui._refreshRankedWarSortForFaction(null);
} catch(_) { /* noop */ }
},
_favStorageKey(factionId) {
try { return `tdm.favorites.faction_${String(factionId ?? 'global')}`; } catch(_) { return 'tdm.favorites.faction_global'; }
},
getFavoritesForFaction(factionId) {
try {
const key = ui._favStorageKey(factionId);
const map = storage.get(key, {});
return (map && typeof map === 'object') ? { ...map } : {};
} catch(_) { return {}; }
},
isFavorite(playerId, factionId) {
try {
const favs = ui.getFavoritesForFaction(factionId);
return !!(favs && favs[String(playerId)]);
} catch(_) { return false; }
},
_setFavoriteRowHighlight(row, isFavorite) {
if (!row) return;
const active = !!isFavorite;
try { row.classList?.toggle('tdm-favorite-row', active); } catch(_) {}
if (row.dataset) {
if (active) row.dataset.tdmFavorite = '1';
else delete row.dataset.tdmFavorite;
}
},
_collectRowsByPlayerId(root) {
const map = new Map();
try {
if (!root) return map;
const rows = root.querySelectorAll('li, .table-row');
rows.forEach(row => {
const id = ui._extractPlayerIdFromRow(row);
if (id) map.set(String(id), row);
});
} catch(_) {}
return map;
},
_resolveRankedWarTableFactionId(tableContainer, visibleFactions) {
try {
if (!tableContainer) return null;
const datasetId = tableContainer.dataset?.factionId;
if (datasetId) return datasetId;
const vis = visibleFactions || utils.getVisibleRankedWarFactionIds?.() || {};
const linkCandidates = tableContainer.querySelectorAll?.('a[href*="factions.php"]') || [];
for (const link of linkCandidates) {
const parsed = utils.parseFactionIdFromHref?.(link?.href);
if (parsed) return parsed;
}
const isLeft = tableContainer.classList?.contains('left');
const isRight = tableContainer.classList?.contains('right');
if (isLeft && vis.leftId) return vis.leftId;
if (isRight && vis.rightId) return vis.rightId;
const siblings = Array.from(tableContainer.parentElement?.querySelectorAll?.('.tab-menu-cont') || []);
if (vis.ids?.length && siblings.length) {
const idx = siblings.indexOf(tableContainer);
if (idx >= 0 && idx < vis.ids.length && vis.ids[idx]) return vis.ids[idx];
}
if (vis.ids?.length === 1) return vis.ids[0];
return null;
} catch(_) { return null; }
},
_getFactionIdForMembersList(container) {
try {
const toStr = (val) => (val != null ? String(val) : null);
if (container?.dataset?.factionId) return toStr(container.dataset.factionId);
const attr = container?.getAttribute?.('data-faction-id');
if (attr) return toStr(attr);
const ancestor = container?.closest?.('[data-faction-id]');
if (ancestor?.dataset?.factionId) return toStr(ancestor.dataset.factionId);
const urlId = state.page?.url?.searchParams?.get('ID');
if (urlId) return toStr(urlId);
if (state.page?.isMyFactionPage && state.user?.factionId) return toStr(state.user.factionId);
const headerLink = document.querySelector('.faction-info-head a[href*="factions.php"]');
const parsed = headerLink ? utils.parseFactionIdFromHref?.(headerLink.href) : null;
if (parsed) return toStr(parsed);
return state.user?.factionId != null ? String(state.user.factionId) : null;
} catch(_) { return state.user?.factionId != null ? String(state.user.factionId) : null; }
},
_syncFavoriteHeartsForPlayer(playerId, factionId) {
try {
if (!ui._areRankedWarFavoritesEnabled()) return;
const pid = String(playerId);
const selector = `.tdm-fav-heart[data-player-id='${pid}']`;
document.querySelectorAll(selector).forEach(heart => {
const heartFaction = heart.getAttribute('data-faction-id') || heart.dataset.factionId || '';
if (factionId && heartFaction && String(factionId) !== String(heartFaction)) return;
const effective = heartFaction || factionId || null;
const fav = ui.isFavorite(pid, effective);
heart.setAttribute('aria-pressed', fav ? 'true' : 'false');
heart.classList.toggle('tdm-fav-heart--active', fav);
ui._setFavoriteRowHighlight(heart.closest('li, .table-row'), fav);
});
} catch(_) { /* noop */ }
},
_pinFavoritesEverywhere() {
if (!ui._areRankedWarFavoritesEnabled()) return;
try { ui._pinFavoritesInAllVisibleTables(); } catch(_) {}
try { ui._pinFavoritesInFactionList(); } catch(_) {}
},
_pinFavoritesInAllVisibleTables() {
try {
if (!ui._areRankedWarFavoritesEnabled()) return;
ui._refreshRankedWarSortForAll();
} catch(_) { /* noop */ }
},
requestRankedWarFavoriteRepin() {
try {
if (!ui._areRankedWarFavoritesEnabled()) return;
ui._refreshRankedWarSortForAll();
setTimeout(() => ui._refreshRankedWarSortForAll(), 150);
} catch(_) { /* noop */ }
},
_pinFavoritesInTable(tableContainer, factionId) {
try {
if (!ui._areRankedWarFavoritesEnabled()) return;
if (!tableContainer) return;
let resolvedFactionId = factionId ?? tableContainer.dataset?.factionId ?? ui._resolveRankedWarTableFactionId(tableContainer);
if (resolvedFactionId == null) return;
resolvedFactionId = String(resolvedFactionId);
if (tableContainer.dataset) tableContainer.dataset.factionId = resolvedFactionId;
const list = ui._resolveRankedWarListEl(tableContainer) || tableContainer.querySelector('.members-list, .members-cont');
if (!list) return;
const rowSelector = ':scope > li, :scope > .table-row';
const rows = Array.from(list.querySelectorAll(rowSelector));
if (!rows.length) return;
const favRows = [];
const normalRows = [];
let needsReorder = false;
let seenNonFav = false;
rows.forEach(row => {
const isFav = row.dataset?.tdmFavorite === '1';
if (isFav) {
if (seenNonFav) needsReorder = true;
favRows.push(row);
} else {
seenNonFav = true;
normalRows.push(row);
}
ui._setFavoriteRowHighlight(row, isFav);
});
if (!favRows.length) return;
if (!needsReorder) return;
const frag = document.createDocumentFragment();
favRows.forEach(row => frag.appendChild(row));
normalRows.forEach(row => frag.appendChild(row));
list.appendChild(frag);
} catch(_) { /* noop */ }
},
_pinFavoritesInFactionList(container) {
try {
if (!ui._areRankedWarFavoritesEnabled()) return;
const root = container || state.dom?.factionListContainer;
if (!root) return;
const factionId = ui._getFactionIdForMembersList(root);
if (!factionId) return;
const body = root.matches?.('.table-body') ? root : root.querySelector('.table-body');
if (!body) return;
const rows = Array.from(body.querySelectorAll(':scope > li.table-row'));
if (!rows.length) return;
const favs = ui.getFavoritesForFaction(factionId);
const favSet = new Set(Object.keys(favs || {}));
if (!favSet.size) {
rows.forEach(row => ui._setFavoriteRowHighlight(row, false));
return;
}
const favRows = [];
const normalRows = [];
let needsReorder = false;
let seenNonFav = false;
rows.forEach(row => {
const pid = ui._extractPlayerIdFromRow(row);
const isFav = pid && favSet.has(String(pid));
if (isFav) {
if (seenNonFav) needsReorder = true;
favRows.push(row);
} else {
seenNonFav = true;
normalRows.push(row);
}
ui._setFavoriteRowHighlight(row, isFav);
});
if (!favRows.length) return;
if (!needsReorder) return;
const frag = document.createDocumentFragment();
favRows.forEach(row => frag.appendChild(row));
normalRows.forEach(row => frag.appendChild(row));
body.appendChild(frag);
} catch(_) { /* noop */ }
},
toggleFavorite(playerId, factionId, el) {
try {
if (!ui._areRankedWarFavoritesEnabled()) return false;
if (!playerId) return false;
const factionKey = factionId != null ? String(factionId) : null;
const key = ui._favStorageKey(factionKey);
const favs = ui.getFavoritesForFaction(factionKey);
const id = String(playerId);
const was = !!favs[id];
if (was) delete favs[id]; else favs[id] = { addedAt: Date.now() };
// Immediate UI feedback
if (el?.classList) {
el.classList.toggle('tdm-fav-heart--active', !was);
el.setAttribute('aria-pressed', (!was).toString());
}
ui._syncFavoriteHeartsForPlayer(id, factionKey);
// Persist and run heavier repin/sort asynchronously to keep UI snappy
setTimeout(() => { try { storage.set(key, favs); } catch(_) {} }, 50);
setTimeout(() => { try { ui._pinFavoritesEverywhere(); ui._refreshRankedWarSortForFaction(factionKey); } catch(_) {} }, 120);
return !was;
} catch(_) { return false; }
},
_ensureRankedWarFavoriteHeart(subrow, playerId, factionId) {
if (!subrow || !playerId || !factionId) return;
try {
if (!ui._areRankedWarFavoritesEnabled()) {
const existing = subrow.querySelector('.tdm-fav-heart');
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
return;
}
let heart = subrow.querySelector('.tdm-fav-heart');
if (!heart) {
heart = utils.createElement('button', { className: 'tdm-fav-heart', innerHTML: '\u2665', title: 'Favorite', type: 'button' });
heart.style.marginRight = '2px';
heart.style.marginLeft = '2px';
heart.style.border = 'none';
heart.style.background = 'transparent';
heart.style.cursor = 'pointer';
heart.addEventListener('click', (e) => {
try {
e.preventDefault();
e.stopPropagation();
ui.toggleFavorite(playerId, factionId, heart);
} catch(_) {}
});
subrow.insertAdjacentElement('afterbegin', heart);
}
heart.dataset.playerId = String(playerId);
heart.dataset.factionId = String(factionId);
const favActive = ui.isFavorite(playerId, factionId);
heart.setAttribute('aria-pressed', favActive ? 'true' : 'false');
heart.classList.toggle('tdm-fav-heart--active', favActive);
ui._setFavoriteRowHighlight(subrow.closest('li, .table-row'), favActive);
} catch(_) { /* noop */ }
},
_ensureMembersListFavoriteHeart(row, factionId) {
if (!row) return;
try {
if (!ui._areRankedWarFavoritesEnabled()) {
const existing = row.querySelector('.tdm-fav-heart');
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
return;
}
const playerId = ui._extractPlayerIdFromRow(row);
if (!playerId) return;
const memberCell = row.querySelector('.member') || row.querySelector('.table-body .table-cell');
if (!memberCell) return;
let heart = memberCell.querySelector('.tdm-fav-heart');
if (!heart) {
heart = utils.createElement('button', { className: 'tdm-fav-heart', innerHTML: '\u2665', title: 'Favorite', type: 'button' });
heart.style.marginRight = '6px';
heart.style.border = 'none';
heart.style.background = 'transparent';
heart.style.cursor = 'pointer';
heart.addEventListener('click', (e) => {
try {
e.preventDefault();
e.stopPropagation();
ui.toggleFavorite(playerId, factionId, heart);
} catch(_) {}
});
memberCell.insertBefore(heart, memberCell.firstChild);
}
if (factionId != null) heart.dataset.factionId = String(factionId);
heart.dataset.playerId = String(playerId);
const favActive = ui.isFavorite(playerId, factionId);
heart.setAttribute('aria-pressed', favActive ? 'true' : 'false');
heart.classList.toggle('tdm-fav-heart--active', favActive);
ui._setFavoriteRowHighlight(row, favActive);
} catch(_) { /* noop */ }
},
ensureBadgeDock() {
// Reclaim existing dock if script re-injected
if (!state.ui.badgeDockEl) {
const existing = document.querySelector('.torn-badge-dock');
if (existing) {
state.ui.badgeDockEl = existing;
state.ui.badgeDockItemsEl = existing.querySelector('.torn-badge-dock__block');
state.ui.badgeDockActionsEl = existing.querySelector('.torn-badge-dock__actions');
}
}
// Hard de-duplication: if multiple docks somehow exist (race / double inject), keep the first and remove the rest
try {
const docks = document.querySelectorAll('.torn-badge-dock');
if (docks && docks.length > 1) {
for (let i = 1; i < docks.length; i++) {
docks[i].remove();
}
// Rebind references to the surviving dock
const first = docks[0];
if (first) {
state.ui.badgeDockEl = first;
state.ui.badgeDockItemsEl = first.querySelector('.torn-badge-dock__block');
state.ui.badgeDockActionsEl = first.querySelector('.torn-badge-dock__actions');
}
}
} catch(_) { /* noop */ }
if (!state.ui.badgeDockEl) {
const dock = document.createElement('div');
dock.className = 'torn-badge-dock';
Object.assign(dock.style, {
position: 'fixed',
left: '18px',
right: 'auto',
bottom: '18px',
zIndex: '2000',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '6px',
pointerEvents: 'none',
width: 'max-content',
transformOrigin: 'bottom left'
});
const actions = document.createElement('div');
actions.className = 'torn-badge-dock__actions';
Object.assign(actions.style, {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '4px',
pointerEvents: 'auto'
});
const block = document.createElement('div');
block.className = 'torn-badge-dock__block';
Object.assign(block.style, {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '4px',
pointerEvents: 'auto',
padding: '0',
borderRadius: '10px',
background: 'transparent',
border: 'none',
boxShadow: 'none',
maxWidth: 'none',
fontSize: '10px',
width: 'max-content'
});
dock.appendChild(actions);
dock.appendChild(block);
document.body.appendChild(dock);
state.ui.badgeDockEl = dock;
state.ui.badgeDockActionsEl = actions;
state.ui.badgeDockItemsEl = block;
} else {
Object.assign(state.ui.badgeDockEl.style, {
left: '18px',
right: 'auto',
alignItems: 'flex-start',
gap: '6px',
pointerEvents: 'none',
width: 'max-content'
});
if (!state.ui.badgeDockItemsEl) state.ui.badgeDockItemsEl = state.ui.badgeDockEl.querySelector('.torn-badge-dock__block');
if (!state.ui.badgeDockActionsEl) state.ui.badgeDockActionsEl = state.ui.badgeDockEl.querySelector('.torn-badge-dock__actions');
if (state.ui.badgeDockActionsEl) {
Object.assign(state.ui.badgeDockActionsEl.style, {
alignItems: 'flex-start',
gap: '4px',
pointerEvents: 'auto'
});
}
if (state.ui.badgeDockItemsEl) {
Object.assign(state.ui.badgeDockItemsEl.style, {
alignItems: 'flex-start',
gap: '4px',
padding: '0',
maxWidth: 'none',
fontSize: '10px',
background: 'transparent',
border: 'none',
borderRadius: '10px',
boxShadow: 'none',
pointerEvents: 'auto',
width: 'max-content'
});
}
}
try { if (window.__TDM_SINGLETON__) window.__TDM_SINGLETON__.badgeDockEnsures++; } catch(_) {}
return state.ui.badgeDockEl;
},
_composeDockBadgeStyle(overrides = {}) {
return Object.assign({
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
padding: '2px 5px',
borderRadius: '8px',
background: 'rgba(12, 18, 28, 0.9)',
border: '1px solid rgba(255,255,255,0.08)',
color: '#e3ebf5',
fontSize: '10px',
fontWeight: '600',
letterSpacing: '0.015em',
lineHeight: '1.05',
whiteSpace: 'nowrap',
boxShadow: '0 4px 12px rgba(0,0,0,0.22)',
pointerEvents: 'auto'
}, overrides || {});
},
// Unified badge/timer ensure orchestrator.
// to avoid scattered duplication logic and racing ensure calls.
ensureBadgesSuite(force = false) {
try {
// Lightweight throttle: skip if we already ensured within the last animation frame unless force is true
const now = performance.now();
if (!force && state._lastBadgesSuiteAt && (now - state._lastBadgesSuiteAt) < 30) return; // ~1 frame @ 60fps
state._lastBadgesSuiteAt = now;
} catch(_) {}
const leader = state.script?.isLeaderTab !== false; // treat undefined as true initially
// Always start by ensuring dock + toggle (they internally dedupe)
if (leader) {
ui.ensureBadgeDock();
ui.ensureBadgeDockToggle();
} else {
// Non-leader tabs only reclaim existing dock to prevent duplication
ui.ensureBadgeDock();
}
// Core timers / badges (leader ensures structure; non-leader only updates existing)
const exec = (ensureFn, updateFallback) => {
try {
if (leader) ensureFn(); else if (updateFallback) updateFallback();
} catch(_) {}
};
// Ordered badge ensures (explicit ordering preserved for stable dock layout):
// 1) API usage counter
exec(ui.ensureApiUsageBadge, ui.updateApiUsageBadge);
// 2) Inactivity timer
exec(ui.ensureInactivityTimer, null);
// 3) Opponent status
exec(ui.ensureOpponentStatus, null);
// 4) Faction score
exec(ui.ensureFactionScoreBadge, ui.updateFactionScoreBadge);
// 5) User score
exec(ui.ensureUserScoreBadge, ui.updateUserScoreBadge);
// 6) Dibs/Deals
exec(ui.ensureDibsDealsBadge, ui.updateDibsDealsBadge);
// 7) Chain watcher badge (kept last so it's visually stable and can expand)
exec(ui.ensureChainWatcherBadge, ui.updateChainWatcherBadge);
// Chain timer intentionally not part of the primary badge order; ensure separately (keeps top-left logical grouping)
exec(ui.ensureChainTimer, () => { /* chain timer skipped on passive tab to avoid duplication */ });
},
ensureBadgeDockToggle() {
ui.ensureBadgeDock();
// Dedupe existing toggles (can accumulate if script reinjected)
try {
const toggles = state.ui.badgeDockEl ? state.ui.badgeDockEl.querySelectorAll('.torn-badge-dock__toggle') : [];
if (toggles && toggles.length > 1) {
for (let i = 1; i < toggles.length; i++) toggles[i].remove();
}
if (!state.ui.badgeDockToggleEl && toggles && toggles.length === 1) {
state.ui.badgeDockToggleEl = toggles[0];
return state.ui.badgeDockToggleEl;
}
} catch(_) { /* noop */ }
if (!state.ui.badgeDockToggleEl) {
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'torn-badge-dock__toggle';
Object.assign(toggle.style, {
border: '2px solid #ffcc00',
borderRadius: '4px',
background: 'linear-gradient(to bottom, #00b300, #008000)',
boxSizing: 'border-box',
color: '#d7e6ff',
width: '26px',
height: '26px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 3px 10px rgba(0,0,0,0.35)',
transition: 'transform 0.12s ease, opacity 0.12s ease',
pointerEvents: 'auto',
alignSelf: 'flex-start'
});
toggle.style.fontSize = '12px';
// Initial visual state: will be overridden after we read persisted value
toggle.title = 'Collapse badge dock';
toggle.textContent = '⤢';
toggle.addEventListener('click', () => {
const collapsed = state.ui.badgeDockEl?.dataset.collapsed === 'true';
ui.setBadgeDockCollapsed(!collapsed, { persist: true });
});
state.ui.badgeDockToggleEl = toggle;
const dock = state.ui.badgeDockEl;
if (dock) {
// Position the toggle button below the dock container
dock.appendChild(toggle);
}
// Restore persisted collapsed state (defaults to false if not set)
let persistedCollapsed = false;
try { persistedCollapsed = !!storage.get(state.ui.badgeDockCollapsedKey, false); } catch(_) {}
ui.setBadgeDockCollapsed(persistedCollapsed, { skipPersist: true });
}
return state.ui.badgeDockToggleEl;
},
ensureBadgeDockItems() {
ui.ensureBadgeDock();
return state.ui.badgeDockItemsEl;
},
setBadgeDockCollapsed(collapsed, opts = {}) {
ui.ensureBadgeDock();
if (!state.ui.badgeDockEl) return;
state.ui.badgeDockEl.dataset.collapsed = collapsed ? 'true' : 'false';
if (state.ui.badgeDockItemsEl) {
state.ui.badgeDockItemsEl.style.display = collapsed ? 'none' : 'flex';
}
if (state.ui.badgeDockToggleEl) {
state.ui.badgeDockToggleEl.textContent = collapsed ? '⤢' : '⤡';
state.ui.badgeDockToggleEl.title = collapsed ? 'Expand badge dock' : 'Collapse badge dock';
state.ui.badgeDockToggleEl.style.transform = collapsed ? 'rotate(180deg)' : 'rotate(0deg)';
}
if (!opts.skipPersist && opts.persist !== false) {
try { storage.set(state.ui.badgeDockCollapsedKey, !!collapsed); } catch(_) {}
}
},
toggleDebugOverlayMinimized: () => {
const overlay = ui.ensureDebugOverlayContainer();
if (!overlay) return;
ui.setDebugOverlayMinimized(!state.ui.debugOverlayMinimized);
},
setDebugOverlayMinimized: (minimized, opts = {}) => {
const next = !!minimized;
const prev = !!state.ui.debugOverlayMinimized;
state.ui.debugOverlayMinimized = next;
if (!opts.skipPersist && prev !== next) {
try { storage.set(state.ui.debugOverlayMinimizedKey, next); } catch(_) {}
}
const overlay = document.getElementById('tdm-live-track-overlay');
if (!overlay) return;
overlay.dataset.minimized = next ? 'true' : 'false';
const wrap = overlay._wrapRef || overlay.querySelector('[data-tdm-overlay-wrap="1"]') || null;
const body = overlay._innerBodyRef || overlay.querySelector('#tdm-live-track-overlay-body') || null;
const help = overlay._helpRef || (wrap ? wrap.querySelector('#tdm-live-track-overlay-help') : null);
const buttonBar = overlay._buttonBarRef || (wrap ? wrap.querySelector('[data-tdm-overlay-button-bar="1"]') : null);
const copyBtn = overlay._copyBtnRef || (buttonBar ? buttonBar.querySelector('button[data-role="tdm-overlay-copy"]') : null);
if (body) {
body.style.display = next ? 'none' : '';
}
if (wrap) {
if (next) {
wrap.style.paddingTop = '0';
wrap.style.minHeight = '';
wrap.style.minWidth = '';
wrap.style.paddingRight = '0';
wrap.style.display = 'inline-flex';
wrap.style.alignItems = 'center';
} else {
wrap.style.paddingTop = '20px';
wrap.style.minHeight = '';
wrap.style.minWidth = '';
wrap.style.paddingRight = '';
wrap.style.display = 'block';
wrap.style.alignItems = '';
}
}
const btn = state.ui.debugOverlayMinimizeEl || (wrap ? wrap.querySelector('#tdm-live-track-overlay-minimize') : null);
if (btn) {
const label = next ? 'Restore debug overlay' : 'Minimize debug overlay';
btn.textContent = next ? '🐞' : '[ _ ]';
btn.title = label;
btn.setAttribute('aria-label', label);
}
if (help) {
help.style.display = next ? 'none' : 'inline-flex';
}
if (buttonBar) {
if (next) {
buttonBar.style.position = 'static';
buttonBar.style.top = '';
buttonBar.style.right = '';
buttonBar.style.gap = '0';
buttonBar.style.alignItems = 'center';
} else {
buttonBar.style.position = 'absolute';
buttonBar.style.top = '4px';
buttonBar.style.right = '4px';
buttonBar.style.gap = '4px';
buttonBar.style.alignItems = 'center';
}
}
if (copyBtn) {
copyBtn.style.display = next ? 'none' : 'inline-flex';
}
const expandedStyle = overlay.dataset.tdmOverlayExpandedStyle;
if (next) {
overlay.style.background = 'transparent';
overlay.style.boxShadow = 'none';
overlay.style.padding = '0';
overlay.style.borderRadius = '0';
overlay.style.maxWidth = 'none';
overlay.style.position = 'fixed';
overlay.style.top = '8px';
overlay.style.left = '8px';
overlay.style.zIndex = '99999';
overlay.style.color = '#fff';
overlay.style.font = '11px/1.3 monospace';
overlay.style.cursor = 'default';
overlay.style.display = 'block';
overlay.style.opacity = '1';
} else {
if (expandedStyle) {
overlay.setAttribute('style', expandedStyle);
} else {
overlay.style.background = 'rgba(0,0,0,.65)';
overlay.style.boxShadow = '0 0 4px rgba(0,0,0,.4)';
overlay.style.padding = '6px 8px 4px';
overlay.style.borderRadius = '6px';
overlay.style.maxWidth = '300px';
overlay.style.position = 'fixed';
overlay.style.top = '8px';
overlay.style.left = '8px';
overlay.style.zIndex = '99999';
overlay.style.color = '#fff';
overlay.style.font = '11px/1.3 monospace';
overlay.style.cursor = 'default';
}
overlay.style.display = 'block';
overlay.style.opacity = '1';
}
},
ensureDebugOverlayContainer: (opts = {}) => {
let el = document.getElementById('tdm-live-track-overlay');
const created = !el;
// Initialize persisted minimized state once per load
try {
if (state.ui._overlayMinInitDone !== true) {
const persisted = storage.get('debugOverlayMinimized', null);
if (persisted === true || persisted === false) {
state.ui.debugOverlayMinimized = persisted;
}
state.ui._overlayMinInitDone = true;
}
} catch(_) {}
if (!el) {
el = document.createElement('div');
el.id = 'tdm-live-track-overlay';
el.style.cssText = 'position:fixed;top:8px;left:8px;z-index:99999;background:rgba(0,0,0,.65);color:#fff;font:11px/1.3 monospace;padding:6px 8px 4px;border-radius:6px;max-width:300px;box-shadow:0 0 4px rgba(0,0,0,.4);cursor:default;';
el.dataset.tdmOverlayExpandedStyle = el.getAttribute('style');
document.body.appendChild(el);
} else if (!el.dataset.tdmOverlayExpandedStyle) {
const current = el.getAttribute('style');
el.dataset.tdmOverlayExpandedStyle = current && current.trim().length ? current : 'position:fixed;top:8px;left:8px;z-index:99999;background:rgba(0,0,0,.65);color:#fff;font:11px/1.3 monospace;padding:6px 8px 4px;border-radius:6px;max-width:300px;box-shadow:0 0 4px rgba(0,0,0,.4);cursor:default;';
}
let wrap = el._wrapRef;
if (!wrap || !wrap.isConnected) {
wrap = el.querySelector('[data-tdm-overlay-wrap="1"]');
if (!wrap) {
wrap = document.createElement('div');
wrap.dataset.tdmOverlayWrap = '1';
wrap.style.position = 'relative';
wrap.style.pointerEvents = 'auto';
while (el.firstChild) {
wrap.appendChild(el.firstChild);
}
el.appendChild(wrap);
}
el._wrapRef = wrap;
}
let buttonBar = wrap.querySelector('[data-tdm-overlay-button-bar="1"]');
if (!buttonBar) {
buttonBar = document.createElement('div');
buttonBar.dataset.tdmOverlayButtonBar = '1';
wrap.insertBefore(buttonBar, wrap.firstChild);
}
buttonBar.style.position = 'absolute';
buttonBar.style.top = '4px';
buttonBar.style.right = '4px';
buttonBar.style.display = 'flex';
buttonBar.style.gap = '4px';
buttonBar.style.alignItems = 'center';
buttonBar.style.pointerEvents = 'auto';
el._buttonBarRef = buttonBar;
let help = wrap.querySelector('#tdm-live-track-overlay-help');
if (!help) {
help = document.createElement('span');
help.id = 'tdm-live-track-overlay-help';
help.textContent = '?';
}
help.title = 'tick=total cycle; api/build/apply=sub-sections; drift=timer slip vs planned; tpm=transitions/min; skipped=unchanged ticks; landed transients=recent arrivals.';
Object.assign(help.style, { background: '#374151', color: '#fff', borderRadius: '50%', width: '16px', height: '16px', fontSize: '10px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'help', pointerEvents: 'auto', marginRight: '2px', position: 'static', top: 'auto', left: 'auto', padding: '0' });
let copyBtn = buttonBar.querySelector('button[data-role="tdm-overlay-copy"]');
if (!copyBtn) {
copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.dataset.role = 'tdm-overlay-copy';
copyBtn.textContent = '⧉';
copyBtn.title = 'Copy diagnostics lines to clipboard';
copyBtn.setAttribute('aria-label', 'Copy diagnostics');
Object.assign(copyBtn.style, { background: '#2563eb', color: '#fff', border: 'none', padding: '2px 4px', fontSize: '10px', cursor: 'pointer', borderRadius: '4px', lineHeight: '1', pointerEvents: 'auto' });
copyBtn.addEventListener('click', (e) => {
e.stopPropagation();
try {
const body = el._innerBodyRef || el;
const text = body.innerText || body.textContent || '';
navigator.clipboard.writeText(text.trim());
copyBtn.textContent = '✔';
setTimeout(() => { copyBtn.textContent = '⧉'; }, 1200);
} catch (_) {
copyBtn.textContent = '✖';
setTimeout(() => { copyBtn.textContent = '⧉'; }, 1500);
}
});
}
el._copyBtnRef = copyBtn;
let minBtn = wrap.querySelector('#tdm-live-track-overlay-minimize');
if (!minBtn) {
minBtn = document.createElement('button');
minBtn.id = 'tdm-live-track-overlay-minimize';
minBtn.type = 'button';
Object.assign(minBtn.style, { background: '#1f2937', color: '#e5e7eb', border: 'none', padding: '2px 5px', fontSize: '10px', cursor: 'pointer', borderRadius: '4px', lineHeight: '1', pointerEvents: 'auto' });
minBtn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
const now = Date.now();
// Simple debounce: ignore rapid double clicks within 300ms
if (state.ui._lastOverlayToggle && (now - state.ui._lastOverlayToggle) < 300) return;
state.ui._lastOverlayToggle = now;
ui.toggleDebugOverlayMinimized();
try { storage.set('debugOverlayMinimized', !!state.ui.debugOverlayMinimized); } catch(_) {}
});
minBtn.textContent = state.ui.debugOverlayMinimized ? '🐞' : '[ _ ]';
const initLabel = state.ui.debugOverlayMinimized ? 'Restore debug overlay' : 'Minimize debug overlay';
minBtn.title = initLabel;
minBtn.setAttribute('aria-label', initLabel);
}
if (!buttonBar.contains(copyBtn)) buttonBar.appendChild(copyBtn);
buttonBar.insertBefore(help, copyBtn);
if (!buttonBar.contains(minBtn)) buttonBar.appendChild(minBtn);
else if (minBtn !== buttonBar.lastChild) buttonBar.appendChild(minBtn);
state.ui.debugOverlayMinimizeEl = minBtn;
el._helpRef = help;
let inner = el._innerBodyRef;
if (!inner || !inner.isConnected) {
inner = wrap.querySelector('#tdm-live-track-overlay-body');
if (!inner) {
inner = document.createElement('div');
inner.id = 'tdm-live-track-overlay-body';
wrap.appendChild(inner);
}
el._innerBodyRef = inner;
}
ui.setDebugOverlayMinimized(state.ui.debugOverlayMinimized, { skipPersist: true });
if (created) {
try { tdmlogger('info', '[LiveTrackOverlay] Container created'); } catch(_) {}
}
// Avoid forcing visible flash if caller requested skipShow (used on PDA minimized state restoration)
// Passive ensures (background refresh / cadence updates) should not un-hide or flash the overlay
const passive = !!opts.passive;
if (!opts.skipShow) {
try {
// If user has explicitly minimized, suppress passive reopening and respect persistent preference
if (state.ui.debugOverlayMinimized) {
if (!passive) {
// Non-passive explicit call (e.g., user toggling) still ensures container exists but keeps it minimized
el.style.display = 'block';
// In minimized mode we rely on setDebugOverlayMinimized styling; avoid opacity flicker
}
} else {
el.style.display = 'block';
el.style.opacity = '1';
}
} catch(_) {}
}
try { if (window.__TDM_SINGLETON__) window.__TDM_SINGLETON__.overlayEnsures++; } catch(_) {}
// Prevent persistent native tooltips on touch devices
try { ui._sanitizeTouchTooltips(el); } catch(_) {}
return el;
},
updateDebugOverlayFingerprints: () => {
try {
if (!state.debug?.apiLogs && !state.debug?.cadence) return; // only show when debug logging is enabled
const overlay = ui.ensureDebugOverlayContainer({ passive: true });
if (!overlay) return;
const wrap = overlay._wrapRef || overlay;
let body = overlay._innerBodyRef;
if (!body) {
body = document.createElement('div');
body.dataset.tdmOverlayBody = '1';
body.style.marginTop = '18px';
overlay._innerBodyRef = body;
wrap.appendChild(body);
}
let section = body.querySelector('[data-fp-section="1"]');
if (!section) {
section = document.createElement('div');
section.dataset.fpSection = '1';
section.style.marginTop = '4px';
section.style.borderTop = '1px solid rgba(255,255,255,.12)';
section.style.paddingTop = '4px';
body.appendChild(section);
}
const dFp = state._fingerprints?.dibs || '-';
const mFp = state._fingerprints?.medDeals || '-';
const dAge = state._fingerprintsMeta?.dibsChangedAt ? (Date.now() - state._fingerprintsMeta.dibsChangedAt) : null;
const mAge = state._fingerprintsMeta?.medDealsChangedAt ? (Date.now() - state._fingerprintsMeta.medDealsChangedAt) : null;
const fmtAge = (ms) => (ms == null) ? '—' : (ms < 1000 ? ms + 'ms' : (ms < 60000 ? (ms/1000).toFixed(1)+'s' : Math.round(ms/60000)+'m'));
section.innerHTML = `\n<strong>Fingerprints</strong>\n<pre style="white-space:pre-wrap;margin:2px 0 0;font-size:10px;line-height:1.3;">dibs: ${dFp} (age ${fmtAge(dAge)})\nmed : ${mFp} (age ${fmtAge(mAge)})</pre>`;
// War status augmentation
try {
const warMeta = (state.rankedWarLastSummaryMeta) || {};
const warSource = state.rankedWarLastSummarySource || '-';
const manifestFp = warMeta.manifestFingerprint || warMeta.manifestFP || '-';
const summaryFp = warMeta.summaryFingerprint || warMeta.summaryFP || '-';
const summaryVer = warMeta.summaryVersion != null ? warMeta.summaryVersion : (warMeta.version != null ? warMeta.version : '-');
const lastApplyMs = warMeta.appliedAtMs ? (Date.now() - warMeta.appliedAtMs) : null;
const attacksCache = state.rankedWarAttacksCache || {};
let attacksMetaLine = '';
try {
// Grab first war cache entry (active war) heuristically
const firstKey = Object.keys(attacksCache)[0];
if (firstKey) {
const ac = attacksCache[firstKey];
const lastSeq = ac?.lastSeq ?? ac?.lastSequence ?? '-';
const cnt = Array.isArray(ac?.attacks) ? ac.attacks.length : (ac?.attacks ? Object.keys(ac.attacks).length : 0);
attacksMetaLine = `attacks: war=${firstKey} seq=${lastSeq} count=${cnt}`;
}
} catch(_) {}
const ageTxt = fmtAge(lastApplyMs);
section.innerHTML += `\n<strong>War Status</strong>\n<pre style="white-space:pre-wrap;margin:2px 0 0;font-size:10px;line-height:1.3;">src=${warSource} ver=${summaryVer}\nsummary=${summaryFp}\nmanifest=${manifestFp}\nlastApply=${ageTxt}${attacksMetaLine? '\n'+attacksMetaLine:''}</pre>`;
} catch(_) { /* ignore war status overlay errors */ }
} catch(_) { /* noop */ }
},
ensureDebugOverlayStyles: (enabled = true) => {
try {
const styleId = 'tdm-live-track-overlay-override';
const blocker = document.querySelector('style[data-tdm-overlay-hide]');
if (blocker) blocker.remove();
let styleEl = document.getElementById(styleId);
if (enabled) {
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = styleId;
styleEl.textContent = '#tdm-live-track-overlay{display:block !important;opacity:1 !important;visibility:visible !important;}';
document.head.appendChild(styleEl);
}
} else if (styleEl) {
styleEl.remove();
}
} catch(_) {}
},
// Brief window to force-show badges on tab focus using cached values while data refreshes
_badgesForceShowUntil: 0,
updateApiCadenceInfo: (opts = {}) => {
try {
const throttleState = state.uiCadenceInfoThrottle || (state.uiCadenceInfoThrottle = { lastRender: 0, pending: null });
const now = Date.now();
if (!opts.force) {
const elapsed = now - (throttleState.lastRender || 0);
const minInterval = 900; // soften rapid DOM churn on slower devices
if (elapsed < minInterval) {
if (!throttleState.pending) {
const delay = Math.max(150, minInterval - elapsed);
throttleState.pending = utils.registerTimeout(setTimeout(() => {
throttleState.pending = null;
try { ui.updateApiCadenceInfo({ force: true }); } catch(_) { /* ignore re-entrant errors */ }
}, delay));
}
return;
}
}
throttleState.lastRender = now;
if (throttleState.pending) {
utils.unregisterTimeout(throttleState.pending);
throttleState.pending = null;
}
const line = document.getElementById('tdm-last-faction-refresh');
const poll = document.getElementById('tdm-polling-status');
const opponentLine = document.getElementById('tdm-opponent-poll-line');
const extraStatus = document.getElementById('tdm-additional-factions-status');
const extraSummary = document.getElementById('tdm-additional-factions-summary');
const s = state.script || {};
const extraRaw = utils.coerceStorageString(storage.get('tdmExtraFactionPolls', ''), '');
const extraList = utils.parseFactionIdList(extraRaw);
if (extraStatus) extraStatus.textContent = extraList.length ? `Polling ${extraList.length} extra faction${extraList.length === 1 ? '' : 's'}.` : 'No extra factions configured.';
if (extraSummary) extraSummary.textContent = extraList.length ? `IDs: ${extraList.join(', ')}` : '—';
// Avoid overwriting user edits; cadence updater intentionally does not touch the input field.
if (opponentLine) {
const oppId = state.lastOpponentFactionId || state?.warData?.opponentId || null;
if (!oppId) {
opponentLine.textContent = 'Opponent polling: no opponent detected.';
} else {
const active = !!s.lastOpponentPollActive;
const reason = s.lastOpponentPollReason || (active ? 'active' : 'paused');
const agoTxt = (() => {
if (!s.lastOpponentPollAt) return '';
const diffSec = Math.floor((Date.now() - s.lastOpponentPollAt) / 1000);
if (diffSec < 0) return '';
if (diffSec < 60) return `${diffSec}s ago`;
return utils.formatAgoShort(Math.floor(s.lastOpponentPollAt / 1000));
})();
let descriptor;
const oppLabel = `faction ${oppId}`;
if (active) {
if (reason === 'war-active') descriptor = `active (ranked war, ${oppLabel})`;
else if (reason === 'war-pre') descriptor = `active (upcoming war, ${oppLabel})`;
else descriptor = `active (forced list, ${oppLabel})`;
} else if (reason === 'paused') {
descriptor = `paused (war inactive, ${oppLabel})`;
} else if (reason === 'none') {
descriptor = 'paused (no opponent detected)';
} else {
descriptor = `paused (${reason})`;
}
const suffix = agoTxt ? ` • last fetch ${agoTxt}` : '';
opponentLine.textContent = `Opponent polling: ${descriptor}${suffix}`;
}
}
if (line) {
const parts = [];
if (s.lastFactionRefreshAttemptMs) {
const sec = Math.floor((Date.now() - s.lastFactionRefreshAttemptMs) / 1000);
const ago = sec < 60 ? `${sec}s ago` : utils.formatAgoShort(Math.floor(s.lastFactionRefreshAttemptMs/1000));
const ids = Array.isArray(s.lastFactionRefreshAttemptIds) ? s.lastFactionRefreshAttemptIds.slice(0,3).join(',') : '';
parts.push(`attempt ${ago}${ids ? ` [${ids}]` : ''}`);
}
if (s.lastFactionRefreshFetchMs) {
const sec = Math.floor((Date.now() - s.lastFactionRefreshFetchMs) / 1000);
const ago = sec < 60 ? `${sec}s ago` : utils.formatAgoShort(Math.floor(s.lastFactionRefreshFetchMs/1000));
parts.push(`fetch ${ago}`);
}
if (s.lastFactionRefreshBackgroundMs) {
const sec = Math.floor((Date.now() - s.lastFactionRefreshBackgroundMs) / 1000);
const ago = sec < 60 ? `${sec}s ago` : utils.formatAgoShort(Math.floor(s.lastFactionRefreshBackgroundMs/1000));
parts.push(`bg ${ago}`);
}
if (s.lastFactionRefreshSkipReason) {
parts.push(`skip: ${s.lastFactionRefreshSkipReason}`);
}
const txt = `Last faction refresh: ${parts.length ? parts.join(' | ') : '—'}`;
line.textContent = txt;
}
if (poll) {
try {
const trackingEnabled = storage.get('tdmActivityTrackingEnabled', false);
const idleOverride = utils.isActivityKeepActiveEnabled();
const active = (s.isWindowActive !== false) || idleOverride;
const status = active ? 'active' : 'paused (tab hidden)';
let suffix = '';
if (idleOverride) suffix = ' [Idle tracking: Torn API only]';
else if (trackingEnabled) suffix = ' [Activity tracking]';
poll.textContent = `Polling status: ${status}${suffix}`;
} catch(_) {}
}
} catch(_) { /* noop */ }
},
showTosComplianceModal: () => {
const { modal, header, controls, tableWrap, footer } = ui.createReportModal({ id: 'tdm-tos-modal', title: 'Torn API Terms of Service Compliance', maxWidth: '760px' });
// Intro blurb
const intro = utils.createElement('div', { style: { fontSize: '0.9em', lineHeight: '1.4', margin: '4px 0 10px 0', color: '#ddd' } });
intro.textContent = 'This userscript is designed to comply with Torn\'s API Terms of Service. Review the summary below and ensure your usage aligns with Torn\'s policies.';
controls.appendChild(intro);
const rows = [
{ aspect: 'Data Storage', details: 'Cached locally in browser; backend endpoints receive only the payloads needed for faction coordination.' },
{ aspect: 'Data Sharing', details: 'Faction-wide: dibs, war metrics (attack counts & timing), user notes, organized crimes metadata.' },
{ aspect: 'Purpose of Use', details: 'Competitive advantage & coordination during ranked wars.' },
{ aspect: 'Key Storage', details: 'Custom API keys stay in browser storage. Backend services use the key transiently to mint Firebase tokens and do not persist the raw value.' },
{ aspect: 'Key Access Level', details: 'Required custom scopes: factions.basic, factions.members, factions.rankedwars, factions.chain, users.basic, users.attacks. Legacy Limited keys remain supported during migration.' }
];
ui.renderReportTable(tableWrap, { columns: [
{ key: 'aspect', label: 'Aspect', width: '170px' },
{ key: 'details', label: 'Details', width: 'auto', render: r => r.details }
], rows, tableId: 'tdm-tos-table' });
footer.innerHTML = '<span style="color:#bbb;font-size:0.8em;">Always follow Torn\'s official API Terms. Misuse of API data can result in revocation.</span>';
const ackBtn = utils.createElement('button', { style: { marginTop: '8px', background: '#2e7d32', color: 'white', border: 'none', padding: '6px 14px', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold' }, textContent: 'Acknowledge & Close' });
ackBtn.onclick = () => {
try { storage.set(TDM_TOS_ACK_KEY, Date.now()); } catch(_) {}
modal.style.display = 'none';
};
controls.appendChild(ackBtn);
},
ensureTosComplianceLink: () => {
try {
const container = state.dom.customControlsContainer || document.querySelector('.dibs-system-main-controls');
if (!container) return;
if (container.querySelector('#tdm-tos-link')) return; // already
const link = utils.createElement('a', { id: 'tdm-tos-link', style: { marginLeft: '8px', fontSize: '11px', cursor: 'pointer', textDecoration: 'underline', color: '#9ecbff' }, textContent: 'TOS' });
link.onclick = (e) => { e.preventDefault(); ui.showTosComplianceModal(); };
container.appendChild(link);
} catch(_) { /* noop */ }
},
// Generic, reusable report modal scaffold to keep a consistent look & feel across modals
createReportModal: function({ id, title, width = '90%', maxWidth = '1000px' } = {}) {
let modal = document.getElementById(id);
if (!modal) {
modal = utils.createElement('div', {
id,
className: 'tdm-report-modal',
style: {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: 'var(--tdm-modal-bg)',
border: '1px solid var(--tdm-modal-border)',
borderRadius: 'var(--tdm-radius-lg)',
padding: 'var(--tdm-space-lg)',
zIndex: 'var(--tdm-z-modal)',
color: 'var(--tdm-text-primary)',
width,
maxWidth,
minWidth: '320px',
maxHeight: '80vh',
overflowY: 'auto',
overflowX: 'auto',
boxShadow: 'var(--tdm-shadow-modal)',
animation: 'tdmFadeIn var(--tdm-transition-normal) forwards'
}
});
document.body.appendChild(modal);
}
modal.innerHTML = '';
modal.style.display = 'block';
const closeBtn = utils.createElement('button', {
className: `${id}-close tdm-btn tdm-btn--danger tdm-btn--sm`,
style: {
position: 'absolute',
top: 'var(--tdm-space-md)',
right: 'var(--tdm-space-lg)',
minWidth: '32px',
fontWeight: 'bold'
},
textContent: 'X'
});
const header = utils.createElement('h2', {
style: {
marginTop: '0',
marginRight: '40px',
marginBottom: 'var(--tdm-space-md)',
fontSize: 'var(--tdm-font-size-lg)',
color: 'var(--tdm-text-accent)'
},
textContent: title || 'Report'
});
const controls = utils.createElement('div', { id: `${id}-controls`, style: { marginBottom: 'var(--tdm-space-md)' } });
const tableWrap = utils.createElement('div', { id: `${id}-table`, style: { width: '100%' } });
const footer = utils.createElement('div', { id: `${id}-footer`, style: { marginTop: 'var(--tdm-space-lg)', fontSize: 'var(--tdm-font-size-sm)', color: 'var(--tdm-text-secondary)' } });
modal.appendChild(closeBtn);
modal.appendChild(header);
modal.appendChild(controls);
modal.appendChild(tableWrap);
modal.appendChild(footer);
if (!modal._tdmCloseBound) {
modal.addEventListener('click', (event) => {
if (event.target.classList.contains(`${id}-close`)) {
modal.style.display = 'none';
}
});
modal._tdmCloseBound = true;
}
const setLoading = (msg) => {
const el = document.getElementById(`${id}-loading`) || utils.createElement('div', { id: `${id}-loading` });
el.textContent = msg || 'Loading...';
el.style.margin = '6px 0';
el.style.color = '#ccc';
controls.appendChild(el);
};
const clearLoading = () => { const el = document.getElementById(`${id}-loading`); if (el) el.remove(); };
const setError = (msg) => {
clearLoading();
controls.appendChild(utils.createElement('div', { style: { color: 'var(--tdm-color-error)' }, textContent: msg || 'Unexpected error' }));
};
return { modal, header, controls, tableWrap, footer, setLoading, clearLoading, setError };
},
// Reusable sortable table renderer. Columns accept: key, label, width, align, render(row), sortValue(row)
renderReportTable: function(container, { columns, rows, defaultSort = { key: columns?.[0]?.key, asc: true }, tableId, manualSort = false, onSortChange } = {}) {
const table = utils.createElement('table', {
id: tableId || undefined,
className: 'tdm-report-table',
style: {
width: '100%',
minWidth: '900px',
borderCollapse: 'collapse',
background: 'var(--tdm-bg-secondary)',
color: 'var(--tdm-text-primary)',
borderRadius: 'var(--tdm-radius-sm)',
overflow: 'hidden'
}
});
const thead = utils.createElement('thead');
const tbody = utils.createElement('tbody');
let sortKey = defaultSort?.key;
let sortAsc = !!defaultSort?.asc;
const asSortable = (val) => {
if (val instanceof Date) return val.getTime();
if (typeof val === 'number') return val;
if (typeof val === 'boolean') return val ? 1 : 0;
const s = (val == null ? '' : String(val));
const n = Number(s);
return isFinite(n) && s.trim() !== '' ? n : s.toLowerCase();
};
const headerRow = utils.createElement('tr', { style: { background: 'var(--tdm-bg-card)' } });
columns.forEach(col => {
const th = utils.createElement('th', {
style: { padding: 'var(--tdm-space-md)', cursor: 'pointer', textAlign: col.align || 'left', width: col.width || undefined, fontWeight: '600', color: 'var(--tdm-text-accent)' }
}, [document.createTextNode(col.label)]);
th.dataset.sort = col.key;
th.onclick = () => {
const key = th.getAttribute('data-sort');
if (sortKey === key) sortAsc = !sortAsc; else { sortKey = key; sortAsc = true; }
if (manualSort && typeof onSortChange === 'function') {
onSortChange(sortKey, sortAsc);
} else {
renderRows();
}
};
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
const renderCell = (col, row) => {
try { return (typeof col.render === 'function') ? col.render(row) : (row[col.key] ?? ''); } catch(_) { return ''; }
};
const sortVal = (col, row) => {
try { return (typeof col.sortValue === 'function') ? col.sortValue(row) : asSortable(row[col.key]); } catch(_) { return asSortable(row[col.key]); }
};
const renderRows = () => {
const col = columns.find(c => c.key === sortKey) || columns[0];
const data = manualSort ? rows : [...rows].sort((a, b) => {
const A = sortVal(col, a);
const B = sortVal(col, b);
if (A === B) return 0;
return sortAsc ? (A > B ? 1 : -1) : (A < B ? 1 : -1);
});
tbody.innerHTML = '';
for (const r of data) {
const tr = utils.createElement('tr', { style: { background: 'var(--tdm-bg-card)', color: 'var(--tdm-text-primary)', borderBottom: '1px solid var(--tdm-bg-secondary)' } });
for (const c of columns) {
const td = utils.createElement('td', { style: { padding: 'var(--tdm-space-md)', color: 'var(--tdm-text-primary)', textAlign: (c.align || 'left') } });
const val = renderCell(c, r);
if (val instanceof Node) td.appendChild(val); else td.textContent = val == null ? '' : String(val);
tr.appendChild(td);
}
tbody.appendChild(tr);
}
};
table.appendChild(thead);
table.appendChild(tbody);
container.innerHTML = '';
container.appendChild(table);
renderRows();
return { rerender: renderRows, getSort: () => ({ key: sortKey, asc: sortAsc }) };
},
updatePageContext: () => {
state.page.url = new URL(window.location.href);
state.dom.factionListContainer = document.querySelector('.f-war-list.members-list');
state.dom.customControlsContainer = document.querySelector('.dibs-system-main-controls');
state.dom.rankwarContainer = document.querySelector('div.desc-wrap.warDesc___qZfyO');
if (state.dom.rankwarContainer) { state.dom.rankwarmembersWrap = state.dom.rankwarContainer.querySelector('.faction-war.membersWrap___NbYLx'); }
// Scope ranked war tables to the war container to avoid unrelated elements using the same class
state.dom.rankwarfactionTables = state.dom.rankwarContainer
? state.dom.rankwarContainer.querySelectorAll('.tab-menu-cont')
: document.querySelectorAll('.tab-menu-cont');
state.dom.rankBox = document.querySelector('.rankBox___OzP3D');
state.page.isFactionProfilePage = state.page.url.href.includes(`factions.php?step=profile`);
state.page.isMyFactionPrivatePage = state.page.url.href.includes('factions.php?step=your');
state.page.isRankedWarPage = !!state.dom.rankwarContainer;
state.page.isMyFactionYourInfoTab = state.page.url.hash.includes('tab=info') && state.page.isMyFactionPrivatePage;
state.page.isFactionPage = state.page.url.href.includes(`factions.php`);
const isMyFactionById = state.page.isFactionPage && state.user.factionId && state.page.url.searchParams.get('ID') === state.user.factionId;
state.page.isMyFactionProfilePage = isMyFactionById && (state.page.url.searchParams.get('step') === 'your' || state.page.url.searchParams.get('step') === 'profile');
state.page.isMyFactionPage = state.page.isMyFactionProfilePage || state.page.isMyFactionPrivatePage || (state.page.isRankedWarPage && state.factionPull && state.user.factionId && state.factionPull.id?.toString() === state.user.factionId);
state.page.isAttackPage = state.page.url.href.includes('loader.php?sid=attack&user2ID=');
// Cache preferred chain DOM sources (highest fidelity first) so per-second updater avoids repeated heavy queries.
// 1. Primary: chain-box (provides center-stat count + timeleft)
const chainBox = document.querySelector('.chain-box');
if (chainBox) {
state.dom.chainBoxEl = chainBox;
state.dom.chainBoxCountEl = chainBox.querySelector('.chain-box-center-stat');
state.dom.chainBoxTimeEl = chainBox.querySelector('.chain-box-timeleft');
} else {
state.dom.chainBoxEl = state.dom.chainBoxCountEl = state.dom.chainBoxTimeEl = null;
}
// 2. Secondary: compact bar-stats strip (large/medium/small screen variants)
// We locate a bar-stats container whose text includes 'Chain:' then capture value/time if present.
let barStats = null;
try {
const candidates = document.querySelectorAll('.bar-stats___E_LqA');
for (const c of candidates) {
if (/Chain:/i.test(c.textContent || '')) { barStats = c; break; }
}
} catch(_) { /* noop */ }
if (barStats) {
state.dom.barChainStatsEl = barStats;
state.dom.barChainValueEl = barStats.querySelector('.bar-value___uxnah');
// Time may be direct <p> or nested inside span.barDescWrapper___* with parentheses
state.dom.barChainTimeEl = barStats.querySelector('.bar-timeleft___B9RGV');
} else {
state.dom.barChainStatsEl = state.dom.barChainValueEl = state.dom.barChainTimeEl = null;
}
},
updateAllPages: () => {
utils.perf.start('updateAllPages');
utils.perf.start('updateAllPages.updatePageContext');
ui.updatePageContext();
utils.perf.stop('updateAllPages.updatePageContext');
if (state.page.isAttackPage) {
utils.perf.start('updateAllPages.injectAttackPageUI');
// Handle async promise rejection to prevent "Uncaught (in promise)" errors
ui.injectAttackPageUI().catch(err => {
try { tdmlogger('error', `[UI] injectAttackPageUI failed: ${err}`); } catch(_) {}
});
utils.perf.stop('updateAllPages.injectAttackPageUI');
}
if (state.page.isRankedWarPage) {
utils.perf.start('updateAllPages.updateRankedWarUI');
ui._renderEpoch.schedule();
utils.perf.stop('updateAllPages.updateRankedWarUI');
}
if (state.page.isMyFactionPage) {
// Faction cap banner logic now unified inside handlers.checkTermedWarScoreCap
}
if (state.dom.factionListContainer) {
utils.perf.start('updateAllPages.updateFactionPageUI');
// Gate Members List updates through a dedicated epoch scheduler to reduce churn
ui._renderEpochMembers.schedule();
utils.perf.stop('updateAllPages.updateFactionPageUI');
}
utils.perf.start('updateAllPages.updateRetalsButtonCount');
ui.updateRetalsButtonCount();
utils.perf.stop('updateAllPages.updateRetalsButtonCount');
utils.perf.start('updateAllPages.ensureBadgesSuite');
ui.ensureBadgesSuite();
utils.perf.stop('updateAllPages.ensureBadgesSuite');
// Ensure TOS link present where controls live (not part of dock suite)
ui.ensureTosComplianceLink();
utils.perf.stop('updateAllPages');
},
ensureChainTimer: () => {
if (!storage.get('chainTimerEnabled', true)) { ui.removeChainTimer(); return; }
ui.ensureBadgeDock();
ui.ensureBadgeDockToggle();
const items = ui.ensureBadgeDockItems();
if (!items) return;
// Dedupe any stray duplicates (same id)
try {
const dups = document.querySelectorAll('#tdm-chain-timer');
if (dups.length > 1) {
for (let i = 1; i < dups.length; i++) dups[i].remove();
}
if (!state.ui.chainTimerEl && dups.length === 1) {
state.ui.chainTimerEl = dups[0];
state.ui.chainTimerValueEl = state.ui.chainTimerEl.querySelector('.tdm-chain-timer-value');
}
} catch(_) { /* noop */ }
if (!state.ui.chainTimerEl) {
const wrapper = utils.createElement('div', {
id: 'tdm-chain-timer',
style: ui._composeDockBadgeStyle({
background: 'rgba(18,28,38,0.9)',
border: '1px solid rgba(140,255,200,0.35)',
color: '#d9fff0',
gap: '6px',
padding: '4px 8px'
})
});
const label = utils.createElement('span', {
textContent: 'Chain',
style: { textTransform: 'uppercase', fontSize: '9px', letterSpacing: '0.08em', opacity: 0.78, fontWeight: '700' }
});
const value = utils.createElement('span', {
className: 'tdm-chain-timer-value',
textContent: '--:--',
style: { fontSize: '12px', fontWeight: '700', letterSpacing: '0.05em', fontVariantNumeric: 'tabular-nums', color: '#9fffc9' }
});
wrapper.appendChild(label);
wrapper.appendChild(value);
items.appendChild(wrapper);
state.ui.chainTimerEl = wrapper;
state.ui.chainTimerValueEl = value;
} else if (state.ui.chainTimerEl.parentNode !== items) {
items.appendChild(state.ui.chainTimerEl);
}
// Start/refresh updater
ui.startChainTimerUpdater();
},
removeChainTimer: () => {
if (state.ui.chainTimerIntervalId) { try { utils.unregisterInterval(state.ui.chainTimerIntervalId); } catch(_) {} state.ui.chainTimerIntervalId = null; }
if (state.ui.chainTimerEl) { state.ui.chainTimerEl.remove(); state.ui.chainTimerEl = null; }
if (state.ui.chainTimerValueEl) state.ui.chainTimerValueEl = null;
},
startChainTimerUpdater: () => {
if (!state.ui.chainTimerEl) return;
if (state.ui.chainTimerIntervalId) return; // already running
// Lightweight re-discovery every few seconds if elements disappear (layout changes / responsive breakpoints)
let lastDomRefreshMs = 0;
const refreshDomRefsIfNeeded = () => {
const now = Date.now();
if (now - lastDomRefreshMs < 4000) return; // refresh at most every 4s
lastDomRefreshMs = now;
if (!state.dom.chainBoxEl || !document.body.contains(state.dom.chainBoxEl)) {
const cb = document.querySelector('.chain-box');
if (cb) {
state.dom.chainBoxEl = cb;
state.dom.chainBoxCountEl = cb.querySelector('.chain-box-center-stat');
state.dom.chainBoxTimeEl = cb.querySelector('.chain-box-timeleft');
}
}
if (!state.dom.barChainStatsEl || !document.body.contains(state.dom.barChainStatsEl)) {
let barStats = null;
try {
const candidates = document.querySelectorAll('.bar-stats___E_LqA');
for (const c of candidates) { if (/Chain:/i.test(c.textContent || '')) { barStats = c; break; } }
} catch(_) {}
if (barStats) {
state.dom.barChainStatsEl = barStats;
state.dom.barChainValueEl = barStats.querySelector('.bar-value___uxnah');
state.dom.barChainTimeEl = barStats.querySelector('.bar-timeleft___B9RGV');
}
}
};
const parseMmSs = (txt) => {
if (!txt) return null;
const m = txt.match(/^(\d{1,2}):(\d{2})$/);
if (!m) return null;
const mm = parseInt(m[1], 10); const ss = parseInt(m[2], 10);
if (!Number.isFinite(mm) || !Number.isFinite(ss)) return null;
return mm * 60 + ss;
};
const readPreferredDom = () => {
// Returns { current:null|number, remainingSec:null|number }
// Priority 1: chain-box
if (state.dom.chainBoxEl) {
let current = null; let remainingSec = null;
try {
const ct = state.dom.chainBoxCountEl?.textContent?.trim().replace(/[,\s]/g,'');
if (ct && /^\d+$/.test(ct)) current = parseInt(ct,10);
} catch(_) {}
try {
const ttxt = state.dom.chainBoxTimeEl?.textContent?.trim();
const rem = parseMmSs(ttxt === '00:00' ? '' : ttxt);
if (rem != null && rem > 0) remainingSec = rem;
} catch(_) {}
if (current != null || remainingSec != null) return { current, remainingSec };
}
// Priority 2: bar-stats
if (state.dom.barChainStatsEl) {
let current = null; let remainingSec = null;
try {
// Forms: "866/1k" or "866 / 1k" possibly with separate text nodes.
const raw = state.dom.barChainValueEl?.textContent || '';
// Remove spaces & commas for numeric extraction
const cleaned = raw.replace(/[,\s]/g,'');
const m = cleaned.match(/^(\d+)/);
if (m) current = parseInt(m[1],10);
} catch(_) {}
try {
const ttxt = state.dom.barChainTimeEl?.textContent?.trim();
const rem = parseMmSs(ttxt === '00:00' ? '' : ttxt);
if (rem != null && rem > 0) remainingSec = rem;
} catch(_) {}
if (current != null || remainingSec != null) return { current, remainingSec };
}
return { current: null, remainingSec: null };
};
const setDisplay = (remainingSec, current) => {
if (!state.ui.chainTimerEl) return;
const valueEl = state.ui.chainTimerValueEl || state.ui.chainTimerEl.querySelector('.tdm-chain-timer-value');
if (remainingSec > 0 && current > 0) {
const mm = Math.floor(remainingSec / 60);
const ss = (remainingSec % 60).toString().padStart(2,'0');
if (valueEl) {
valueEl.textContent = `(${current}) ${mm}:${ss}`;
// Dynamic color thresholds:
// >120s -> green (default)
// 61-120s -> orange
// <=60s -> red
let color = '#9fffc9'; // default green
let border = '1px solid rgba(140,255,200,0.35)';
if (remainingSec <= 60) {
color = '#ff6666';
border = '1px solid rgba(255,102,102,0.55)';
} else if (remainingSec <= 120) {
color = '#ffb347';
border = '1px solid rgba(255,179,71,0.55)';
}
try {
valueEl.style.color = color;
// Subtle emphasis: pulse transition (no CSS animation to keep simple) by adjusting parent border/color.
state.ui.chainTimerEl.style.border = border;
} catch(_) { /* styling best-effort */ }
// Tooltip update for clarity
try { state.ui.chainTimerEl.title = `Chain: ${current} | Remaining: ${mm}:${ss}`; } catch(_) {}
}
ui._orchestrator.setDisplay(state.ui.chainTimerEl, 'inline-flex');
} else {
if (valueEl) valueEl.textContent = '--:--';
ui._orchestrator.setDisplay(state.ui.chainTimerEl, 'none');
}
};
const tick = async () => {
refreshDomRefsIfNeeded();
const nowSec = Math.floor(Date.now()/1000);
// Read DOM preferred values
const dom = readPreferredDom();
let current = Number(state.ui.chainFallback.current||0);
if (dom.current != null && dom.current > 0) {
current = dom.current;
// Keep fallback current in sync if DOM is ahead
if (dom.current > (state.ui.chainFallback.current||0)) state.ui.chainFallback.current = dom.current;
}
// Derive remaining seconds
let remaining = 0;
if (dom.remainingSec != null) {
remaining = dom.remainingSec;
// Update fallback epoch using fresher DOM timer
state.ui.chainFallback.timeoutEpoch = nowSec + dom.remainingSec;
} else {
const timeout = Number(state.ui.chainFallback.timeoutEpoch||0);
remaining = timeout > nowSec ? (timeout - nowSec) : 0;
if (remaining <= 0 && current > 0) {
const endEpoch = Number(state.ui.chainFallback.endEpoch||0);
if (endEpoch > nowSec) remaining = endEpoch - nowSec;
}
}
setDisplay(remaining, current);
};
// Initial tick and interval
tick();
state.ui.chainTimerIntervalId = utils.registerInterval(setInterval(tick, 1000));
},
ensureInactivityTimer: () => {
if (!storage.get('inactivityTimerEnabled', false)) { ui.removeInactivityTimer(); return; }
ui.ensureBadgeDock();
ui.ensureBadgeDockToggle();
const items = ui.ensureBadgeDockItems();
if (!items) return;
// Dedupe duplicates
try {
const dups = document.querySelectorAll('#tdm-inactivity-timer');
if (dups.length > 1) {
for (let i = 1; i < dups.length; i++) dups[i].remove();
}
if (!state.ui.inactivityTimerEl && dups.length === 1) {
state.ui.inactivityTimerEl = dups[0];
state.ui.inactivityTimerValueEl = state.ui.inactivityTimerEl.querySelector('.tdm-inactivity-value');
}
} catch(_) { /* noop */ }
if (!state.ui.inactivityTimerEl) {
const wrapper = utils.createElement('div', {
id: 'tdm-inactivity-timer',
style: ui._composeDockBadgeStyle({
background: 'rgba(32, 24, 6, 0.9)',
border: '1px solid rgba(255, 214, 102, 0.32)',
color: '#ffe082',
gap: '6px',
padding: '4px 8px'
})
});
const label = utils.createElement('span', {
textContent: 'Inactivity',
style: { textTransform: 'uppercase', fontSize: '9px', letterSpacing: '0.08em', opacity: 0.78, fontWeight: '700' }
});
const value = utils.createElement('span', {
className: 'tdm-inactivity-value',
textContent: '00:00',
style: {
fontSize: '12px',
fontWeight: '700',
letterSpacing: '0.05em',
fontVariantNumeric: 'tabular-nums',
color: '#ffeb3b'
}
});
wrapper.appendChild(label);
wrapper.appendChild(value);
items.appendChild(wrapper);
state.ui.inactivityTimerEl = wrapper;
state.ui.inactivityTimerValueEl = value;
} else if (state.ui.inactivityTimerEl.parentNode !== items) {
items.appendChild(state.ui.inactivityTimerEl);
}
ui._orchestrator.setDisplay(state.ui.inactivityTimerEl, 'inline-flex');
ui.startInactivityUpdater();
},
removeInactivityTimer: () => {
if (state.ui.inactivityTimerIntervalId) { try { utils.unregisterInterval(state.ui.inactivityTimerIntervalId); } catch(_) {} state.ui.inactivityTimerIntervalId = null; }
if (state.ui.inactivityTimerEl) { state.ui.inactivityTimerEl.remove(); state.ui.inactivityTimerEl = null; }
if (state.ui.inactivityTimerValueEl) state.ui.inactivityTimerValueEl = null;
},
startInactivityUpdater: () => {
if (!state.ui.inactivityTimerEl) return;
if (state.ui.inactivityTimerIntervalId) return;
const tick = () => {
const ms = Date.now() - (state.script.lastActivityTime || Date.now());
// set color orange after 4 minutes, red after 5 minutes
const valueEl = state.ui.inactivityTimerValueEl || state.ui.inactivityTimerEl.querySelector('.tdm-inactivity-value');
if (valueEl) {
valueEl.textContent = utils.formatTimeHMS(totalSec);
let color = '#ffeb3b';
if (totalSec >= 300) color = '#ff5370';
else if (totalSec >= 240) color = '#ffb74d';
valueEl.style.color = color;
}
};
tick();
state.ui.inactivityTimerIntervalId = utils.registerInterval(setInterval(tick, 1000));
},
ensureOpponentStatus: () => {
if (!storage.get('opponentStatusTimerEnabled', true)) { ui.removeOpponentStatus(); return; }
ui.ensureBadgeDock();
ui.ensureBadgeDockToggle();
const items = ui.ensureBadgeDockItems();
if (!items) return;
try {
const dups = document.querySelectorAll('#tdm-opponent-status');
if (dups.length > 1) {
for (let i = 1; i < dups.length; i++) dups[i].remove();
}
if (!state.ui.opponentStatusEl && dups.length === 1) {
state.ui.opponentStatusEl = dups[0];
state.ui.opponentStatusValueEl = state.ui.opponentStatusEl.querySelector('.tdm-opponent-status-value');
}
} catch(_) { /* noop */ }
if (!state.ui.opponentStatusEl) {
const wrapper = utils.createElement('div', {
id: 'tdm-opponent-status',
style: ui._composeDockBadgeStyle({
background: 'rgba(12, 24, 36, 0.9)',
border: '1px solid rgba(112, 197, 255, 0.35)',
color: '#c8ecff',
gap: '6px',
padding: '4px 8px'
})
});
// Keep the dock compact — don't show a full "Opponent" label to save space.
// Some hosts of older UI expect a span, so create an empty placeholder with minimal footprint.
const label = utils.createElement('span', {
textContent: '',
style: { display: 'none' }
});
const value = utils.createElement('span', {
className: 'tdm-opponent-status-value',
innerHTML: '<span style="opacity:0.6">No active dib</span>',
style: { fontSize: '12px', fontWeight: '700', letterSpacing: '0.05em', color: '#9dd6ff' }
});
wrapper.appendChild(label);
wrapper.appendChild(value);
items.appendChild(wrapper);
state.ui.opponentStatusEl = wrapper;
state.ui.opponentStatusValueEl = value;
ui._orchestrator.setDisplay(wrapper, 'inline-flex');
} else if (state.ui.opponentStatusEl.parentNode !== items) {
items.appendChild(state.ui.opponentStatusEl);
}
ui.startOpponentStatusUpdater();
},
ensureApiUsageBadge: () => {
if (!storage.get('apiUsageCounterEnabled', false)) { ui.removeApiUsageBadge(); return; }
ui.ensureBadgeDock();
ui.ensureBadgeDockToggle();
const items = ui.ensureBadgeDockItems();
if (!items) return;
try {
const dups = document.querySelectorAll('#tdm-api-usage');
if (dups.length > 1) {
for (let i = 1; i < dups.length; i++) dups[i].remove();
}
if (!state.ui.apiUsageEl && dups.length === 1) {
state.ui.apiUsageEl = dups[0];
state.ui.apiUsageValueEl = state.ui.apiUsageEl.querySelector('.tdm-api-usage-value');
state.ui.apiUsageDetailEl = state.ui.apiUsageEl.querySelector('.tdm-api-usage-detail');
}
} catch(_) { /* noop */ }
if (!state.ui.apiUsageEl) {
const wrapper = utils.createElement('div', {
id: 'tdm-api-usage',
style: ui._composeDockBadgeStyle({
background: 'rgba(12, 26, 11, 0.9)',
border: '1px solid rgba(126, 206, 102, 0.38)',
color: '#c8f7ab',
gap: '6px'
})
});
const label = utils.createElement('span', {
textContent: 'API',
style: { fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.08em', fontSize: '9px', opacity: 0.78 }
});
const value = utils.createElement('span', {
className: 'tdm-api-usage-value',
textContent: '0',
style: { fontSize: '11px', fontWeight: '700', letterSpacing: '0.04em', fontVariantNumeric: 'tabular-nums', color: '#dcff93' }
});
const detail = utils.createElement('span', {
className: 'tdm-api-usage-detail',
textContent: 'C0/B0',
style: { fontSize: '10px', opacity: 0.75 }
});
wrapper.appendChild(label);
wrapper.appendChild(value);
wrapper.appendChild(detail);
items.appendChild(wrapper);
state.ui.apiUsageEl = wrapper;
state.ui.apiUsageValueEl = value;
state.ui.apiUsageDetailEl = detail;
} else if (state.ui.apiUsageEl.parentNode !== items) {
items.appendChild(state.ui.apiUsageEl);
}
if (handlers?.debouncedUpdateApiUsageBadge) { handlers.debouncedUpdateApiUsageBadge(); } else { ui.updateApiUsageBadge(); }
},
ensureAttackModeBadge: () => {
// Respect user-configured visibility
if (!storage.get('attackModeBadgeEnabled', true)) { const ex = document.getElementById('tdm-attack-mode'); if (ex) ex.remove(); state.ui.attackModeEl = null; return; }
// Compact badge next to other chat header widgets
const chatRoot = document.querySelector('.root___lv7vM');
if (!chatRoot) return;
const isRanked = (state.warData?.warType === 'Ranked War');
const warActive = !!(state.lastRankWar && utils.isWarActive?.(state.lastRankWar.id));
// Only show attack mode during active ranked wars
if (!isRanked || !warActive) {
const existing = document.getElementById('tdm-attack-mode');
if (existing) existing.remove();
state.ui.attackModeEl = null;
return;
}
if (!isRanked) {
const existing = document.getElementById('tdm-attack-mode');
if (existing) existing.remove();
state.ui.attackModeEl = null;
return;
}
const fs = (state.script && state.script.factionSettings) || {};
const mode = (fs.options && fs.options.attackMode) || fs.attackMode || 'FFA';
// Always read-only badge; no admin editing from header
const isAdmin = false;
const existing = document.getElementById('tdm-attack-mode');
if (!existing) {
const el = utils.createElement('div', {
id: 'tdm-attack-mode',
className: 'tdm-text-halo',
title: 'Faction attack mode',
style: { display: 'inline-flex', alignItems: 'center', gap: '6px', marginRight: '8px', color: '#ffd166', fontWeight: '700', fontSize: '12px', padding: '0 2px' }
});
chatRoot.insertBefore(el, chatRoot.firstChild);
state.ui.attackModeEl = el;
} else if (existing.parentNode !== chatRoot) {
chatRoot.insertBefore(existing, chatRoot.firstChild);
state.ui.attackModeEl = existing;
} else {
state.ui.attackModeEl = existing;
}
if (!state.ui.attackModeEl) return;
// If already rendered with same admin state, update in place and return
const prev = state.ui._attackModeRendered || {};
if (prev.isAdmin === isAdmin) {
const span = state.ui.attackModeEl.querySelector('.tdm-attack-mode-value');
if (span) {
if (span.textContent !== String(mode)) span.textContent = String(mode);
state.ui._attackModeRendered = { mode, isAdmin };
return;
}
}
// Full (re)render
state.ui.attackModeEl.innerHTML = '';
const label = utils.createElement('span', { textContent: 'Atk Mode:' });
let valueNode;
// Always read-only span
valueNode = utils.createElement('span', { className: 'tdm-attack-mode-value', textContent: mode, style: { color: '#fff' } });
state.ui.attackModeEl.appendChild(label);
state.ui.attackModeEl.appendChild(valueNode);
state.ui._attackModeRendered = { mode, isAdmin };
},
removeAttackModeBadge: () => {
const el = document.getElementById('tdm-attack-mode');
if (el) el.remove();
state.ui.attackModeEl = null;
},
ensureUserScoreBadge: () => {
const enabled = storage.get('userScoreBadgeEnabled', true);
const existing = document.getElementById('tdm-user-score');
if (!enabled) { if (existing) ui._orchestrator.setDisplay(existing, 'none'); return; }
const warId = state.lastRankWar?.id;
const inWindow = warId && utils.isWarInActiveOrGrace?.(warId, 6);
// Hide entirely if war not active or in grace window
if (!inWindow) { if (existing) ui._orchestrator.setDisplay(existing, 'none'); return; }
const force = ui._badgesForceShowUntil && Date.now() < ui._badgesForceShowUntil;
ui.ensureBadgeDock();
ui.ensureBadgeDockToggle();
const items = ui.ensureBadgeDockItems();
if (!items) return;
let el = existing;
if (!el) {
const initialLabel = (() => {
const cached = storage.get('badge.user');
const isExpired = !cached || (Date.now() - (cached.ts || 0) > 2 * 60 * 60 * 1000);
const isWar = (warId || null) === (cached?.warId || null);
return (!isExpired && isWar && cached?.v) ? cached.v : 'Me: 0';
})();
el = utils.createElement('div', {
id: 'tdm-user-score',
style: ui._composeDockBadgeStyle({
background: 'rgba(14, 33, 24, 0.88)',
border: '1px solid rgba(110, 204, 163, 0.42)',
color: '#9ef5c8'
}),
title: 'Your personal score this war'
}, [document.createTextNode(initialLabel)]);
items.appendChild(el);
} else if (el.parentNode !== items) {
items.appendChild(el);
}
state.ui.userScoreBadgeEl = el;
ui._orchestrator.setDisplay(el, 'inline-flex');
ui.updateUserScoreBadge();
},
updateUserScoreBadge: async () => {
const el = document.getElementById('tdm-user-score');
if (!el) return;
const warId = state.lastRankWar?.id;
const inWindow = warId && utils.isWarInActiveOrGrace?.(warId, 6);
const force = ui._badgesForceShowUntil && Date.now() < ui._badgesForceShowUntil;
if (!inWindow) { ui._orchestrator.setDisplay(el, 'none'); return; }
// Determine score type and compute user's current score
const scoreType = state.warData?.scoreType || 'Respect';
let value = 0;
let assists = 0;
let usedLightweight = false;
// Enhancement #1: Use lightweight userScore if available and sufficient
if (state.userScore) {
// Support both short keys (r, s) and long keys (respect, successful) from backend
if (scoreType === 'Respect') { value = Number(state.userScore.r || state.userScore.respect || 0); usedLightweight = true; }
else if (scoreType === 'Attacks') { value = Number(state.userScore.s || state.userScore.successful || 0); usedLightweight = true; }
else if (scoreType === 'Respect (no chain)') { value = Number(state.userScore.rnc || state.userScore.respectNoChain || 0); usedLightweight = true; }
else if (scoreType === 'Respect (no bonus)') { value = Number(state.userScore.rnb || state.userScore.respectNoBonus || 0); usedLightweight = true; }
if (usedLightweight) assists = Number(state.userScore.as || state.userScore.assists || 0);
}
// TODO: Prune now that userScore comes from back end
if (!usedLightweight) {
try {
const rows = await utils.getSummaryRowsCached(warId, state.user.factionId);
// Anti-flicker: if rows empty (fetch fail?), try to preserve cache
if ((!rows || rows.length === 0)) {
const cached = storage.get('badge.user');
const isExpired = !cached || (Date.now() - (cached.ts || 0) > 2 * 60 * 60 * 1000);
const isWar = (warId || null) === (cached?.warId || null);
// If we have a valid non-zero cache, use it
if (!isExpired && isWar && cached.v && !/Me:\s*0\b/.test(cached.v)) {
ui._orchestrator.setText(el, cached.v);
ui._orchestrator.setDisplay(el, storage.get('userScoreBadgeEnabled', true) ? 'inline-flex' : 'none');
return;
}
}
const me = rows.find(r => String(r.attackerId) === String(state.user.tornId));
value = utils.computeScoreFromRow(me, scoreType);
assists = Number(me?.assistCount || 0);
} catch(_) {}
}
// During force window, if computed value is 0 but we have a cached label, prefer cached and avoid overwriting it
if (force && (!value || value === 0)) {
const cached = storage.get('badge.user');
const isExpired = !cached || (Date.now() - (cached.ts || 0) > 2 * 60 * 60 * 1000);
const isWar = (warId || null) === (cached?.warId || null);
const v = (!isExpired && isWar) ? cached.v : null;
if (v) { ui._orchestrator.setText(el, v); ui._orchestrator.setDisplay(el, storage.get('userScoreBadgeEnabled', true) ? 'inline-flex' : 'none'); return; }
}
// --- Anti-flicker & precision formatting ---
state._scoreCaches = state._scoreCaches || {};
const cacheKey = 'user';
const prev = state._scoreCaches[cacheKey];
const typeAbbr = (scoreType === 'Attacks') ? 'Hits' : (scoreType.startsWith('Respect') ? 'R' : scoreType);
const formatted = utils.formatScore ? utils.formatScore(value, scoreType) : String(value);
const shouldUpdate = !prev || prev.type !== scoreType || utils.scores?.shouldUpdate(prev.raw, value) || (prev.assists !== assists);
if (shouldUpdate) {
let label = `Me: ${formatted} ${typeAbbr}`;
if (assists > 0) label += ` / ${assists} A`;
ui._orchestrator.setText(el, label);
state._scoreCaches[cacheKey] = { raw: value, formatted, type: scoreType, assists, ts: Date.now() };
// Avoid persisting a zero overwriting a good cached label during force window
if (!(force && (!value || value === 0))) { storage.set('badge.user', { v: label, ts: Date.now(), warId }); }
} else {
// preserve prior visible text if we temporarily can’t recompute
if (!el.textContent || /Me:\s*0\b/.test(el.textContent)) {
const cached = storage.get('badge.user');
const isExpired = !cached || (Date.now() - (cached.ts || 0) > 2 * 60 * 60 * 1000);
const isWar = (warId || null) === (cached?.warId || null);
const v = (!isExpired && isWar) ? cached.v : null;
if (v) ui._orchestrator.setText(el, v);
}
}
ui._orchestrator.setDisplay(el, storage.get('userScoreBadgeEnabled', true) ? 'inline-flex' : 'none');
},
removeUserScoreBadge: () => {
const el = document.getElementById('tdm-user-score');
if (el) el.remove();
state.ui.userScoreBadgeEl = null;
},
ensureFactionScoreBadge: () => {
const enabled = storage.get('factionScoreBadgeEnabled', true);
const existing = document.getElementById('tdm-faction-score');
if (!enabled) { if (existing) ui._orchestrator.setDisplay(existing, 'none'); return; }
const warId = state.lastRankWar?.id;
const inWindow = warId && utils.isWarInActiveOrGrace?.(warId, 6);
if (!inWindow) { if (existing) ui._orchestrator.setDisplay(existing, 'none'); return; }
const force = ui._badgesForceShowUntil && Date.now() < ui._badgesForceShowUntil;
ui.ensureBadgeDock();
ui.ensureBadgeDockToggle();
const items = ui.ensureBadgeDockItems();
if (!items) return;
let el = existing;
if (!el) {
const initialLabel = (() => {
const cached = storage.get('badge.faction');
const isExpired = !cached || (Date.now() - (cached.ts || 0) > 2 * 60 * 60 * 1000);
const isWar = (warId || null) === (cached?.warId || null);
return (!isExpired && isWar && cached?.v) ? cached.v : 'Faction: 0';
})();
el = utils.createElement('div', {
id: 'tdm-faction-score',
style: ui._composeDockBadgeStyle({
background: 'rgba(16, 26, 44, 0.9)',
border: '1px solid rgba(104, 162, 247, 0.38)',
color: '#a8cfff'
}),
title: 'Our faction score this war'
}, [document.createTextNode(initialLabel)]);
items.appendChild(el);
} else if (el.parentNode !== items) {
items.appendChild(el);
}
state.ui.factionScoreBadgeEl = el;
ui._orchestrator.setDisplay(el, 'inline-flex');
ui.updateFactionScoreBadge();
},
updateFactionScoreBadge: async () => {
const el = document.getElementById('tdm-faction-score');
if (!el) return;
const warId = state.lastRankWar?.id;
const inWindow = warId && utils.isWarInActiveOrGrace?.(warId, 6);
const force = ui._badgesForceShowUntil && Date.now() < ui._badgesForceShowUntil;
if (!inWindow) { ui._orchestrator.setDisplay(el, 'none'); return; }
const scoreType = state.warData?.scoreType || 'Respect';
// New simplified logic: rely solely on lastRankWar.factions which is authoritative & freshest
let total = 0;
try {
const lw = state.lastRankWar;
if (lw && Array.isArray(lw.factions)) {
const ourFac = lw.factions.find(f => String(f.id) === String(state.user.factionId));
if (ourFac && typeof ourFac.score === 'number') {
total = Number(ourFac.score) || 0;
}
}
} catch(_) { /* noop */ }
// During force window, if computed total is 0 but we have a cached label, prefer cached and avoid overwriting it
if (force && (!total || total === 0)) {
const cached = storage.get('badge.faction');
const isExpired = !cached || (Date.now() - (cached.ts || 0) > 2 * 60 * 60 * 1000);
const isWar = (warId || null) === (cached?.warId || null);
const v = (!isExpired && isWar) ? cached.v : null;
if (v) { ui._orchestrator.setText(el, v); ui._orchestrator.setDisplay(el, storage.get('factionScoreBadgeEnabled', true) ? 'inline-flex' : 'none'); return; }
}
// --- Anti-flicker & precision formatting ---
state._scoreCaches = state._scoreCaches || {};
const cacheKey = 'faction';
const prev = state._scoreCaches[cacheKey];
const formatted = utils.formatScore ? utils.formatScore(total, scoreType) : String(total);
const shouldUpdate = !prev || prev.type !== scoreType || utils.scores?.shouldUpdate(prev.raw, total);
if (shouldUpdate) {
const label = `Faction: ${formatted}`;
ui._orchestrator.setText(el, label);
state._scoreCaches[cacheKey] = { raw: total, formatted, type: scoreType, ts: Date.now() };
if (!(force && (!total || total === 0))) { storage.set('badge.faction', { v: label, ts: Date.now(), warId }); }
} else {
if (!el.textContent || /Faction:\s*0\b/.test(el.textContent)) {
const cached = storage.get('badge.faction');
const isExpired = !cached || (Date.now() - (cached.ts || 0) > 2 * 60 * 60 * 1000);
const isWar = (warId || null) === (cached?.warId || null);
const v = (!isExpired && isWar) ? cached.v : null;
if (v) ui._orchestrator.setText(el, v);
}
}
ui._orchestrator.setDisplay(el, storage.get('factionScoreBadgeEnabled', true) ? 'inline-flex' : 'none');
},
removeFactionScoreBadge: () => {
const el = document.getElementById('tdm-faction-score');
if (el) el.remove();
state.ui.factionScoreBadgeEl = null;
},
updateApiUsageBadge: () => {
const el = state.ui.apiUsageEl || document.getElementById('tdm-api-usage');
if (!el) return;
const total = Number(state.session.apiCalls || 0);
const client = Number(state.session.apiCallsClient || 0);
const backend = Number(state.session.apiCallsBackend || 0);
const valueEl = state.ui.apiUsageValueEl || el.querySelector('.tdm-api-usage-value');
if (valueEl) {
const prev = valueEl.textContent;
const next = total.toString();
if (prev !== next) valueEl.textContent = next;
}
const detailEl = state.ui.apiUsageDetailEl || el.querySelector('.tdm-api-usage-detail');
if (detailEl) {
const combo = `C${client}/B${backend}`;
if (detailEl.textContent !== combo) detailEl.textContent = combo;
}
el.title = `Torn API calls (session, user key)\n• Client: ${client}\n• Backend: ${backend}\n• Total: ${total}`;
ui._orchestrator.setDisplay(el, storage.get('apiUsageCounterEnabled', false) ? 'inline-flex' : 'none');
},
removeApiUsageBadge: () => {
if (state.ui.apiUsageEl) { state.ui.apiUsageEl.remove(); state.ui.apiUsageEl = null; }
state.ui.apiUsageValueEl = null;
state.ui.apiUsageDetailEl = null;
},
ensureDibsDealsBadge: () => {
if (!storage.get('dibsDealsBadgeEnabled', true)) { ui.removeDibsDealsBadge(); return; }
// Removed war active/grace gating so badge can show pre-war (setup / staging phase)
const warId = state.lastRankWar?.id; // May be undefined pre-war; still proceed.
ui.ensureBadgeDock();
ui.ensureBadgeDockToggle();
const items = ui.ensureBadgeDockItems();
if (!items) return;
let el = document.getElementById('tdm-dibs-deals');
if (!el) {
el = utils.createElement('div', {
id: 'tdm-dibs-deals',
style: ui._composeDockBadgeStyle({
background: 'rgba(36, 24, 6, 0.88)',
border: '1px solid rgba(247, 183, 65, 0.4)',
color: '#fcd77d'
}),
title: 'Active dibs and med deals'
}, [document.createTextNode('Dibs: 0, Deals: 0')]);
items.appendChild(el);
} else if (el.parentNode !== items) {
items.appendChild(el);
}
state.ui.dibsDealsBadgeEl = el;
// Immediate paint using raw updater (debounced variant may be pending already)
ui.updateDibsDealsBadge();
// Start periodic refresh (idempotent) always (lightweight)
if (!state._dibsDealsInterval) {
state._dibsDealsInterval = utils.registerInterval(setInterval(() => {
try {
// Prefer debounced variant if initialized; fallback to direct
if (handlers?.debouncedUpdateDibsDealsBadge) handlers.debouncedUpdateDibsDealsBadge(); else ui.updateDibsDealsBadge();
} catch(_) {}
}, 8000));
}
},
updateDibsDealsBadge: () => {
const el = document.getElementById('tdm-dibs-deals');
if (!el) return;
if (!storage.get('dibsDealsBadgeEnabled', true)) { ui._orchestrator.setDisplay(el, 'none'); return; }
// War activity window check removed; we allow badge visibility regardless of active/grace status
// Count active dibs (dibsActive true) and active med deals (medDeals entries with isMedDeal true)
let activeDibs = 0;
try {
if (Array.isArray(state.dibsData)) {
for (const d of state.dibsData) if (d && d.dibsActive) activeDibs++;
}
} catch(_) {}
let activeDeals = 0;
try {
for (const v of Object.values(state.medDeals || {})) {
if (v && v.isMedDeal) activeDeals++;
}
} catch(_) {}
// Stabilization: if backend temporarily returned empty structures (race) retain last non-zero counts for a short grace window to avoid flicker
const GRACE_MS = 15000; // 15s retention of last non-zero display when new counts drop to zero unexpectedly
if (!state._dibsDealsLast) state._dibsDealsLast = { dibs: 0, deals: 0, ts: 0 };
const nowMs = Date.now();
const hadPrev = (state._dibsDealsLast.dibs > 0 || state._dibsDealsLast.deals > 0);
const countsNowZero = activeDibs === 0 && activeDeals === 0;
if (countsNowZero && hadPrev && (nowMs - state._dibsDealsLast.ts) < GRACE_MS) {
// Retain previous non-zero snapshot (assume transient fetch gap) but slowly age out
activeDibs = state._dibsDealsLast.dibs;
activeDeals = state._dibsDealsLast.deals;
} else if (activeDibs > 0 || activeDeals > 0) {
state._dibsDealsLast = { dibs: activeDibs, deals: activeDeals, ts: nowMs };
}
// Build display parts only for non-zero counts
const parts = [];
if (activeDibs > 0) parts.push(`Dibs: ${activeDibs}`);
if (activeDeals > 0) parts.push(`Deals: ${activeDeals}`);
if (parts.length === 0) {
// Hide the badge entirely when no active dibs or deals are present to avoid noise
ui._orchestrator.setDisplay(el, 'none');
try { if (el.title !== 'No active dibs or med deals') ui._orchestrator.setTitle(el, 'No active dibs or med deals'); } catch(_) {}
return;
}
const text = parts.join(', ');
if (el.textContent !== text) ui._orchestrator.setText(el, text);
ui._orchestrator.setDisplay(el, 'inline-flex');
ui._orchestrator.setTitle(el, `${activeDibs} active dib${activeDibs===1?'':'s'}${activeDeals>0?` | ${activeDeals} active deal${activeDeals===1?'':'s'}`:''}`);
try { if (window.__TDM_SINGLETON__) window.__TDM_SINGLETON__.dibsDealsUpdates++; } catch(_) {}
},
removeDibsDealsBadge: () => {
const el = document.getElementById('tdm-dibs-deals');
if (el) el.remove();
state.ui.dibsDealsBadgeEl = null;
if (state._dibsDealsInterval) { try { utils.unregisterInterval(state._dibsDealsInterval); } catch(_) {} state._dibsDealsInterval = null; }
},
// ChainWatcher Badge
ensureChainWatcherBadge: () => {
// Respect user toggle
if (!storage.get('chainWatcherBadgeEnabled', true)) { ui.removeChainWatcherBadge(); return; }
// Do not create unless explicitly enabled via presence of selections
ui.ensureBadgeDock();
ui.ensureBadgeDockToggle();
const items = ui.ensureBadgeDockItems();
if (!items) return;
let el = document.getElementById('tdm-chain-watchers');
if (!el) {
el = utils.createElement('div', {
id: 'tdm-chain-watchers',
style: ui._composeDockBadgeStyle({ background: 'rgba(10,36,66,0.9)', border: '1px solid rgba(59,130,246,0.3)', color: '#9fd3ff' }),
title: 'Selected Chain Watchers'
}, [document.createTextNode('Watchers: —')]);
items.appendChild(el);
} else if (el.parentNode !== items) {
items.appendChild(el);
}
state.ui.chainWatcherBadgeEl = el;
ui.updateChainWatcherBadge();
// Start periodic status refresher while badge is present
try {
if (state.ui.chainWatcherIntervalId) { try { utils.unregisterInterval(state.ui.chainWatcherIntervalId); } catch(_) {} state.ui.chainWatcherIntervalId = null; }
state.ui.chainWatcherIntervalId = utils.registerInterval(setInterval(() => {
try { ui.updateChainWatcherDisplayedStatuses && ui.updateChainWatcherDisplayedStatuses(); } catch(_) {}
}, 2500)); // refresh every 2.5s
} catch(_) {}
},
updateChainWatcherBadge: () => {
// Respect user toggle
if (!storage.get('chainWatcherBadgeEnabled', true)) { ui.removeChainWatcherBadge(); return; }
const el = document.getElementById('tdm-chain-watchers');
if (!el) return;
const stored = storage.get('chainWatchers', []);
const membersById = (Array.isArray(state.factionMembers) ? state.factionMembers : []).reduce((acc, m) => { if (m && m.id) acc[String(m.id)] = m; return acc; }, {});
const names = Array.isArray(stored) ? stored.map(s => {
const name = s && s.name ? s.name : (s || '');
const id = String(s && (s.id || s.tornId || s) || '');
return { id, name };
}).filter(Boolean) : [];
if (!names.length) { ui._orchestrator.setDisplay(el, 'none'); return; }
// Build DOM: "Watchers: " + colored name spans
while (el.firstChild) el.removeChild(el.firstChild);
el.appendChild(document.createTextNode('Watchers: '));
names.forEach((n, idx) => {
const span = document.createElement('span');
span.className = 'tdm-chainwatcher-badge-name';
span.textContent = n.name;
try { span.dataset.memberId = String(n.id); } catch(_) {}
// Apply color based on live member status (if available)
const mem = membersById[String(n.id)];
try { if (utils.addLastActionStatusColor && typeof utils.addLastActionStatusColor === 'function') utils.addLastActionStatusColor(span, mem); } catch(_) {}
el.appendChild(span);
if (idx < names.length - 1) el.appendChild(document.createTextNode(', '));
});
ui._orchestrator.setDisplay(el, 'inline-flex');
try { ui._orchestrator.setTitle(el, names.map(n => n.name).join(', ')); } catch(_) {}
// Also update the small header display with names in yellow (plain text)
try {
const hdr = document.getElementById('tdm-chainwatcher-header-names');
if (hdr) hdr.textContent = names.map(n => n.name).join(', ');
} catch(_) {}
},
// Refresh displayed status spans for chain watcher UI elements (checkbox list + badge)
updateChainWatcherDisplayedStatuses: () => {
try {
// Update checkbox list spans
const members = Array.isArray(state.factionMembers) ? state.factionMembers : [];
for (const m of members) {
const id = String(m.id);
// Color the name span instead of rendering textual status
const nameSpan = document.querySelector(`#tdm-chainwatcher-checkbox-list span.tdm-chainwatcher-name[data-member-id="${id}"]`);
if (nameSpan) {
try { if (utils.addLastActionStatusColor && typeof utils.addLastActionStatusColor === 'function') utils.addLastActionStatusColor(nameSpan, m); } catch(_) {}
}
}
// Update options in hidden select (if present)
const chainSelect = document.getElementById('tdm-chainwatcher-select');
if (chainSelect) {
const opts = Array.from(chainSelect.options || []);
for (const opt of opts) {
const id = String(opt.value);
const mem = members.find(mm => String(mm.id) === id);
const baseName = mem && mem.name ? `${mem.name} [${id}]` : opt.textContent.split(' - ')[0];
opt.textContent = baseName;
}
}
// Refresh badge text
try { ui.updateChainWatcherBadge && ui.updateChainWatcherBadge(); } catch(_) {}
} catch(_) {}
},
updateChainWatcherMeta: (meta) => {
try {
const el = document.getElementById('tdm-chainwatcher-meta');
if (!el) return;
if (!meta || !meta.lastWriter) {
el.textContent = 'Last updated: —';
return;
}
const lw = meta.lastWriter || {};
const name = lw.username || (lw.tornId ? `Torn#${lw.tornId}` : (lw.uid ? `uid:${lw.uid}` : 'Unknown'));
// Firestore serialized shape: { _seconds, _nanoseconds }
let updatedAt = null;
try {
if (meta && meta.updatedAt && typeof meta.updatedAt._seconds === 'number') {
updatedAt = new Date(meta.updatedAt._seconds * 1000 + Math.floor((meta.updatedAt._nanoseconds || 0) / 1e6));
}
} catch (_) { updatedAt = null; }
const timeStr = updatedAt && !Number.isNaN(updatedAt.getTime()) ? updatedAt.toLocaleString(undefined, { hour12: false }) + ' LT' : 'Invalid Date';
el.textContent = `Last updated: ${name} @ ${timeStr}`;
// Also update header names to match stored watchers if present
try {
const stored = storage.get('chainWatchers', []);
const names = Array.isArray(stored) ? stored.map(s => s && s.name ? s.name : (s || '')).filter(Boolean) : [];
const hdr = document.getElementById('tdm-chainwatcher-header-names');
if (hdr) hdr.textContent = names.length ? names.join(', ') : '—';
} catch(_) {}
} catch(_) { /* noop */ }
},
removeChainWatcherBadge: () => {
const el = document.getElementById('tdm-chain-watchers');
if (el) el.remove();
state.ui.chainWatcherBadgeEl = null;
if (state.ui.chainWatcherIntervalId) { try { utils.unregisterInterval(state.ui.chainWatcherIntervalId); } catch(_) {} state.ui.chainWatcherIntervalId = null; }
},
// Optional: prune cache to last 10 wars to avoid unlimited growth
removeOpponentStatus: () => {
if (state.ui.opponentStatusIntervalId) { try { utils.unregisterInterval(state.ui.opponentStatusIntervalId); } catch(_) {} state.ui.opponentStatusIntervalId = null; }
if (state.ui.opponentStatusEl) { state.ui.opponentStatusEl.remove(); state.ui.opponentStatusEl = null; }
if (state.ui.opponentStatusValueEl) state.ui.opponentStatusValueEl = null;
},
// TDM_REF: hospital refocus reconciliation
// Force a lightweight reconciliation of hospital timers & opponent status cache
// after the tab regains visibility (prevents long-sleep drift / stale hospital displays).
forceHospitalCountdownRefocus: () => {
try {
const nowSec = Math.floor(Date.now() / 1000);
let changed = false;
// Reconcile medDeals hospital expirations
if (state.medDeals && typeof state.medDeals === 'object') {
for (const [oid, meta] of Object.entries(state.medDeals)) {
if (!meta || typeof meta !== 'object') continue;
if (meta.activeType === 'status' && typeof meta.hospitalUntil === 'number' && meta.hospitalUntil > 0) {
if (meta.hospitalUntil <= nowSec) {
// Expired while tab hidden: normalize to Okay
meta.prevStatus = meta.prevStatus || 'Hospital';
meta.newStatus = 'Okay';
meta.hospitalUntil = 0;
changed = true;
}
}
}
if (changed) {
try { state._mutate.setMedDeals({ ...state.medDeals }, { source: 'hospital-refocus' }); }
catch(_) { try { storage.set('medDeals', state.medDeals); } catch(_) {} }
}
}
// Force opponent status widget immediate refresh
if (state.ui && state.ui.opponentStatusCache) {
state.ui.opponentStatusCache.lastFetch = 0; // so next tick refetches
}
if (state._opponentStatusStable) {
state._opponentStatusStable.lastRendered = 0; // allow immediate rerender
}
// Touch any active alert hospital countdowns (simple text recompute)
const alerts = document.querySelectorAll('.tdm-alert');
alerts.forEach(el => {
const hospMatch = /Hosp\s+(\d+):(\d{2})/.exec(el.textContent || '');
if (hospMatch) {
// If underlying meta expired, swap to Okay (cheap heuristic)
if (/hospital/i.test(el.textContent) || true) {
// Derive an opponent id if embedded
const idMatch = /(Opp|Target)\s*#?(\d{1,9})/i.exec(el.textContent || '');
let expired = false;
if (idMatch) {
const oid = idMatch[2];
const meta = state.medDeals?.[oid];
if (meta && meta.hospitalUntil === 0) expired = true;
else if (meta && typeof meta.hospitalUntil === 'number' && meta.hospitalUntil < nowSec) expired = true;
}
if (expired) {
try {
el.textContent = el.textContent.replace(/Hosp\s+\d+:\d{2}/, 'Okay');
} catch(_) {}
}
}
}
});
} catch(_) { /* non-fatal */ }
},
startOpponentStatusUpdater: () => {
if (!state.ui.opponentStatusEl) return;
if (state.ui.opponentStatusIntervalId) return;
// Consolidated throttling state
if (!state._opponentStatusStable) {
state._opponentStatusStable = { lastCanon: null, lastActivity: null, lastRendered: 0, lastId: null, lastDest: null };
state._opponentStatusMinRenderIntervalMs = 1200; // min 1.2s between identical renders
state._opponentStatusForceIntervalMs = 8000; // hard refresh every 8s even if unchanged
// Canonicalization enhancement: treat Travel 'in <place>' as Abroad.
// Removed legacy opponent status canonicalization wrapper.
}
const getMyActiveDibOpponentId = () => {
try {
if (Array.isArray(state.dibsData)) {
const dib = state.dibsData.find(d => d && d.dibsActive && d.userId === state.user.tornId);
if (dib?.opponentId) return dib.opponentId;
}
} catch(_) { /* noop */ }
return null;
};
const tick = async () => {
const dibOppId = getMyActiveDibOpponentId();
const oppId = dibOppId ? String(dibOppId) : null;
if (!oppId) {
const valueEl = state.ui.opponentStatusValueEl || state.ui.opponentStatusEl.querySelector('.tdm-opponent-status-value');
if (valueEl) {
valueEl.innerHTML = '<span style="opacity:0.6">No active dib</span>';
valueEl.style.color = '#94b9d6';
}
if (state.ui.opponentStatusEl) ui._orchestrator.setDisplay(state.ui.opponentStatusEl, 'none');
state.ui.opponentStatusCache = { lastFetch: 0, untilEpoch: 0, text: '', opponentId: null, canonical: null, activity: null, dest: null, unified: null };
return;
}
// Try to reuse cached hospital release time for the same opponent
const nowMs = Date.now();
if (!state.ui.opponentStatusCache) {
state.ui.opponentStatusCache = { lastFetch: 0, untilEpoch: 0, text: '', opponentId: oppId, canonical: null, activity: null, dest: null, unified: null };
} else if (state.ui.opponentStatusCache.opponentId !== oppId) {
state.ui.opponentStatusCache = { lastFetch: 0, untilEpoch: 0, text: '', opponentId: oppId, canonical: null, activity: null, dest: null, unified: null };
}
// Prefer seeding from a local active dib if available. This avoids flicker
// by populating the cache early with name/faction/last-action/activity hints.
try {
if (Array.isArray(state.dibsData) && state.dibsData.length) {
const dib = state.dibsData.find(d => String(d.opponentId) === String(oppId) && !!d.dibsActive);
if (dib) {
// Normalize possible field name variants
const name = dib.opponentname || dib.opponentName || dib.opponent || dib.username || null;
if (name) state.ui.opponentStatusCache.name = String(name);
const opFac = dib.opponentFactionId || dib.opponentFaction || dib.opponentFactionID || null;
if (opFac) state.ui.opponentStatusCache.opponentFactionId = String(opFac);
// last action timestamp: prefer explicit last_action.lastAction.timestamp fields
// Avoid seeding from the dib creation timestamp (dibbedAt/lastActionTimestamp used during optimistic creates)
// which would make "Last action" display show the age of the dib rather than the player's real last action.
try {
// Candidate timestamp sources in descending reliability when present in dibsData
const candidateTsRaw = (dib.last_action && (dib.last_action.timestamp || dib.last_action)) || (dib.lastAction && (dib.lastAction.timestamp || dib.lastAction)) || null;
let candidateTs = 0;
if (candidateTsRaw && typeof candidateTsRaw === 'object' && (candidateTsRaw._seconds || candidateTsRaw.seconds)) {
candidateTs = Number(candidateTsRaw._seconds || candidateTsRaw.seconds || 0) || 0;
} else if (typeof candidateTsRaw === 'number') {
candidateTs = candidateTsRaw > 1e12 ? Math.floor(candidateTsRaw / 1000) : Math.floor(candidateTsRaw);
}
// Compare against dibbedAt (if present). If candidate equals dibbedAt (or is implausibly close)
// it's likely the optimistic dib creation time and not a genuine last action — skip it.
const dibbedRaw = dib.dibbedAt || dib.dibbed_at || dib.createdAt || dib.created || null;
let dibbedTs = 0;
if (dibbedRaw && typeof dibbedRaw === 'object' && (dibbedRaw._seconds || dibbedRaw.seconds)) {
dibbedTs = Number(dibbedRaw._seconds || dibbedRaw.seconds || 0) || 0;
} else if (typeof dibbedRaw === 'number') {
dibbedTs = dibbedRaw > 1e12 ? Math.floor(dibbedRaw / 1000) : Math.floor(dibbedRaw);
}
// If candidate exists use it to seed cached lastActionTs. We don't try to
// detect optimistic dib creation time here — authoritative sources (status/session)
// will be preferred later for rendering.
if (candidateTs > 0) {
state.ui.opponentStatusCache.lastActionTs = candidateTs;
}
} catch (_) { /* non-fatal */ }
// activity hint (status at dib)
const act = dib.opponentStatusAtDib || dib.opponent_status_at_dib || null;
if (act) state.ui.opponentStatusCache.activity = act;
}
}
} catch (_) { /* non-fatal */ }
let canonicalHint = state.ui.opponentStatusCache.canonical || null;
let activityHint = state.ui.opponentStatusCache.activity || null;
// If activity hint came from cached dibsData, ensure it's recent enough to trust
try {
const cachedTs = Number(state.ui.opponentStatusCache?.lastActionTs || 0) || 0;
if (activityHint && cachedTs) {
const nowSec = Math.floor(Date.now()/1000);
const age = nowSec - cachedTs;
const ACTIVITY_HINT_STALE = 120; // 2 minutes
if (age > ACTIVITY_HINT_STALE) activityHint = null;
}
} catch(_) { /* noop */ }
let destHint = state.ui.opponentStatusCache.dest || null;
// Only refresh every 10s
if (nowMs - state.ui.opponentStatusCache.lastFetch >= 10000) {
state.ui.opponentStatusCache.lastFetch = nowMs;
try {
// Prefer cached opponent faction members first. Use seeded opponentFactionId
// (from dibsData) if available so lookups succeed earlier and avoid stale fallbacks.
const tf = state.tornFactionData || {};
const oppFactionId = state.ui.opponentStatusCache?.opponentFactionId || state.lastOpponentFactionId || state?.warData?.opponentId;
const entry = oppFactionId ? tf[oppFactionId] : null;
let status = null;
if (entry?.data?.members) {
const arr = Array.isArray(entry.data.members) ? entry.data.members : Object.values(entry.data.members);
const m = arr.find(x => String(x.id) === String(oppId));
if (m) {
// Prefer the precomputed unifiedStatus entry (if present) for canonical/activity
// but ensure we attach the faction member's last_action (timestamp + status)
// so activity and lastActionTs come from the same snapshot.
const unifiedRec = state.unifiedStatus?.[String(oppId)] || null;
if (unifiedRec) {
// Build a status-like object from unified + member's last_action if available
status = status || {};
// Use rawState/rawDescription/rawUntil from unified if available, else member.status
if (unifiedRec.rawState) status.state = unifiedRec.rawState;
if (unifiedRec.rawDescription) status.description = unifiedRec.rawDescription;
if (unifiedRec.rawUntil) status.until = unifiedRec.rawUntil;
// Ensure last_action is attached from the member record when possible
if (m.last_action || m.lastAction) {
status.last_action = m.last_action || m.lastAction;
// Also include direct activity hint on cache if present
activityHint = (m.last_action?.status || m.lastAction?.status || activityHint) || activityHint;
}
}
// If we still don't have a status object, fall back to the member.status
if (!status && m.status) {
status = { ...m.status };
if (m.last_action || m.lastAction) status.last_action = m.last_action || m.lastAction;
}
// Seed the cache with opponent faction id and name if absent
try { if (!state.ui.opponentStatusCache.opponentFactionId) state.ui.opponentStatusCache.opponentFactionId = String(m?.faction?.id || m?.faction_id || m?.faction?.faction_id || state.lastOpponentFactionId || ''); } catch(_) {}
try { if (!state.ui.opponentStatusCache.name) state.ui.opponentStatusCache.name = m?.name || m?.username || m?.playername || state.ui.opponentStatusCache.name; } catch(_) {}
}
}
if (!status) {
// If not found in the primary opponent faction bundle, try to locate the member
// in any other loaded faction bundle we have cached. This helps in cases where
// we have multiple faction bundles loaded and the member lives in a different one.
try {
for (const fEntry of Object.values(tf || {})) {
const membersObj2 = fEntry?.data?.members || fEntry?.data?.member || null;
const membersArr2 = membersObj2 ? (Array.isArray(membersObj2) ? membersObj2 : Object.values(membersObj2)) : null;
if (!membersArr2 || !membersArr2.length) continue;
const found = membersArr2.find(x => String(x.id) === String(oppId));
if (found) {
// Use the found member's status and attach last_action if present
if (found.status) status = { ...found.status };
if ((found.last_action || found.lastAction) && status) status.last_action = found.last_action || found.lastAction;
// Seed cache name/opponentFactionId for later usage
try { if (!state.ui.opponentStatusCache.opponentFactionId) state.ui.opponentStatusCache.opponentFactionId = String(found?.faction?.id || found?.faction_id || ''); } catch(_) {}
try { if (!state.ui.opponentStatusCache.name) state.ui.opponentStatusCache.name = found?.name || found?.username || found?.playername || state.ui.opponentStatusCache.name; } catch(_) {}
break;
}
}
} catch(_) { /* ignore */ }
// Optionally refresh opponent faction bundle (members) respecting 10s freshness
if (!status && oppFactionId) {
try { await api.getTornFaction(state.user.actualTornApiKey, 'members', oppFactionId); } catch (_) {}
const e2 = state.tornFactionData?.[oppFactionId];
if (e2?.data?.members) {
const arr2 = Array.isArray(e2.data.members) ? e2.data.members : Object.values(e2.data.members);
const m2 = arr2.find(x => String(x.id) === String(oppId));
if (m2?.status) {
status = { ...m2.status };
if (m2.last_action || m2.lastAction) status.last_action = m2.last_action || m2.lastAction;
}
}
}
}
if (!status) {
// Fallback to cached user status (member-first, 10s TTL)
const s = await utils.getUserStatus(oppId);
status = s?.raw || (s?.canonical ? { state: s.canonical, until: s.until } : null);
if (s?.activity && !activityHint) activityHint = s.activity;
}
let text = '';
let until = 0;
let canonicalLocal = null;
let destLocal = destHint || null;
// activityLocal must come from the same authoritative `status` source when available.
// Only fall back to the cached dibs-derived hint if we couldn't obtain a status.
let activityLocal = null;
let unifiedLocal = null;
const stateStrRaw = String(status?.state || '').trim();
const stateStr = stateStrRaw;
const desc = String(status?.description || '').trim();
if (stateStr === 'Hospital') {
until = Number(status?.until) || 0;
text = desc || 'Hosp';
const hospDest = utils.parseHospitalAbroadDestination(desc) || utils.parseUnifiedDestination(desc) || null;
if (hospDest) destLocal = hospDest;
} else if (stateStr) {
text = desc || stateStr;
if (stateStr === 'Abroad') {
const abroadDest = utils.parseUnifiedDestination(desc) || null;
if (abroadDest) destLocal = abroadDest;
}
} else {
text = 'Okay';
}
const actFromStatus = status?.last_action?.status || status?.lastAction?.status || status?.activity || null;
if (actFromStatus) {
// Check timestamp for staleness before accepting an activity token.
// Prefer a recent last_action timestamp (seconds) else treat activity as stale.
let actTs = 0;
try {
actTs = Number(status?.last_action?.timestamp || status?.lastAction?.timestamp || state.ui.opponentStatusCache?.lastActionTs || 0) || 0;
// Normalize ms->s if needed
if (actTs > 1e12) actTs = Math.floor(actTs / 1000);
else if (actTs > 1e11) actTs = Math.floor(actTs / 1000);
} catch (_) { actTs = 0; }
const nowSec = Math.floor(Date.now() / 1000);
const age = actTs > 0 ? (nowSec - actTs) : Number.POSITIVE_INFINITY;
const ACTIVITY_STALE_SECONDS = 120; // 2 minutes
if (actTs && Number.isFinite(age) && age <= ACTIVITY_STALE_SECONDS) {
activityLocal = actFromStatus;
} else {
// stale or missing last_action timestamp on the authoritative status ->
// do not mix with a dibs-derived activity. Leave activityLocal null.
activityLocal = null;
}
}
if (status && stateStr) {
try {
const payload = { id: oppId, status };
if (activityLocal) payload.last_action = { status: activityLocal };
const prevUnified = state.ui.opponentStatusCache.unified || null;
unifiedLocal = utils.buildUnifiedStatusV2(payload, prevUnified || undefined);
} catch(_) {
unifiedLocal = null;
}
}
if (unifiedLocal) {
canonicalLocal = unifiedLocal.canonical || canonicalLocal;
if (unifiedLocal.dest) destLocal = unifiedLocal.dest;
// unifiedLocal.activity is coming from the same `status` record; prefer it
// (we already validated freshness above), otherwise leave activityLocal as-is.
if (unifiedLocal.activity) activityLocal = unifiedLocal.activity;
}
if (destLocal && /torn\s*(city)?/i.test(destLocal)) destLocal = null;
if (stateStr === 'Hospital') {
const nonTornDest = destLocal && !/torn\s*(city)?/i.test(destLocal);
if (nonTornDest) {
if (!canonicalLocal || canonicalLocal === 'Hospital') canonicalLocal = 'HospitalAbroad';
} else if (!canonicalLocal) {
canonicalLocal = 'Hospital';
}
}
if (!canonicalLocal && stateStr) {
const mapState = stateStr.toLowerCase();
if (mapState === 'traveling') canonicalLocal = 'Travel';
else if (mapState === 'returning') canonicalLocal = 'Returning';
else if (mapState === 'abroad') canonicalLocal = 'Abroad';
else if (mapState === 'hospitalabroad') canonicalLocal = 'HospitalAbroad';
else if (mapState === 'hospital') canonicalLocal = 'Hospital';
else if (mapState === 'jail') canonicalLocal = 'Jail';
else if (mapState === 'okay') canonicalLocal = 'Okay';
}
state.ui.opponentStatusCache.untilEpoch = until;
state.ui.opponentStatusCache.text = text || 'Okay';
state.ui.opponentStatusCache.canonical = canonicalLocal || null;
state.ui.opponentStatusCache.activity = activityLocal || null;
state.ui.opponentStatusCache.dest = destLocal || null;
state.ui.opponentStatusCache.unified = unifiedLocal || null;
canonicalHint = state.ui.opponentStatusCache.canonical;
activityHint = state.ui.opponentStatusCache.activity;
destHint = state.ui.opponentStatusCache.dest;
} catch (_) { /* noop */ }
}
const until = state.ui.opponentStatusCache.untilEpoch;
const baseText = state.ui.opponentStatusCache.text || '';
const cacheEntry = state.session.userStatusCache?.[String(oppId)];
const unifiedRecCache = state.unifiedStatus?.[String(oppId)] || null;
// If we don't have a unified record, try to find the member inside any loaded faction bundle
// so we can construct canonical/activity from the bundle when available.
let memberFromBundle = null;
if (!unifiedRecCache && state.tornFactionData) {
try {
for (const fEntry of Object.values(state.tornFactionData || {})) {
const membersObj = fEntry?.data?.members || fEntry?.data?.member || null;
const membersArr = membersObj ? (Array.isArray(membersObj) ? membersObj : Object.values(membersObj)) : null;
if (!membersArr || !membersArr.length) continue;
const mm = membersArr.find(x => String(x?.id) === String(oppId));
if (mm) { memberFromBundle = mm; break; }
}
} catch(_) { memberFromBundle = null; }
}
// If we have an until timer it's very likely Hospital — treat as canonical Hospital
// Prefer unified records (faction bundle) first, then bundle member-derived status if we found it,
// then session cache, and finally any cached hint.
let canon = unifiedRecCache?.canonical || (memberFromBundle ? (utils.buildUnifiedStatusV2(memberFromBundle)?.canonical || null) : null) || cacheEntry?.canonical || canonicalHint || (until > 0 ? 'Hospital' : (/(Hosp)/i.test(baseText) ? 'Hospital' : null));
let activity = unifiedRecCache?.activity || (memberFromBundle ? (memberFromBundle?.last_action?.status || memberFromBundle?.lastAction?.status || null) : null) || cacheEntry?.activity || activityHint || null;
let dest = unifiedRecCache?.dest || (memberFromBundle ? (utils.buildUnifiedStatusV2(memberFromBundle)?.dest || null) : null) || destHint || null;
const stable = state._opponentStatusStable;
const now = Date.now();
const computeCountdown = () => {
if (until > 0) {
const nowSec = Math.floor(Date.now() / 1000);
const rem = until - nowSec;
if (rem > 0) {
const mm = Math.floor(rem / 60);
const ss = String(rem % 60).padStart(2, '0');
return `${mm}:${ss}`;
}
return null;
}
const match = baseText.match(/(\d+:\d{2})/);
return match ? match[1] : null;
};
const countdown = computeCountdown();
if (!dest && state.ui.opponentStatusCache.unified?.dest) dest = state.ui.opponentStatusCache.unified.dest;
if (dest && /torn\s*(city)?/i.test(dest)) dest = null;
const destAbbrev = dest ? utils.abbrevDest(dest) : '';
const actToken = activity ? utils.abbrevActivity(activity) : null;
// Prefer canonical abbreviation when it conveys more useful information
// than a generic baseText like 'Okay'. If canonical is meaningful
// (e.g., 'Abroad' -> 'Abrd'), prefer it over baseText.
const canonAbbrev = canon ? utils.abbrevStatus(canon) : null;
const fallbackText = (canonAbbrev && String(canonAbbrev).toLowerCase() !== 'okay') ? (canonAbbrev || baseText) : (baseText || (canonAbbrev || 'Okay'));
// Helper to avoid duplicating similar short tokens (e.g. 'Ok' vs 'Okay')
const shouldAppendActivity = (base, token) => {
if (!base || !token) return false;
// normalize into meaningful word tokens, drop common connectors like 'now'
const stop = new Set(['now','last','action','the','a','an','to','in','on','of']);
const normalizeTokens = (txt) => String(txt||'').toLowerCase().replace(/[^a-z\s]/g, ' ').split(/\s+/).filter(Boolean).map(w => (w==='ok' ? 'okay' : w)).filter(w => !stop.has(w));
const bTokens = normalizeTokens(base);
const tTokens = normalizeTokens(token);
if (!bTokens.length || !tTokens.length) return true;
// If every token in token is present or contained in a base token, treat as redundant
const allContained = tTokens.every(t => bTokens.some(b => b === t || b.includes(t) || t.includes(b) || (b.startsWith('ok') && t.startsWith('ok'))));
return !allContained;
};
let composed = '';
if (canon === 'HospitalAbroad') {
const baseLabel = (destAbbrev ? `${destAbbrev} Hosp` : 'Abrd Hosp').trim();
const label = countdown ? `${baseLabel} ${countdown}` : baseLabel;
// For hospital states prefer the hospital label/countdown only — activity token isn't useful here
composed = label;
} else if (canon === 'Hospital') {
const baseLabel = 'Hosp';
const label = countdown ? `${baseLabel} ${countdown}` : baseLabel;
// For hospital states prefer the hospital label/countdown only — activity token isn't useful here
composed = label;
} else if (canon === 'Abroad') {
const baseLabel = destAbbrev || 'Abrd';
composed = (actToken && shouldAppendActivity(baseLabel, actToken)) ? `${baseLabel} ${actToken}` : baseLabel;
} else {
let baseLabel = fallbackText;
if (canon) {
const abbrev = utils.abbrevStatus(canon);
if (abbrev) baseLabel = abbrev;
}
if (/^Hosp/i.test(baseLabel) && countdown) {
baseLabel = `${baseLabel.replace(/\s+\d+:\d{2}.*/, '')} ${countdown}`.trim();
}
composed = actToken ? `${baseLabel} ${actToken}` : baseLabel;
}
if (!composed || !composed.trim()) {
composed = fallbackText || 'Okay';
}
composed = composed.replace(/\s+/g, ' ').trim();
const changed = (
(canon && (canon !== stable.lastCanon || activity !== stable.lastActivity || oppId !== stable.lastId || (dest || null) !== (stable.lastDest || null))) ||
(!canon && stable.lastCanon != null)
);
const since = now - (stable.lastRendered || 0);
if (changed || since >= state._opponentStatusMinRenderIntervalMs || since >= state._opponentStatusForceIntervalMs) {
stable.lastCanon = canon || null;
stable.lastActivity = activity || null;
stable.lastRendered = now;
stable.lastId = oppId;
stable.lastDest = dest || null;
const href = `https://www.torn.com/loader.php?sid=attack&user2ID=${oppId}`;
const valueEl = state.ui.opponentStatusValueEl || state.ui.opponentStatusEl.querySelector('.tdm-opponent-status-value');
if (valueEl) {
// Compute opponent display name (prefer seeded dibsData, then snapshot, faction members, session cache, DOM)
// Persist chosen name into opponentStatusCache to avoid rapid source-flip flicker.
let oppNameFull = '';
// Simplified name resolution: prefer seeded dibs name, then session cache, then snapshot, else fallback to ID.
try {
const seeded = state.ui.opponentStatusCache?.name;
const sessionName = state.session?.userStatusCache?.[oppId]?.name;
const snap = state.rankedWarTableSnapshot?.[oppId];
const snapName = snap ? (snap.name || snap.username || null) : null;
oppNameFull = String(seeded || sessionName || snapName || `ID ${oppId}` || `ID ${oppId}`);
} catch (_) {
oppNameFull = `ID ${oppId}`;
}
// Store chosen name on the opponentStatusCache so subsequent renders are stable
try {
state.ui.opponentStatusCache = state.ui.opponentStatusCache || {};
state.ui.opponentStatusCache.name = oppNameFull;
} catch(_) {}
// Expose sanitized name on the wrapper element to make it available to other handlers
try {
if (state.ui.opponentStatusEl && state.ui.opponentStatusEl.dataset) state.ui.opponentStatusEl.dataset.opponentName = oppNameFull;
} catch(_) {}
// Limit display name to 12 chars for badge
let displayName = String(oppNameFull || '').trim();
const displayShort = displayName.length > 12 ? displayName.slice(0, 12) + '\u2026' : displayName;
// Attempt to find last-action timestamp from various caches.
// Prefer authoritative sources (status from API), then faction member bundle (tornFactionData / unified),
// then session cache, snapshot, and finally seeded dibsData cache.
let lastActionTs = 0;
try {
lastActionTs = Number(status?.last_action?.timestamp || status?.lastAction?.timestamp || 0) || 0;
// If not present on status, try to read from the faction bundle member record
if (!lastActionTs) {
const oppFacId = state.ui.opponentStatusCache?.opponentFactionId || state.lastOpponentFactionId || state?.warData?.opponentFactionId || null;
if (oppFacId) {
const bundle = state.tornFactionData?.[oppFacId];
const membersObj = bundle?.data?.members || bundle?.data?.member || null;
const membersArr = membersObj ? (Array.isArray(membersObj) ? membersObj : Object.values(membersObj)) : null;
const mm = membersArr ? membersArr.find(x => String(x.id) === String(oppId)) : null;
if (mm) lastActionTs = Number(mm?.last_action?.timestamp || mm?.lastAction?.timestamp || 0) || 0;
}
}
// session cache then snapshot then seeded cache
if (!lastActionTs) lastActionTs = Number(state.session?.userStatusCache?.[oppId]?.last_action?.timestamp || state.session?.userStatusCache?.[oppId]?.lastAction?.timestamp || 0) || 0;
if (!lastActionTs) lastActionTs = Number(state.rankedWarTableSnapshot?.[oppId]?.last_action?.timestamp || state.rankedWarTableSnapshot?.[oppId]?.lastAction?.timestamp || 0) || 0;
if (!lastActionTs) lastActionTs = Number(state.ui.opponentStatusCache?.lastActionTs || 0) || 0;
} catch(_) { lastActionTs = Number(state.ui.opponentStatusCache?.lastActionTs || 0) || 0; }
// Normalize ms -> seconds if needed
if (lastActionTs > 1e12) lastActionTs = Math.floor(lastActionTs / 1000);
else if (lastActionTs > 1e11) lastActionTs = Math.floor(lastActionTs / 1000);
// Build badge nodes: username (limited), last-action inline, then composed status
try {
valueEl.innerHTML = '';
const nameNode = document.createElement('span');
nameNode.className = 'tdm-opponent-name';
nameNode.textContent = displayShort;
nameNode.title = displayName;
nameNode.style.fontWeight = '700';
// Keep tight spacing — reduce name->status gap
nameNode.style.marginRight = '4px';
nameNode.style.maxWidth = '140px';
nameNode.style.overflow = 'hidden';
nameNode.style.textOverflow = 'ellipsis';
nameNode.style.whiteSpace = 'nowrap';
const lastEl = document.createElement('span');
lastEl.className = 'tdm-last-action-inline';
// Always include the element to keep badge layout stable and
// provide a consistent click target. Store timestamp or '0'.
if (lastActionTs && Number.isFinite(lastActionTs) && lastActionTs > 0) {
lastEl.setAttribute('data-last-ts', String(Math.floor(lastActionTs)));
try { lastEl.textContent = utils.formatAgoShort(lastActionTs) || ''; } catch(_) { lastEl.textContent = ''; }
try { lastEl.title = `Last Action: ${utils.formatAgoLong ? utils.formatAgoLong(lastActionTs) : ''}`; } catch(_) { lastEl.title = ''; }
} else {
lastEl.setAttribute('data-last-ts', '0');
lastEl.textContent = '';
lastEl.title = '';
}
lastEl.style.display = 'inline-block';
lastEl.style.marginLeft = '4px';
lastEl.style.marginRight = '4px';
lastEl.style.cursor = 'pointer';
// Click should send last action details to chat (if supported)
try {
lastEl.onclick = (e) => {
try { e.preventDefault(); e.stopPropagation(); } catch(_) {}
const tsAttr = lastEl.getAttribute('data-last-ts');
const short = tsAttr && Number(tsAttr) > 0 ? (utils.formatAgoShort ? utils.formatAgoShort(Number(tsAttr)) : '') : '';
const statusLabelForChat = activity || (canon ? utils.abbrevStatus(canon) : null);
ui.sendLastActionToChat && ui.sendLastActionToChat(oppId, oppNameFull, statusLabelForChat || null, short || null);
};
} catch(_) {}
// Attempt to attach color class based on cached data
try {
// Try several places for an authoritative member object so last-action color can be applied.
const mem = Array.isArray(state.factionMembers) ? state.factionMembers.find(m => String(m.id) === String(oppId)) : null;
if (mem && utils.addLastActionStatusColor) {
utils.addLastActionStatusColor(lastEl, mem);
} else {
// Fallback: check seeded opponent faction bundle (tornFactionData) for member details
try {
const oppFacId = state.ui.opponentStatusCache?.opponentFactionId || null;
if (oppFacId) {
const bundle = state.tornFactionData?.[oppFacId];
const membersObj = bundle?.data?.members || bundle?.data?.member || null;
const membersArr = membersObj ? (Array.isArray(membersObj) ? membersObj : Object.values(membersObj)) : null;
const m2 = membersArr ? membersArr.find(x => String(x.id) === String(oppId)) : null;
if (m2 && utils.addLastActionStatusColor) utils.addLastActionStatusColor(lastEl, m2);
}
} catch(_) { /* ignore */ }
}
} catch(_) {}
const statusNode = document.createElement('span');
statusNode.className = 'tdm-opponent-status-text';
statusNode.style.color = '#9dd6ff';
statusNode.textContent = composed;
valueEl.appendChild(nameNode);
valueEl.appendChild(lastEl);
valueEl.appendChild(statusNode);
// When any part of the opponent badge is clicked open a detailed dib popup
try {
if (state.ui.opponentStatusEl) {
state.ui.opponentStatusEl.onclick = (e) => {
try { e.preventDefault(); e.stopPropagation(); } catch(_) {}
ui.openOpponentPopup && ui.openOpponentPopup(oppId, oppNameFull);
};
}
} catch(_) {}
} catch (ex) {
// Fallback to original simple display
valueEl.innerHTML = `<span style="color:#9dd6ff;text-decoration:underline;">${composed}</span>`;
}
}
ui._orchestrator.setDisplay(state.ui.opponentStatusEl, 'inline-flex');
}
};
tick();
state.ui.opponentStatusIntervalId = utils.registerInterval(setInterval(tick, 1000));
},
_ensureRankedWarRowInlineOrder: (row, subrow) => {
if (!row || !subrow) return;
try {
const notesBtnEl = subrow.querySelector('.note-button');
let la = subrow.querySelector('.tdm-last-action-inline');
if (!la) {
la = utils.createElement('span', { className: 'tdm-last-action-inline', textContent: '' });
try {
la.style.cursor = 'pointer';
la.dataset.tdmLastClick = '1';
la.onclick = (e) => {
try {
e.preventDefault();
e.stopPropagation();
const link = row.querySelector('a[href*="profiles.php?XID="]');
const id = link ? (link.href.match(/[?&]XID=(\d+)/i) || [])[1] : null;
const name = link ? utils.sanitizePlayerName(utils.extractPlayerNameFromAnchor(link), id) : null;
const ts = Number(la.getAttribute('data-last-ts') || 0);
const short = utils.formatAgoShort(ts) || '';
const status = (row && utils.getActivityStatusFromRow) ? utils.getActivityStatusFromRow(row) : null;
ui.sendLastActionToChat(id, name, status, short);
} catch (_) {}
};
} catch (_) {}
if (notesBtnEl) notesBtnEl.insertAdjacentElement('afterend', la);
else subrow.appendChild(la);
} else if (notesBtnEl && la.previousElementSibling !== notesBtnEl) {
notesBtnEl.insertAdjacentElement('afterend', la);
}
if (la) {
let eta = subrow.querySelector('.tdm-travel-eta');
if (!eta) {
eta = utils.createElement('span', { className: 'tdm-travel-eta', textContent: '' });
la.insertAdjacentElement('afterend', eta);
} else if (eta.previousElementSibling !== la) {
la.insertAdjacentElement('afterend', eta);
}
}
const lastActionRow = row.querySelector('.last-action-row');
if (lastActionRow) {
try {
const raw = (lastActionRow.textContent || '').trim();
const cleaned = raw.replace(/^Last Action:\s*/i, '').trim();
if (cleaned) row.setAttribute('data-last-action', cleaned);
lastActionRow.remove();
} catch (_) {}
}
} catch (_) {}
},
_ensureRankedWarRowSkeleton: (row, isCurrentTableOurFaction) => {
if (!row) return { subrow: null, existed: false };
let subrow = row.querySelector('.dibs-notes-subrow');
const existed = !!subrow;
if (!subrow) subrow = utils.createElement('div', { className: 'dibs-notes-subrow' });
if (!isCurrentTableOurFaction) {
if (!subrow.querySelector('.dibs-btn')) {
subrow.appendChild(utils.createElement('button', { className: 'btn tdm-btn dibs-btn btn-dibs-inactive tdm-soften', textContent: 'Dibs' }));
}
if (!subrow.querySelector('.btn-med-deal-default')) {
subrow.appendChild(utils.createElement('button', { className: 'btn tdm-btn btn-med-deal-default tdm-soften', textContent: 'Med Deal', style: { display: 'none' } }));
}
utils.ensureNoteButton(subrow, { compact: true, withLabel: true });
let retalContainer = subrow.querySelector('.tdm-retal-container');
if (!retalContainer) {
// Use a non-growing flex container constrained to the subrow so it
// can't push past the row on narrow viewports (PDA). Ensure it can
// shrink when space is tight by allowing min-width:0.
retalContainer = utils.createElement('div', { className: 'tdm-retal-container', style: { flex: '0 0 auto', display: 'flex', justifyContent: 'flex-end', maxWidth: '100%', minWidth: '0', boxSizing: 'border-box' } });
subrow.appendChild(retalContainer);
}
if (!retalContainer.querySelector('.retal-btn')) {
retalContainer.appendChild(utils.createElement('button', { className: 'btn tdm-btn retal-btn tdm-soften', textContent: 'Retal', style: { display: 'none' } }));
}
} else {
utils.ensureNoteButton(subrow, { disabled: true, compact: true, withLabel: true });
}
if (!existed) row.appendChild(subrow);
ui._ensureRankedWarRowInlineOrder(row, subrow);
return { subrow, existed };
},
_buildRankedWarMemberLookup: (data) => {
const lookup = new Map();
if (!data) return lookup;
const members = data.members || data.faction?.members || null;
if (!members) return lookup;
const memberArray = Array.isArray(members) ? members : Object.values(members);
memberArray.forEach(member => {
const id = String(member?.id || member?.player_id || member?.user_id || member?.player?.id || '');
if (id) lookup.set(id, member);
});
return lookup;
},
_updateRankedWarNotesButton: (subrow, opponentId, opponentName) => {
if (!subrow || !opponentId) return;
const notesBtn = subrow.querySelector('.note-button');
if (!notesBtn) return;
const userNote = (state.userNotes && typeof state.userNotes === 'object') ? state.userNotes[opponentId] : null;
const noteText = userNote?.noteContent || '';
utils.updateNoteButtonState(notesBtn, noteText);
if (notesBtn.title !== noteText) notesBtn.title = noteText;
if (notesBtn.getAttribute('aria-label') !== noteText) notesBtn.setAttribute('aria-label', noteText);
notesBtn.onclick = (e) => ui.openNoteModal(opponentId, opponentName, noteText, e.currentTarget);
notesBtn.disabled = false;
},
_updateRankedWarDibsAndMedDeal: (subrow, opponentId, opponentName) => {
if (!subrow || !opponentId) return;
const dibsBtn = subrow.querySelector('.dibs-btn');
if (dibsBtn && utils.updateDibsButton) utils.updateDibsButton(dibsBtn, opponentId, opponentName);
let medDealBtn = subrow.querySelector('.btn-med-deal-default');
if (!medDealBtn) {
try {
medDealBtn = utils.createElement('button', { className: 'btn tdm-btn btn-med-deal-default tdm-soften', textContent: 'Med Deal', style: { display: 'none' } });
const dibsBtnForInsert = subrow.querySelector('.dibs-btn');
const notesBtnForInsert = subrow.querySelector('.note-button');
if (dibsBtnForInsert && dibsBtnForInsert.nextSibling) {
dibsBtnForInsert.parentNode.insertBefore(medDealBtn, dibsBtnForInsert.nextSibling);
} else if (notesBtnForInsert) {
notesBtnForInsert.parentNode.insertBefore(medDealBtn, notesBtnForInsert);
} else {
subrow.appendChild(medDealBtn);
}
} catch (_) {}
}
if (medDealBtn && utils.updateMedDealButton) utils.updateMedDealButton(medDealBtn, opponentId, opponentName);
},
_updateRankedWarRetalButton: (row, subrow, opponentId, opponentName) => {
if (!row || !subrow || !opponentId) return;
const retalBtn = subrow.querySelector('.retal-btn');
if (!retalBtn) return;
const meta = state.rankedWarChangeMeta[opponentId];
const hasAlert = !!(meta && (meta.activeType || meta.pendingText));
if (!hasAlert) ui.updateRetaliationButton(retalBtn, opponentId, opponentName);
},
_updateRankedWarTravelStatus: (row, subrow, opponentId, opponentName) => {
if (!row || !subrow || !opponentId) return;
try {
const unified = state.unifiedStatus?.[opponentId] || null;
let etaEl = subrow.querySelector('.tdm-travel-eta');
if (!unified || !unified.canonical) {
if (etaEl && etaEl.parentNode) etaEl.parentNode.removeChild(etaEl);
return;
}
const canon = unified.canonical;
const dest = unified.dest || '';
const mins = unified.durationMins || 0;
const plane = unified.plane || '';
const prevUnified = state._previousUnifiedStatus?.[opponentId] || null;
if (plane) { utils.ensureBusinessUpgrade(unified, unified.id, { prevRec: prevUnified }); }
const confidence = unified.confidence || 'LOW';
const arrivalMs = Number(unified.arrivalMs) || 0;
const isTravel = canon === 'Travel';
const isReturning = canon === 'Returning';
const isAbroad = canon === 'Abroad';
const isHospAbroad = canon === 'HospitalAbroad';
const isLanded = unified.landedTornRecent || unified.landedAbroadRecent || unified.landedGrace;
if (!(isTravel || isReturning || isAbroad || isHospAbroad || isLanded)) {
if (etaEl && etaEl.parentNode) etaEl.parentNode.removeChild(etaEl);
return;
}
if (!etaEl) {
etaEl = document.createElement('span');
etaEl.className = 'tdm-travel-eta';
try { etaEl.dataset.oppId = String(opponentId); } catch (_) {}
const inline = subrow.querySelector('.tdm-last-action-inline');
if (inline && inline.nextSibling) inline.parentNode.insertBefore(etaEl, inline.nextSibling);
else if (inline) inline.parentNode.appendChild(etaEl);
else subrow.appendChild(etaEl);
}
let line = '';
if (isAbroad) {
line = `Abroad${dest ? ' ' + utils.abbrevDest(dest) : ''}`;
} else if (isHospAbroad) {
line = `HospAbroad${dest ? ' ' + utils.abbrevDest(dest) : ''}`;
} else if (isLanded) {
const arrow = unified.isreturn ? '\u2190' : '\u2192';
line = `${arrow} ${utils.abbrevDest(dest) || dest} Landed`;
} else if (isTravel || isReturning) {
const arrow = isReturning || unified.isreturn ? '\u2190' : '\u2192';
if (confidence === 'HIGH' && arrivalMs) {
const now = Date.now();
let remMs = arrivalMs - now;
if (remMs < 0) remMs = 0;
const remTotalMin = Math.ceil(remMs / 60000);
const rh = Math.floor(remTotalMin / 60);
const rm = remTotalMin % 60;
const remStr = rh > 0 ? `${rh}h${rm ? ' ' + rm + 'm' : ''}` : `${remTotalMin}m`;
line = `${arrow} ${utils.abbrevDest(dest) || dest} LAND~${remStr}`.trim();
} else {
const durStr = mins > 0 ? (Math.floor(mins / 60) ? `${Math.floor(mins / 60)}h${mins % 60 ? ' ' + (mins % 60) + 'm' : ''}` : `${mins}m`) : '?';
line = `${arrow} ${utils.abbrevDest(dest) || dest} dur. ${durStr}`.trim();
}
}
line = line.replace(/\s+/g, ' ').trim();
if (etaEl.textContent !== line) etaEl.textContent = line;
if (confidence === 'HIGH') {
etaEl.classList.remove('tdm-travel-lowconf');
etaEl.classList.add('tdm-travel-conf');
} else {
etaEl.classList.remove('tdm-travel-conf');
etaEl.classList.add('tdm-travel-lowconf');
}
let tooltip = '';
if (isLanded && unified.landedatms) {
const landedLocal = new Date(unified.landedatms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
tooltip = `${line} * ${plane} * Landed ${landedLocal}`;
} else if (mins > 0) {
tooltip = `${line} * ${plane} * Flight ${mins}m`;
} else {
tooltip = line;
}
if (etaEl.title !== tooltip) etaEl.title = tooltip;
try {
const shouldHaveHandler = !(isAbroad || isHospAbroad) && (confidence === 'HIGH') && (isTravel || isReturning) && !!arrivalMs;
if (shouldHaveHandler) {
if (!etaEl._tdmTravelClickHandler) {
etaEl._tdmTravelClickHandler = function (ev) {
try {
if (isAbroad || isHospAbroad) return;
if (confidence !== 'HIGH' || !(isTravel || isReturning) || !arrivalMs) return;
const minsLeft = Math.max(0, Math.round((arrivalMs - Date.now()) / 60000));
const flightStr = minsLeft > 0 ? `${minsLeft}m left` : 'Landed';
const etaUtc = arrivalMs ? new Date(arrivalMs).toUTCString().split(' ')[4].slice(0, 5) : '';
const newContent = `Travel: ${utils.abbrevDest(dest) || dest} - ${flightStr}${etaUtc ? ' (ETA UTC ' + etaUtc + ')' : ''}`;
const userLink = row.querySelector('a[href*="profiles.php?XID="]');
const resolvedOpponentName = userLink?.textContent?.trim() || opponentName || '';
ui.openNoteModal(opponentId, resolvedOpponentName, newContent, etaEl);
} catch (e) { tdmlogger('error', '[travel eta click err]', e); }
};
etaEl.addEventListener('click', etaEl._tdmTravelClickHandler);
}
etaEl._tdmTravelClickBound = true;
} else {
if (etaEl._tdmTravelClickHandler) {
try { etaEl.removeEventListener('click', etaEl._tdmTravelClickHandler); } catch (_) {}
etaEl._tdmTravelClickHandler = null;
}
etaEl._tdmTravelClickBound = false;
}
} catch (_) { /* ignore travel handler attach/remove issues */ }
} catch (_) { /* ignore travel icon errors */ }
},
processRankedWarTables: async () => {
const factionTables = state.dom.rankwarfactionTables;
if (!factionTables || !factionTables.length) {
return;
}
tdmlogger('debug', '[processRankedWarTables] found tables', {factionTablesCount: factionTables.length});
state.script.hasProcessedRankedWarTables = true;
const ourFactionName = state.factionPull?.name;
const opponentFactionLink = state.dom.rankBox?.querySelector('.nameWp___EX6gT .opponentFactionName___vhESM');
const currentFactionLink = state.dom.rankBox?.querySelector('.nameWp___EX6gT .currentFactionName___eq7n8');
const opponentFactionName = opponentFactionLink ? opponentFactionLink.textContent.trim() : 'N/A';
const currentFactionName = currentFactionLink ? currentFactionLink.textContent.trim() : 'N/A';
const overlayScopes = [];
const favoritesEnabled = ui._areRankedWarFavoritesEnabled();
const ffscouterIds = new Set();
factionTables.forEach(tableContainer => {
overlayScopes.push(tableContainer);
let isCurrentTableOurFaction = false;
const tableFactionId = ui._resolveRankedWarTableFactionId(tableContainer);
if (tableFactionId != null && tableContainer?.dataset) {
tableContainer.dataset.factionId = String(tableFactionId);
}
if (tableContainer.classList.contains('left')) {
if (ourFactionName && opponentFactionName === ourFactionName) isCurrentTableOurFaction = true;
} else if (tableContainer.classList.contains('right')) {
if (ourFactionName && currentFactionName === ourFactionName) isCurrentTableOurFaction = true;
}
tableContainer.querySelectorAll('.members-list > li, .members-cont > li').forEach(row => {
const userLink = row.querySelector('a[href*="profiles.php?XID="]');
if (!userLink) {
tdmlogger('debug', '[processRankedWarTables] Row skipped: no userLink', row);
return;
}
// Extract user ID
const match = userLink.href.match(/XID=(\d+)/);
const userId = match ? match[1] : null;
if (userId) ffscouterIds.add(userId);
// tdmlogger('debug', '[processRankedWarTables] Processing row', { userId, isCurrentTableOurFaction });
// (Add a log if timeline/status update would be called here)
// Example: if (shouldUpdateTimeline) { tdmlogger('debug', '[processRankedWarTables] Would update timeline for', userId); }
const skeleton = ui._ensureRankedWarRowSkeleton(row, isCurrentTableOurFaction);
const subrow = skeleton.subrow;
if (!subrow) {
tdmlogger('debug', '[processRankedWarTables] Row skipped: skeleton failed', { userId });
return;
}
if (favoritesEnabled && userId && tableFactionId) {
try { ui._ensureRankedWarFavoriteHeart(subrow, userId, tableFactionId); } catch(_) {}
}
});
try { ui._ensureRankedWarDefaultSort(tableContainer); } catch(_) {}
try { ui._ensureRankedWarSortObserver(tableContainer); } catch(_) {}
});
try { ui._refreshRankedWarSortForAll(); } catch(_) {}
if (favoritesEnabled) {
try { ui.requestRankedWarFavoriteRepin?.({ delays: [0, 150, 400] }); } catch(_) {}
}
// Trigger FFScouter fetch for collected IDs
if (ffscouterIds.size > 0) {
api.fetchFFScouterStats?.(Array.from(ffscouterIds));
}
ui._ensureRankedWarOverlaySortHandlers(state.dom.rankwarContainer || document);
ui._ensureRankedWarSortHandlers(state.dom.rankwarContainer || document);
try { ui.queueLevelOverlayRefresh({ scopes: overlayScopes, reason: 'ranked-war-process' }); } catch(_) {}
ui._renderEpoch.schedule(); // Gate updates through render epochs for stability
// Ensure observers/tickers are running
try { ui.ensureRankedWarAlertObserver(); } catch(_) {}
try { ui.ensureActivityAlertTicker && ui.ensureActivityAlertTicker(); } catch(_) {}
try { ui.ensureLastActionTicker && ui.ensureLastActionTicker(); } catch(_) {}
// Seed faction bundles for both visible factions on this page exactly once per render
try {
const vis = utils.getVisibleRankedWarFactionIds?.();
const ids = (vis && vis.ids) ? vis.ids.filter(Boolean) : [];
if (ids.length) {
const now = Date.now();
const last = state.script.lastProcessTableBundleMs || 0;
const cadence = state.script.factionBundleRefreshMs || config.DEFAULT_FACTION_BUNDLE_REFRESH_MS;
if (!last || (now - last) >= Math.max(config.MIN_GLOBAL_FETCH_INTERVAL_MS || 2000, cadence / 3)) {
state.script.lastProcessTableBundleMs = now;
api.refreshFactionBundles?.({ pageFactionIds: ids, source: 'rankedWarTable' }).catch(e => {
try { tdmlogger('warn', `[processRankedWarTables] bundle refresh failed: ${e}`); } catch(_) {}
});
}
}
} catch(_) { /* noop */ }
},
processFactionPageMembers: async (container) => {
const members = container.querySelectorAll('.table-body > li.table-row');
if (!members.length) {
return;
}
const factionId = ui._getFactionIdForMembersList(container);
if (factionId && container && container.dataset) container.dataset.factionId = String(factionId);
const ffscouterIds = new Set();
const headerUl = container.querySelector('.table-header');
if (headerUl) {
// Ensure a small member-index header exists (for rows that include a per-row index element)
if (!headerUl.querySelector('#col-header-member-index')) {
// Try to insert before the member header if present, otherwise append
const memberEl = headerUl.querySelector('.member');
const idxEl = utils.createElement('li', { id: 'col-header-member-index', className: 'table-cell table-header-column', innerHTML: `<span></span>` });
idxEl.style.minWidth = '3%'; idxEl.style.maxWidth = '3%';
if (memberEl) headerUl.insertBefore(idxEl, memberEl);
else headerUl.appendChild(idxEl);
}
// Ensure separate headers for Dibs/Deals and Notes
if (!headerUl.querySelector('#col-header-dibs-deals')) {
headerUl.appendChild(utils.createElement('li', { id: 'col-header-dibs-deals', className: 'table-cell table-header-column', innerHTML: `<span>Dibs/Deals</span>` }));
}
if (!headerUl.querySelector('#col-header-notes')) {
headerUl.appendChild(utils.createElement('li', { id: 'col-header-notes', className: 'table-cell table-header-column', innerHTML: `<span>Notes</span>` }));
}
}
members.forEach(row => {
// Ensure status cell has marker class for refresh
const statusCell = row.querySelector('.status');
if (statusCell && !statusCell.classList.contains('tdm-status-cell')) {
statusCell.classList.add('tdm-status-cell');
}
let dibsDealsContainer = row.querySelector('.tdm-dibs-deals-container');
if (!dibsDealsContainer) {
dibsDealsContainer = utils.createElement('div', { className: 'table-cell tdm-dibs-deals-container torn-divider divider-vertical' });
const dibsCell = utils.createElement('div', { className: 'dibs-cell' });
dibsCell.appendChild(utils.createElement('button', { className: 'btn tdm-btn dibs-button tdm-soften', textContent: 'Dibs' }));
dibsCell.appendChild(utils.createElement('button', { className: 'btn tdm-btn med-deal-button tdm-soften', style: { display: 'none' }, textContent: 'Med Deal' }));
dibsDealsContainer.appendChild(dibsCell);
row.appendChild(dibsDealsContainer);
}
let notesContainer = row.querySelector('.tdm-notes-container');
if (!notesContainer) {
notesContainer = utils.createElement('div', { className: 'table-cell tdm-notes-container torn-divider divider-vertical' });
const notesCell = utils.createElement('div', { className: 'notes-cell' });
utils.ensureNoteButton(notesCell, { withLabel: true });
notesContainer.appendChild(notesCell);
row.appendChild(notesContainer);
}
try { ui._ensureMembersListFavoriteHeart(row, factionId); } catch(_) {}
const pid = ui._extractPlayerIdFromRow(row);
if (pid) ffscouterIds.add(pid);
});
if (ffscouterIds.size > 0) {
api.fetchFFScouterStats?.(Array.from(ffscouterIds));
}
ui._ensureMembersListOverlaySortHandlers(container);
// Coalesce follow-up UI updates via epoch to avoid immediate flush
ui._renderEpochMembers.schedule();
try { ui.queueLevelOverlayRefresh({ scope: container, reason: 'faction-members-process' }); } catch(_) {}
try { ui._pinFavoritesInFactionList(container); } catch(_) {}
},
// Batches per-row updates and consumes recent-window timeline samples to render stable signals
updateRankedWarUI: async () => {
try {
const now = Date.now();
const metricsRoot = state.metrics || (state.metrics = {});
const tracker = metricsRoot.uiRankedWarUi || (metricsRoot.uiRankedWarUi = { total: 0, perMinute: 0, recent: [], history: [] });
tracker.total = (tracker.total || 0) + 1;
tracker.lastTs = now;
tracker.recent = Array.isArray(tracker.recent) ? tracker.recent.filter(ts => (now - ts) <= 60000) : [];
tracker.recent.push(now);
tracker.perMinute = tracker.recent.length;
tracker.history = Array.isArray(tracker.history) ? tracker.history : [];
tracker.history.push(now);
if (tracker.history.length > 5) tracker.history.splice(0, tracker.history.length - 5);
if (state?.debug?.cadence && (!tracker._lastLog || (now - tracker._lastLog) >= 30000)) {
tracker._lastLog = now;
try { tdmlogger('debug', '[ui.updateRankedWarUI]', { total: tracker.total, perMinute: tracker.perMinute }); } catch (_) {}
}
} catch (_) { /* non-fatal metrics capture */ }
const factionTables = state.dom.rankwarfactionTables;
if (!factionTables) {
return;
}
const favoritesEnabled = ui._areRankedWarFavoritesEnabled();
ui._ensureRankedWarOverlaySortHandlers(state.dom.rankwarContainer || document);
ui._ensureRankedWarSortHandlers(state.dom.rankwarContainer || document);
const ourFactionName = state.factionPull?.name;
const opponentFactionLink = state.dom.rankBox?.querySelector('.nameWp___EX6gT .opponentFactionName___vhESM');
const currentFactionLink = state.dom.rankBox?.querySelector('.nameWp___EX6gT .currentFactionName___eq7n8');
const opponentFactionName = opponentFactionLink ? opponentFactionLink.textContent.trim() : 'N/A';
const currentFactionName = currentFactionLink ? currentFactionLink.textContent.trim() : 'N/A';
// Determine warKey once for this render (used to fetch timeline windows)
let warKey = null;
try {
const pageKeyRaw = utils.getCurrentWarPageKey?.();
warKey = pageKeyRaw ? pageKeyRaw.replace(/[^a-z0-9_\-]/gi, '_') : (state.lastRankWar?.id ? `id_${String(state.lastRankWar.id)}` : null);
} catch(_) { warKey = state.lastRankWar?.id ? `id_${String(state.lastRankWar.id)}` : null; }
factionTables.forEach((tableContainer) => {
let isCurrentTableOurFaction = false;
if (tableContainer.classList.contains('left')) {
if (ourFactionName && opponentFactionName === ourFactionName) isCurrentTableOurFaction = true;
} else if (tableContainer.classList.contains('right')) {
if (ourFactionName && currentFactionName === ourFactionName) isCurrentTableOurFaction = true;
}
const tableFactionId = ui._resolveRankedWarTableFactionId(tableContainer);
if (tableFactionId != null && tableContainer?.dataset) {
tableContainer.dataset.factionId = String(tableFactionId);
}
// Pass 0: Ensure skeleton subrows/placeholders for ALL rows (lightweight, avoids incomplete rows)
try {
const allRows = Array.from(tableContainer.querySelectorAll('.members-list > li, .members-cont > li'));
allRows.forEach(row => {
const userLink = row.querySelector('a[href*="profiles.php?XID="]');
if (!userLink) return;
ui._ensureRankedWarRowSkeleton(row, isCurrentTableOurFaction);
});
} catch(_) { /* skeleton ensure pass */ }
// Collect opponent IDs visible in this container to compute a recent-window stable snapshot in one pass
let idsInContainer = [];
try {
idsInContainer = Array.from(tableContainer.querySelectorAll('.members-list > li a[href*="profiles.php?XID="], .members-cont > li a[href*="profiles.php?XID="]'))
.map(a => (a.href.match(/XID=(\d+)/) || [null, null])[1])
.filter(Boolean);
// Ensure uniqueness
idsInContainer = Array.from(new Set(idsInContainer));
} catch(_) { idsInContainer = []; }
let stableById = {};
try {
if (idsInContainer.length && ui._tdmConfig?.timeline?.enabled) {
const windowMap = {};
stableById = ui._recentWindow?.deriveStableMap(windowMap) || {};
}
} catch(_) { stableById = {}; }
const tf = state.tornFactionData || {};
const visibleFactionIds = utils.getVisibleRankedWarFactionIds?.() || {};
const leftData = visibleFactionIds.leftId ? tf[visibleFactionIds.leftId]?.data : null;
const rightData = visibleFactionIds.rightId ? tf[visibleFactionIds.rightId]?.data : null;
const leftMemberLookup = ui._buildRankedWarMemberLookup(leftData);
const rightMemberLookup = ui._buildRankedWarMemberLookup(rightData);
// Process all rows in this container
const rows = Array.from(tableContainer.querySelectorAll('.members-list > li, .members-cont > li'));
const cap = 120; // safety incase more than 100 members
let processed = 0;
rows.forEach(row => {
if (processed >= cap) return;
processed++;
const userLink = row.querySelector('a[href*="profiles.php?XID="]');
if (!userLink) return;
const opponentId = userLink.href.match(/XID=(\d+)/)[1];
const opponentName = utils.sanitizePlayerName(utils.extractPlayerNameFromAnchor(userLink), opponentId, { fallbackPrefix: 'Opponent' });
let subrow = row.querySelector('.dibs-notes-subrow');
if (!subrow) return;
if (favoritesEnabled && tableFactionId) {
try { ui._ensureRankedWarFavoriteHeart(subrow, opponentId, tableFactionId); } catch(_) {}
}
// Apply compact last-action/status tag derived from timeline recent-window to reduce flicker
try {
const stable = stableById?.[opponentId] || null;
if (stable && stable.tag) {
if (row.getAttribute('data-last-action') !== stable.tag) {
row.setAttribute('data-last-action', stable.tag);
}
}
if (row.hasAttribute('data-refreshing')) row.removeAttribute('data-refreshing');
} catch(_) { /* non-fatal */ }
ui._updateRankedWarNotesButton(subrow, opponentId, opponentName);
if (!isCurrentTableOurFaction) {
ui._updateRankedWarDibsAndMedDeal(subrow, opponentId, opponentName);
ui._updateRankedWarRetalButton(row, subrow, opponentId, opponentName);
}
// --- Our inline last-action renderer (colored) START ---
try {
const inline = row.querySelector('.tdm-last-action-inline');
if (inline) {
const containerIsLeft = !!row.closest('.tab-menu-cont.left, .members-cont.left');
const primaryLookup = containerIsLeft ? leftMemberLookup : rightMemberLookup;
const secondaryLookup = containerIsLeft ? rightMemberLookup : leftMemberLookup;
const member = primaryLookup.get(opponentId) || secondaryLookup.get(opponentId) || null;
const la = member?.last_action || member?.lastAction || null;
const relProvided = (la && (la.relative || la.last_action_relative || la.rel)) ? (la.relative || la.last_action_relative || la.rel) : '';
const ts = Number(la?.timestamp || 0);
const stat = String(la?.status || '').trim();
let cls = 'tdm-last-action-inline';
if (/online/i.test(stat)) cls += ' tdm-la-online';
else if (/idle/i.test(stat)) cls += ' tdm-la-idle';
else if (/offline/i.test(stat)) cls += ' tdm-la-offline';
if (inline.className !== cls) inline.className = cls;
const relStable = Number.isFinite(ts) && ts > 0 ? utils.formatAgoShort(ts) : relProvided;
const txt = relStable || '';
if (inline.textContent !== txt) inline.textContent = txt;
if (Number.isFinite(ts) && ts > 0) {
const prevTs = inline.getAttribute('data-last-ts');
const tsStr = String(ts);
if (prevTs !== tsStr) inline.setAttribute('data-last-ts', tsStr);
const full = `Last Action: ${utils.formatAgoFull(ts)}`;
if (inline.title !== full) inline.title = full;
} else {
inline.removeAttribute('data-last-ts');
if (inline.title) inline.title = '';
}
}
} catch(_) { /* non-fatal */ }
// --- Our inline last-action renderer (colored) END ---
// Refactored: Use only unified status V2 record for travel/status/ETA rendering
ui._updateRankedWarTravelStatus(row, subrow, opponentId, opponentName);
});
try { ui._ensureRankedWarDefaultSort(tableContainer); } catch(_) {}
try { ui._ensureRankedWarSortObserver(tableContainer); } catch(_) {}
});
if (favoritesEnabled) {
try { ui.requestRankedWarFavoriteRepin?.({ delays: [0, 200, 600] }); } catch(_) {}
}
try {
// ui.queueLevelOverlayRefresh({ scopes: Array.from(factionTables), reason: 'ranked-war-update' });
} catch(_) { /* optional overlay refresh */ }
},
// TDM Timeline: simplified config and KV adapter
_tdmConfig: {
timeline: (() => {
const cfg = { enabled: true, sampleEveryMs: 10 * 1000, horizonMs: 36 * 60 * 60 * 1000, maxEntriesPerOpponent: 300 };
try {
const hasStorage = (typeof storage !== 'undefined' && storage && typeof storage.get === 'function');
// Timeline override removed – always treated as disabled for legacy sampler
const override = false; // disables legacy timeline sampling path
cfg.enabled = !!override;
// Sampling override
try {
const sampleMs = null; // legacy sample interval retired
if (Number.isFinite(sampleMs) && sampleMs > 0) cfg.sampleEveryMs = Number(sampleMs);
} catch(_) { /* keep default */ }
} catch(_) { /* keep defaults */ }
return cfg;
})()
},
_kv: {
_db: null,
_idbSupported: (typeof window !== 'undefined') && !!window.indexedDB,
_quotaExceeded: false,
// Approximate size accounting & eviction
_approxBytes: 0, // running estimate of stored value bytes
_keySizes: new Map(), // key -> size bytes
_sizeScanComplete: false,
_evicting: false,
_lastUsageUpdate: 0,
_usageUpdateDebounceMs: 1500,
// Per-op log threshold (raise default to reduce noise)
_logThreshMs: (function(){ try { return Number(storage.get('idbLogThresholdMs', 120)) || 120; } catch(_) { return 120; } })(),
_largeThreshBytes: (function(){ try { return Number(storage.get('idbLargeItemBytes', 200*1024)) || (200*1024); } catch(_) { return 200*1024; } })(),
// Log rate limiting: max lines per window
_logWindowMs: (function(){ try { return Number(storage.get('idbLogWindowMs', 2000)) || 2000; } catch(_) { return 2000; } })(),
_logMaxPerWindow: (function(){ try { return Number(storage.get('idbLogMaxPerWindow', 50)) || 50; } catch(_) { return 50; } })(),
_logWinStart: 0,
_logWinCount: 0,
// Debug auto-off after N ms when state.debug.idbLogs is true
_debugOnSince: 0,
_debugAutoOffMs: (function(){ try { return Number(storage.get('idbDebugAutoOffMs', 30000)) || 30000; } catch(_) { return 30000; } })(),
// listKeys cache (per-prefix TTL)
_listCache: new Map(), // prefix -> { ts, keys }
_listCacheTtlMs: (function(){ try { return Number(storage.get('idbListCacheTtlMs', 60000)) || 60000; } catch(_) { return 60000; } })(),
// In-memory read-through cache
_memCache: new Map(), // key -> { ts, v }
_memTtlMs: (function(){ try { return Number(storage.get('idbMemCacheTtlMs', 3000)) || 3000; } catch(_) { return 3000; } })(),
// Write dedupe and coalescing
_lastWriteHash: new Map(), // key -> stringified value
_pendingSets: new Map(), // key -> { v, timer, promise, resolve }
_coalesceMs: (function(){ try { return Number(storage.get('idbCoalesceMs', 150)) || 150; } catch(_) { return 150; } })(),
_coalesceEnabled: (function(){ try { return storage.get('idbCoalesceEnabled', true) !== false; } catch(_) { return true; } })(),
// Delete the entire tdm-store IndexedDB database
async deleteDb() {
return new Promise((resolve) => {
if (!this._idbSupported) return resolve(false);
try {
// Close the db if open
if (this._db) {
try { this._db.close(); } catch(_) {}
this._db = null;
}
const req = window.indexedDB.deleteDatabase('tdm-store');
req.onsuccess = () => {
this._sizeScanComplete = false;
this._approxBytes = 0;
this._keySizes = new Map();
this._listCache = new Map();
this._memCache = new Map();
resolve(true);
};
req.onerror = () => resolve(false);
req.onblocked = () => resolve(false);
} catch(_) { resolve(false); }
});
},
_sizeOf(v) {
try {
if (v == null) return 0;
if (typeof v === 'string') return v.length;
// estimate JSON size cost
return JSON.stringify(v).length;
} catch(_) { return 0; }
},
_maxBytes() {
try {
const mb = Number(storage.get('tdmIdbMaxSizeMB', '')) || 0; // blank / 0 => unlimited (browser managed)
if (!mb) return 0;
return mb * 1024 * 1024;
} catch(_) { return 0; }
},
_scheduleUsageUpdate() {
try {
const now = Date.now();
if (now - this._lastUsageUpdate < this._usageUpdateDebounceMs) return;
this._lastUsageUpdate = now;
setTimeout(() => { try { ui.updateIdbUsageLine && ui.updateIdbUsageLine(); } catch(_) {} }, 250);
} catch(_) {}
},
async _ensureSizeScan() {
if (this._sizeScanComplete) return;
try {
const db = await this._openDb();
if (!db) { this._sizeScanComplete = true; return; }
const keys = await this.listKeys('');
let total = 0;
// Limit deep scan time – only scan first 200 keys synchronously
const slice = keys.slice(0, 200);
for (const k of slice) {
try {
const v = await this.getItem(k);
const sz = this._sizeOf(v);
this._keySizes.set(k, sz);
total += sz;
} catch(_) {}
}
this._approxBytes = total; // partial (will refine as keys touched)
this._sizeScanComplete = true;
} catch(_) { this._sizeScanComplete = true; }
},
_updateApproxOnSet(key, val, knownSz) {
try {
const sz = (typeof knownSz === 'number') ? knownSz : this._sizeOf(val);
const prev = this._keySizes.get(key) || 0;
this._keySizes.set(key, sz);
this._approxBytes += (sz - prev);
if (this._approxBytes < 0) this._approxBytes = 0;
this._scheduleUsageUpdate();
} catch(_) {}
},
_updateApproxOnRemove(key) {
try {
const prev = this._keySizes.get(key) || 0;
if (prev) this._approxBytes = Math.max(0, this._approxBytes - prev);
this._keySizes.delete(key);
this._scheduleUsageUpdate();
} catch(_) {}
},
async _maybeEvict() {
try {
const maxBytes = this._maxBytes();
if (!maxBytes || this._evicting) return;
if (this._approxBytes <= maxBytes) return;
this._evicting = true;
await this._ensureSizeScan();
const keys = await this.listKeys('');
const items = [];
for (const k of keys) {
let sz = this._keySizes.get(k);
if (sz == null) {
try { const v = await this.getItem(k); sz = this._sizeOf(v); this._keySizes.set(k, sz); } catch(_) { sz = 0; }
}
items.push({ k, sz });
}
// Sort largest first (greedy remove big blobs) – could refine with LRU later
items.sort((a,b) => b.sz - a.sz);
const target = Math.floor(maxBytes * 0.9); // leave headroom
let removed = 0, removedBytes = 0;
for (const it of items) {
if (this._approxBytes <= target) break;
try { await this.removeItem(it.k); removed++; removedBytes += it.sz; } catch(_) {}
}
try { tdmlogger('warn', '[IDB] Evicted ${removed} item(s) (~${(removedBytes/1024).toFixed(1)} KB) to honor max ${Math.round(maxBytes/1024/1024)} MB'); } catch(_) {}
} catch(e) { try { tdmlogger('warn', '[IDB] eviction error', e); } catch(_) {} }
finally { this._evicting = false; this._scheduleUsageUpdate(); }
},
_isDebugOn() {
try { return !!(state && state.debug && state.debug.idbLogs); } catch(_) { return false; }
},
_isSlowLogEnabled() {
try { return !!(typeof storage !== 'undefined' && storage && storage.get('idbSlowLogsEnabled', false)); } catch(_) { return false; }
},
_maybeAutoDisableDebug() {
try {
if (!this._isDebugOn()) { this._debugOnSince = 0; return; }
const now = Date.now();
if (!this._debugOnSince) this._debugOnSince = now;
if (now - this._debugOnSince > this._debugAutoOffMs) {
// Turn off and persist flag off if used
try { state.debug.idbLogs = false; } catch(_) {}
try { window.localStorage && window.localStorage.setItem('tdm.debugIdbLogs', 'false'); } catch(_) {}
this._debugOnSince = 0;
try { tdmlogger('warn', '[IDB] auto-disabled verbose IDB logs after timeout'); } catch(_) {}
}
} catch(_) { /* noop */ }
},
_shouldPerOpLog(dt) {
try {
const verbose = this._isDebugOn();
const slow = this._isSlowLogEnabled();
this._maybeAutoDisableDebug();
// Only log when explicitly enabled (verbose or slow). Threshold-based logging is disabled by default.
if (!verbose && !slow) return false;
if (!verbose && slow && !(dt >= this._logThreshMs)) return false;
const now = Date.now();
if (!this._logWinStart || (now - this._logWinStart) > this._logWindowMs) {
this._logWinStart = now; this._logWinCount = 0;
}
if (this._logWinCount >= this._logMaxPerWindow) return false;
this._logWinCount += 1;
return true;
} catch(_) { return false; }
},
_openDb() {
if (!this._idbSupported) return Promise.resolve(null);
if (this._db) return Promise.resolve(this._db);
const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
return new Promise((resolve) => {
try {
const req = window.indexedDB.open('tdm-store', 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('kv')) db.createObjectStore('kv');
};
req.onsuccess = (e) => {
this._db = e.target.result;
try { ui._stats.idbOps.open += 1; } catch(_) {}
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
const dt = t1 - t0;
if (this._shouldPerOpLog(dt * 0.34)) { tdmlogger('log', `[IDB] open ok in ${dt.toFixed(1)} ms`); }
resolve(this._db);
};
req.onerror = () => {
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
const dt = t1 - t0;
tdmlogger('warn', `[IDB] open failed in ${dt.toFixed(1)} ms`);
resolve(null);
};
} catch(_) { resolve(null); }
});
},
async getItem(key) {
const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
// In-memory cache
try {
const mc = this._memCache.get(key);
if (mc && (Date.now() - mc.ts) < this._memTtlMs) {
return mc.v;
}
} catch(_) {}
try {
const db = await this._openDb();
if (!db) {
try { return window.localStorage.getItem(key); } catch(_) { return null; }
}
return await new Promise((resolve) => {
try {
const tx = db.transaction(['kv'], 'readonly');
const store = tx.objectStore('kv');
const req = store.get(key);
req.onsuccess = (e) => {
const val = e.target.result ?? null;
try {
ui._stats.idbOps.get += 1;
const sz = this._sizeOf(val);
ui._stats.idbBytes.read += sz;
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
const dt = t1 - t0;
if (this._shouldPerOpLog(dt)) { tdmlogger('log', `[IDB] get ${key} in ${dt.toFixed(1)} ms, bytes=${sz}`); }
const warnLarge = this._isDebugOn() || (typeof storage !== 'undefined' && storage && storage.get('idbWarnLarge', false));
if (warnLarge && sz >= this._largeThreshBytes) tdmlogger('warn', `[IDB][large-read] ${key} bytes=${sz}`);
ui._stats.maybeLogIdbSummary();
} catch(_) {}
try { this._memCache.set(key, { ts: Date.now(), v: val }); } catch(_) {}
resolve(val);
};
req.onerror = () => {
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
const dt = t1 - t0;
tdmlogger('warn', `[IDB] get ${key} failed in ${dt.toFixed(1)} ms`);
resolve(null);
};
} catch(_) { resolve(null); }
});
} catch(_) { return null; }
},
async setItem(key, value) {
// Update in-memory cache immediately to keep readers consistent
try { this._memCache.set(key, { ts: Date.now(), v: value }); } catch(_) {}
const doCommit = async (val) => {
const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
try {
const db = await this._openDb();
if (!db) {
try {
const vStr = (typeof val === 'string') ? val : JSON.stringify(val);
// Dedup localStorage writes
const last = this._lastWriteHash.get(key);
if (last === vStr) return;
window.localStorage.setItem(key, vStr);
this._lastWriteHash.set(key, vStr);
try { ui._stats.idbOps.set += 1; ui._stats.idbBytes.write += this._sizeOf(vStr); } catch(_) {}
} catch(err) { this._quotaExceeded = true; }
return;
}
await new Promise((resolve) => {
try {
// Dedup IndexedDB writes by stringifying for hash only
let vStr = null;
try { vStr = (typeof val === 'string') ? val : JSON.stringify(val); } catch(_) { vStr = null; }
const last = this._lastWriteHash.get(key);
if (vStr && last === vStr) { resolve(); return; }
const tx = db.transaction(['kv'], 'readwrite');
const store = tx.objectStore('kv');
const req = store.put(val, key);
req.onsuccess = () => {
try {
if (vStr) this._lastWriteHash.set(key, vStr);
ui._stats.idbOps.set += 1;
const sz = this._sizeOf(val);
ui._stats.idbBytes.write += sz;
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
const dt = t1 - t0;
if (this._shouldPerOpLog(dt)) { tdmlogger('log', `[IDB] set ${key} in ${dt.toFixed(1)} ms, bytes=${sz}`); }
const warnLarge = this._isDebugOn() || (typeof storage !== 'undefined' && storage && storage.get('idbWarnLarge', false));
if (warnLarge && sz >= this._largeThreshBytes) tdmlogger('warn', `[IDB][large-write] ${key} bytes=${sz}`);
ui._stats.maybeLogIdbSummary();
// size accounting & eviction
this._updateApproxOnSet(key, val, sz);
this._maybeEvict();
} catch(_) {}
resolve();
};
req.onerror = () => { this._quotaExceeded = true; resolve(); };
} catch(_) { resolve(); }
});
} catch(_) { /* no-op */ }
};
// Coalesce rapid successive writes per key
if (this._coalesceEnabled && this._coalesceMs > 0) {
const prev = this._pendingSets.get(key);
if (prev) {
// update value and reset timer
prev.v = value;
utils.unregisterTimeout(prev.timer);
prev.timer = utils.registerTimeout(setTimeout(async () => {
try { await doCommit(prev.v); prev.resolve(); } finally { this._pendingSets.delete(key); }
}, this._coalesceMs));
return prev.promise;
}
let resolvePromise;
const p = new Promise((res) => { resolvePromise = res; });
const timer = utils.registerTimeout(setTimeout(async () => {
try { await doCommit(value); resolvePromise(); } finally { this._pendingSets.delete(key); }
}, this._coalesceMs));
this._pendingSets.set(key, { v: value, timer, promise: p, resolve: resolvePromise });
return p;
}
// No coalescing
return doCommit(value);
},
async removeItem(key) {
const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
try {
const db = await this._openDb();
if (!db) {
try { window.localStorage.removeItem(key); } catch(_) {}
return;
}
await new Promise((resolve) => {
try {
const tx = db.transaction(['kv'], 'readwrite');
const store = tx.objectStore('kv');
const req = store.delete(key);
req.onsuccess = () => {
try {
ui._stats.idbOps.remove += 1;
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
const dt = t1 - t0;
if (this._shouldPerOpLog(dt)) { tdmlogger('log', `[IDB] remove ${key} in ${dt.toFixed(1)} ms`); }
ui._stats.maybeLogIdbSummary();
this._updateApproxOnRemove(key);
} catch(_) {}
try { this._memCache.delete(key); this._lastWriteHash.delete(key); } catch(_) {}
resolve();
};
req.onerror = () => resolve();
} catch(_) { resolve(); }
});
} catch(_) { /* noop */ }
},
async listKeys(prefix = '') {
const keys = [];
const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
try {
// Cache hit?
try {
const c = this._listCache.get(prefix);
if (c && (Date.now() - c.ts) < this._listCacheTtlMs) {
// Return a shallow copy to avoid accidental mutation
return [...c.keys];
}
} catch(_) {}
const db = await this._openDb();
if (!db) {
try {
for (let i = 0; i < window.localStorage.length; i++) {
const k = window.localStorage.key(i);
if (!k) continue;
if (!prefix || k.startsWith(prefix)) keys.push(k);
}
} catch(_) {}
try { ui._stats.idbOps.list += 1; } catch(_) {}
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
const dt = t1 - t0;
if (this._shouldPerOpLog(dt)) { tdmlogger('log', `[IDB] listKeys(ls) prefix='${prefix}' in ${dt.toFixed(1)} ms, count=${keys.length}`); }
ui._stats.maybeLogIdbSummary();
try { this._listCache.set(prefix, { ts: Date.now(), keys: [...keys] }); } catch(_) {}
return keys;
}
await new Promise((resolve) => {
try {
const tx = db.transaction(['kv'], 'readonly');
const store = tx.objectStore('kv');
// Prefer getAllKeys when available, else fallback to cursor
if (store.getAllKeys) {
const req = store.getAllKeys();
req.onsuccess = (e) => {
const all = e.target.result || [];
for (const k of all) {
if (!prefix || String(k).startsWith(prefix)) keys.push(String(k));
}
try { ui._stats.idbOps.list += 1; } catch(_) {}
resolve();
};
req.onerror = () => resolve();
} else {
const req = store.openCursor();
req.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
const k = String(cursor.key);
if (!prefix || k.startsWith(prefix)) keys.push(k);
cursor.continue();
} else {
try { ui._stats.idbOps.list += 1; } catch(_) {}
resolve();
}
};
req.onerror = () => resolve();
}
} catch(_) { resolve(); }
});
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
const dt = t1 - t0;
if (this._shouldPerOpLog(dt)) { tdmlogger('log', `[IDB] listKeys prefix='${prefix}' in ${dt.toFixed(1)} ms, count=${keys.length}`); }
ui._stats.maybeLogIdbSummary();
try { this._listCache.set(prefix, { ts: Date.now(), keys: [...keys] }); } catch(_) {}
} catch(_) { /* swallow */ }
return keys;
},
async removeByPrefix(prefix) {
try {
const keys = await this.listKeys(prefix);
for (const k of keys) {
await this.removeItem(k);
}
try { this._listCache.delete(prefix); } catch(_) {}
} catch(_) { /* noop */ }
}
},
// Lightweight persistence helper using localStorage with TTL and optional war scoping
_persist: {
set(key, value, opts = {}) {
try {
const payload = {
v: value,
ts: Date.now(),
warId: opts.warId ?? null,
};
window.localStorage.setItem(key, JSON.stringify(payload));
} catch(_) { /* ignore quota */ }
},
get(key, opts = {}) {
try {
const raw = window.localStorage.getItem(key);
if (!raw) return null;
const payload = JSON.parse(raw);
if (opts.warId != null && payload && payload.warId != null && String(payload.warId) !== String(opts.warId)) {
return null; // scoped to a different war
}
if (opts.maxAgeMs != null && payload && typeof payload.ts === 'number') {
const age = Date.now() - payload.ts;
if (age > opts.maxAgeMs) return null;
}
return payload ? payload.v : null;
} catch(_) { return null; }
},
remove(key) {
try { window.localStorage.removeItem(key); } catch(_) {}
}
},
// Lightweight in-memory stats (opt-in via storage flag) for timeline writes
_stats: {
tl2Writes: 0,
tl2Bytes: 0,
// New: IndexedDB op stats
idbOps: { get: 0, set: 0, remove: 0, list: 0, open: 0 },
idbBytes: { read: 0, write: 0 },
_lastSummary: 0,
maybeLogIdbSummary() {
try {
const now = Date.now();
// Summarize at most every 30s
if (now - (this._lastSummary || 0) < 60000) return;
this._lastSummary = now;
// Only log summaries if verbose debug is on, or explicit flag enabled
try {
const verbose = !!(state && state.debug && state.debug.idbLogs);
const enabled = (typeof storage !== 'undefined' && storage) ? !!storage.get('idbSummaryEnabled', false) : false;
if (!verbose && !enabled) return;
// Suppress when tab hidden or not on ranked war page
if (document.hidden || !(state && state.page && state.page.isRankedWarPage)) return;
} catch(_) {}
const ops = this.idbOps || {};
const bytes = this.idbBytes || {};
const totalOps = (ops.get||0)+(ops.set||0)+(ops.remove||0)+(ops.list||0)+(ops.open||0);
if (!totalOps) return;
tdmlogger('debug', `[IDB][summary]`, { ops, bytes });
// Reset running counters to avoid unbounded growth; keep open count cumulative
this.idbOps.get = 0; this.idbOps.set = 0; this.idbOps.remove = 0; this.idbOps.list = 0;
this.idbBytes.read = 0; this.idbBytes.write = 0;
} catch(_) { /* noop */ }
}
},
// Timeline sampler: collects light-weight status/activity snapshots per opponent
// Timeline logic is now fully unified; legacy tracking, segment writing, and status schema removed.
_timeline: {
// Only unified status records and canonical status mapping are used.
async updateUnifiedStatusRecord({ id, stateStr, descStr, kv, now }) {
// Centralized status update logic; see above for details.
// ...implementation unchanged...
},
async readEntries({ id, kv }) {
// Return current unified status snapshot for compatibility.
try {
const rec = await kv.getItem(`tdm.tl2.status_unified.id_${String(id)}`);
if (!rec) return [];
return [{ t: rec.updated || rec.ct1 || Date.now(), sc: rec.canonical || '', a: '', s: 'u' }];
} catch(_) { return []; }
},
async getRecentWindow({ warKey, ids, kv, windowMs }) {
const out = {};
for (const id of ids) {
out[id] = await this.readEntries({ id, kv });
}
return out;
},
async getStateAt({ id, kv }) {
try {
const rec = await kv.getItem(`tdm.tl2.status_unified.id_${String(id)}`);
if (!rec) return { sc: '', a: '', laTs: 0 };
return { sc: rec.canonical || '', a: '', laTs: 0 };
} catch(_) { return { sc: '', a: '', laTs: 0 }; }
}
},
// --- Render Orchestrator: batch DOM updates to avoid flicker and animation reset ---
_orchestrator: {
_pending: new Map(),
_scheduled: false,
applyOrQueue(el, props) {
if (!el || !props) return;
const prev = this._pending.get(el) || {};
this._pending.set(el, { ...prev, ...props });
if (!this._scheduled) {
this._scheduled = true;
const raf = (typeof window !== 'undefined' && window.requestAnimationFrame) ? window.requestAnimationFrame.bind(window) : null;
(raf || setTimeout)(() => this.flush(), 0);
}
},
_apply(el, props) {
try {
if ('display' in props && el.style.display !== props.display) el.style.display = props.display;
if ('disabled' in props && el.disabled !== props.disabled) el.disabled = props.disabled;
if ('className' in props && el.className !== props.className) el.className = props.className;
if ('textContent' in props && el.textContent !== props.textContent) el.textContent = props.textContent;
if ('innerHTML' in props && el.innerHTML !== props.innerHTML) el.innerHTML = props.innerHTML;
if ('title' in props && el.title !== props.title) el.title = props.title;
if ('style' in props && props.style && typeof props.style === 'object') {
for (const [k, v] of Object.entries(props.style)) {
if (el.style[k] !== v) el.style[k] = v;
}
}
} catch(_) { /* noop */ }
},
flush() {
this._scheduled = false;
const entries = Array.from(this._pending.entries());
this._pending.clear();
for (const [el, props] of entries) {
this._apply(el, props);
}
},
setText(el, text) { this.applyOrQueue(el, { textContent: String(text ?? '') }); },
setClass(el, className) { this.applyOrQueue(el, { className }); },
setTitle(el, title) { this.applyOrQueue(el, { title: String(title ?? '') }); },
setDisplay(el, display) { this.applyOrQueue(el, { display }); },
setDisabled(el, disabled) { this.applyOrQueue(el, { disabled: !!disabled }); },
setStyle(el, styleObj) { this.applyOrQueue(el, { style: styleObj || {} }); },
setHtml(el, html) { this.applyOrQueue(el, { innerHTML: String(html ?? '') }); }
},
// --- Recent-window consumers: produce stable, flicker-resistant row signals ---
_recentWindow: {
// Given a map id => [{t, sc, a, s}], compute a stable representation per id
deriveStableMap(windowMap) {
const out = {};
if (!windowMap || typeof windowMap !== 'object') return out;
const now = Date.now();
for (const [id, entries] of Object.entries(windowMap)) {
if (!Array.isArray(entries) || entries.length === 0) continue;
// Sort by time ascending just in case; guard against large arrays
const arr = entries.slice(-50).sort((x,y) => (x.t||0) - (y.t||0));
const last = arr[arr.length - 1];
// Confidence: prefer RW samples over API within a short window
// Also enforce a minimum age for changes (debounce) to avoid transient blips
const ageMs = now - (last.t || 0);
const recent = arr.filter(e => (now - (e.t||0)) <= 5000);
const hasRecentRw = recent.some(e => e.s === 'rw');
const hasRecentApiOnly = recent.length > 0 && !hasRecentRw;
// Determine blip: if only API changed in the last 3s and disagrees with prior RW sample
let blip = false;
if (hasRecentApiOnly) {
const lastRw = [...arr].reverse().find(e => e.s === 'rw');
if (lastRw && (lastRw.sc !== last.sc || lastRw.a !== last.a) && ((now - lastRw.t) <= 3000)) blip = true;
}
// Derive a compact tag, e.g., "Hosp 3:12", "Abroad·Fly", "Okay", "Jail"
let tag = '';
const sc = last.sc || '';
const a = last.a || '';
// Travel/Hospital formatting heuristics
if (/Hospital/i.test(sc)) {
// Keep simple; viewer may already render countdown elsewhere
tag = 'Hosp';
} else if (/Travel|Abroad/i.test(sc)) {
tag = 'Abroad' + (a ? `·${utils.abbrevActivity(a)}` : '');
} else if (sc) {
tag = utils.abbrevStatus(sc) + (a ? `·${utils.abbrevActivity(a)}` : '');
} else {
tag = a ? utils.abbrevActivity(a) : 'Okay';
}
const confidence = hasRecentRw ? 'high' : (ageMs <= 15000 ? 'medium' : 'low');
out[id] = { tag, confidence, blip, ts: last.t, src: last.s };
}
return out;
}
},
// Lightweight render epoch scheduler to coalesce frequent updates
_renderEpoch: {
_pending: false,
_last: 0,
_minIntervalMs: 150,
schedule() {
if (this._pending) return;
const now = Date.now();
const wait = Math.max(0, this._minIntervalMs - (now - this._last));
this._pending = true;
const cb = async () => {
this._pending = false;
this._last = Date.now();
try { await ui.updateRankedWarUI(); } catch(e) {
try { tdmlogger('error', `[updateRankedWarUI] failed: ${e}`); } catch(_) {}
}
};
const raf = (typeof window !== 'undefined' && window.requestAnimationFrame) ? window.requestAnimationFrame.bind(window) : null;
if (wait === 0) {
(raf || setTimeout)(cb, 0);
} else {
setTimeout(() => (raf || setTimeout)(cb, 0), wait);
}
}
},
// Lightweight render epoch scheduler for Faction Members List updates
_renderEpochMembers: {
_pending: false,
_last: 0,
_minIntervalMs: 150,
schedule() {
if (this._pending) return;
const now = Date.now();
const wait = Math.max(0, this._minIntervalMs - (now - this._last));
this._pending = true;
const cb = async () => {
this._pending = false;
this._last = Date.now();
try {
if (state.dom.factionListContainer) {
await ui.processFactionPageMembers(state.dom.factionListContainer);
ui.updateFactionPageUI(state.dom.factionListContainer);
}
} catch(_) {}
};
const raf = (typeof window !== 'undefined' && window.requestAnimationFrame) ? window.requestAnimationFrame.bind(window) : null;
if (wait === 0) {
(raf || setTimeout)(cb, 0);
} else {
setTimeout(() => (raf || setTimeout)(cb, 0), wait);
}
}
},
// --- Ranked War Alerts: Snapshot and Observer ---
ensureRankedWarAlertObserver: () => {
// Run alerts for any war type. If no war containers/tables are present, disconnect and exit gracefully.
const containers = (state.dom.rankwarfactionTables && state.dom.rankwarfactionTables.length)
? state.dom.rankwarfactionTables
: document.querySelectorAll('.tab-menu-cont');
if (!containers.length) {
try {
if (state._rankedWarObserver) { state._rankedWarObserver.disconnect(); }
if (state._rankedWarScoreObserver) { state._rankedWarScoreObserver.disconnect(); }
} catch(_) { /* noop */ }
state._rankedWarObserver = null;
state._rankedWarScoreObserver = null;
return;
}
const ourFactionName = state.factionPull?.name;
const opponentFactionLink = state.dom.rankBox?.querySelector('.nameWp___EX6gT .opponentFactionName___vhESM');
const currentFactionLink = state.dom.rankBox?.querySelector('.nameWp___EX6gT .currentFactionName___eq7n8');
const opponentFactionName = opponentFactionLink ? opponentFactionLink.textContent.trim() : '';
const currentFactionName = currentFactionLink ? currentFactionLink.textContent.trim() : '';
// Build candidate list (prefer enemy-faction, within war container)
const containerRoot = state.dom.rankwarContainer || document;
const enemyCandidates = Array.from(containerRoot.querySelectorAll('.tab-menu-cont.enemy-faction .members-list, .tab-menu-cont.enemy-faction .members-cont'));
const allCandidates = enemyCandidates.length
? enemyCandidates
: Array.from(containers)
.map(c => c.querySelector('.members-list') || c.querySelector('.members-cont'))
.filter(Boolean);
// Helper to classify if a container is "ours" using label mapping (fallback when class is missing)
const isOurSide = (tabContainer) => {
// Prefer explicit CSS classes if present
if (tabContainer.closest('.tab-menu-cont')?.classList.contains('our-faction')) return true;
if (tabContainer.closest('.tab-menu-cont')?.classList.contains('enemy-faction')) return false;
// Fallback to left/right mapping with labels
const side = tabContainer.closest('.tab-menu-cont')?.classList.contains('left') ? 'left' : (tabContainer.closest('.tab-menu-cont')?.classList.contains('right') ? 'right' : '');
if (side === 'left') return !!(ourFactionName && opponentFactionName === ourFactionName);
if (side === 'right') return !!(ourFactionName && currentFactionName === ourFactionName);
return false; // default to opponent side if unknown
};
// One-time restore of persisted travel meta per load
if (!state._travelMetaRestored) {
state._travelMetaRestored = true;
try { ui._restorePersistedTravelMeta?.(); } catch(_) {}
}
// Prioritize selection:
// 1) opponents with TDM subrows (pick the last to prefer deeper index)
// 2) opponents with any rows (pick the last)
let opponentTableEl = null;
const opponentWithSubrows = allCandidates
.map(members => ({ members, container: members.closest('.tab-menu-cont') }))
.filter(x => x.members.querySelector('li .dibs-notes-subrow') && !isOurSide(x.members));
if (opponentWithSubrows.length) {
const chosen = opponentWithSubrows[opponentWithSubrows.length - 1];
opponentTableEl = chosen.members;
const idx = Array.from(containers).indexOf(chosen.container);
} else {
const opponentAnyRows = allCandidates
.map(members => ({ members, container: members.closest('.tab-menu-cont') }))
.filter(x => x.members.querySelector('> li') && !isOurSide(x.members));
if (opponentAnyRows.length) {
const chosen = opponentAnyRows[opponentAnyRows.length - 1];
opponentTableEl = chosen.members;
const idx = Array.from(containers).indexOf(chosen.container);
}
}
const table = opponentTableEl;
// Determine the actual list element that owns the LIs (some DOMs use div.members-list > ul > li)
const listEl = table && (table.matches('ul') ? table : (table.querySelector(':scope > ul') || table.querySelector('ul') || table));
if (table) {
try {
const cont = table.closest('.tab-menu-cont');
const liCount = listEl ? listEl.querySelectorAll(':scope > li').length : 0;
tdmlogger('debug', `[Observer] selected container class="${cont?.className || ''}" usingListEl=<${(listEl?.tagName || '').toLowerCase()}> rows=${liCount}`);
} catch(_) {}
}
// Attempt to restore prior snapshot and alert meta from local persistence (persist across reloads)
const pageKeyRaw = utils.getCurrentWarPageKey?.();
const pageKey = pageKeyRaw ? pageKeyRaw.replace(/[^a-z0-9_\-]/gi,'_') : null;
const warKey = pageKey || (state.lastRankWar?.id ? `id_${String(state.lastRankWar.id)}` : null);
if (warKey) {
try {
const metaKey = `tdm.rw_meta_${warKey}`;
const snapKey = `tdm.rw_snap_${warKey}`;
const rawMetaObj = storage.get(metaKey);
const rawMeta = (rawMetaObj && rawMetaObj.warId === state.lastRankWar?.id && Date.now() - rawMetaObj.ts < 2 * 60 * 60 * 1000) ? rawMetaObj.v : undefined;
const rawSnapObj = storage.get(snapKey);
const rawSnap = (rawSnapObj && rawSnapObj.warId === state.lastRankWar?.id && Date.now() - rawSnapObj.ts < 2 * 60 * 60 * 1000) ? rawSnapObj.v : undefined;
if (!state.rankedWarChangeMeta && rawMeta) {
if (rawMeta && typeof rawMeta === 'object') state.rankedWarChangeMeta = rawMeta;
}
if ((!state.rankedWarTableSnapshot || Object.keys(state.rankedWarTableSnapshot).length === 0) && rawSnap) {
if (rawSnap && typeof rawSnap === 'object') state.rankedWarTableSnapshot = rawSnap;
}
// Prune expired metas on restore based on TTLs used in rendering
if (state.rankedWarChangeMeta && typeof state.rankedWarChangeMeta === 'object') {
const now = Date.now();
for (const [id, meta] of Object.entries(state.rankedWarChangeMeta)) {
const type = meta?.activeType;
const ttl = type === 'travel' ? 60*60*1000 : ((type === 'retalDone' || type === 'score') ? 60000 : 120000);
if (!type || !meta?.ts || (now - meta.ts) >= ttl) delete state.rankedWarChangeMeta[id];
}
}
} catch(_) { /* ignore restore issues */ }
}
// Initialize snapshot once or when empty
if (!state.rankedWarTableSnapshot || Object.keys(state.rankedWarTableSnapshot).length === 0) {
const snap = {};
const srcPrefInit = 'rw';
const apiOnlyInit = srcPrefInit === 'api';
const tfInit = state.tornFactionData || {};
const oppFactionIdInit = state?.warData?.opponentFactionId || state?.warData?.opponentId || null;
(listEl || table).querySelectorAll(':scope > li').forEach(row => {
const userLink = row.querySelector('a[href*="profiles.php?XID="]');
if (!userLink) return;
const id = userLink.href.match(/XID=(\d+)/)?.[1];
if (!id) return;
// Prefer API-sourced status when timeline source is API-only
let member = null;
try {
if (oppFactionIdInit && tfInit[oppFactionIdInit]?.data?.members) {
const arr = Array.isArray(tfInit[oppFactionIdInit].data.members)
? tfInit[oppFactionIdInit].data.members
: Object.values(tfInit[oppFactionIdInit].data.members);
member = arr.find(x => String(x.id) === String(id)) || null;
}
} catch(_) {}
const domStatusInit = apiOnlyInit ? '' : utils.getStatusTextFromRow(row);
const statusObjInit = member?.status;
const statusTextInit = statusObjInit?.description || domStatusInit || '';
snap[id] = {
status: statusTextInit,
activity: utils.getActivityStatusFromRow(row),
retal: !!state.retaliationOpportunities[id],
ts: Date.now()
};
});
state.rankedWarTableSnapshot = snap;
tdmlogger('debug', `[Observer] snapshot initialized for ${Object.keys(snap).length} rows`);
// Initialize scoreboard snapshot from lastRankWar factions (storage authoritative)
try {
const lw = state.lastRankWar;
if (lw && Array.isArray(lw.factions)) {
const ourFac = lw.factions.find(f => String(f.id) === String(state.user.factionId));
const oppFac = lw.factions.find(f => String(f.id) !== String(state.user.factionId));
if (ourFac || oppFac) {
const snap = { opp: Number(oppFac?.score || 0), our: Number(ourFac?.score || 0) };
state.rankedWarScoreSnapshot = snap;
tdmlogger('debug', `[Observer] score snapshot init (lastRankWar) opp=${snap.opp} our=${snap.our}`);
}
}
} catch(_) { /* noop */ }
}
// Build a throttled scanner to avoid floods
const runScan = () => {
const scanTime = new Date().toLocaleTimeString();
let anyChange = false;
const rowsAll = (listEl || table).querySelectorAll(':scope > li');
// Scan all rows
const rows = Array.from(rowsAll);
let loggedCount = 0;
// Check scoreboard changes once per scan and create alerts before row loop
try {
// Use lastRankWar updates (assumed refreshed by polling) as source of truth
const lw = state.lastRankWar;
if (lw && Array.isArray(lw.factions)) {
const ourFac = lw.factions.find(f => String(f.id) === String(state.user.factionId));
const oppFac = lw.factions.find(f => String(f.id) !== String(state.user.factionId));
if (ourFac || oppFac) {
const nextScores = { opp: Number(oppFac?.score || 0), our: Number(ourFac?.score || 0) };
const hadBoth = !!(ourFac && oppFac);
const prevScores = state.rankedWarScoreSnapshot || { opp: null, our: null };
const prevOpp = (prevScores.opp == null ? nextScores.opp : prevScores.opp);
const prevOur = (prevScores.our == null ? nextScores.our : prevScores.our);
// If we don't yet have both factions resolved, delay baseline stamping to avoid false +0 logs
if (!hadBoth) {
// Still persist partial so that when other side appears we can compute properly
state.rankedWarScoreSnapshot = { opp: prevOpp, our: prevOur };
} else {
let deltaOpp = Math.max(0, nextScores.opp - (prevOpp || 0));
let deltaOur = Math.max(0, nextScores.our - (prevOur || 0));
// Baseline catch-up: if our previous was 0/null but opponent already had a delta earlier, treat first non-zero our score as a catch-up delta exactly once
if ((prevOur == null || prevOur === 0) && nextScores.our > 0 && !state._scoreboardInitializedOur) {
deltaOur = nextScores.our; // full catch-up
state._scoreboardInitializedOur = true;
}
if ((prevOpp == null || prevOpp === 0) && nextScores.opp > 0 && !state._scoreboardInitializedOpp) {
deltaOpp = nextScores.opp;
state._scoreboardInitializedOpp = true;
}
// Anomaly detection: opponent shows large positive but our delta repeatedly zero while our absolute > 0
try {
if (!state._scoreboardAnomalyCount) state._scoreboardAnomalyCount = 0;
const anomaly = (nextScores.our > 0 && deltaOur === 0 && deltaOpp > 0 && prevOur === 0);
if (anomaly) {
state._scoreboardAnomalyCount += 1;
if (state._scoreboardAnomalyCount <= 3) {
tdmlogger('debug', `[Scoreboard][Anomaly] ourFac score=${nextScores.our} prevOur=${prevOur} deltaOur=0 while opp delta=${deltaOpp}`);
}
}
} catch(_) {}
if (deltaOpp > 0 || deltaOur > 0) {
anyChange = true;
state.rankedWarScoreSnapshot = nextScores;
tdmlogger('info', `[Scoreboard] Change (lastRankWar) opp +${deltaOpp}, our +${deltaOur} (totals opp=${nextScores.opp}, our=${nextScores.our})`);
} else {
// Ensure snapshot persists even with no delta (so nulls don't re-trigger catch-up incorrectly)
state.rankedWarScoreSnapshot = { opp: nextScores.opp, our: nextScores.our };
}
}
}
}
} catch(_) { /* noop */ }
rows.forEach(row => {
const userLink = row.querySelector('a[href*="profiles.php?XID="]');
if (!userLink) return;
const id = userLink.href.match(/XID=(\d+)/)?.[1];
if (!id) return;
const opponentName = utils.sanitizePlayerName(utils.extractPlayerNameFromAnchor(userLink), id, { fallbackPrefix: 'Opponent' });
const prev = state.rankedWarTableSnapshot[id] || {};
// Prefer cached opponent member data over DOM text when available
const tf = state.tornFactionData || {};
// Only use the current page's opponent faction id; avoid stale lastOpponentFactionId to prevent cross-war contamination
const oppFactionId = state?.warData?.opponentFactionId || state?.warData?.opponentId || null;
let member = null;
if (oppFactionId && tf[oppFactionId]?.data?.members) {
const arr = Array.isArray(tf[oppFactionId].data.members)
? tf[oppFactionId].data.members
: Object.values(tf[oppFactionId].data.members);
member = arr.find(x => String(x.id) === String(id)) || null;
}
// Respect timeline source preference strictly when set to API-only
const srcPrefStrict = 'rw';
const apiOnly = srcPrefStrict === 'api';
const domStatus = apiOnly ? '' : utils.getStatusTextFromRow(row);
const statusObj = member?.status;
let statusText = statusObj?.description || domStatus || '';
const prevUnified = state.unifiedStatus?.[id] || null;
const memberForCanon = statusObj
? { id, status: statusObj, last_action: member?.last_action || member?.lastAction || null }
: null;
const currRec = memberForCanon
? utils.buildUnifiedStatusV2(memberForCanon, prevUnified)
: utils.buildUnifiedStatusV2({ state: domStatus, description: domStatus });
// Canonicalized status to smooth FF Scouter rewrites (e.g., Hosp countdowns, "in CI", "T CI")
let currCanon = currRec?.canonical || '';
// Derive DOM-only canonical for mismatch detection (API vs page)
// canonicalizeStatus removed. Use buildUnifiedStatusV2 for canonical status records.
const domRec = utils.buildUnifiedStatusV2({ state: domStatus, description: domStatus });
const domCanon = domRec?.canonical || '';
const currStatus = statusText;
// If API says Okay but DOM still clearly shows Hospital (countdown) and prior hospital meta not expired, trust DOM to avoid premature early-release alert
const existingMetaForMismatch = state.rankedWarChangeMeta[id];
const prevHospUntil = existingMetaForMismatch && typeof existingMetaForMismatch.hospitalUntil === 'number' ? existingMetaForMismatch.hospitalUntil : 0;
const nowSecMismatch = Math.floor(Date.now() / 1000);
const domShowsHospital = /hosp/i.test(domStatus || '');
const apiSaysOkay = /okay/i.test(currCanon || '') || /okay/i.test(statusObj?.description || '');
const hospitalTimeRemaining = prevHospUntil > nowSecMismatch ? (prevHospUntil - nowSecMismatch) : 0;
let suppressedEarlyHosp = false;
if (!apiOnly && domShowsHospital && apiSaysOkay && hospitalTimeRemaining > 30) {
// Treat as still Hospital
currCanon = 'Hospital';
statusText = domStatus;
suppressedEarlyHosp = true;
}
const statusUntil = Number(statusObj?.until) || 0;
const currActivity = (member?.last_action?.status) ? member.last_action.status : utils.getActivityStatusFromRow(row);
// Phased timeline sampling (non-blocking, respects runtime toggle)
try {
const explicit = null; // legacy toggle disabled
const cfg = ui._tdmConfig?.timeline || {};
const enabled = (explicit !== null) ? !!explicit : !!cfg.enabled;
if (enabled) {
const pageKey = utils.getCurrentWarPageKey?.();
const warKey = pageKey ? pageKey.replace(/[^a-z0-9_\-]/gi,'_') : (state.lastRankWar?.id ? `id_${String(state.lastRankWar.id)}` : 'unknown');
// (timeline sampling removed)
}
} catch(_) { /* ignore sampling errors */ }
const currRetal = !!state.retaliationOpportunities[id];
// Per-row points delta to detect which opponent scored
const currPoints = utils.getPointsFromRow(row);
const hadPrevPoints = typeof prev.points === 'number';
const pointsDelta = (typeof currPoints === 'number' && hadPrevPoints && currPoints > prev.points) ? (currPoints - prev.points) : 0;
// Row state log (debug only)
// logRow(`[TDM][RowState] ${opponentName} [${id}] status="${currStatus}" activity="${currActivity}" retal=${currRetal} @ ${scanTime}`);
// Compare canonical to avoid flicker on countdown-only changes
// Bugfix: compare state-to-state; do not re-canonicalize using previous description text
const prevCanon = prev.statusCanon || prev.canon || '';
// Add previous-status validity TTL to suppress stale transitions
const prevStatusValidTtlMs = (ui._tdmConfig?.timeline?.prevStatusValidTtlMs != null)
? Number(ui._tdmConfig.timeline.prevStatusValidTtlMs)
: (30 * 60 * 1000); // default 30m
const prevTs = (state.rankedWarChangeMeta?.[id]?.ts) || 0;
const nowForPrev = Date.now();
const prevStillValid = prevTs && (nowForPrev - prevTs) <= prevStatusValidTtlMs;
const statusChanged = !!(prevCanon && currCanon && prevCanon !== currCanon && prevStillValid);
const activityChanged = !!(prev.activity && currActivity && prev.activity !== currActivity);
const retalChanged = typeof prev.retal === 'boolean' ? (prev.retal !== currRetal) : !!currRetal;
anyChange = anyChange || statusChanged || activityChanged || retalChanged;
// Console logs for changes (follow suppression rule for activity Idle/Online -> Offline)
if (state.debug && state.debug.rowLogs) {
if (statusChanged) {
tdmlogger('debug', `[StatusChange] ${opponentName} [${id}] ${prev.status} -> ${currStatus} @ ${new Date().toLocaleTimeString()}`);
}
if (activityChanged && !((prev.activity === 'Idle' || prev.activity === 'Online') && currActivity === 'Offline')) {
try {
if (state?.debug?.activityLogs || storage.get('debugActivity', false)) {
tdmlogger('debug', `[ActivityChange] ${opponentName} [${id}] ${prev.activity} -> ${currActivity} @ ${new Date().toLocaleTimeString()}`);
}
} catch(_) {}
}
if (retalChanged) {
try { tdmlogger('debug', `[RetalChange] ${opponentName} [${id}] ${prev.retal ? 'ON' : 'OFF'} -> ${currRetal ? 'ON' : 'OFF'} @ ${new Date().toLocaleTimeString()}`); } catch(_) {}
}
}
// If user is currently in Hospital, clear any lingering score bump effects for this player
try {
if (/^hospital$/i.test(String(currCanon || ''))) {
const idStr = String(id);
if (state._scoreBumpTimers && state._scoreBumpTimers[idStr]) {
const prev = state._scoreBumpTimers[idStr];
try { if (prev.fade) utils.unregisterTimeout(prev.fade); } catch(_) {}
try { if (prev.redToOrange) utils.unregisterTimeout(prev.redToOrange); } catch(_) {}
try { if (prev.persist) utils.unregisterTimeout(prev.persist); } catch(_) {}
delete state._scoreBumpTimers[idStr];
}
const scoreEl = row.querySelector('.points___TQbnu, .points');
try { if (scoreEl) scoreEl.classList.remove('tdm-score-bump', 'fade', 'tdm-score-bump-orange'); } catch(_) {}
}
} catch(_) {}
// Apply score border highlight orthogonally — never affects alertType
if (pointsDelta > 0) {
try {
const scoreEl = row.querySelector('.points___TQbnu, .points');
if (scoreEl) {
// Reset any previous timers and orange state if re-bumped
const idStr = String(id);
state._scoreBumpTimers = state._scoreBumpTimers || {};
if (state._scoreBumpTimers[idStr]) {
const prev = state._scoreBumpTimers[idStr];
try { if (prev.fade) utils.unregisterTimeout(prev.fade); } catch(_) {}
try { if (prev.redToOrange) utils.unregisterTimeout(prev.redToOrange); } catch(_) {}
try { if (prev.persist) utils.unregisterTimeout(prev.persist); } catch(_) {}
}
scoreEl.classList.remove('tdm-score-bump-orange');
scoreEl.classList.remove('fade');
scoreEl.classList.add('tdm-score-bump');
// Short red flash then transition to orange persistent highlight
const fadeTimer = utils.registerTimeout(setTimeout(() => { try { scoreEl.classList.add('fade'); } catch(_) {} }, 19500));
const redToOrangeTimer = utils.registerTimeout(setTimeout(() => {
try { scoreEl.classList.remove('tdm-score-bump', 'fade'); } catch(_) {}
try { scoreEl.classList.add('tdm-score-bump-orange'); } catch(_) {}
}, 20000));
// Ensure the combined time (red + orange + fade) is no more than 5 minutes (300000ms) total
// red phase starts immediately and lasts ~20s; persist removal should fire at 5min from now
const TOTAL_MS = 5 * 60 * 1000; // 300000 ms
const persistTimer = utils.registerTimeout(setTimeout(() => {
try { scoreEl.classList.remove('tdm-score-bump', 'fade', 'tdm-score-bump-orange'); } catch(_) {}
delete state._scoreBumpTimers[idStr];
}, TOTAL_MS));
state._scoreBumpTimers[idStr] = { fade: fadeTimer, redToOrange: redToOrangeTimer, persist: persistTimer };
}
} catch(_) {}
}
let alertType = null, alertText = '', alertData = {};
let freshChange = false; // true only when a new change is detected in this scan
const existingMeta = state.rankedWarChangeMeta[id];
// Priority 1: Retal — render countdown here to avoid legacy/observer flicker
if (currRetal) {
// Defensive: only render active retaliation opportunities with positive remaining time
const ret = state.retaliationOpportunities[id];
const nowSec = Math.floor(Date.now() / 1000);
const rem = (ret && typeof ret.retaliationEndTime === 'number') ? Math.floor(ret.retaliationEndTime - nowSec) : null;
if (rem == null || rem <= 0) {
// expired or malformed — treat as no retaliation
} else {
alertType = 'retal';
const mm = Math.floor(rem / 60);
const ss = String(rem % 60).padStart(2, '0');
alertText = `Retal👉${mm}:${ss}`;
freshChange = true;
}
}
// Priority 2b: Remove any lingering old score meta beyond previous behavior
else if (existingMeta?.activeType === 'score') {
const ttlMs = 60000;
if (Date.now() - (existingMeta.ts || 0) >= ttlMs) {
if (state.rankedWarChangeMeta[id]?.activeType === 'travel') { try { ui._kv.removeItem(`tdm.travel.${id}`); } catch(_) {} }
delete state.rankedWarChangeMeta[id];
}
}
// Cleanup stickies: if abroad hospital meta exists but target is no longer Hospital, remove it
else if (existingMeta?.activeType === 'status' && existingMeta.hospitalAbroad && !/^hospital$/i.test(currCanon || '')) {
if (state.rankedWarChangeMeta[id]?.activeType === 'travel') { try { ui._kv.removeItem(`tdm.travel.${id}`); } catch(_) {} }
delete state.rankedWarChangeMeta[id];
}
// Priority 3: Active travel meta (60 minutes; degrade display if stale)
else if (existingMeta?.activeType === 'travel') {
const meta = existingMeta;
const nowMs = Date.now();
// Keep travel meta visible until ETA passes (with small buffer) when we have a confident ETA.
// If no ETA, fallback to a generous TTL to avoid leaks.
const hasEta = Number.isFinite(meta?.etaMs) && meta.etaMs > 0;
const bufferMs = 5 * 60 * 1000; // 5 minutes buffer after ETA
const fallbackTtlMs = 2 * 60 * 60 * 1000; // 2 hours if we lack ETA
const age = nowMs - (meta.ts || 0);
const withinTtl = hasEta ? (nowMs <= (meta.etaMs + bufferMs)) : (age < fallbackTtlMs);
if (withinTtl) {
// Keep meta but no longer render via alert button
alertType = null;
alertText = '';
alertData = meta;
} else {
if (state.rankedWarChangeMeta[id]?.activeType === 'travel') { try { ui._kv.removeItem(`tdm.travel.${id}`); } catch(_) {} }
delete state.rankedWarChangeMeta[id];
}
}
// Priority 3: Status change (always show for any status change; travel has extra logic)
else if ((prevCanon && currCanon && prevCanon !== currCanon) || (!prevCanon && currCanon === 'Hospital')) {
// Early Hospital Out: Previously in Hospital with >60s remaining, now Okay
const nowSecEarly = Math.floor(Date.now() / 1000);
const hadHospMeta = existingMeta && existingMeta.activeType === 'status' && /hosp/i.test(existingMeta.newStatus || '') && typeof existingMeta.hospitalUntil === 'number';
// Raw row text still includes 'hosp'? Then don't treat as released yet (prevents flicker false positives)
const rawStillHosp = /hosp/i.test(statusText || '');
// Only flag early release if: we previously had Hospital meta with >60s remaining, canonical now says Okay, AND raw text no longer shows Hospital
const earlyRelease = hadHospMeta && !rawStillHosp && !suppressedEarlyHosp && (existingMeta.hospitalUntil - nowSecEarly) > 60 && (/^okay$/i.test(currCanon));
if (earlyRelease) {
alertType = 'earlyHospOut';
alertText = 'EarlyHospOut👉';
alertData = { prevStatus: 'Hospital', newStatus: 'Okay', hospitalUntil: existingMeta.hospitalUntil };
freshChange = true;
}
// Special handling for travel
if (!alertType && currCanon === 'Travel') {
const prevWasGate = !prevCanon || /^(okay|abroad|hospital)$/i.test(prevCanon);
const existing = state.rankedWarChangeMeta[id];
const nowMsTravel = Date.now();
// Debounce: require two consecutive scans seeing Travel before committing (unless no previous snapshot)
const prevSnapshotCanon = prevCanon;
const confirmNeeded = prevWasGate && (!existing || existing.activeType !== 'travel');
if (confirmNeeded) {
// If previous snapshot already recorded Travel (prevCanon === 'Travel'), we confirm now; otherwise store a provisional marker and skip
if (prevSnapshotCanon !== 'Travel') {
state.rankedWarChangeMeta[id] = { activeType: 'travelPending', ts: nowMsTravel };
alertType = null; // travel handled inline
alertText = '';
alertData = state.rankedWarChangeMeta[id];
} else {
// Confirmed departure
const leftAtMs = nowMsTravel;
const dest = utils.parseUnifiedDestination(statusText);
const mins = dest ? utils.travel.getMinutes(dest) : 0;
if (!dest || !mins) utils.travel.logUnknownDestination(statusText);
const etaMs = utils.travel.computeEtaMs(leftAtMs, mins);
const etaLocal = etaMs ? new Date(etaMs).toLocaleTimeString([], { hour:'2-digit', minute:'2-digit' }) : '';
const text = etaLocal ? utils.travel.formatTravelLine(leftAtMs, mins, statusText) : (statusText || 'Travel');
const etaUTC = (() => { if (!etaMs) return ''; const d = new Date(etaMs); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')} UTC`; })();
const meta = { activeType: 'travel', pendingText: text, ts: leftAtMs, dest, mins, leftAtMs, etaMs, etaUTC, etaLocal, leavingAtMs: leftAtMs, firstSeenMs: state.rankedWarChangeMeta[id]?.firstSeenMs || leftAtMs };
state.rankedWarChangeMeta[id] = meta;
alertType = null;
alertText = '';
alertData = meta;
freshChange = true;
}
} else if (existing && existing.activeType === 'travelPending') {
// Escalate pending to confirmed if still travel after >3s
if (nowMsTravel - (existing.ts||0) > 3000) {
const leftAtMs = existing.ts || nowMsTravel;
const dest = utils.parseUnifiedDestination(statusText);
const mins = dest ? utils.travel.getMinutes(dest) : 0;
if (!dest || !mins) utils.travel.logUnknownDestination(statusText);
const etaMs = utils.travel.computeEtaMs(leftAtMs, mins);
const etaLocal = etaMs ? new Date(etaMs).toLocaleTimeString([], { hour:'2-digit', minute:'2-digit' }) : '';
const text = etaLocal ? utils.travel.formatTravelLine(leftAtMs, mins, statusText) : (statusText || 'Travel');
const etaUTC = (() => { if (!etaMs) return ''; const d = new Date(etaMs); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')} UTC`; })();
const meta = { activeType: 'travel', pendingText: text, ts: leftAtMs, dest, mins, leftAtMs, etaMs, etaUTC, etaLocal, leavingAtMs: leftAtMs, firstSeenMs: state.rankedWarChangeMeta[id]?.firstSeenMs || leftAtMs };
state.rankedWarChangeMeta[id] = meta;
alertType = null;
alertText = '';
alertData = meta;
freshChange = true;
}
} else if (existing && existing.activeType === 'travelPendingReturn') {
// Similar escalation for return travel pending marker
if (nowMsTravel - (existing.ts||0) > 3000) {
const leftAtMs = existing.ts || nowMsTravel;
const dest = existing.dest || null; // Returning from dest
const mins = dest ? (existing.mins || utils.travel.getMinutes(dest)) : 0;
if (!dest || !mins) utils.travel.logUnknownDestination(statusText);
const etaMs = utils.travel.computeEtaMs(leftAtMs, mins);
const etaLocal = etaMs ? new Date(etaMs).toLocaleTimeString([], { hour:'2-digit', minute:'2-digit' }) : '';
const text = etaLocal ? utils.travel.formatTravelLine(leftAtMs, mins, statusText) : (statusText || 'Travel');
const etaUTC = (() => { if (!etaMs) return ''; const d = new Date(etaMs); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')} UTC`; })();
const meta = { activeType: 'travel', isReturn:true, pendingText: text, ts: leftAtMs, dest, mins, leftAtMs, etaMs, etaUTC, etaLocal, leavingAtMs: leftAtMs, firstSeenMs: state.rankedWarChangeMeta[id]?.firstSeenMs || leftAtMs };
state.rankedWarChangeMeta[id] = meta;
alertType = null;
alertText = '';
alertData = meta;
freshChange = true;
}
} else if (/^returning to torn from /i.test(statusText || '')) {
// Handle immediate detection of inbound travel (return flight). We treat like travelPendingReturn first pass.
const dest = utils.parseUnifiedDestination(statusText);
const mins = dest ? utils.travel.getMinutes(dest) : 0;
if (!dest || !mins) utils.travel.logUnknownDestination(statusText);
const nowMsReturn = Date.now();
if (!existing || !/travel/i.test(existing.activeType)) {
// Enrich pending return meta so UI can compute ETA immediately. Mark confident since we have mins+timestamp.
state.rankedWarChangeMeta[id] = { activeType: 'travelPendingReturn', ts: nowMsReturn, leavingAtMs: nowMsReturn, dest, mins, isReturn: true, confident: true };
}
alertType = null; // inline only
const existingReturn = state.rankedWarChangeMeta[id];
const leftMsTmp = existingReturn?.ts || nowMsReturn;
const etaMsTmp = utils.travel.computeEtaMs(leftMsTmp, existingReturn?.mins || 0);
const etaLocalTmp = etaMsTmp ? new Date(etaMsTmp).toLocaleTimeString([], { hour:'2-digit', minute:'2-digit' }) : '';
alertText = '';
alertData = state.rankedWarChangeMeta[id];
} else if (apiOnly && statusObj && Number(statusObj.until) === 0) {
// API-only mode sometimes reports Travel with until:0. Infer ETA from destination heuristics.
const dest = utils.parseUnifiedDestination(statusText);
const mins = dest ? utils.travel.getMinutes(dest) : 0;
if (!dest || !mins) utils.travel.logUnknownDestination(statusText);
const leftAtMs = Date.now();
const etaMs = utils.travel.computeEtaMs(leftAtMs, mins);
const etaLocal = etaMs ? new Date(etaMs).toLocaleTimeString([], { hour:'2-digit', minute:'2-digit' }) : '';
const text = etaLocal ? utils.travel.formatTravelLine(leftAtMs, mins, statusText) : (statusText || 'Travel');
const meta = { activeType: 'travel', pendingText: text, ts: leftAtMs, dest, mins, leftAtMs, etaMs, etaLocal, leavingAtMs: leftAtMs, firstSeenMs: state.rankedWarChangeMeta[id]?.firstSeenMs || leftAtMs };
state.rankedWarChangeMeta[id] = meta;
alertType = null;
alertText = '';
alertData = meta;
freshChange = true;
} else if (!existing && currCanon === 'Travel') {
// Travel meta missing; backfill from legacy timeline disabled – rely on forward detection only
} else if (existing && existing.activeType === 'travel') {
// After 10 min, show elapsed instead of static ETA? if we lacked reliable ETA or it passed
const ageMs = nowMsTravel - (existing.leavingAtMs || existing.ts || 0);
let text = existing.pendingText || '';
if (existing.etaMs && nowMsTravel > existing.etaMs + 2*60*1000) {
// ETA passed by >2m: degrade to elapsed
const minsElapsed = Math.floor(ageMs/60000);
text = `Travel ${minsElapsed}m+`;
} else if (!existing.etaMs && ageMs > 10*60*1000) {
const minsElapsed = Math.floor(ageMs/60000);
text = `Travel ${minsElapsed}m`;
}
// Safety: if text became blank due to truncation or mutation, rebuild from meta fields
if (!text.trim()) {
const abbr = utils.abbrevDest(existing.dest || '') || '';
if (existing.etaLocal && existing.etaLocal !== '??:??') text = `ETA ${existing.etaLocal}`;
else if (abbr) text = `Travel ${abbr}`; else text = 'Travel';
existing.pendingText = text;
}
alertType = null;
existing.pendingText = text; // keep text for inline usage
alertText = '';
alertData = existing;
}
} else if (!alertType && (/^Hospital$/i.test(currCanon) || /^HospitalAbroad$/i.test(currCanon))) {
// Explicit Hospital message with destination prefix when abroad; show time left if available
const nowSec = Math.floor(Date.now() / 1000);
const rem = statusUntil > 0 ? Math.max(0, statusUntil - nowSec) : 0;
const mm = Math.floor(rem / 60);
const ss = String(rem % 60).padStart(2, '0');
const timeTxt = rem > 0 ? `${mm}:${ss}` : '';
const hadActiveTravel = (state.rankedWarChangeMeta[id]?.activeType === 'travel');
const fromWasTravelOrAbroad = /travel|abroad/i.test(prevCanon || '');
// Determine if abroad by parsing destination inside hospital description as well
let destFromHosp = utils.parseHospitalAbroadDestination(statusText) || '';
const hospitalAbroad = !!(hadActiveTravel || fromWasTravelOrAbroad || destFromHosp);
let prefix = '';
let destForMeta = '';
if (hospitalAbroad) {
const destFull = destFromHosp || utils.parseUnifiedDestination(statusText) || (state.rankedWarChangeMeta[id]?.dest) || '';
destForMeta = destFull;
const destAbbrev = utils.abbrevDest(destFull) || '';
prefix = destAbbrev ? `${destAbbrev} ` : '';
}
let label = `${prefix}Hosp`.trim();
if (hospitalAbroad && !prefix) label = 'Abrd Hosp';
const text = timeTxt ? `${label} ${timeTxt}` : label;
// statusUntil already holds the server-provided epoch when hospital ends; hospitalUntil local var was previously undefined (bug)
// Note: hospital countdown source of truth is hospitalUntil in this meta; we do not derive it from tdm.status.id_* cache.
const meta = { activeType: 'status', pendingText: text, ts: Date.now(), prevStatus: prev.status || prevCanon, newStatus: (hospitalAbroad ? 'HospitalAbroad' : 'Hospital'), hospitalUntil: statusUntil, hospitalAbroad };
meta.hospitalUntil = statusUntil;
meta.hospitalAbroad = hospitalAbroad;
if (destForMeta) meta.dest = destForMeta;
state.rankedWarChangeMeta[id] = meta;
alertType = 'status';
alertText = text;
alertData = meta;
freshChange = true;
} else if (!alertType) {
// Generic status change
// Suppress trivial Travel↔Okay transitions without a validated prior snapshot
const trivialTravelOkay = ((/^(travel)$/i.test(prevCanon) && /^okay$/i.test(currCanon)) || (/^okay$/i.test(prevCanon) && /^(travel)$/i.test(currCanon)));
if (trivialTravelOkay && !prevStillValid) {
// Skip creating a button; rely on travel meta or next scan
} else {
alertType = 'status';
const fromAbbr = utils.abbrevStatus(prevCanon || '');
const toAbbr = utils.abbrevStatus(currCanon || '');
alertText = `${fromAbbr}→${toAbbr}`.trim();
alertData = { prevStatus: prev.status || prevCanon, newStatus: statusText };
freshChange = true;
}
}
}
// Priority 4: Activity change (except Idle/Online -> Offline)
else if (prev.activity && currActivity && prev.activity !== currActivity) {
if (!((prev.activity === 'Idle' || prev.activity === 'Online') && currActivity === 'Offline')) {
alertType = 'activity';
const abbr = currActivity === 'Online' ? 'On' : currActivity === 'Offline' ? 'Off' : currActivity === 'Idle' ? 'Idle' : (currActivity || '').toString();
const firstSeenAtMs = Date.now();
alertText = `${abbr} 00:00`;
alertData = { prevActivity: prev.activity, newActivity: currActivity, firstSeenAtMs, abbr };
freshChange = true;
}
}
// Persist recent status/activity/retal-done alerts (abroad hospital is sticky until status changes or higher-priority alert)
if (!alertType) {
const meta = state.rankedWarChangeMeta[id];
if (meta && meta.activeType !== 'retal') {
const isStickyAbroadHosp = (meta.activeType === 'status' && meta.hospitalAbroad === true);
// Default TTL 120s; Retal Done is shorter (60s)
const ttlMs = (meta.activeType === 'retalDone') ? 60000 : 120000;
if (isStickyAbroadHosp || (Date.now() - (meta.ts || 0) < ttlMs)) {
if (meta.activeType === 'travelPending' || meta.activeType === 'travelPendingReturn') {
alertType = 'travel';
alertText = meta.pendingText || 'ETA …';
} else {
alertType = meta.activeType;
}
// Live-update hospital countdown if we have an 'until'
if (meta.activeType === 'status' && typeof meta.hospitalUntil === 'number' && meta.hospitalUntil > 0) {
const nowSec = Math.floor(Date.now() / 1000);
const rem = Math.max(0, meta.hospitalUntil - nowSec);
const mm = Math.floor(rem / 60);
const ss = String(rem % 60).padStart(2, '0');
const timeTxt = rem > 0 ? `${mm}:${ss}` : '';
let label = 'Hosp';
if (meta.hospitalAbroad) {
const destAbbrev = utils.abbrevDest(meta.dest || '') || '';
label = destAbbrev ? `${destAbbrev} Hosp` : 'Abrd Hosp';
}
alertText = timeTxt ? `${label} ${timeTxt}` : label;
} else if (meta.activeType === 'activity') {
const first = meta.firstSeenAtMs || meta.ts || Date.now();
const elapsed = Date.now() - first;
const mm = Math.floor(elapsed / 60000);
const ss = Math.floor((elapsed % 60000) / 1000);
alertText = `${meta.abbr || 'On'} ${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}`;
meta.pendingText = alertText;
} else {
alertText = meta.pendingText || '';
}
alertData = meta;
}
}
// Fallback: show abroad hospital label even without a fresh change if API shows Hospital abroad and no other alert
if (!alertType && /^hospital$/i.test(currCanon || '')) {
const nowSec = Math.floor(Date.now() / 1000);
const rem = statusUntil > 0 ? Math.max(0, statusUntil - nowSec) : 0;
const mm = Math.floor(rem / 60);
const ss = String(rem % 60).padStart(2, '0');
const timeTxt = rem > 0 ? `${mm}:${ss}` : '';
const destFull = utils.parseHospitalAbroadDestination(statusText) || utils.parseUnifiedDestination(statusText) || '';
if (destFull) {
const destAbbrev = utils.abbrevDest(destFull) || '';
const label = destAbbrev ? `${destAbbrev} Hosp` : 'Abrd Hosp';
// Create/refresh sticky meta
const metaSticky = { activeType: 'status', pendingText: timeTxt ? `${label} ${timeTxt}` : label, ts: Date.now(), prevStatus: prev.status || prevCanon, newStatus: 'Hospital', hospitalUntil: statusUntil, hospitalAbroad: true, dest: destFull };
state.rankedWarChangeMeta[id] = metaSticky;
alertType = 'status';
alertText = metaSticky.pendingText;
alertData = metaSticky;
}
}
}
// Update snapshot
// Persist latest row snapshot, including points if available
state.rankedWarTableSnapshot[id] = { status: statusText, activity: currActivity, retal: currRetal, statusCanon: currCanon, points: (typeof currPoints === 'number' ? currPoints : (hadPrevPoints ? prev.points : undefined)) };
// Update button UI
const subrow = row.querySelector('.dibs-notes-subrow');
if (!subrow) return;
const retalBtn = subrow.querySelector('.retal-btn');
if (!retalBtn) return;
// Respect global setting to hide alert buttons
const alertsEnabled = storage.get('alertButtonsEnabled', true);
if (!alertsEnabled) {
const desiredClass = 'btn retal-btn tdm-alert-btn tdm-alert-inactive';
if (retalBtn.className !== desiredClass) retalBtn.className = desiredClass;
if (retalBtn.style.display !== 'none') retalBtn.style.display = 'none';
if (retalBtn.disabled !== true) retalBtn.disabled = true;
if (retalBtn.textContent !== '') retalBtn.textContent = '';
if (retalBtn.title) retalBtn.title = '';
if (retalBtn.dataset.tdmClickType) delete retalBtn.dataset.tdmClickType;
return;
}
const showAlerts = storage.get('alertButtonsEnabled', true);
// Inject abroadHosp alert (post-processing) if no higher-priority alert selected.
if (showAlerts && !alertType) {
try {
// Prefer existing meta indicating hospitalAbroad or unified status record (rec.canonical === 'abroad_hosp')
const metaHospAbroad = state.rankedWarChangeMeta[id]?.hospitalAbroad === true;
const unifiedAbroadHosp = !!(rec && rec.canonical === 'HospitalAbroad');
if (metaHospAbroad || unifiedAbroadHosp) {
alertType = 'abroadHosp';
let label = 'Hosp Abroad';
let remTxt = '';
const until = state.rankedWarChangeMeta[id]?.hospitalUntil;
if (typeof until === 'number' && until > 0) {
const nowSec = Math.floor(Date.now()/1000);
const rem = Math.max(0, until - nowSec);
if (rem > 0) {
const mm = Math.floor(rem/60); const ss = String(rem%60).padStart(2,'0');
remTxt = `${mm}:${ss}`;
}
}
alertText = remTxt ? `${label} ${remTxt}` : label;
alertData = { hospitalAbroad: true, hospitalUntil: state.rankedWarChangeMeta[id]?.hospitalUntil };
}
} catch(_) { /* swallow */ }
}
// Restrict to allowed button alert types only
const allowedAlertTypes = new Set(['retal','retalDone','earlyHospOut','abroadHosp']);
if (alertType && showAlerts && allowedAlertTypes.has(alertType)) {
// Only stamp a new timestamp when this scan detected a fresh change.
if (freshChange) {
state.rankedWarChangeMeta[id] = { activeType: alertType, pendingText: alertText, ts: Date.now(), ...alertData };
}
// --- Travel Fallback Handling START ---
// Travel handling removed: inline .tdm-travel-eta now owns travel display.
// --- Travel Fallback Handling END ---
// Choose a variant class by change type and direction
let baseClass = 'btn retal-btn tdm-alert-btn';
let variant = 'tdm-alert-retal';
let addGlow = false;
if (alertType === 'activity') {
const from = (alertData.prevActivity || '').toLowerCase();
const to = (alertData.newActivity || '').toLowerCase();
if ((from === 'offline' || from === 'idle') && to === 'online') { variant = 'tdm-alert-green'; addGlow = true; }
else if (from === 'online' && to === 'idle') { variant = 'tdm-alert-grey'; addGlow = false; }
else if ((from === 'online' || from === 'idle') && to === 'offline') { variant = 'tdm-alert-grey'; addGlow = false; }
else { variant = 'tdm-alert-grey'; addGlow = false; }
} else if (alertType === 'status') {
const fromS = (alertData.prevStatus || '').toLowerCase();
const toS = (alertData.newStatus || '').toLowerCase();
const isTravelToAbroadOrOkay = /travel/.test(fromS) && (toS === 'abroad' || toS === 'okay');
const isOkayToHosp = fromS === 'okay' && /hosp/i.test(toS);
const isHospToOkay = /hosp/i.test(fromS) && toS === 'okay';
if (alertData.hospitalAbroad) variant = 'tdm-alert-red';
else if (isTravelToAbroadOrOkay) variant = 'tdm-alert-red';
else if (isOkayToHosp) variant = 'tdm-alert-grey';
else if (isHospToOkay) variant = 'tdm-alert-grey';
else {variant = 'tdm-alert-grey'; addGlow = false; }
} else if (alertType === 'earlyHospOut') {
variant = 'tdm-alert-green';
addGlow = true;
} else if (alertType === 'retalDone') {
variant = 'tdm-alert-grey';
addGlow = false;
}
const desiredDisplay = 'inline-block';
const desiredDisabled = false;
const desiredClass = `${baseClass} ${variant} ${addGlow ? 'tdm-alert-glow' : ''}`.trim();
// Tooltip for overflow and richer info
let desiredTitle = alertText;
if (alertType === 'status' && alertData?.hospitalUntil) {
const until = Number(alertData.hospitalUntil);
const localUntil = isFinite(until) && until > 0 ? new Date(until * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
desiredTitle = `${alertText}${localUntil ? ` • Until ${localUntil}` : ''}`;
}
// Apply only if changed to prevent flicker and CSS animation restart
if ((!alertText || !alertText.trim()) && state.debug && state.debug.alerts) {
try { console.warn('[TDM][AlertRender] Blank alertText after processing', { id, alertType, meta: state.rankedWarChangeMeta[id] }); } catch(_) {}
}
// Apply via orchestrator to batch DOM writes
ui._orchestrator.applyOrQueue(retalBtn, {
display: desiredDisplay,
disabled: desiredDisabled,
className: desiredClass,
textContent: alertText,
title: desiredTitle
});
// Click handlers by type: retal sends chat; earlyHospOut sends chat; travel updates note; others no-op.
if (retalBtn.dataset.tdmClickType !== alertType) {
retalBtn.onclick = (e) => {
e.preventDefault(); e.stopPropagation();
const metaNow = state.rankedWarChangeMeta[id];
if (alertType === 'retal') {
// Send faction chat alert for retaliation
const userLink = row.querySelector('a[href*="profiles.php?XID="]');
const opponentName = utils.sanitizePlayerName(userLink?.textContent, id, { fallbackPrefix: 'Opponent' });
ui.sendRetaliationAlert(id, opponentName);
} else if (alertType === 'earlyHospOut') {
const userLink = row.querySelector('a[href*="profiles.php?XID="]');
const opponentName = userLink?.textContent?.trim() || '';
const facId = state.user.factionId;
const msg = `Early Hospital Release ${opponentName}`;
let chatTextbox = document.querySelector(`#faction-${facId} > div.content___n3GFQ > div.root___WUd1h > textarea`);
const chatButton = document.querySelector(`#channel_panel_button\\:faction-${facId}`);
if (storage.get('pasteMessagesToChatEnabled', true)) {
if (!chatTextbox && chatButton) {
chatButton.click();
setTimeout(() => ui.populateChatMessage(msg),900);
} else if (chatTextbox) {
setTimeout(() => ui.populateChatMessage(msg),600);
}
}
} else if (alertType === 'abroadHosp') {
// Placeholder: future chat / tracking action could go here
}
};
retalBtn.dataset.tdmClickType = alertType;
}
} else {
// No alert and no recent meta; clear and hide
if (state.rankedWarChangeMeta[id] && state.rankedWarChangeMeta[id].activeType !== 'retal') {
const type = state.rankedWarChangeMeta[id].activeType;
const ttlMs = type === 'travel' ? (60 * 60 * 1000) : ((type === 'retalDone' || type === 'score') ? 60000 : 120000);
if (Date.now() - (state.rankedWarChangeMeta[id].ts || 0) >= ttlMs) {
if (state.rankedWarChangeMeta[id]?.activeType === 'travel') { try { ui._kv.removeItem(`tdm.travel.${id}`); } catch(_) {} }
delete state.rankedWarChangeMeta[id];
}
}
// Fall back to legacy retal rendering (apply no-op updates when unchanged)
const desiredClass = 'btn retal-btn tdm-alert-btn tdm-alert-inactive';
ui._orchestrator.applyOrQueue(retalBtn, {
display: 'none',
disabled: true,
className: desiredClass,
textContent: '',
title: ''
});
if (retalBtn.dataset.tdmClickType) delete retalBtn.dataset.tdmClickType;
}
});
if (!anyChange && state.debug && state.debug.rowLogs) {
const now = Date.now();
if (!state._rankedWarLastNoChangeLogMs || now - state._rankedWarLastNoChangeLogMs > 5000) {
state._rankedWarLastNoChangeLogMs = now;
}
}
state._rankedWarLastScanMs = Date.now();
// Persist snapshot and meta for reload/hash-change continuity
try {
if (warKey) {
// Use debounced cross-tab-safe persistence helpers
try { utils._initRwMetaSignalListener?.(); } catch(_) {}
try { utils.schedulePersistRankedWarMeta?.(warKey); } catch(_) {}
try { utils.schedulePersistRankedWarSnapshot?.(warKey); } catch(_) {}
}
} catch(_) { /* ignore quota */ }
};
const scheduleScan = () => {
if (state._rankedWarScanScheduled) return;
state._rankedWarScanScheduled = true;
const coalesceDelay = 120;
setTimeout(() => {
state._rankedWarScanScheduled = false;
const minInterval = 800; // ms between scans
const now = Date.now();
const since = now - (state._rankedWarLastScanMs || 0);
if (since < minInterval) {
setTimeout(runScan, minInterval - since);
} else {
runScan();
}
}, coalesceDelay);
};
state._rankedWarOnMut = scheduleScan; // keep compatibility with existing calls
state._rankedWarScheduleScan = scheduleScan;
if (state._rankedWarObserver) { try { utils.unregisterObserver(state._rankedWarObserver); } catch(_) {} state._rankedWarObserver = null; }
state._rankedWarObserver = utils.registerObserver(new MutationObserver((mutations) => {
// Ignore mutations originating from our own UI to reduce noise
const allOwn = mutations.every(m => {
const t = m.target && typeof m.target.closest === 'function' ? m.target : null;
if (!t) return false;
return !!t.closest('.dibs-notes-subrow, .retal-btn, #tdm-attack-container, #tdm-chain-timer, #tdm-inactivity-timer, #tdm-opponent-status, #tdm-api-usage');
});
if (allOwn) return; // skip scheduling
scheduleScan();
// Keep badges fresh during scans
try { ui.updateUserScoreBadge?.(); ui.updateFactionScoreBadge?.(); } catch(_) {}
}));
state._rankedWarObserver.observe((listEl || table), { childList: true, subtree: true, attributes: true, attributeFilter: ['class','title','aria-label','href'] });
// Observe rank box score changes to trigger scans promptly
try {
const rankBox = state.dom.rankBox || document.querySelector('.rankBox___OzP3D');
if (rankBox) {
if (state._rankedWarScoreObserver) { try { state._rankedWarScoreObserver.disconnect(); } catch(_) {} }
state._rankedWarScoreObserver = utils.registerObserver(new MutationObserver(() => {
// Debounce frequent slider anim mutations by coalescing
scheduleScan();
}));
state._rankedWarScoreObserver.observe(rankBox, { childList: true, subtree: true, characterData: true });
}
} catch(_) { /* noop */ }
try { ui.ensureTravelTicker(); } catch(_) {}
// Prime once
// If no changes detected in last scan, log a summary line
try {
// Wrap next microtask to allow onMut to complete row processing
Promise.resolve().then(() => {
// This relies on the anyChange variable inside onMut; if needed for future, move to outer scope
// For now, a simple periodic summary can be added elsewhere if desired
});
} catch(_) {}
},
dumpUnifiedStatus: async (id, kv, log=true) => {
try {
const key = `tdm.tl2.status.id_${id}`;
const raw = await kv.getItem(key);
if (log) tdmlogger('debug', `[dumpUnifiedStatus] ${id} ${raw}`);
return raw;
} catch(e) { if (log) tdmlogger('warn', `[dumpUnifiedStatus error] ${id} ${e}`); return null; }
},
dumpUnifiedAll: async ({ kv, limit=50, prefix='tdm.tl2.status.id_' }={}) => {
try {
const keys = await kv.listKeys(prefix);
const out = [];
for (const k of keys.slice(0, limit)) {
try { out.push({ k, v: await kv.getItem(k) }); } catch(_) {}
}
tdmlogger('info', `[dumpUnifiedAll] { count: ${out.length}, keys: ${out.map(e=>e.k)} }`);
return out;
} catch(e) { tdmlogger('warn', `[dumpUnifiedAll error] ${e}`); return []; }
},
_restorePersistedTravelMeta: async () => {
try {
const now = Date.now();
// Phase 0: Legacy standalone travel keys (remove soon)
try {
const keys = await ui._kv.listKeys('tdm.travel.');
const maxAgeMs = 6 * 60 * 60 * 1000;
for (const k of keys) {
try {
const obj = await ui._kv.getItem(k);
if (!obj || typeof obj !== 'object') continue;
const age = now - (obj.firstSeenMs || obj.leavingAtMs || obj.ts || 0);
if (age > maxAgeMs) { try { ui._kv.removeItem(k); } catch(_) {}; continue; }
const id = String(obj.id || k.split('.').pop());
if (!id) continue;
if (state.rankedWarChangeMeta[id] && state.rankedWarChangeMeta[id].activeType === 'travel') continue;
state.rankedWarChangeMeta[id] = {
activeType: 'travel',
dest: obj.dest || null,
mins: Number(obj.mins)||0,
leavingAtMs: obj.leavingAtMs || obj.firstSeenMs || obj.ts || (now - age),
firstSeenMs: obj.firstSeenMs || obj.leavingAtMs || now,
etaMs: obj.etaMs || 0,
isReturn: !!obj.isReturn,
confident: obj.confident !== false,
ts: obj.leavingAtMs || obj.firstSeenMs || obj.ts || Date.now()
};
} catch(_) { /* legacy item error */ }
}
} catch(_) { /* legacy list error */ }
// Phase 1: Embedded travelMeta inside last tl2 status segment
try {
const statusKeys = await ui._kv.listKeys('tdm.tl2.status.id_');
for (const sk of statusKeys) {
try {
const id = sk.replace(/^tdm\.tl2\.status\.id_/, '');
if (!id) continue;
const segs = await ui._kv.getItem(sk);
const arr = Array.isArray(segs) ? segs : (segs ? JSON.parse(segs) : []);
if (!arr.length) continue;
const last = arr[arr.length - 1];
const tmeta = last && last.travelMeta;
if (!tmeta || typeof tmeta !== 'object') continue;
const leftAt = tmeta.at || tmeta.firstSeenMs || 0;
if (!leftAt) continue;
// Staleness rules
if (now - leftAt > 8 * 60 * 60 * 1000) continue;
if (tmeta.etaMs && tmeta.etaMs > 0) {
if (now - tmeta.etaMs > 4 * 60 * 60 * 1000) continue;
}
const existing = state.rankedWarChangeMeta[id];
if (existing && existing.activeType === 'travel' && existing.firstSeenMs <= (tmeta.firstSeenMs || existing.firstSeenMs)) continue;
state.rankedWarChangeMeta[id] = {
activeType: (tmeta.landedAtMs && (now - tmeta.landedAtMs) < 2*60*1000) ? 'travelLanded' : 'travel',
dest: tmeta.dest || null,
mins: tmeta.mins || 0,
leavingAtMs: tmeta.at || tmeta.firstSeenMs || leftAt,
firstSeenMs: tmeta.firstSeenMs || tmeta.at || leftAt,
etaMs: tmeta.etaMs || 0,
isReturn: !!tmeta.isReturn,
landedAtMs: tmeta.landedAtMs || 0,
confident: tmeta.confident !== false,
ts: tmeta.at || leftAt
};
} catch(_) { /* per status key */ }
}
} catch(_) { /* embedded restore error */ }
// Phase 2: Load per-player persisted phase history (v2)
try {
const keys = await ui._kv.listKeys('tdm.phaseHistory.id_');
for (const k of keys) {
try {
const id = k.replace(/^tdm\.phaseHistory\.id_/, '');
if (!id) continue;
const arr = await ui._kv.getItem(k);
if (!Array.isArray(arr) || !arr.length) continue;
state._activityTracking = state._activityTracking || {};
state._activityTracking._phaseHistory = state._activityTracking._phaseHistory || {};
state._activityTracking._phaseHistory[id] = (state._activityTracking._phaseHistory[id] || []).concat(arr).slice(-100);
} catch(_) { /* per-key */ }
}
if (storage.get('tdmDebugPersist', false)) tdmlogger('info', '[Restore] rehydrated phaseHistory keys=' + (Array.isArray(keys) ? keys.length : 0));
} catch(_) { /* ignore */ }
try { ui._renderEpoch.schedule(); } catch(_) {}
} catch(_) { /* ignore top-level */ }
},
ensureActivityAlertTicker: () => {
if (state.ui.activityTickerIntervalId) return; // already running
state.ui.activityTickerIntervalId = utils.registerInterval(setInterval(() => {
try {
if (!state.page.isRankedWarPage) return;
const metas = state.rankedWarChangeMeta;
if (!metas) return;
const now = Date.now();
for (const id in metas) {
const meta = metas[id];
if (!meta || meta.activeType !== 'activity' || !meta.firstSeenAtMs) continue;
const elapsed = now - meta.firstSeenAtMs;
const mm = Math.floor(elapsed / 60000);
const ss = Math.floor((elapsed % 60000) / 1000);
const label = `${meta.abbr || 'On'} ${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}`;
if (meta.pendingText !== label) {
meta.pendingText = label;
try {
const link = document.querySelector(`a[href*="profiles.php?XID=${id}"]`);
if (link) {
const row = link.closest('li') || link.closest('tr');
const btn = row && row.querySelector('.dibs-notes-subrow .retal-btn');
if (btn && btn.textContent !== label) btn.textContent = label;
}
} catch(_) { /* noop */ }
}
}
} catch(_) { /* ignore ticker iteration errors */ }
}, 1000));
},
// Keep last-action tooltips dynamic using stored timestamps
ensureLastActionTicker: () => {
if (state.ui.lastActionTickerIntervalId) return;
state.ui.lastActionTickerIntervalId = utils.registerInterval(setInterval(() => {
try {
if (!state.page.isRankedWarPage) return;
document.querySelectorAll('.tdm-last-action-inline[data-last-ts]').forEach(el => {
const ts = Number(el.getAttribute('data-last-ts') || 0);
if (Number.isFinite(ts) && ts > 0) {
const full = `Last Action: ${utils.formatAgoFull(ts)}`;
if (el.title !== full) el.title = full;
// Also refresh the short label to stay current (granularity: s/m/h/d)
const shortTxt = utils.formatAgoShort(ts) || '';
if (el.textContent !== shortTxt) el.textContent = shortTxt;
// Ensure click-to-chat handler exists once
try {
if (!el.dataset.tdmLastClick) {
el.style.cursor = 'pointer';
el.onclick = (e) => {
try {
e.preventDefault(); e.stopPropagation();
const row = el.closest('li, tr');
const link = row ? row.querySelector('a[href*="profiles.php?XID="]') : null;
const id = link ? (link.href.match(/XID=(\d+)/) || [])[1] : null;
const name = link ? utils.sanitizePlayerName(utils.extractPlayerNameFromAnchor(link), (link.href.match(/[?&]XID=(\d+)/i)||[])[1]) : (el.dataset?.name || null);
const ts = Number(el.getAttribute('data-last-ts') || 0);
const short = utils.formatAgoShort(ts) || '';
const status = (row && utils.getActivityStatusFromRow) ? utils.getActivityStatusFromRow(row) : null;
ui.sendLastActionToChat(id || null, name || null, status || null, short || null);
} catch(_) { /* noop */ }
};
el.dataset.tdmLastClick = '1';
}
} catch(_) { /* noop */ }
}
});
} catch(_) { /* ignore */ }
}, 1000));
},
ensureTravelTicker: () => {
try {
if (ui._travelTickerInterval) return;
ui._travelTickerInterval = utils.registerInterval(setInterval(() => {
try {
const nodes = document.querySelectorAll('.tdm-travel-eta');
if (!nodes.length) return;
const now = Date.now();
nodes.forEach(el => {
try {
const row = el.closest('li');
if (!row) return;
const userLink = row.querySelector('a[href*="profiles.php?XID="]');
const id = userLink?.href?.match(/XID=(\d+)/)?.[1];
if (!id) return;
const meta = state.rankedWarChangeMeta[id];
if (!meta || (meta.activeType !== 'travel' && meta.activeType !== 'travelLanded')) return;
const destAbbr = meta.dest ? (utils.abbrevDest(meta.dest) || meta.dest.split(/[\s,]/)[0]) : '';
const arrow = meta.isReturn ? '\u2190' : '\u2192';
const leaving = meta.leavingAtMs || meta.ts || meta.firstSeenMs;
const leftLocal = leaving ? new Date(leaving).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) : '';
// Upgrade confidence if mins appear
if (el.classList.contains('tdm-travel-lowconf')) {
if (meta.mins && meta.mins > 0 && meta.leavingAtMs) {
meta.confident = true;
el.classList.remove('tdm-travel-lowconf');
el.style.transition = 'opacity 0.4s';
el.style.opacity = '0.2';
setTimeout(()=>{ el.style.opacity='1'; },30);
}
}
if (meta.activeType === 'travel') {
const mins = Number(meta.mins)||0;
if (mins>0 && meta.leavingAtMs) {
const etaMs = meta.etaMs || utils.travel.computeEtaMs(meta.leavingAtMs, mins);
meta.etaMs = etaMs;
let remMs = etaMs - now;
const remMin = Math.max(0, Math.ceil(remMs/60000));
const rh = Math.floor(remMin/60); const rm = remMin % 60;
const remStr = rh>0?`${rh}h${rm? ' ' + rm + 'm':''}`:`${remMin}m`;
const planeType = (state.unifiedStatus?.[id]?.plane) || (utils.getKnownPlaneTypeForId?.(id) || 'light_aircraft');
tdmlogger('debug', '[BusinessDetect] User plane type detected:', { id, planeType });
// Keep inline travel text compact (no 'LEFT <time>')
let newText = `${arrow} ${destAbbr} *${planeType}* LAND~${remStr}`.trim();
if (remMs <= 0) {
meta.activeType = 'travelLanded';
meta.landedAtMs = now;
const landedLocal = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
newText = `${arrow} ${destAbbr} Landed at ${landedLocal}`;
}
if (el.textContent !== newText) el.textContent = newText;
}
}
if (meta.activeType === 'travelLanded') {
const elapsedMs = now - (meta.landedAtMs || now);
if (elapsedMs > 60000) {
const em = Math.floor(elapsedMs/60000);
if (!/\+\d+m$/.test(el.textContent)) el.textContent = `${el.textContent} +${em}m`;
if (elapsedMs > 115000) { // ~1m55s expire
try { ui._kv.removeItem(`tdm.travel.${id}`); } catch(_) {}
delete state.rankedWarChangeMeta[id];
el.style.transition='opacity .4s';
el.style.opacity='0';
setTimeout(()=>{ try { el.remove(); } catch(_) {}; },420);
}
}
}
} catch(_) { /* per element */ }
});
} catch(_) { /* loop */ }
}, 60000));
} catch(_) { /* ignore */ }
},
updateFactionPageUI: (container) => {
// --- Faction Header Metrics (Dibs & Med Deals Summary) ---
try {
if (state.page.isFactionPage && !state.page.isMyFactionPage) {
let header = document.querySelector('#tdm-faction-header-metrics');
if (!header) {
const anchor = document.querySelector('.faction-info-wrap');
if (anchor && anchor.parentElement) {
header = utils.createElement('div', { id: 'tdm-faction-header-metrics', style: { margin: '6px 0 4px 0', padding: '4px 6px', background: '#2d2c2c', border: '1px solid #444', borderRadius: '4px', fontSize: '0.75em', display: 'flex', gap: '12px', flexWrap: 'wrap' } });
anchor.parentElement.insertBefore(header, anchor.nextSibling);
}
}
if (header) {
const myIdStr = String(state.user?.tornId || '');
const activeDibs = (state.dibsData || []).filter(d => d.dibsActive);
const myDibsCount = activeDibs.filter(d => String(d.userId) === myIdStr).length;
const medDealsArr = Object.values(state.medDeals || {}).filter(s => s && s.isMedDeal);
const myMedDeals = medDealsArr.filter(s => String(s.medDealForUserId) === myIdStr).length;
const totalMedDeals = medDealsArr.length;
const html = `
<span style="color:#4caf50">My Dibs: ${myDibsCount}</span>
<span style="color:#ff9800">All Dibs: ${activeDibs.length}</span>
<span style="color:#2196f3">My Med Deals: ${myMedDeals}</span>
<span style="color:#9c27b0">All Med Deals: ${totalMedDeals}</span>
`;
if (header.innerHTML.trim() !== html.trim()) header.innerHTML = html;
}
}
} catch(_) { /* non-fatal */ }
const members = container.querySelectorAll('.f-war-list .table-body > li.table-row');
if (!members.length) {
return;
}
members.forEach(memberLi => {
const dibsDealsContainer = memberLi.querySelector('.tdm-dibs-deals-container');
const notesContainer = memberLi.querySelector('.tdm-notes-container');
if (!dibsDealsContainer && !notesContainer) return;
const memberIdLink = memberLi.querySelector('a[href*="profiles.php?XID="]');
if (!memberIdLink) return;
const opponentId = memberIdLink.href.match(/XID=(\d+)/)[1];
const opponentName = utils.sanitizePlayerName(utils.extractPlayerNameFromAnchor(memberIdLink), opponentId, { fallbackPrefix: 'Opponent' });
const dibsCell = dibsDealsContainer ? dibsDealsContainer.querySelector('.dibs-cell') : null;
const notesCell = notesContainer ? notesContainer.querySelector('.notes-cell') : null;
// --- Update Dibs & Med Deal Cell ---
// Note: column hiding on own-faction page handled centrally by updateColumnVisibilityStyles
if (dibsDealsContainer) {
const dibsButton = dibsCell?.querySelector('.dibs-button');
const medDealButton = dibsCell?.querySelector('.med-deal-button');
// Update buttons if present (harmless if the column is hidden by CSS)
if (dibsButton && utils.updateDibsButton) utils.updateDibsButton(dibsButton, opponentId, opponentName);
if (medDealButton && utils.updateMedDealButton) utils.updateMedDealButton(medDealButton, opponentId, opponentName);
}
// --- Conditional Notes Cell Update START ---
const noteButton = notesCell.querySelector('.note-button');
const userNote = state.userNotes[opponentId];
if (noteButton) {
const content = userNote?.noteContent || '';
utils.updateNoteButtonState(noteButton, content);
noteButton.onclick = (e) => ui.openNoteModal(opponentId, opponentName, content, e.currentTarget);
noteButton.disabled = false;
}
// --- Conditional Notes Cell Update END ---
});
},
injectAttackPageUI: async () => {
const opponentId = new URLSearchParams(window.location.search).get('user2ID');
if (!opponentId) return;
ui.createSettingsButton();
const appHeaderWrapper = document.querySelector('.playersModelWrap___dkqHO');
if (!appHeaderWrapper) return;
let attackContainer = document.getElementById('tdm-attack-container');
if (!attackContainer) {
attackContainer = utils.createElement('div', { id: 'tdm-attack-container', style: { margin: '10px', padding: '10px', background: '#2d2c2c', borderRadius: '5px', border: '1px solid #444', textAlign: 'center', color: 'white' } });
appHeaderWrapper.insertAdjacentElement('afterend', attackContainer);
}
// Robustly derive opponentName from attack page header — handle multiple id/class patterns & fallbacks
let opponentName = null;
try {
const playersEl = document.querySelector('.players___eKiHL');
if (playersEl) {
// Candidate selectors include legacy 'playername_', Torn's '-name' suffixes, and common user-name classes
const candidates = playersEl.querySelectorAll('span[id^="playername_"], span[id$="-name"], span.user-name, span.userName___loAWK, a[href*="profiles.php?XID="]');
if (candidates && candidates.length) {
// Prefer the last non-empty candidate (opponent name usually appears second)
for (let i = candidates.length - 1; i >= 0; i--) {
const txt = (candidates[i].textContent || '').trim();
if (!txt) continue;
if (/^back to profile$/i.test(txt) || /^profile$/i.test(txt)) continue;
opponentName = txt;
break;
}
}
}
} catch(_) { opponentName = null; }
// Fallback to known local dibs data or explicit ID if still missing
if (!opponentName) {
try {
const dib = Array.isArray(state.dibsData) ? state.dibsData.find(d => d && String(d.opponentId) === String(opponentId) && (d.opponentname || d.opponentName)) : null;
if (dib) opponentName = dib.opponentname || dib.opponentName || null;
} catch(_) { opponentName = null; }
}
const safeOpponentName = opponentName || `ID ${opponentId}`;
opponentName = utils.sanitizePlayerName(safeOpponentName, opponentId);
// --- FIX START: Build new content in a fragment to prevent flicker ---
const contentFragment = document.createDocumentFragment();
// Defer Score Cap Notification (non-blocking UI)
// Buttons render immediately; warning is verified asynchronously and updated in place
setTimeout(() => {
const existingWarning = attackContainer.querySelector('.score-cap-warning');
// Individual cap banner on attack page only after user cap reached
if (!state.user.hasReachedScoreCap) { if (existingWarning) existingWarning.remove(); }
(async () => {
try {
const oppFactionId = state.warData?.opponentFactionId || state.lastOpponentFactionId || state?.warData?.opponentId;
if (!oppFactionId) { if (existingWarning) existingWarning.remove(); return; }
// Prefer cached opponent faction members to determine membership
const tf = state.tornFactionData || {};
let inOppFaction = false;
const entry = tf[oppFactionId];
const readMembers = (data) => {
const m = data?.members || data?.member || data?.faction?.members;
if (!m) return [];
return Array.isArray(m) ? m : Object.values(m);
};
if (entry?.data) {
const arr = readMembers(entry.data);
inOppFaction = !!arr.find(x => String(x.id) === String(opponentId));
}
// If not found and freshness window allows, try a light members refresh
if (!inOppFaction) {
try { await api.getTornFaction(state.user.actualTornApiKey, 'members', oppFactionId); } catch(_) {}
const e2 = (state.tornFactionData || {})[oppFactionId];
if (e2?.data) {
const arr2 = readMembers(e2.data);
inOppFaction = !!arr2.find(x => String(x.id) === String(opponentId));
}
}
if (inOppFaction && state.user.hasReachedScoreCap) {
if (!existingWarning) {
const scoreCapWarning = utils.createElement('div', {
className: 'score-cap-warning',
style: { padding: '10px', marginBottom: '10px', backgroundColor: 'var(--tdm-color-error)', color: 'white', borderRadius: '5px', fontWeight: 'bold' },
textContent: 'Your individual score cap is reached. Do not attack.'
});
attackContainer.insertBefore(scoreCapWarning, attackContainer.firstChild);
}
} else if (existingWarning) {
existingWarning.remove();
}
} catch (error) {
tdmlogger('warn', `[Failed to verify opponent's faction for score cap warning] ${error}`);
}
})();
}, 0);
try {
// Build the button rows
const opponentDibs = state.dibsData.find(d => d.opponentId === opponentId && d.dibsActive);
const opponentMedDeal = state.medDeals[opponentId];
const opponentNote = state.userNotes[opponentId];
const buttonRow = utils.createElement('div', { style: { display: 'flex', gap: '4px', justifyContent: 'center', flexWrap: 'nowrap', marginBottom: '8px' } });
// ... (Button creation logic is the same)
const dibsBtn = utils.createElement('button', { className: 'btn tdm-btn dibs-btn', style: { minWidth: '70px', maxWidth: '70px', minHeight: '24px', boxSizing: 'border-box', fontSize: '0.75em', padding: '4px 6px', borderRadius: '3px' } });
if (utils.updateDibsButton || opponentDibs) utils.updateDibsButton(dibsBtn, opponentId, opponentName, { opponentPolicyCheck: true });
buttonRow.appendChild(dibsBtn);
if (state.warData.warType === 'Termed War') {
const medDealBtn = utils.createElement('button', { className: 'btn tdm-btn med-deal-btn', style: { minWidth: '70px', maxWidth: '70px', minHeight: '24px', boxSizing: 'border-box', fontSize: '0.75em', padding: '4px 6px', borderRadius: '3px' } });
if (utils.updateMedDealButton || opponentMedDeal) utils.updateMedDealButton(medDealBtn, opponentId, opponentName);
buttonRow.appendChild(medDealBtn);
}
const noteContent = opponentNote?.noteContent || '';
const notesBtn = utils.createElement('button', { textContent: noteContent || 'Note', title: noteContent, className: 'btn tdm-btn ' + (noteContent.trim() !== '' ? 'active-note-button' : 'inactive-note-button'), style: { minWidth: '70px', maxWidth: '70px', minHeight: '24px', boxSizing: 'border-box', fontSize: '0.75em', padding: '4px 6px', borderRadius: '3px' }, onclick: (e) => ui.openNoteModal(opponentId, opponentName, noteContent, e.currentTarget) });
buttonRow.appendChild(notesBtn);
// Always create a Retal button placeholder; updater will control visibility
const retalBtn = utils.createElement('button', { className: 'btn tdm-btn retal-btn btn-retal-inactive', style: { minWidth: '70px', maxWidth: '70px', minHeight: '24px', boxSizing: 'border-box', fontSize: '0.75em', padding: '4px 6px', borderRadius: '3px', marginLeft: 'auto', display: 'none' }, disabled: true, onclick: () => ui.sendRetaliationAlert(opponentId, opponentName) });
buttonRow.appendChild(retalBtn);
ui.updateRetaliationButton(retalBtn, opponentId, opponentName);
contentFragment.appendChild(buttonRow);
const assistRow = utils.createElement('div', { style: { display: 'flex', gap: '4px', justifyContent: 'center', flexWrap: 'wrap' } });
assistRow.appendChild(utils.createElement('span', { textContent: 'Need Assistance:', style: { alignSelf: 'center', fontSize: '0.9em', color: '#ffffffff', marginRight: '2px' } }));
const assistanceButtons = [{ text: 'Smoke/Flash (Speed)', message: 'Need Smoke/Flash on' }, { text: 'Tear/Pepper (Dex)', message: 'Need Tear/Pepper on' }, { text: 'Help Kill', message: 'Help Kill' }, { text: 'Target Down', message: 'Target Down' }];
assistanceButtons.forEach(btnInfo => {
assistRow.appendChild(utils.createElement('button', { className: 'btn tdm-btn req-assist-button', textContent: btnInfo.text, onclick: () => ui.sendAssistanceRequest(btnInfo.message, opponentId, opponentName), style: { minWidth: '70px', maxWidth: '70px', minHeight: '24px', boxSizing: 'border-box', fontSize: '0.75em', padding: '4px 6px', borderRadius: '3px' } }));
});
contentFragment.appendChild(assistRow);
// Replace only the button/assist rows, leaving the warning intact
const rowsToRemove = attackContainer.querySelectorAll('div:not(.score-cap-warning)');
rowsToRemove.forEach(row => row.remove());
attackContainer.appendChild(contentFragment);
// Ownership summary line (Dibs / Med Deal owners)
try {
let ownershipLine = document.getElementById('tdm-ownership-line');
if (!ownershipLine) {
ownershipLine = utils.createElement('div', { id: 'tdm-ownership-line', style: { marginTop: '6px', fontSize: '0.75em', opacity: 0.9 } });
attackContainer.appendChild(ownershipLine);
}
const activeDib = (state.dibsData || []).find(d => d.dibsActive && String(d.opponentId) === String(opponentId));
const medDeal = state.medDeals?.[opponentId];
const dibOwner = activeDib ? (activeDib.userId === state.user.tornId ? 'You' : activeDib.username) : 'None';
const medOwner = (medDeal && medDeal.isMedDeal) ? (medDeal.medDealForUserId === state.user.tornId ? 'You' : (medDeal.medDealForUsername || 'Someone')) : 'None';
const dibColor = dibOwner === 'You' ? '#4caf50' : (dibOwner === 'None' ? '#aaa' : '#ff9800');
const medColor = medOwner === 'You' ? '#2196f3' : (medOwner === 'None' ? '#aaa' : '#9c27b0');
const html = `<span style="color:${dibColor};">Dibs: ${dibOwner}</span> | <span style="color:${medColor};">Med Deal: ${medOwner}</span>`;
if (ownershipLine.innerHTML !== html) ownershipLine.innerHTML = html;
if (activeDib && activeDib.userId !== state.user.tornId) {
dibsBtn.title = `Owned by ${activeDib.username}. Removing requires permission.`;
}
if (medDeal && medDeal.isMedDeal && medDeal.medDealForUserId !== state.user.tornId) {
const medBtn = attackContainer.querySelector('button.med-deal-btn');
if (medBtn) medBtn.title = `Med Deal owned by ${medDeal.medDealForUsername}`;
}
} catch(_) { /* non-fatal */ }
// --- FIX END ---
// Display dib / med summary via existing message box system
try { ui.showAttackPageDibMedSummary?.(opponentId); } catch(_) {}
// Auto-remove dib on successful attack if policy requires re-dib
try {
const opts = utils.getDibsStyleOptions();
if (opts.mustRedibAfterSuccess) {
// Only if I have active dib on this opponent
const myDib = (state.dibsData || []).find(d => d.dibsActive && d.userId === state.user.tornId && String(d.opponentId) === String(opponentId));
if (myDib) {
// Observe for Torn result title appearing
const selector = 'div.title___fOh2J';
const checkAndRemove = async (node) => {
try {
const el = node && node.nodeType === 1 ? node.querySelector(selector) : document.querySelector(selector);
const text = el?.textContent?.trim() || '';
const defeated = text.startsWith('You defeated');
const includesName = defeated && (text.toLowerCase().includes(String(opponentName).toLowerCase()));
if (defeated && includesName) {
// Fire remove with explicit reason
await api.post('removeDibs', { dibsDocId: myDib.id, removedByUsername: state.user.tornUsername, factionId: state.user.factionId, removalReason: 'Dib Successfully Completed' });
ui.showMessageBox(`Dibs removed: You defeated ${opponentName}.`, 'success');
handlers.debouncedFetchGlobalData();
if (state.script.mutationObserver) { try { state.script.mutationObserver.disconnect(); } catch(_){} }
}
} catch(_) { /* ignore */ }
};
// Initial check in case the element already exists
checkAndRemove(document);
// Observe body for changes
if (state.script.mutationObserver) { try { state.script.mutationObserver.disconnect(); } catch(_){} }
state.script.mutationObserver = utils.registerObserver(new MutationObserver((mutations) => {
for (const m of mutations) {
for (const n of m.addedNodes) {
checkAndRemove(n);
}
}
}));
state.script.mutationObserver.observe(document.body, { childList: true, subtree: true });
}
}
} catch(_) { /* non-fatal */ }
} catch (error) {
tdmlogger('error', `[Error in attack page UI injection] ${error}`);
attackContainer.innerHTML = '<p style="color: #ff6b6b;">Error loading attack page UI</p>';
}
},
// Prominent single-line (or two-line) message summarizing active dib / med deal on attack page.
// Uses cached snapshots only; no network calls.
showAttackPageDibMedSummary: (explicitOpponentId = null) => {
try {
if (!state.page.isAttackPage) return;
const opponentId = explicitOpponentId || new URLSearchParams(window.location.search).get('user2ID');
if (!opponentId) return;
const activeDib = (state.dibsData || []).find(d => d.dibsActive && String(d.opponentId) === String(opponentId));
const medDeal = state.medDeals?.[opponentId];
if (!activeDib && !(medDeal && medDeal.isMedDeal)) return; // Nothing to show
const dibOwner = activeDib ? (activeDib.userId === state.user.tornId ? 'You' : (activeDib.username || 'Unknown')) : null;
const medOwner = (medDeal && medDeal.isMedDeal) ? (medDeal.medDealForUserId === state.user.tornId ? 'You' : (medDeal.medDealForUsername || 'Unknown')) : null;
const parts = [];
if (dibOwner) parts.push(`Dib: ${dibOwner}`);
if (medOwner) parts.push(`Med Deal: ${medOwner}`);
const msg = parts.join(' | ');
// Use info variant and short duration (3s) so it does not clutter
ui.showMessageBox(msg, 'info', 3000);
} catch(_) { /* silent */ }
},
sendAssistanceRequest: async (message, opponentId, opponentName) => {
const _pl = utils.buildProfileLink(opponentId, opponentName);
const opponentLink = (_pl && _pl.outerHTML) ? _pl.outerHTML : (`<a href="/profiles.php?XID=${opponentId}">${utils.sanitizePlayerName(opponentName, opponentId)}</a>`);
const fullMessage = `TDM - ${message} ${opponentLink}`;
const facId = state.user.factionId;
// Copy to clipboard as fallback
try { if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(fullMessage); } catch(_) {}
// Or get the faction button by id
const chatButton = document.querySelector(`#channel_panel_button\\:faction-${facId}`);
if (storage.get('pasteMessagesToChatEnabled', true)) {
ui.enqueueFactionChatMessage(fullMessage, facId, chatButton);
} else {
// User opted to only copy messages to clipboard (no auto-paste)
ui.showTransientMessage('Message copied to clipboard (auto-paste disabled)', { type: 'info', timeout: 1400 });
}
},
sendDibsMessage: async (opponentId, opponentName, assignedToUsername = null, customPrefix = null) => {
try {
// Always create/copy the dibs message to clipboard. The paste behavior
// (automatically populating faction chat) is controlled separately by
// the unified `pasteMessagesToChatEnabled` setting later in this function.
// Simple cooldown to avoid duplicate messages if UI double-fires
const now = Date.now();
const last = state.session._lastDibsChat || { ts: 0, opponentId: null, text: '' };
if (last.opponentId === String(opponentId) && (now - last.ts) < 3000) return;
// Prefer a cached/stored opponent name to avoid picking up navigation anchors
const effectiveName = opponentName || state.ui?.opponentStatusCache?.name || state.session?.userStatusCache?.[String(opponentId)]?.name || null;
const _pl2 = utils.buildProfileLink(opponentId, effectiveName || opponentName);
const link = (_pl2 && _pl2.outerHTML) ? _pl2.outerHTML : (`<a href="/profiles.php?XID=${opponentId}">${utils.sanitizePlayerName(effectiveName || opponentName, opponentId)}</a>`);
const prefix = customPrefix || (assignedToUsername ? `Dibs (for ${assignedToUsername}):` : 'Dibs:');
// Derive opponent status (Hospital/Travel/etc) and remaining time if relevant.
// Reuse logic from opponentStatusCache where possible; fallback to cached member/user status.
let statusFragment = '';
try {
const oppIdStr = String(opponentId);
let canonical = '';
let until = 0;
let rawDesc = '';
// 1. Try opponentStatusCache if same opponent & fresh (<15s)
const osc = state.ui?.opponentStatusCache;
if (osc && osc.opponentId === oppIdStr && (now - (osc.lastFetch||0)) < 15000) {
rawDesc = osc.text || '';
// Heuristic: if we stored Hospital countdown externally keep untilEpoch
until = osc.untilEpoch || 0;
if (/hospital/i.test(rawDesc) || rawDesc === 'Hosp') canonical = 'Hospital';
}
// 2. Fallback to faction member data (already cached by other flows)
if (!canonical) {
const oppFactionId = state.lastOpponentFactionId || state?.warData?.opponentId;
const tf = state.tornFactionData?.[oppFactionId];
if (tf?.data?.members) {
const arr = Array.isArray(tf.data.members) ? tf.data.members : Object.values(tf.data.members);
const m = arr.find(x => String(x.id) === oppIdStr);
if (m?.status) {
// Check for previous status from unified tracking to help HospitalAbroad detection
const prevUnified = state.unifiedStatus?.[oppIdStr];
const rec = utils.buildUnifiedStatusV2(m, prevUnified);
canonical = rec?.canonical || '';
rawDesc = m.status.description || m.status.state || '';
if (canonical === 'Hospital' || canonical === 'HospitalAbroad') until = Number(m.status.until)||0;
}
}
}
// 3. Fallback to per-user cached status helper (10s TTL internally managed)
if (!canonical) {
// getUserStatus returns a promise; we'll queue a secondary update if it resolves after initial send
try {
utils.getUserStatus(oppIdStr).then(us => {
if (!us) return;
// If we already populated a fragment skip unless we had none
if (statusFragment) return;
let c2 = us.canonical || us.raw?.state || '';
if (!c2 || c2 === 'Okay') return;
let until2 = (c2 === 'Hospital' || c2 === 'HospitalAbroad') ? (us.until || 0) : 0;
let frag = '';
if (c2 === 'Hospital' || c2 === 'HospitalAbroad') {
let remain2 = until2 ? (Math.floor(until2) - Math.floor(Date.now()/1000)) : 0;
if (remain2 > 0 && remain2 < 3600) {
const mm2 = Math.floor(remain2/60);
const ss2 = (remain2%60).toString().padStart(2,'0');
const hospLabel = c2 === 'HospitalAbroad' ? 'HospAbroad' : 'Hosp';
frag = ` - ${hospLabel} ${mm2}:${ss2}`;
} else {
const hospLabel = c2 === 'HospitalAbroad' ? 'HospAbroad' : 'Hosp';
frag = ` - ${hospLabel}`;
}
} else {
const shortMap = { Travel: 'Trav', Abroad: 'Abroad', Jail: 'Jail' };
frag = ` - ${shortMap[c2]||c2}`;
}
// Attempt to append by editing textarea if value still matches base message
const facId = state.user.factionId;
const chatTextArea = document.querySelector(`#faction-${facId} > div.content___n3GFQ > div.root___WUd1h > textarea`);
if (chatTextArea && chatTextArea.value && chatTextArea.value.includes(link) && !chatTextArea.value.includes(frag.trim())) {
chatTextArea.value = `${chatTextArea.value}${frag}`.trim();
chatTextArea.dispatchEvent(new Event('input', { bubbles: true }));
}
}).catch(()=>{});
} catch(_) { /* noop */ }
}
// Format time left for Hospital/HospitalAbroad (avoid travel ETA noise for simplicity unless clearly available)
if (canonical === 'Hospital' || canonical === 'HospitalAbroad') {
let suffix = '';
let remain = until ? (Math.floor(until) - Math.floor(Date.now()/1000)) : 0;
if (remain > 0 && remain < 3600) { // cap to <1h for compactness
const mm = Math.floor(remain/60);
const ss = (remain%60).toString().padStart(2,'0');
suffix = `${mm}:${ss}`;
}
if (/\d+:\d{2}/.test(rawDesc)) {
// Already has time string
const hospLabel = canonical === 'HospitalAbroad' ? 'HospAbroad' : 'Hosp';
statusFragment = ` - ${hospLabel} ${rawDesc.match(/\d+:\d{2}/)[0]}`;
} else if (suffix) {
const hospLabel = canonical === 'HospitalAbroad' ? 'HospAbroad' : 'Hosp';
statusFragment = ` - ${hospLabel} ${suffix}`;
} else {
const hospLabel = canonical === 'HospitalAbroad' ? 'HospAbroad' : 'Hosp';
statusFragment = ` - ${hospLabel}`;
}
} else if (canonical && canonical !== 'Okay') {
// Short labels
const shortMap = { Travel: 'Trav', Abroad: 'Abroad', Jail: 'Jail' };
statusFragment = ` - ${shortMap[canonical]||canonical}`;
}
} catch (e) { /* swallow status errors */ }
const fullMessage = `${prefix} ${link}${statusFragment}`.trim();
// Copy to clipboard as fallback
try { if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(fullMessage); } catch(_) {}
const facId = state.user.factionId;
const chatButton = document.querySelector(`#channel_panel_button\\:faction-${facId}`);
if (storage.get('pasteMessagesToChatEnabled', true)) {
ui.enqueueFactionChatMessage(fullMessage, facId, chatButton);
// ui.showTransientMessage('Sent to chat (and copied to clipboard)', { type: 'info', timeout: 1400 });
} else {
// User opted to only copy messages to clipboard (no auto-paste)
ui.showTransientMessage('Message copied to clipboard (auto-paste disabled)', { type: 'info', timeout: 1400 });
}
state.session._lastDibsChat = { ts: now, opponentId: String(opponentId), text: fullMessage };
} catch (_) { /* non-fatal */ }
},
// Send a short last-action message to faction chat (uses same enqueue/populate helpers)
sendLastActionToChat: async (opponentId, opponentName, statusLabel = null, lastActionShort = null) => {
try {
if (!opponentId) return;
const facId = state.user.factionId;
// Build profile link
const _pl3 = utils.buildProfileLink(opponentId, opponentName || `ID ${opponentId}`);
const link = (_pl3 && _pl3.outerHTML) ? _pl3.outerHTML : (`<a href="/profiles.php?XID=${opponentId}">${utils.sanitizePlayerName(opponentName || `ID ${opponentId}`, opponentId)}</a>`);
// Status and last action fragments
const statusFrag = statusLabel ? ` - now ${statusLabel}` : '';
const lastFrag = lastActionShort ? ` - Last Action: ${lastActionShort}` : '';
const fullMessage = `${link}${statusFrag}${lastFrag}`.trim();
// Copy to clipboard as fallback
try { if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(fullMessage); } catch(_) {}
// Try to enqueue/paste to faction chat only if user wants auto-paste.
const chatBtn = document.querySelector(`#channel_panel_button\\:faction-${facId}`);
if (storage.get('pasteMessagesToChatEnabled', true)) {
ui.enqueueFactionChatMessage(fullMessage, facId, chatBtn);
// ui.showTransientMessage('Sent to chat (and copied to clipboard)', { type: 'info', timeout: 1400 });
} else {
// User opted to only copy messages to clipboard (no auto-paste)
ui.showTransientMessage('Message copied to clipboard (auto-paste disabled)', { type: 'info', timeout: 1400 });
}
} catch (_) { /* non-fatal */ }
},
// Queue a faction chat message ensuring the correct channel is open and textarea ready.
enqueueFactionChatMessage(message, factionId, chatButton) {
const MAX_WAIT_MS = 5000; // Increased timeout
const CHECK_INTERVAL = 200; // Slightly longer checks
const started = Date.now();
let injected = false;
const normalizedMessage = (typeof message === 'string' ? message.trim() : '');
const textareaSelector = `#faction-${factionId} > div.content___n3GFQ > div.root___WUd1h > textarea`;
const messageAlreadyPresent = () => {
const chatTextArea = document.querySelector(textareaSelector);
if (!chatTextArea) return false;
const currentValue = (chatTextArea.value || '').trim();
return Boolean(normalizedMessage && currentValue.includes(normalizedMessage));
};
let chatWasAlreadyOpen = !!document.querySelector(textareaSelector);
const attempt = () => {
const chatTextArea = document.querySelector(textareaSelector);
if (messageAlreadyPresent()) {
injected = true;
return;
}
const correctChannel = !!document.querySelector(`#channel_panel_button\\:faction-${factionId}.active, #channel_panel_button\\:faction-${factionId}[aria-selected="true"]`);
const ready = chatTextArea && (chatWasAlreadyOpen || correctChannel);
if (ready) {
injected = true;
const delay = chatWasAlreadyOpen ? 500 : 900;
setTimeout(() => {
ui.populateChatMessage(message);
}, delay);
return;
}
if ((Date.now() - started) >= MAX_WAIT_MS) {
if (messageAlreadyPresent()) return;
if (chatTextArea) {
ui.populateChatMessage(message);
setTimeout(() => {
if (!messageAlreadyPresent()) {
ui.fallbackCopyToClipboard(message, 'Paste Manually');
}
}, 400);
return;
}
ui.fallbackCopyToClipboard(message, 'Paste Manually');
return;
}
setTimeout(attempt, CHECK_INTERVAL);
};
if (!chatWasAlreadyOpen && chatButton) {
chatButton.click();
setTimeout(attempt, 400);
} else {
attempt();
}
},
populateChatMessage(message) {
const facId = state.user.factionId;
const chatTextArea = document.querySelector(`#faction-${facId} > div.content___n3GFQ > div.root___WUd1h > textarea`);
if (chatTextArea) {
// 1. Set the value of the textarea.
// This might be read by the component during its sync process.
chatTextArea.value = message;
// Optional: Dispatch the 'input' event.
// Keep this in case the component also relies on it in combination with the mutation.
const inputEvent = new Event('input', { bubbles: true });
chatTextArea.dispatchEvent(inputEvent);
// 2. Trigger a DOM mutation to force the component to sync.
// Toggling a data attribute is a reliable and non-visual way to do this.
const dataAttributeName = 'data-userscript-synced'; // Use a distinct attribute name
if (chatTextArea.hasAttribute(dataAttributeName)) {
chatTextArea.removeAttribute(dataAttributeName);
} else {
chatTextArea.setAttribute(dataAttributeName, Date.now().toString()); // Add or update the attribute
}
// Focus the textarea to ensure it's ready for user to send
try {
chatTextArea.focus();
} catch(_) { /* ignore focus errors */ }
tdmlogger('debug', 'Textarea value set and DOM mutation triggered.');
ui.showMessageBox('Message added to faction chat. Click send manually.', 'success');
} else {
tdmlogger('warn', `Chat textbox not found, message: ${message}`);
ui.fallbackCopyToClipboard(message, 'Chat not found');
}
},
fallbackCopyToClipboard(text, reason) {
// Robust clipboard fallback
const perform = async () => {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
// ui.showMessageBox(`${reason}. Message Copied!`, 'info');
return true;
}
} catch (_) { /* fallback to legacy */ }
// Legacy method
try {
const ta = document.createElement('textarea');
ta.style.position = 'fixed';
ta.style.opacity = '0';
ta.value = text;
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
// ui.showMessageBox(`${reason}. ${ok ? 'Message Copied!' : 'Copy failed.'}`, ok ? 'info' : 'error');
} catch (e) {
ui.showMessageBox(`${reason}. Copy unavailable.`, 'error');
}
};
perform();
},
sendRetaliationAlert: async (opponentId, opponentName) => {
const retalOpp = state.retaliationOpportunities[opponentId];
if (!retalOpp) return;
const now = Math.floor(Date.now() / 1000);
const timeRemaining = retalOpp.retaliationEndTime - now;
let timeStr = 'expired';
if (timeRemaining > 0) {
const minutes = Math.floor(timeRemaining / 60);
const seconds = Math.floor(timeRemaining % 60);
timeStr = `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
const _pl4 = utils.buildProfileLink(opponentId, opponentName);
const opponentLink = (_pl4 && _pl4.outerHTML) ? _pl4.outerHTML : (`<a href="/profiles.php?XID=${opponentId}">${utils.sanitizePlayerName(opponentName, opponentId)}</a>`);
const fullMessage = `Retal Available ${opponentLink} time left: ${timeStr} Hospitalize`;
const facId = state.user.factionId;
const chatButton = document.querySelector(`#channel_panel_button\\:faction-${facId}`);
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(fullMessage);
// ui.showTransientMessage('Retal message copied to clipboard', { type: 'info', timeout: 1600 });
} else {
ui.fallbackCopyToClipboard(fullMessage, 'Retal message copied to clipboard');
}
} catch (e) {
try { ui.fallbackCopyToClipboard(fullMessage, 'Retal message copied to clipboard'); } catch(_) {}
}
if (storage.get('pasteMessagesToChatEnabled', true)) {
ui.enqueueFactionChatMessage(fullMessage, facId, chatButton);
} else {
ui.showTransientMessage('Message copied to clipboard (auto-paste disabled)', { type: 'info', timeout: 1400 });
}
},
updateRetaliationButton: (button, opponentId, opponentName) => {
// Check if alert buttons are globally disabled
const alertButtonsEnabled = storage.get('alertButtonsEnabled', true);
if (!alertButtonsEnabled) {
// Clear any prior interval and hide the button
if (button._retalIntervalId) {
try { utils.unregisterInterval(button._retalIntervalId); } catch(_) {}
button._retalIntervalId = null;
}
button.style.display = 'none';
button.disabled = true;
button.innerHTML = '';
button.className = 'btn retal-btn tdm-alert-btn tdm-alert-inactive';
return;
}
// Check if the alert observer is actively managing this button
const meta = state.rankedWarChangeMeta && state.rankedWarChangeMeta[opponentId];
const hasActiveAlert = !!(meta && (meta.activeType || meta.pendingText));
if (hasActiveAlert) {
// Let the alert observer handle this button - don't interfere
return;
}
// Clear any prior interval tied to this button
if (button._retalIntervalId) {
try { utils.unregisterInterval(button._retalIntervalId); } catch(_) {}
button._retalIntervalId = null;
}
const show = () => {
button.style.display = 'inline-block';
button.disabled = false;
button.innerHTML = '';
button.className = 'btn retal-btn tdm-alert-btn tdm-alert-retal';
button.onclick = () => ui.sendRetaliationAlert(opponentId, opponentName);
};
const hide = () => {
button.style.display = 'none';
button.disabled = true;
button.innerHTML = '';
button.className = 'btn retal-btn tdm-alert-btn tdm-alert-inactive';
if (button._retalIntervalId) {
try { utils.unregisterInterval(button._retalIntervalId); } catch(_) {}
button._retalIntervalId = null;
}
};
const computeAndRender = () => {
const current = state.retaliationOpportunities[opponentId];
if (!current) {
hide();
return false;
}
const now = Math.floor(Date.now() / 1000);
const timeRemaining = current.retaliationEndTime - now;
if (timeRemaining <= 0) {
hide();
return false;
}
show();
const mm = Math.floor(timeRemaining / 60);
const ss = ('0' + (timeRemaining % 60)).slice(-2);
button.textContent = `Retal👉${mm}:${ss}`;
return true;
};
// Initial render
const active = computeAndRender();
if (!active) return;
// Keep ticking; auto-hide when expired or fulfilled
button._retalIntervalId = utils.registerInterval(setInterval(() => {
const stillActive = computeAndRender();
if (!stillActive) {
// interval cleared in hide(); just be safe
if (button._retalIntervalId) {
try { utils.unregisterInterval(button._retalIntervalId); } catch(_) {}
button._retalIntervalId = null;
}
}
}, 1000));
},
openNoteModal: (tornID, tornUsername, currentNoteContent, buttonElement) => {
state.ui.currentNoteButtonElement = buttonElement || null;
state.ui.currentNoteTornID = tornID;
state.ui.currentNoteTornUsername = tornUsername;
const parseTags = (txt) => {
if (!txt) return { tags: [], body: '' };
const lines = txt.split(/\n/);
if (lines.length && /^#tags:/i.test(lines[0])) {
const raw = lines.shift().replace(/^#tags:/i,'').trim();
const tags = raw ? raw.split(/[,\s]+/).filter(Boolean).slice(0,12) : [];
return { tags, body: lines.join('\n') };
}
return { tags: [], body: txt };
};
const buildNoteText = (tags, body) => {
const cleanTags = (tags||[]).filter(Boolean);
return cleanTags.length ? `#tags: ${cleanTags.join(', ')}\n${body}`.trim() : body.trim();
};
const ensureModal = () => {
if (state.ui.noteModal) return;
const modal = utils.createElement('div', { id: 'user-note-modal', className: 'tdm-note-modal' });
modal.innerHTML = `
<div class="tdm-note-modal-header">
<div class="tdm-note-title-wrap"><span class="tdm-note-title"></span></div>
<div class="tdm-note-actions">
<button class="tdm-note-btn tdm-note-copy" title="Copy note (Ctrl+C)">⧉</button>
<button class="tdm-note-btn tdm-note-clear" title="Clear note">✕</button>
<button class="tdm-note-btn tdm-note-close" title="Close">×</button>
</div>
</div>
<div class="tdm-note-tags-row">
<div class="tdm-note-quick-tags"></div>
<input type="text" class="tdm-note-tag-input" placeholder="" maxlength="24" style="display:none;margin-left:6px;" />
<button type="button" class="tdm-note-add-tag-btn" style="margin-left:6px;padding:4px 8px;border-radius:4px;border:1px solid var(--tdm-bg-secondary);background:var(--tdm-bg-card);color:var(--tdm-text-primary);">Add New Tag</button>
</div>
<div class="tdm-note-tags-empty" style="display:none"></div>
<textarea class="tdm-note-text" rows="1" placeholder="Enter note..." maxlength="5000" style="resize:vertical;min-height:1.4em;max-height:18em;"></textarea>
<div class="tdm-note-footer">
<span class="tdm-note-status">Idle</span>
<span class="tdm-note-char">0/5000</span>
<span class="tdm-note-meta"></span>
</div>
<div class="tdm-note-snapshot" style="display:none;margin:4px 0 8px;padding:6px 8px;border:1px solid var(--tdm-bg-secondary);background:var(--tdm-bg-card);border-radius:6px;font:11px/1.4 monospace;color:var(--tdm-text-primary)"></div>
<div class="tdm-note-history-wrap" style="margin-top:6px;max-height:130px;overflow:auto;display:none;">
<div class="tdm-note-history-header" style="font-size:10px;color:#64748b;display:flex;align-items:center;gap:8px;">
<span>Recent Status Transitions</span>
<button class="tdm-note-history-refresh" title="Refresh transitions" style="background:#1f2937;border:1px solid #334155;color:#94a3b8;border-radius:4px;font-size:10px;padding:2px 6px;cursor:pointer;">↻</button>
</div>
<ul class="tdm-note-history" style="list-style:none;margin:4px 0 0;padding:0;font:11px/1.3 monospace;">
</ul>
</div>
`;
document.body.appendChild(modal);
state.ui.noteModal = modal;
state.ui.noteTextarea = modal.querySelector('.tdm-note-text');
// Styles
if (!document.getElementById('tdm-note-style')) {
const style = utils.createElement('style', { id:'tdm-note-style', textContent:`
.tdm-note-modal{
position:fixed;
top:50%;
left:50%;
transform:translate(-50%,-50%);
background:var(--tdm-modal-bg);
color:var(--tdm-text-primary);
border:1px solid var(--tdm-modal-border);
border-radius:var(--tdm-radius-xl);
z-index:var(--tdm-z-modal);
box-shadow:var(--tdm-shadow-modal);
width:520px;
max-width:80vw;
padding:var(--tdm-space-xl) var(--tdm-space-xl);
font:var(--tdm-font-size-base)/1.45 'Inter','Segoe UI',sans-serif;
backdrop-filter:blur(12px);
max-height:85vh;
overflow-y:auto;
display:none;
}
.tdm-note-modal[style*="display: block"]{
display:block !important;
opacity:1;
}
.tdm-note-modal-header{
display:flex;
align-items:flex-start;
justify-content:space-between;
margin-bottom:var(--tdm-space-lg);
gap:var(--tdm-space-lg);
border-bottom:1px solid var(--tdm-modal-border);
padding-bottom:var(--tdm-space-lg);
}
.tdm-note-title-wrap{display:flex;flex-direction:column;gap:var(--tdm-space-sm);min-width:0;}
.tdm-note-title{font-weight:600;font-size:var(--tdm-font-size-xl);letter-spacing:.02em;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--tdm-text-primary);}
.tdm-note-actions{display:flex;gap:var(--tdm-space-md);}
.tdm-note-btn{
background:var(--tdm-bg-card);
border:1px solid var(--tdm-bg-secondary);
color:var(--tdm-text-secondary);
cursor:pointer;
border-radius:var(--tdm-radius-md);
width:32px;
height:32px;
display:flex;
align-items:center;
justify-content:center;
font-size:14px;
padding:0;
transition:all var(--tdm-transition-fast);
}
.tdm-note-btn:hover{background:var(--tdm-color-info);border-color:var(--tdm-color-info);color:var(--tdm-text-primary);}
.tdm-note-btn:active{transform:scale(0.96);}
.tdm-note-text{
width:85%;
background:var(--tdm-bg-secondary);
border:1px solid var(--tdm-bg-secondary);
color:var(--tdm-text-primary);
padding:var(--tdm-space-md) var(--tdm-space-lg);
border-radius:var(--tdm-radius-md);
resize:vertical;
font:var(--tdm-font-size-base)/1.55 'JetBrains Mono',monospace;
min-height:40px;
transition:border-color var(--tdm-transition-fast);
}
.tdm-note-text:focus{outline:none;border-color:var(--tdm-color-info);box-shadow:0 0 0 1px var(--tdm-color-info);}
.tdm-note-footer{display:grid;grid-template-columns:auto auto 1fr;align-items:center;gap:var(--tdm-space-lg);margin-top:var(--tdm-space-md);font-size:var(--tdm-font-size-sm);color:var(--tdm-text-secondary);}
.tdm-note-status.saving{color:var(--tdm-color-warning);} .tdm-note-status.saved{color:var(--tdm-color-success);} .tdm-note-status.error{color:var(--tdm-color-error);}
.tdm-note-meta{font-family:'JetBrains Mono',monospace;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.tdm-note-char{font-family:'JetBrains Mono',monospace;}
.tdm-note-tags-row{display:flex;flex-wrap:wrap;gap:var(--tdm-space-md);margin:var(--tdm-space-md) 0;align-items:center;}
.tdm-note-tag{
background:var(--tdm-color-info);
color:var(--tdm-text-primary);
border:none;
padding:var(--tdm-space-xs) var(--tdm-space-md);
font-size:var(--tdm-font-size-sm);
border-radius:var(--tdm-radius-full);
display:inline-flex;
align-items:center;
gap:var(--tdm-space-md);
cursor:pointer;
box-shadow:var(--tdm-shadow-sm);
transition:background var(--tdm-transition-fast),transform var(--tdm-transition-fast);
}
.tdm-note-tag:hover{background:#1976D2;transform:translateY(-1px);}
.tdm-note-tag-remove{font-size:12px;line-height:1;cursor:pointer;color:var(--tdm-text-secondary);}
.tdm-note-tag-remove:hover{color:var(--tdm-color-error);}
.tdm-note-tag-input{
flex:1;
min-width:140px;
background:var(--tdm-bg-secondary);
border:1px solid var(--tdm-bg-secondary);
color:var(--tdm-text-primary);
padding:var(--tdm-space-sm) var(--tdm-space-md);
border-radius:var(--tdm-radius-md);
font:var(--tdm-font-size-sm)/1.35 'JetBrains Mono',monospace;
transition:border-color var(--tdm-transition-fast);
}
.tdm-note-tag-input:focus{outline:none;border-color:var(--tdm-color-info);}
.tdm-note-tags-empty{font-size:var(--tdm-font-size-sm);color:var(--tdm-text-muted);margin-bottom:var(--tdm-space-md);display:flex;flex-wrap:wrap;gap:var(--tdm-space-md);align-items:center;}
.tdm-note-tags-empty span{background:var(--tdm-bg-card);color:var(--tdm-text-accent);padding:1px 6px;border-radius:var(--tdm-radius-full);font-family:'JetBrains Mono',monospace;}
.tdm-note-help{margin-top:var(--tdm-space-md);font-size:var(--tdm-font-size-xs);color:var(--tdm-text-muted);display:flex;flex-wrap:wrap;gap:var(--tdm-space-md);}
.tdm-conf-badge{display:inline-block;font:var(--tdm-font-size-xs)/1.1 monospace;padding:2px 4px;border-radius:var(--tdm-radius-sm);margin:0 2px 0 4px;vertical-align:baseline;letter-spacing:.5px}
.tdm-conf-LOW{background:var(--tdm-bg-card);color:var(--tdm-text-primary);border:1px solid var(--tdm-bg-secondary);text-decoration:underline dotted;}
.tdm-conf-MED{background:var(--tdm-color-warning);color:#000;border:1px solid var(--tdm-color-warning);}
.tdm-conf-HIGH{background:var(--tdm-color-success);color:var(--tdm-text-primary);border:1px solid var(--tdm-color-success);}
.tdm-note-snapshot-line{white-space:normal;word-break:break-word;margin:0 0 2px;}
.tdm-note-snapshot-line span.label{color:var(--tdm-text-muted);font-weight:600;margin-right:var(--tdm-space-sm);}
.tdm-note-modal.shake{animation:tdmNoteShake .4s linear;}
@keyframes tdmNoteShake{10%,90%{transform:translate(-50%,-50%) translateX(-1px);}20%,80%{transform:translate(-50%,-50%) translateX(2px);}30%,50%,70%{transform:translate(-50%,-50%) translateX(-4px);}40%,60%{transform:translate(-50%,-50%) translateX(4px);}}
@media (max-width: 600px){
.tdm-note-modal{width:80vw;max-width:80vw;height:40vh;max-height:40vh;padding:var(--tdm-space-lg);overflow-y:auto;}
.tdm-note-text{max-height:96px;}
.tdm-note-history-wrap{max-height:72px;}
}
`});
document.head.appendChild(style);
}
};
ensureModal();
const modal = state.ui.noteModal; const ta = state.ui.noteTextarea; if (!modal || !ta) return;
const statusEl = modal.querySelector('.tdm-note-status');
const metaEl = modal.querySelector('.tdm-note-meta');
const charEl = modal.querySelector('.tdm-note-char');
const tagInput = modal.querySelector('.tdm-note-tag-input');
const quickTagContainer = modal.querySelector('.tdm-note-quick-tags');
const tagsEmpty = modal.querySelector('.tdm-note-tags-empty');
const addTagBtn = modal.querySelector('.tdm-note-add-tag-btn');
const titleEl = modal.querySelector('.tdm-note-title');
const copyBtn = modal.querySelector('.tdm-note-copy');
const clearBtn = modal.querySelector('.tdm-note-clear');
const closeBtn = modal.querySelector('.tdm-note-close');
const histWrap = modal.querySelector('.tdm-note-history-wrap');
const histList = modal.querySelector('.tdm-note-history');
const histRefresh = modal.querySelector('.tdm-note-history-refresh');
const snapshotBox = modal.querySelector('.tdm-note-snapshot');
titleEl.textContent = `${tornUsername} [${tornID}]`;
// Tags + body separation
const parsed = parseTags(currentNoteContent||'');
let currentTags = parsed.tags;
ta.value = parsed.body;
// Snapshot compact header builder
const fmtDur = (ms) => {
if (!ms || ms < 0) return '0s';
const s = Math.floor(ms/1000); const h=Math.floor(s/3600); const m=Math.floor((s%3600)/60); const sec=s%60;
const parts=[]; if (h) parts.push(h+'h'); if (m) parts.push(m+'m'); if (!h && !m) parts.push(sec+'s'); else if (sec && parts.length<2) parts.push(sec+'s');
return parts.slice(0,3).join(' ');
};
const confBadge = (c) => `<span class="tdm-conf-badge tdm-conf-${c}" aria-label="${c} confidence" title="Confidence: ${c}">${c}</span>`;
const buildSnapshot = () => {
if (!snapshotBox) return;
const rec = state.unifiedStatus?.[tornID];
if (!rec) { snapshotBox.style.display='none'; snapshotBox.innerHTML=''; return; }
const now = Date.now();
const history = state._activityTracking?._phaseHistory?.[tornID] || [];
const lastTransition = history.length ? history[history.length-1] : null;
const currentPhase = rec.canonical || rec.phase || '?';
const prevPhase = rec.previousPhase || (lastTransition ? lastTransition.from : null);
const started = rec.startedMs || rec.startMs || (lastTransition ? lastTransition.ts : now);
const elapsed = fmtDur(now - started);
const dest = rec.dest ? (utils.travel?.abbrevDest?.(rec.dest) || rec.dest) : '';
// Arrival / until handling: include rawUntil fallback and prefer arrivalMs for ETA
const arrMs = rec.arrivalMs || rec.etams || rec.etaMs || rec.etamsMs || (rec.rawUntil ? (Number(rec.rawUntil||0)*1000) : null);
let etaSeg = '';
if (arrMs && arrMs > now) {
if (/Hospital/i.test(currentPhase)) {
// For hospital-type phases show time remaining
etaSeg = `${fmtDur(arrMs - now)} remaining`;
} else {
etaSeg = `ETA ${new Date(arrMs).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}`;
}
} else if (arrMs && arrMs <= now && now - arrMs < 300000) {
etaSeg = 'arrived';
}
const lines = [];
const conf = rec.confidence || 'LOW';
const parts = [currentPhase, dest? '• '+dest:'' , etaSeg? '• '+etaSeg:'' ].filter(Boolean).join(' ');
lines.push(`<div class="tdm-note-snapshot-line"><span class="label">Current</span>${parts} ${confBadge(conf)}</div>`);
if (lastTransition) {
const age = fmtDur(now - lastTransition.ts);
lines.push(`<div class="tdm-note-snapshot-line" style="opacity:.7"><span class="label">Changed</span>${age} ago (${lastTransition.from}→${lastTransition.to})</div>`);
}
// Append up to 3 recent phase history entries if available for richer snapshot
// try {
// const phEntries = (state._activityTracking?._phaseHistory?.[tornID] || []).slice(-5).reverse();
// let added = 0;
// for (const e of phEntries) {
// if (added >= 3) break;
// const age = fmtDur(now - (e.ts || now));
// const conf = e.confTo || e.conf || '';
// const confDisp = conf && conf !== 'HIGH' ? ` (${conf[0]})` : '';
// const dest = e.dest ? ` ${utils.travel?.abbrevDest?.(e.dest) || e.dest}` : '';
// const etaPart = e.arrivalMs ? ` eta:${new Date(e.arrivalMs).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}` : '';
// lines.push(`<div class="tdm-note-snapshot-line" style="opacity:.85"><span class="label">Hist</span>${age} ago ${e.from}→${e.to}${confDisp}${dest}${etaPart}</div>`);
// added++;
// }
// } catch(_) {}
snapshotBox.innerHTML = lines.join('');
snapshotBox.style.display='block';
};
buildSnapshot();
// If phaseHistory for this player is not yet present in-memory (restore may be async on init),
// attempt an on-demand hydrate from KV so the modal shows history even if background restore hasn't finished.
(async () => {
try {
const hasInMem = !!(state._activityTracking && state._activityTracking._phaseHistory && Array.isArray(state._activityTracking._phaseHistory[tornID]) && state._activityTracking._phaseHistory[tornID].length);
if (!hasInMem && typeof ui !== 'undefined' && ui && ui._kv && typeof ui._kv.getItem === 'function') {
try {
const key = 'tdm.phaseHistory.id_' + String(tornID);
const arr = await ui._kv.getItem(key);
if (Array.isArray(arr) && arr.length) {
state._activityTracking = state._activityTracking || {};
state._activityTracking._phaseHistory = state._activityTracking._phaseHistory || {};
// merge but keep most recent up to 100
state._activityTracking._phaseHistory[tornID] = (state._activityTracking._phaseHistory[tornID] || []).concat(arr).slice(-100);
if (storage.get('tdmDebugPersist', false)) tdmlogger('debug', '[NoteModal] hydrated phaseHistory for ' + tornID + ' len=' + state._activityTracking._phaseHistory[tornID].length);
}
} catch(_) { /* ignore per-key errors */ }
}
} catch(_) {}
try { buildSnapshot(); } catch(_) {}
try { renderHistory(); } catch(_) {}
})();
const renderTags = () => {
quickTagContainer.innerHTML='';
currentTags.forEach(tag => {
const t = document.createElement('span'); t.className='tdm-note-tag'; t.textContent=tag;
const x=document.createElement('span'); x.className='tdm-note-tag-remove'; x.textContent='×'; x.title='Remove tag';
x.onclick=(e)=>{ e.stopPropagation(); currentTags=currentTags.filter(g=>g!==tag); renderTags(); scheduleSave(); };
t.appendChild(x);
quickTagContainer.appendChild(t);
});
// Quick add presets from storage (not already added)
let presets = utils.coerceStorageString(storage.get('tdmNoteQuickTags', 'dex+,def+,str+,spd+,hosp,retal'), 'dex+,def+,str+,spd+,hosp,retal');
const presetArr = presets.split(/[,\s]+/).filter(Boolean).slice(0,12);
const remain = presetArr.filter(p=>!currentTags.includes(p));
remain.slice(0,6).forEach(tag => {
const add = document.createElement('span'); add.className='tdm-note-tag'; add.textContent=tag; add.title='Add tag';
add.onclick=()=>{ currentTags.push(tag); renderTags(); scheduleSave(); };
quickTagContainer.appendChild(add);
});
if (tagsEmpty) {
tagsEmpty.style.display = currentTags.length ? 'none' : 'flex';
}
};
renderTags();
const updateChar = () => { charEl.textContent = `${ta.value.length}/5000`; };
updateChar();
// Remove autosave: only update status on edit, do not save until Save is clicked
const scheduleEdit = () => {
statusEl.textContent='Editing'; statusEl.className='tdm-note-status';
};
// Add New Tag button reveals the hidden tag input for mobile friendliness
if (addTagBtn) {
addTagBtn.addEventListener('click', (ev) => {
try { tagInput.style.display = ''; tagInput.focus(); tagInput.value = ''; } catch(_) {}
});
}
tagInput.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const v = (tagInput.value || '').trim();
if (v && !currentTags.includes(v)) { currentTags.push(v); tagInput.value = ''; renderTags(); scheduleEdit(); }
else { tagInput.value = ''; }
}
};
ta.oninput = () => { updateChar(); scheduleEdit(); };
// Remove markdown and keyboard shortcuts: keep only Escape to close
ta.onkeydown = (e) => {
if (e.key === 'Escape') { ui.closeNoteModal(); }
};
// Remove save on blur
// Buttons
closeBtn.onclick = ui.closeNoteModal;
copyBtn.onclick = () => { try { navigator.clipboard.writeText(buildNoteText(currentTags, ta.value)); copyBtn.textContent='✔'; setTimeout(()=>copyBtn.textContent='⧉',1200);} catch(_) { copyBtn.textContent='✖'; setTimeout(()=>copyBtn.textContent='⧉',1400);} };
clearBtn.onclick = () => { if (ta.value.trim()==='' && !currentTags.length) { modal.classList.add('shake'); setTimeout(()=>modal.classList.remove('shake'),400); return; } if (!confirm('Clear note & tags?')) return; ta.value=''; currentTags=[]; renderTags(); updateChar(); scheduleEdit(); };
// Add Save and Cancel buttons to footer
let footer = modal.querySelector('.tdm-note-footer');
if (footer && !footer.querySelector('.tdm-note-save')) {
const saveBtn = document.createElement('button');
saveBtn.className = 'tdm-note-save tdm-note-btn';
saveBtn.textContent = 'Save';
saveBtn.style.marginLeft = '12px';
saveBtn.style.minWidth = '60px';
saveBtn.onclick = async () => { await saveAndClose(); };
const cancelBtn = document.createElement('button');
cancelBtn.className = 'tdm-note-cancel tdm-note-btn';
cancelBtn.textContent = 'Cancel';
cancelBtn.style.marginLeft = '6px';
cancelBtn.style.minWidth = '60px';
cancelBtn.onclick = () => { ui.closeNoteModal(); };
footer.appendChild(saveBtn);
footer.appendChild(cancelBtn);
}
async function saveAndClose() {
try {
statusEl.textContent='Saving...'; statusEl.className='tdm-note-status saving';
const finalText = buildNoteText(currentTags, ta.value);
await handlers.handleSaveUserNote(state.ui.currentNoteTornID, finalText, state.ui.currentNoteButtonElement, { silent:false });
statusEl.textContent='Saved'; statusEl.className='tdm-note-status saved';
setTimeout(()=>{ ui.closeNoteModal(); }, 300);
} catch(e){ statusEl.textContent='Error'; statusEl.className='tdm-note-status error'; }
}
// Render phase history timeline
const renderHistory = () => {
if (!histWrap || !histList) return; histList.innerHTML='';
const ph = state._activityTracking?._phaseHistory?.[tornID];
if (!ph || !ph.length) { histWrap.style.display='none'; return; }
histWrap.style.display='block';
// Work with the entries in chronological order so we can compute first/last seen
const entries = [...ph].slice(-15); // oldest->newest
const now = Date.now();
const rec = state.unifiedStatus?.[tornID] || null;
// iterate newest first for UX
for (let i = entries.length - 1; i >= 0; i--) {
const tr = entries[i];
const li = document.createElement('li');
const firstSeenMs = tr.ts || 0;
// lastSeen is timestamp of next (newer) entry if present, else use rec.updated or now
const nextEntry = entries[i+1] || null;
const lastSeenMs = nextEntry ? (nextEntry.ts || now) : (rec?.updated || now);
const sinceStr = firstSeenMs ? new Date(firstSeenMs).toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'}) : '';
const lastSeenStr = lastSeenMs ? new Date(lastSeenMs).toLocaleString([], {hour:'2-digit', minute:'2-digit'}) : '';
const durationMs = Math.max(0, (lastSeenMs || now) - (firstSeenMs || now));
const durationStr = fmtDur(durationMs);
const conf = tr.confTo || tr.conf || ''; const confDisp = conf ? ` [${conf}]` : '';
const dest = tr.dest ? ` ${utils.travel?.abbrevDest?.(tr.dest) || tr.dest}` : '';
const etaPart = tr.arrivalMs ? ` • ETA ${new Date(tr.arrivalMs).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}` : '';
const left = `${tr.from}->${tr.to}`;
const sincePart = sinceStr ? ` Since: ${sinceStr}` : '';
const lastPart = lastSeenStr ? ` Last:${lastSeenStr}` : '';
li.textContent = `${left}${dest}${sincePart} Duration:${durationStr}${lastPart}${etaPart}${confDisp}`.trim();
li.setAttribute('aria-label', `Phase ${tr.from} to ${tr.to}. First seen ${sinceStr}. Duration ${durationStr}. Last seen ${lastSeenStr}.${conf ? ' Confidence ' + conf : ''}${dest ? ' destination ' + dest.trim() : ''}`);
li.style.padding='1px 0';
histList.appendChild(li);
}
};
histRefresh && (histRefresh.onclick = (e)=>{ e.preventDefault(); renderHistory(); });
renderHistory();
modal.style.display='block'; ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); statusEl.textContent='Idle'; statusEl.className='tdm-note-status';
// Keep snapshot live (lightweight)
if (snapshotBox) {
if (state.ui._noteSnapshotInterval) try { utils.unregisterInterval(state.ui._noteSnapshotInterval); } catch(_) {}
state.ui._noteSnapshotInterval = utils.registerInterval(setInterval(()=>{
try { if (modal.style.display!=='block') { try { utils.unregisterInterval(state.ui._noteSnapshotInterval); } catch(_) {} state.ui._noteSnapshotInterval = null; return; } buildSnapshot(); } catch(_){}
}, 5000));
}
},
closeNoteModal: () => {
if (state.ui.noteModal) state.ui.noteModal.style.display = 'none';
state.ui.currentNoteTornID = null;
state.ui.currentNoteTornUsername = null;
state.ui.currentNoteButtonElement = null;
if (state.ui._noteSnapshotInterval) { try { utils.unregisterInterval(state.ui._noteSnapshotInterval); } catch(_) {} state.ui._noteSnapshotInterval=null; }
},
openSetterModal: async (opponentId, opponentName, buttonElement, type) => {
// If admin functionality is disabled (or user lacks admin rights), treat buttons as simple toggles for self
const adminEnabled = !!state.script.canAdministerMedDeals && (storage.get('adminFunctionality', true) === true);
if (!adminEnabled) {
const defaultText = type === 'medDeal' ? 'Set Med Deal' : 'Dibs';
try {
if (buttonElement) {
buttonElement.dataset.originalText = buttonElement.dataset.originalText || buttonElement.textContent;
}
const resolveHandler = (debouncedName, plainName) => {
if (typeof handlers[debouncedName] === 'function') return handlers[debouncedName];
if (typeof handlers[plainName] === 'function') return handlers[plainName];
return null;
};
if (type === 'medDeal') {
const mds = (state.medDeals || {})[opponentId];
const isMyMedDeal = !!(mds && mds.isMedDeal && String(mds.medDealForUserId) === String(state.user.tornId));
// Toggle med deal for current user (resolve debounced or plain handler)
const medHandler = resolveHandler('debouncedHandleMedDealToggle', 'handleMedDealToggle');
if (medHandler) {
await medHandler(
opponentId,
opponentName,
!isMyMedDeal,
state.user.tornId,
state.user.tornUsername,
buttonElement
);
} else {
ui.showMessageBox('Action unavailable. Please reload the page.', 'warning', 4000);
}
} else {
// type === 'dibs': toggle dib for current user
const myActive = (state.dibsData || []).find(d => d.opponentId === opponentId && d.dibsActive && String(d.userId) === String(state.user.tornId));
if (myActive) {
const remHandler = resolveHandler('debouncedRemoveDibsForTarget', 'removeDibsForTarget');
if (remHandler) {
await remHandler(opponentId, buttonElement);
} else {
ui.showMessageBox('Action unavailable. Please reload the page.', 'warning', 4000);
}
} else {
const addHandler = resolveHandler('debouncedDibsTarget', 'dibsTarget');
if (addHandler) {
await addHandler(opponentId, opponentName, buttonElement);
} else {
ui.showMessageBox('Action unavailable. Please reload the page.', 'warning', 4000);
}
}
}
} catch (_) {
// noop; handlers already show messages
} finally {
}
return; // do not open modal
}
if (buttonElement) {
buttonElement.dataset.originalText = buttonElement.dataset.originalText || buttonElement.textContent;
}
state.ui.currentOpponentId = opponentId;
state.ui.currentOpponentName = opponentName;
state.ui.currentButtonElement = buttonElement;
state.ui.currentSetterType = type;
const title = type === 'medDeal' ? 'Set Med Deal for' : 'Assign Dibs for';
const defaultText = type === 'medDeal' ? 'Set Med Deal' : 'Dibs';
if (!state.ui.setterModal) {
state.ui.setterModal = utils.createElement('div', { id: 'setter-modal', style: { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', backgroundColor: '#1a1a1a', border: '1px solid #333', borderRadius: '8px', padding: '20px', zIndex: 10002, boxShadow: '0 4px 8px rgba(0,0,0,0.5)', maxWidth: '400px', width: '90%', color: 'white' } });
state.ui.setterModal.innerHTML = `
<h3 style="margin-top: 0;">${title} ${opponentName}</h3>
<input type="text" id="setter-search" placeholder="Search members..." style="width: calc(100% - 10px); padding: 5px; margin-bottom: 10px; background-color: #222; border: 1px solid #555; color: white; border-radius: 4px;">
<ul id="setter-list" style="list-style: none; padding: 0; margin: 0; max-height: 200px; overflow-y: auto; border: 1px solid #555; border-radius: 4px;"></ul>
<button id="cancel-setter" style="background-color: #f44336; color: white; border: none; border-radius: 4px; padding: 8px 15px; cursor: pointer; margin-top: 10px;">Cancel</button>
`;
document.body.appendChild(state.ui.setterModal);
state.ui.setterSearchInput = document.getElementById('setter-search');
state.ui.setterList = document.getElementById('setter-list');
document.getElementById('cancel-setter').onclick = (e) => { e.preventDefault(); e.stopPropagation(); ui.closeSetterModal(); };
state.ui.setterSearchInput.addEventListener('input', ui.filterSetterList);
} else {
state.ui.setterModal.querySelector('h3').textContent = `${title} ${opponentName}`;
state.ui.setterSearchInput.value = '';
}
state.ui.setterList.innerHTML = '<li style="padding: 8px; text-align: center; color: #aaa;"><span class="dibs-spinner"></span> Loading...</li>';
state.ui.setterSearchInput.disabled = true;
try {
// Ensure faction members are loaded (race guard). Heavy orchestrator / timeline work can delay API hydration.
await ui._ensureFactionMembersLoaded?.();
ui.populateSetterList();
} catch (error) {
tdmlogger('error', `[Error populating ${type} setter modal] ${error}`);
state.ui.setterList.innerHTML = '<li style="padding: 8px; text-align: center; color: #f44336;">Failed to load members.</li>';
} finally {
state.ui.setterSearchInput.disabled = false;
}
state.ui.setterModal.style.display = 'block';
},
openDibsSetterModal: (opponentId, opponentName, buttonElement) => ui.openSetterModal(opponentId, opponentName, buttonElement, 'dibs'),
openMedDealSetterModal: (opponentId, opponentName, buttonElement) => ui.openSetterModal(opponentId, opponentName, buttonElement, 'medDeal'),
closeSetterModal: () => {
if (state.ui.setterModal) state.ui.setterModal.style.display = 'none';
const defaultText = state.ui.currentSetterType === 'medDeal' ? 'Set Med Deal' : 'Dibs';
if (state.ui.currentButtonElement) {
state.ui.currentButtonElement.disabled = false;
state.ui.currentButtonElement.textContent = state.ui.currentButtonElement.dataset.originalText || defaultText;
state.ui.currentButtonElement = null;
}
state.ui.currentOpponentId = null;
state.ui.currentOpponentName = null;
state.ui.currentSetterType = null;
handlers.debouncedFetchGlobalData();
},
// Show opponent detail popup (used when clicking opponent badge) - includes Attack Page button & enriched status
openOpponentPopup: async (opponentId, opponentName) => {
if (!opponentId) return;
// derive full name if not provided
try {
if (!opponentName) {
const snap = state.rankedWarTableSnapshot && state.rankedWarTableSnapshot[opponentId];
if (snap && (snap.name || snap.username)) opponentName = snap.name || snap.username;
if (!opponentName) {
const mem = Array.isArray(state.factionMembers) ? state.factionMembers.find(m => String(m.id) === String(opponentId)) : null;
if (mem && mem.name) opponentName = mem.name;
}
if (!opponentName) {
const cached = state.session?.userStatusCache?.[opponentId] || {};
if (cached?.name) opponentName = cached.name;
}
if (!opponentName) {
try {
const anchors = Array.from(document.querySelectorAll(`a[href*="profiles.php?XID=${opponentId}"]`));
for (const a of anchors) {
const txt = (a.textContent || '').trim();
if (!txt) continue;
if (/^back to profile$/i.test(txt)) continue;
if (/^profile$/i.test(txt)) continue;
opponentName = txt;
break;
}
} catch(_) {}
}
}
} catch(_) { /* ignore */ }
opponentName = String(opponentName || `ID ${opponentId}`);
// Build modal
if (!state.ui.opponentPopup) {
state.ui.opponentPopup = utils.createElement('div', { id: 'tdm-opponent-popup', style: { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', backgroundColor: '#141414', border: '1px solid #333', borderRadius: '8px', padding: '18px', zIndex: 10003, boxShadow: '0 6px 18px rgba(0,0,0,0.6)', width: 'min(520px, 94%)', color: '#fff' } });
document.body.appendChild(state.ui.opponentPopup);
}
const modal = state.ui.opponentPopup;
modal.innerHTML = '';
const header = utils.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' } });
const h = utils.createElement('h3', { textContent: `${opponentName}`, style: { margin: '0', fontSize: '16px' } });
header.appendChild(h);
modal.appendChild(header);
const infoWrap = utils.createElement('div', { style: { marginTop: '8px', display: 'flex', gap: '10px', flexDirection: 'column' } });
// Friendly details: activity, status, destination, last action
const cache = state.ui.opponentStatusCache && state.ui.opponentStatusCache.opponentId === String(opponentId) ? state.ui.opponentStatusCache : null;
let activity = cache?.activity || null;
let statusText = cache?.text || null;
let dest = cache?.dest || null;
const until = cache?.untilEpoch || cache?.until || 0;
// last action ts: try session cache
let lastActionTs = Number(state.session?.userStatusCache?.[opponentId]?.last_action?.timestamp || state.session?.userStatusCache?.[opponentId]?.lastAction?.timestamp || 0) || 0;
if (!lastActionTs && cache?.unified && cache.unified.last_action && cache.unified.last_action.timestamp) lastActionTs = Number(cache.unified.last_action.timestamp || 0);
if (lastActionTs > 1e12) lastActionTs = Math.floor(lastActionTs/1000);
// If we don't have useful info, try to fetch a fresh user status
if ((activity === null || activity === 'Unknown' || !statusText) && utils.getUserStatus) {
try {
const fresh = await utils.getUserStatus(opponentId).catch(()=>null);
if (fresh) {
// if the modal is currently showing only an "ID ####" fallback, prefer the real name from fresh
try {
const looksLikeId = String(opponentName || '').startsWith('ID ') || String(opponentName || '') === String(opponentId);
if (looksLikeId && fresh.name) {
opponentName = fresh.name;
if (h && h.textContent) h.textContent = opponentName;
}
} catch(_) {}
// Prefer last_action status for activity; fallback to other fields
activity = fresh.last_action?.status || fresh.activity || fresh.lastAction?.status || activity || null;
statusText = statusText || (fresh.raw?.state || fresh.canonical || fresh.raw?.description || fresh.description || null);
dest = dest || fresh.dest || fresh.city || null;
// Try to update lastActionTs if present in fresh
if (!lastActionTs) {
const candidateTs = Number(fresh.last_action?.timestamp || fresh.lastAction?.timestamp || 0) || 0;
if (candidateTs) lastActionTs = candidateTs > 1e12 ? Math.floor(candidateTs/1000) : candidateTs;
}
}
} catch (_) { /* ignore */ }
// Final fallback: if we still don't have an activity value, try derive it from the DOM row
if (!activity) {
try {
const anchor = document.querySelector(`a[href*="profiles.php?XID=${opponentId}"]`);
const row = anchor ? (anchor.closest('li') || anchor.closest('tr')) : null;
if (row && typeof utils.getActivityStatusFromRow === 'function') {
const domActivity = utils.getActivityStatusFromRow(row);
if (domActivity) activity = domActivity;
}
} catch(_) { /* noop */ }
}
}
// Populate details
const list = utils.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px', marginTop: '6px' } });
list.appendChild(utils.createElement('div', { innerHTML: `<strong>Activity:</strong> ${activity || 'Unknown'}` }));
list.appendChild(utils.createElement('div', { innerHTML: `<strong>Status:</strong> ${statusText || 'Unknown'}${dest ? ` · ${dest}` : ''}` }));
if (lastActionTs && Number.isFinite(lastActionTs) && lastActionTs > 0) {
const el = utils.createElement('div', { innerHTML: `<strong>Last action:</strong> <span class='tdm-last-action-inline' data-last-ts='${Math.floor(lastActionTs)}'></span>` });
list.appendChild(el);
}
infoWrap.appendChild(list);
modal.appendChild(infoWrap);
// Footer actions
const footer = utils.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '12px' } });
const sendDibsBtn = utils.createElement('button', { textContent: 'Send Dibs to Chat', style: { background: '#4CAF50', color: 'white', border: 'none', padding: '8px 12px', borderRadius: '6px', cursor: 'pointer' } });
sendDibsBtn.title = `Send dibs for ${opponentName} to faction chat`;
sendDibsBtn.onclick = async (e) => { try { e.preventDefault(); e.stopPropagation(); await ui.sendDibsMessage(opponentId, opponentName, null); ui.showMessageBox('Sent dibs to chat (or copied to clipboard).', 'info', 2200); } catch(_) {} };
const openAttack = utils.createElement('button', { textContent: `Open Attack Page — ${opponentName}`, style: { background: '#0b74ff', color: 'white', border: 'none', padding: '8px 12px', borderRadius: '6px', cursor: 'pointer' } });
openAttack.title = `Open Attack Page - ${opponentName}`;
openAttack.onclick = (e) => { e.preventDefault(); e.stopPropagation(); window.open(`https://www.torn.com/loader.php?sid=attack&user2ID=${opponentId}`, '_blank'); };
const closeBtn = utils.createElement('button', { textContent: 'Close', style: { background: '#444', color: 'white', border: 'none', padding: '8px 12px', borderRadius: '6px', cursor: 'pointer' } });
closeBtn.onclick = (e) => { try { state.ui.opponentPopup.style.display = 'none'; } catch(_) {} };
// If there is an active dib for this opponent provide a quick Undib button
try {
const activeDib = Array.isArray(state.dibsData) ? state.dibsData.find(d => d && String(d.opponentId) === String(opponentId) && d.dibsActive) : null;
if (activeDib) {
const undibBtn = utils.createElement('button', { textContent: 'Undib', style: { background: '#e53935', color: 'white', border: 'none', padding: '8px 12px', borderRadius: '6px', cursor: 'pointer' } });
undibBtn.title = `Remove dibs for ${opponentName}`;
undibBtn.onclick = async (e) => {
try {
e.preventDefault(); e.stopPropagation();
undibBtn.disabled = true;
const remover = (typeof handlers.debouncedRemoveDibsForTarget === 'function') ? handlers.debouncedRemoveDibsForTarget : (typeof handlers.removeDibsForTarget === 'function' ? handlers.removeDibsForTarget : null);
if (remover) await remover(opponentId, undibBtn);
else ui.showMessageBox('Remove handler not available', 'error');
} catch (err) {
ui.showMessageBox(`Failed to remove dibs: ${err?.message||err}`, 'error');
} finally {
try { if (state.ui.opponentPopup) state.ui.opponentPopup.style.display = 'none'; } catch(_) {}
try { undibBtn.disabled = false; } catch(_) {}
}
};
footer.appendChild(undibBtn);
}
} catch(_) { /* noop */ }
footer.appendChild(sendDibsBtn);
footer.appendChild(openAttack);
footer.appendChild(closeBtn);
modal.appendChild(footer);
// Ensure last-action short label renders immediately if present
try { ui.ensureLastActionTicker(); } catch(_) {}
modal.style.display = 'block';
},
populateSetterList: () => {
state.ui.setterList.innerHTML = '';
const validMembers = state.factionMembers.filter(member => member.id && member.name);
const sortedMembers = [...validMembers].sort((a, b) => {
const aId = String(a.id);
const bId = String(b.id);
if (aId === state.user.tornId) return -1;
if (bId === state.user.tornId) return 1;
return a.name.localeCompare(b.name);
});
if (sortedMembers.length === 0) {
state.ui.setterList.appendChild(utils.createElement('li', { textContent: 'No faction members found.', style: { padding: '8px', color: '#aaa' } }));
return;
}
sortedMembers.forEach(member => {
const li = utils.createElement('li', {
textContent: member.name,
dataset: { userId: member.id, username: member.name },
style: { padding: '8px', cursor: 'pointer', borderBottom: '1px solid #333', backgroundColor: '#2c2c2c' },
onmouseover: () => li.style.backgroundColor = '#444',
onmouseout: () => li.style.backgroundColor = '#2c2c2c',
onclick: async () => {
const btn = state.ui.currentButtonElement;
if (btn) {
btn.textContent = 'Saving...';
btn.disabled = true;
btn.className = 'btn dibs-btn btn-dibs-inactive';
state.ui.currentButtonElement = null; // Prevent closeSetterModal from resetting
}
if (state.ui.currentSetterType === 'medDeal') {
await handlers.debouncedHandleMedDealToggle(state.ui.currentOpponentId, state.ui.currentOpponentName, true, member.id, member.name, btn);
} else {
await handlers.debouncedAssignDibs(state.ui.currentOpponentId, state.ui.currentOpponentName, member.id, member.name, btn);
}
ui.closeSetterModal();
}
});
state.ui.setterList.appendChild(li);
});
},
// Poll until factionMembers are available or timeout. Avoid extra API calls; just wait for hydration from existing init flow.
_ensureFactionMembersLoaded: async (opts) => {
const maxWaitMs = (opts && opts.maxWaitMs) || 4000;
const pollMs = (opts && opts.pollMs) || 120;
const start = Date.now();
// Already loaded & non-empty
if (Array.isArray(state.factionMembers) && state.factionMembers.length > 0) return true;
// Create a shared waiter to prevent concurrent spinners doing redundant loops
if (state._factionMembersWaiter) return state._factionMembersWaiter;
state._factionMembersWaiter = (async () => {
let lastLog = 0;
while ((Date.now() - start) < maxWaitMs) {
if (Array.isArray(state.factionMembers) && state.factionMembers.length > 0) return true;
// Light console heartbeat every ~1s for diagnostics if still waiting
const now = Date.now();
if (now - lastLog > 1000) { tdmlogger('debug', '[SetterModal] Waiting for factionMembers...'); lastLog = now; }
await new Promise(r => setTimeout(r, pollMs));
}
return false; // timed out
})();
try { return await state._factionMembersWaiter; } finally { delete state._factionMembersWaiter; }
},
filterSetterList: () => {
const searchTerm = state.ui.setterSearchInput.value.toLowerCase();
const items = state.ui.setterList.querySelectorAll('li');
items.forEach(item => {
const username = item.dataset.username?.toLowerCase() || '';
item.style.display = username.includes(searchTerm) ? 'block' : 'none';
});
},
// Unified toast / alert manager
// Dedupe semantics: (type + normalized message) suppressed if shown in last DEDUPE_MS window.
// Reuse a small pooled set of DOM nodes to minimize layout / flicker.
showMessageBox: (() => {
const DEDUPE_MS = 5000;
const MAX_NODES = 3;
const recent = [];// [{ key, ts }]
let pool = [];// recycled divs
const containerId = 'tdm-toast-container';
const ensureContainer = () => {
let c = document.getElementById(containerId);
if (!c) {
c = utils.createElement('div', { id: containerId, style: {
position: 'fixed', top: '12px', right: '12px', zIndex: 9000000005,
display: 'flex', flexDirection: 'column', gap: '8px', maxWidth: '320px'
}});
document.body.appendChild(c);
}
return c;
};
const pruneRecent = (now) => {
for (let i = recent.length - 1; i >= 0; i--) if (now - recent[i].ts > DEDUPE_MS) recent.splice(i,1);
};
const acquireNode = () => {
const reused = pool.pop();
if (reused) {
// Reset state from prior lifecycle so click/expiry works again
reused._closing = false;
if (reused.dataset) { delete reused.dataset.key; delete reused.dataset.expire; }
reused.onclick = null;
reused.textContent = '';
// Reset initial animation baseline
reused.style.opacity = '0';
reused.style.transform = 'translateY(-4px)';
return reused;
}
return utils.createElement('div', { className: 'tdm-toast', style: {
borderRadius: 'var(--tdm-radius-md)', padding: 'var(--tdm-space-md) var(--tdm-space-lg)', fontSize: 'var(--tdm-font-size-sm)', lineHeight: '1.3',
color: 'var(--tdm-text-primary)', boxShadow: 'var(--tdm-shadow-md)', opacity: '0', transform: 'translateY(-4px)',
transition: 'opacity var(--tdm-transition-fast), transform var(--tdm-transition-fast)', cursor: 'pointer', userSelect: 'none'
}});
};
const releaseNode = (node) => {
if (!node) return;
if (node.parentNode) { try { node.parentNode.removeChild(node); } catch(_){} }
node.onclick = null;
node._closing = false;
if (node.dataset) { delete node.dataset.key; delete node.dataset.expire; }
if (pool.length < MAX_NODES) pool.push(node);
};
return (message, type = 'info', duration = 5000, onClick = null) => {
try {
const now = Date.now();
pruneRecent(now);
const normMsg = String(message || '').trim().replace(/\s+/g,' ');
const key = `${type}|${normMsg.toLowerCase()}`;
if (recent.some(r => r.key === key)) return; // suppress duplicate
recent.push({ key, ts: now });
const container = ensureContainer();
// Reuse existing identical active node by updating timer (extend behavior)
const existing = Array.from(container.children).find(ch => ch.dataset.key === key);
if (existing) {
existing.dataset.expire = String(now + duration);
return; // already visible
}
const node = acquireNode();
node.dataset.key = key;
node.dataset.expire = String(now + duration);
node.style.background = config.CSS.colors[type] || config.CSS.colors.info;
node.textContent = normMsg;
const close = (el, clicked = false) => {
if (!el || el._closing) return; el._closing = true;
el.style.opacity='0'; el.style.transform='translateY(-4px)';
setTimeout(()=> { releaseNode(el); }, 220);
};
node.onclick = async (e) => { e.preventDefault(); e.stopPropagation(); if (onClick) { try { await onClick(); } catch(_){} } close(node,true); };
container.appendChild(node);
requestAnimationFrame(()=> { node.style.opacity='1'; node.style.transform='translateY(0)'; });
// Shared sweeper (single interval) stored on container
if (!container._sweeper) {
container._sweeper = utils.registerInterval(setInterval(() => {
const now2 = Date.now();
Array.from(container.children).forEach(ch => {
const exp = Number(ch.dataset.expire||0);
if (exp && now2 >= exp) close(ch);
// Safety: hard cap 60s lifetime regardless of duration param
const born = exp ? exp - duration : now2;
if (now2 - born > 10000) close(ch);
});
}, 500));
}
} catch(_) { /* non-fatal */ }
};
})(),
// Backwards-compatible alias used by some new code paths; thin wrapper.
showTransientMessage: (text, { type='info', timeout=2500 } = {}) => {
try { ui.showMessageBox(text, type, timeout); } catch(_) { /* noop */ }
},
showConfirmationBox: (message, showCancel = true, extra = null) => {
// extra: { thirdLabel: string, onThird: ()=>void }
return new Promise(resolve => {
const confirmBox = utils.createElement('div', {
className: 'tdm-confirm-modal',
style: {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: '#1a1a2e',
border: '1px solid #333',
borderRadius: '8px',
padding: '20px',
zIndex: '10001',
boxShadow: '0 10px 40px rgba(0,0,0,0.5)',
maxWidth: '380px',
width: '90%',
color: '#e2e8f0',
textAlign: 'center',
display: 'block'
}
});
const messagePara = utils.createElement('p', {
style: {
marginBottom: '16px',
fontSize: '14px',
lineHeight: '1.4'
},
textContent: message
});
const buttonsContainer = utils.createElement('div', {
style: {
display: 'flex',
justifyContent: 'center',
gap: '8px',
flexWrap: 'wrap'
}
});
const yesBtn = utils.createElement('button', {
id: 'confirm-ok',
className: 'settings-btn settings-btn-green',
style: {
minWidth: '80px',
padding: '8px 16px',
cursor: 'pointer'
},
textContent: showCancel ? 'Yes' : 'OK',
onclick: () => { confirmBox.remove(); resolve(true); }
});
buttonsContainer.appendChild(yesBtn);
if (extra && extra.thirdLabel) {
const thirdBtn = utils.createElement('button', {
id: 'confirm-third',
className: 'settings-btn settings-btn-blue',
style: {
minWidth: '80px',
padding: '8px 16px',
cursor: 'pointer'
},
textContent: extra.thirdLabel,
onclick: () => { confirmBox.remove(); try { extra.onThird && extra.onThird(); } catch(_) {} resolve('third'); }
});
buttonsContainer.appendChild(thirdBtn);
}
if (showCancel) {
const noBtn = utils.createElement('button', {
id: 'confirm-cancel',
className: 'settings-btn settings-btn-red',
style: {
minWidth: '80px',
padding: '8px 16px',
cursor: 'pointer'
},
textContent: 'No',
onclick: () => { confirmBox.remove(); resolve(false); }
});
buttonsContainer.appendChild(noBtn);
}
confirmBox.appendChild(messagePara);
confirmBox.appendChild(buttonsContainer);
document.body.appendChild(confirmBox);
});
},
createSettingsButton: () => {
if (document.getElementById('tdm-settings-button')) return;
const topPageLinksList = document.querySelector('#top-page-links-list');
if (!topPageLinksList) return;
const settingsButton = utils.createElement('span', { id: 'tdm-settings-button', style: { marginRight: '5px', marginLeft: '10px', cursor: 'pointer', display: 'inline-block', verticalAlign: 'middle' }, innerHTML: `<span class="tdm-settings-label" style="background: linear-gradient(to bottom, #42a5f5, #1976d2); border: 2px solid #90caf9; border-radius: 4px; box-sizing: border-box; color: #ffffff; text-shadow: 1px 1px 1px #0d47a1; cursor: pointer; display: inline-block; font-family: 'Farfetch Basis', 'Helvetica Neue', Arial, sans-serif; font-size: 12px; font-weight: bold; line-height: 20px; height: 20px; margin: 0; padding: 0 8px; text-align: center; text-transform: none;">TreeDibs</span>`, onclick: ui.toggleSettingsPopup });
const retalsButton = utils.createElement('span', { id: 'tdm-retals-button', style: { marginRight: '5px', marginLeft: '5px', cursor: 'pointer', display: 'inline-block', verticalAlign: 'middle' }, innerHTML: `<span style="background: linear-gradient(to bottom, #ab47bc, #7b1fa2); border: 2px solid #ce93d8; border-radius: 4px; box-sizing: border-box; color: #ffffff; text-shadow: 1px 1px 1px #4a148c; cursor: pointer; display: inline-block; font-family: 'Farfetch Basis', 'Helvetica Neue', Arial, sans-serif; font-size: 12px; font-weight: bold; line-height: 20px; height: 20px; margin: 0; padding: 0 8px; text-align: center; text-transform: none;">... Retals</span>`, onclick: () => ui.showAllRetaliationsNotification() });
if (topPageLinksList.firstChild) {
topPageLinksList.insertBefore(retalsButton, topPageLinksList.firstChild);
topPageLinksList.insertBefore(settingsButton, topPageLinksList.firstChild);
} else {
topPageLinksList.appendChild(settingsButton);
topPageLinksList.appendChild(retalsButton);
}
ui.updateRetalsButtonCount(); // Call initially to set the count
ui.updateSettingsButtonUpdateState();
},
updateRetalsButtonCount: () => {
const retalsButton = document.getElementById('tdm-retals-button');
if (!retalsButton) return;
const retalSpan = retalsButton.querySelector('span');
if (!retalSpan) return;
const activeRetals = Object.values(state.retaliationOpportunities).filter(opp => opp.timeRemaining > 0).length;
retalSpan.textContent = `${activeRetals} Retals`;
},
toggleSettingsPopup: async () => {
let settingsPopup = document.getElementById('tdm-settings-popup');
if (settingsPopup) {
// Stop dynamic diagnostics updater when closing
try { if (state.ui && state.ui.apiCadenceInfoIntervalId) { try { utils.unregisterInterval(state.ui.apiCadenceInfoIntervalId); } catch(_) {} state.ui.apiCadenceInfoIntervalId = null; } } catch(_) {}
try { if (state.ui && state.ui.rwTermInfoIntervalId) { try { utils.unregisterInterval(state.ui.rwTermInfoIntervalId); } catch(_) {} state.ui.rwTermInfoIntervalId = null; } } catch(_) {}
try {
if (state.uiCadenceInfoThrottle?.pending) {
try { utils.unregisterTimeout(state.uiCadenceInfoThrottle.pending); } catch(_) {}
state.uiCadenceInfoThrottle.pending = null;
}
if (state.uiCadenceInfoThrottle) state.uiCadenceInfoThrottle.lastRender = 0;
} catch(_) { /* noop */ }
settingsPopup.remove();
return;
}
const contentTitle = document.querySelector('div.content-title.m-bottom10');
const contentWrapper = document.querySelector('.content-wrapper');
if (!contentTitle && !contentWrapper) return;
settingsPopup = utils.createElement('div', { id: 'tdm-settings-popup', style: { width: '100%', maxWidth: '100%', marginBottom: '5px', backgroundColor: '#2c2c2c', border: '1px solid #333', borderRadius: '8px', boxShadow: '0 4px 10px rgba(0,0,0,0.5)', padding: '0', fontFamily: "'Inter', sans-serif", color: '#e0e0e0', overflowX: 'hidden', boxSizing: 'border-box' } });
const latestVersion = state.script.updateAvailableLatestVersion;
const hasUpdate = latestVersion && utils.compareVersions(config.VERSION, latestVersion) < 0;
const preferredUpdateUrl = state.script.updateAvailableLatestVersionUrl || config.GREASYFORK.pageUrl;
const getLink = (hasUpdate && preferredUpdateUrl) ? ` <a href="${preferredUpdateUrl}" target="_blank" rel="noopener" style="color:#fff;text-decoration:underline;">Get v${latestVersion}</a>` : '';
const header = utils.createElement('div', { style: { padding: '10px', backgroundColor: 'var(--tdm-modal-header)', borderTopLeftRadius: '8px', borderTopRightRadius: '8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }, innerHTML: `<h3 style="margin: 0; color: white; font-size: 16px;">TreeDibsMapper v${config.VERSION}${getLink ? ' ' + getLink : ''}</h3><span id="tdm-settings-close" style="cursor: pointer; font-size: 18px;">×</span>` });
const content = utils.createElement('div', { id: 'tdm-settings-content', style: { padding: '5px', maxWidth: '100%', overflowX: 'hidden', boxSizing: 'border-box' } });
settingsPopup.appendChild(header);
settingsPopup.appendChild(content);
header.querySelector('#tdm-settings-close').addEventListener('click', ui.toggleSettingsPopup);
if (contentTitle) contentTitle.parentNode.insertBefore(settingsPopup, contentTitle.nextSibling);
else if (contentWrapper) contentWrapper.insertBefore(settingsPopup, contentWrapper.firstChild);
else document.body.appendChild(settingsPopup);
ui.updateSettingsContent();
// Start dynamic diagnostics updater while panel is open (updates every second)
try {
if (!state.ui) state.ui = {};
if (state.ui.apiCadenceInfoIntervalId) { try { utils.unregisterInterval(state.ui.apiCadenceInfoIntervalId); } catch(_) {} state.ui.apiCadenceInfoIntervalId = null; }
state.ui.apiCadenceInfoIntervalId = utils.registerInterval(setInterval(() => {
try { if (document.getElementById('tdm-settings-popup')) ui.updateApiCadenceInfo?.(); else { try { utils.unregisterInterval(state.ui.apiCadenceInfoIntervalId); } catch(_) {} state.ui.apiCadenceInfoIntervalId = null; } } catch(_) {}
}, 1000));
} catch(_) { /* noop */ }
},
updateSettingsButtonUpdateState: () => {
try {
const label = document.querySelector('#tdm-settings-button .tdm-settings-label');
if (!label) return;
const latest = state.script.updateAvailableLatestVersion;
const hasUpdate = latest && utils.compareVersions(config.VERSION, latest) < 0;
// Activity Tracking indicator (lightweight)
if (storage.get('tdmActivityTrackingEnabled', false)) {
label.textContent = 'TreeDibs (Tracking)';
} else label.textContent = hasUpdate ? 'Update TDM!' : 'TreeDibs';
} catch (_) {}
},
updateSettingsContent: () => {
const perf = utils?.perf;
perf?.start?.('ui.updateSettingsContent.total');
let renderPhaseComplete = false;
let bindPhaseStarted = false;
try {
const content = document.getElementById('tdm-settings-content');
if (!content) {tdmlogger('debug', '[Settings UI] no tdm-settings-content'); return; }
perf?.start?.('ui.updateSettingsContent.snapshot');
// Read persisted collapsed state
const collapsedKey = 'settings_collapsed';
const collapsedState = storage.get(collapsedKey, {});
const warData = state.warData || {};
const warType = warData.warType || 'War Type Not Set';
const termType = warData.termType || 'Set Term Type';
const factionScoreCap = (warData.factionScoreCap ?? warData.scoreCap) ?? 0;
const individualScoreCap = (warData.individualScoreCap ?? warData.scoreCap) ?? 0;
const individualScoreType = warData.individualScoreType || warData.scoreType || 'Respect';
const opponentFactionName = warData.opponentFactionName || state.lastOpponentFactionName;
const opponentFactionId = warData.opponentFactionId || state.lastOpponentFactionId;
const termedWarDisplay = warType === 'Termed War' ? 'block' : 'none';
const warstring = warType === 'Termed War' ? `${termType} to Total ${factionScoreCap}, ${individualScoreCap} ${individualScoreType} Each` : warType;
const ocReminderEnabled = storage.get('ocReminderEnabled', true); // Default to true
const escapeHtml = (value) => String(value ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
const computeApiKeyUi = () => {
const storedKey = getStoredCustomApiKey() || '';
const pdaPlaceholder = (state.script.isPDA && config.PDA_API_KEY_PLACEHOLDER && config.PDA_API_KEY_PLACEHOLDER[0] !== '#') ? config.PDA_API_KEY_PLACEHOLDER : null;
const source = storedKey ? 'local' : (pdaPlaceholder ? 'pda' : 'none');
const pdaKeyActive = (!storedKey && source === 'pda');
const validation = state.user.keyValidation || null;
// keyInfo may be cached on the user state when a key has been inspected
const keyInfo = state.user.keyInfoCache || null;
let tone = state.user.apiKeyUiMessage?.tone || null;
let status = 'Unverified';
if (validation?.isLimited) {
tone = tone || 'warning';
status = 'Limited Access Level';
} else if (validation?.level) {
tone = tone || 'success';
status = 'Ready';
} else if (pdaKeyActive) {
tone = tone || 'info';
status = 'Using PDA-provided key';
} else if (storedKey) {
tone = tone || 'error';
status = 'Unverified';
} else if (state.script.isPDA) {
tone = tone || 'warning';
status = 'PDA placeholder missing';
} else {
tone = tone || 'error';
status = 'No key saved';
}
let message = state.user.apiKeyUiMessage?.text || '';
if (!message) {
if (validation?.isLimited) {
// If we have missing scopes info, include them so users know what to fix
const missing = (validation.missing || []).map(s => String(s).replace('.', ' -> '));
message = missing.length ? `Limited key detected. Missing selections: ${missing.join(', ')}.` : 'Limited key detected.';
}
else if (validation?.level) message = 'Custom API key validated with required selections.';
else if (pdaKeyActive) message = 'Using PDA supplied key. Save your own key to unlock full status checks.';
else if (storedKey) message = 'Verify your custom Torn API key to unlock features.';
else if (state.script.isPDA) message = 'PDA API key placeholder not replaced. Provide a custom key.';
else message = 'Requires factions.basic, members, rankedwars, chain and users.basic, attacks selections.';
}
const sourceNote = (() => {
if (source === 'local') return 'Source: Custom key set in browser.';
if (source === 'pda') return 'Source: PDA provided key.';
return state.script.isPDA ? 'Source: PDA placeholder missing.' : 'Source: None saved yet.';
})();
// If validation indicates the key is limited, emit a compact
// diagnostic to help debug required selections or missing scopes.
// Important: do NOT log the raw API key or any PII here.
if (validation?.isLimited) {
try {
const access = keyInfo?.access || {};
const diag = {
reason: 'limited-diagnostic',
validationLevel: validation.level || null,
accessType: access.type || null,
accessLevel: access.level || null,
factionSelections: !!(keyInfo?.info?.selections?.faction),
missing: validation?.missing || [],
scopes: (validation?.scopes || []).slice(0, 50)
};
try { tdmlogger('warn', '[APIKEY] key marked limited — diagnostic', diag); } catch (_) { console.warn('[APIKEY] key marked limited — diagnostic', diag); }
} catch (e) { try { console.warn('[APIKEY] limited diagnostic failed', e?.message || e); } catch(_) {} }
}
return {
storedKey,
hasCustom: !!storedKey,
source,
sourceNote,
pdaKeyActive,
tone: tone || 'error',
status,
message
};
};
const apiKeyUi = computeApiKeyUi();
const apiKeyStatus = apiKeyUi.status;
const apiKeyTone = apiKeyUi.tone || 'error';
const apiKeyMessage = apiKeyUi.message;
const apiKeyInputValue = apiKeyUi.storedKey ? escapeHtml(apiKeyUi.storedKey) : '';
const apiKeyMessageColor = (apiKeyTone === 'success' ? '#86efac' : apiKeyTone === 'warning' ? '#facc15' : apiKeyTone === 'info' ? '#93c5fd' : '#fca5a5');
const apiKeyMessageHtml = escapeHtml(apiKeyMessage);
const apiKeySourceNote = apiKeyUi.sourceNote;
const apiKeySourceNoteHtml = apiKeySourceNote ? escapeHtml(apiKeySourceNote) : '';
// FFScouter Key UI Logic
const computeFFScouterKeyUi = () => {
const storedKey = storage.get('ffscouterApiKey', '') || '';
const hasKey = !!storedKey;
// When running inside PDA, an injected FFScouter/BSP cache may be present
if (state.script?.isPDA && !hasKey) {
return {
storedKey: '',
status: 'Using FFScouter',
tone: 'info',
message: 'No API key needed. Runs with FFScouter userscript and uses its cache.'
};
}
const status = hasKey ? 'Saved' : 'Not Set';
const tone = hasKey ? 'success' : 'error';
const message = hasKey ? 'FFScouter key saved.' : 'Enter your FFScouter API key to enable Fair Fight & Battle Stats estimates.';
return { storedKey, status, tone, message };
};
const ffUi = computeFFScouterKeyUi();
const ffKeyInputValue = ffUi.storedKey ? escapeHtml(ffUi.storedKey) : '';
const ffKeyMessageColor = (ffUi.tone === 'success' ? '#86efac' : '#fca5a5');
// Determine admin status for settings edits
const adminFunctionalityEnabled = storage.get('adminFunctionality', true) === true;
const isAdmin = !!state.script.canAdministerMedDeals && adminFunctionalityEnabled;
const factionSettings = state.script.factionSettings || {};
const dibsStyle = (factionSettings.options && factionSettings.options.dibsStyle) || {
keepTillInactive: true,
mustRedibAfterSuccess: false,
allowStatuses: { Okay: true, Hospital: true, Travel: false, Abroad: false, Jail: false },
removeOnFly: false,
inactivityTimeoutSeconds: 300,
timeRemainingLimits: { minSecondsToDib: 0 }
};
const attackMode = factionSettings.options?.attackMode || factionSettings.attackMode || 'Mode Not Set';
const showAttackMode = (warType === 'Ranked War');
const activityCadenceMs = Number(storage.get('tdmActivityCadenceMs', 10000)) || 10000;
const noteTagsDefault = 'dex+,def+,str+,spd+,hosp,retal';
const noteTagsValueRaw = utils.coerceStorageString(storage.get('tdmNoteQuickTags', noteTagsDefault), noteTagsDefault);
const noteTagsValue = (noteTagsValueRaw || noteTagsDefault).replace(/"/g, '"');
perf?.stop?.('ui.updateSettingsContent.snapshot');
perf?.start?.('ui.updateSettingsContent.render');
let runRankedWarPrefetch = null;
// Inject helper styles once
try {
if (!document.getElementById('tdm-mini-style')) {
const st = document.createElement('style');
st.id = 'tdm-mini-style';
st.textContent = `.tdm-grid-war{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;align-items:end}`+
`.tdm-mini-lbl{display:block;font-size:11px;color:#fff;margin-bottom:2px;font-weight:500}`+
`.tdm-mini-checkbox{font-size:12px;color:#fff;display:flex;align-items:center;gap:4px}`+
`.tdm-check-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(90px,1fr));gap:4px;margin-top:4px}`+
`.tdm-check-grid label{font-size:11px;display:flex;align-items:center;gap:4px;color:#fff}`+
/* Termed-war input hints */
`.tdm-term-required{border:1px solid #facc15 !important; box-shadow:0 0 0 3px rgba(250,204,21,0.12) !important; border-radius:4px !important;}`+
`.tdm-term-calculated{border:1px solid #4ade80 !important; box-shadow:0 0 0 3px rgba(34,197,94,0.12) !important; border-radius:4px !important;}`+
`.tdm-term-error{border:1px solid #fb7185 !important; box-shadow:0 0 0 3px rgba(248,113,113,0.12) !important; border-radius:4px !important;}`+
`.tdm-initial-readonly{background:transparent;border:1px dashed #333;padding:4px;border-radius:4px;color:#ddd}`
document.head.appendChild(st);
}
if (!document.getElementById('tdm-api-key-style')) {
const st = document.createElement('style');
st.id = 'tdm-api-key-style';
st.textContent = `#tdm-api-key-card{transition:border-color .2s ease,box-shadow .2s ease}`+
`#tdm-api-key-card[data-tone="success"]{border-color:#16a34a}`+
`#tdm-api-key-card[data-tone="warning"]{border-color:#facc15}`+
`#tdm-api-key-card[data-tone="info"]{border-color:#3b82f6}`+
`#tdm-api-key-card[data-tone="error"]{border-color:#f87171}`+
`#tdm-api-key-card.tdm-api-key-highlight{box-shadow:0 0 0 2px rgba(250,204,21,0.8),0 0 14px rgba(250,204,21,0.35)}`;
document.head.appendChild(st);
}
} catch(_) {}
// Build new compact war / dibs layout with tabs
const activeTab = storage.get('tdm_settings_active_tab', 'wardibs');
content.innerHTML = `
<!-- Settings Tab Navigation -->
<div class="tdm-settings-tabs">
<button class="tdm-settings-tab ${activeTab === 'display' ? 'tdm-settings-tab--active' : ''}" data-tab="display">Display</button>
<button class="tdm-settings-tab ${activeTab === 'wardibs' ? 'tdm-settings-tab--active' : ''}" data-tab="wardibs">War/Dibs</button>
<button class="tdm-settings-tab ${activeTab === 'advanced' ? 'tdm-settings-tab--active' : ''}" data-tab="advanced">Advanced</button>
</div>
<!-- DISPLAY TAB PANEL -->
<div class="tdm-settings-panel ${activeTab === 'display' ? 'tdm-settings-panel--active' : ''}" data-panel="display">
<!-- Column Settings -->
<div class="settings-section" data-section="column-settings">
<div class="settings-header">Column Settings</div>
<div style="display:flex; flex-direction:column; gap:8px;">
<div style="display:flex; justify-content:center; gap:8px; margin-bottom:6px;">
<button id="reset-column-widths-btn" class="settings-btn settings-btn-red" title="Reset all column widths to defaults">Reset Column Widths</button>
</div>
<div class="cv-groups" style="display:flex; flex-direction:column; gap:8px;">
<div class="cv-group">
<div class="mini-label" style="font-size:12px; color:#fff; margin-bottom:4px; font-weight:bold; text-align:center;">Ranked War Table</div>
<div id="column-visibility-rw" class="settings-button-group" style="gap:6px;"></div>
</div>
<div class="cv-group">
<div class="mini-label" style="font-size:12px; color:#fff; margin-bottom:4px; font-weight:bold; text-align:center;">Members List Table</div>
<div id="column-visibility-ml" class="settings-button-group" style="gap:6px;"></div>
</div>
</div>
</div>
</div>
<!-- Badges Dock -->
<div class="settings-section" data-section="badges-dock">
<div class="settings-header">Badges Dock</div>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:12px;">
<div style="display:flex; flex-direction:column; gap:8px;">
<div style="font-size:12px; color:#fff; font-weight:bold; text-align:center;">Core Timers</div>
<label style="display:flex; align-items:center; gap:8px; color:#fff; font-size:12px;">
<div class="tdm-toggle-switch ${storage.get('chainTimerEnabled', true) ? 'active' : ''}" id="chain-timer-toggle" data-key="chainTimerEnabled"></div>
Chain Timer
</label>
<label style="display:flex; align-items:center; gap:8px; color:#fff; font-size:12px;">
<div class="tdm-toggle-switch ${storage.get('inactivityTimerEnabled', false) ? 'active' : ''}" id="inactivity-timer-toggle" data-key="inactivityTimerEnabled"></div>
Inactivity Timer
</label>
<label style="display:flex; align-items:center; gap:8px; color:#fff; font-size:12px;">
<div class="tdm-toggle-switch ${storage.get('opponentStatusTimerEnabled', true) ? 'active' : ''}" id="opponent-status-toggle" data-key="opponentStatusTimerEnabled"></div>
Opponent Status
</label>
</div>
<div style="display:flex; flex-direction:column; gap:8px;">
<div style="font-size:12px; color:#fff; font-weight:bold; text-align:center;">Badges</div>
<label style="display:flex; align-items:center; gap:8px; color:#fff; font-size:12px;">
<div class="tdm-toggle-switch ${storage.get('apiUsageCounterEnabled', false) ? 'active' : ''}" id="api-usage-toggle" data-key="apiUsageCounterEnabled"></div>
API Counter
</label>
<label style="display:flex; align-items:center; gap:8px; color:#fff; font-size:12px;">
<div class="tdm-toggle-switch ${storage.get('attackModeBadgeEnabled', true) ? 'active' : ''}" id="attack-mode-badge-toggle" data-key="attackModeBadgeEnabled"></div>
Attack Mode Badge
</label>
<label style="display:flex; align-items:center; gap:8px; color:#fff; font-size:12px;">
<div class="tdm-toggle-switch ${storage.get('chainWatcherBadgeEnabled', true) ? 'active' : ''}" id="chainwatcher-badge-toggle" data-key="chainWatcherBadgeEnabled"></div>
Chain Watchers Badge
</label>
<label style="display:flex; align-items:center; gap:8px; color:#fff; font-size:12px;">
<div class="tdm-toggle-switch ${storage.get('userScoreBadgeEnabled', true) ? 'active' : ''}" id="user-score-badge-toggle" data-key="userScoreBadgeEnabled"></div>
User Score Badge
</label>
<label style="display:flex; align-items:center; gap:8px; color:#fff; font-size:12px;">
<div class="tdm-toggle-switch ${storage.get('factionScoreBadgeEnabled', true) ? 'active' : ''}" id="faction-score-badge-toggle" data-key="factionScoreBadgeEnabled"></div>
Faction Score Badge
</label>
<label style="display:flex; align-items:center; gap:8px; color:#fff; font-size:12px;">
<div class="tdm-toggle-switch ${storage.get('dibsDealsBadgeEnabled', true) ? 'active' : ''}" id="dibs-deals-badge-toggle" data-key="dibsDealsBadgeEnabled"></div>
Dibs/Deals Badge
</label>
</div>
</div>
</div>
<!-- Note Tag Presets -->
<div class="settings-section" data-section="note-tags">
<div class="settings-header">Note Tag Presets</div>
<div style="display:flex; flex-direction:column; gap:8px;">
<div style="font-size:12px; color:#fff;">Configure up to 12 quick-add tags (comma or space separated).</div>
<input type="text" id="note-tags-input" class="settings-input" style="width:100%;" maxlength="240" value="${utils.coerceStorageString(storage.get('tdmNoteQuickTags','dex+,def+,str+,spd+,hosp,retal'), noteTagsDefault).replace(/"/g,'"')}" placeholder="dex+,def+,str+,spd+,hosp,retal" />
<div id="note-tags-preview" style="display:flex; flex-wrap:wrap; gap:6px; min-height:26px;"></div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button id="note-tags-save-btn" class="settings-btn settings-btn-green">Save Presets</button>
<button id="note-tags-reset-btn" class="settings-btn">Reset Default</button>
<div id="note-tags-status" style="font-size:12px; align-self:center; color:#fff;">Idle</div>
</div>
</div>
</div>
<!-- Alerts & Messaging -->
<div class="settings-section" data-section="alerts-messaging">
<div class="settings-header">Alerts & Messaging</div>
<div style="display:flex; flex-direction:column; gap:8px;">
<label style="display:flex; align-items:center; gap:8px; color:#fff; font-size:12px;">
<div class="tdm-toggle-switch ${storage.get('alertButtonsEnabled', true) ? 'active' : ''}" id="alert-buttons-toggle" data-key="alertButtonsEnabled"></div>
Alert Buttons
</label>
<label style="display:flex; align-items:center; gap:8px; color:#fff; font-size:12px;">
<div class="tdm-toggle-switch ${storage.get('pasteMessagesToChatEnabled', true) ? 'active' : ''}" id="paste-messages-toggle" data-key="pasteMessagesToChatEnabled"></div>
Paste Messages to Chat
</label>
<label style="display:flex; align-items:center; gap:8px; color:#fff; font-size:12px;">
<div class="tdm-toggle-switch ${storage.get('ocReminderEnabled', true) ? 'active' : ''}" id="oc-reminder-toggle" data-key="ocReminderEnabled"></div>
OC Reminder
</label>
</div>
</div>
</div><!-- END DISPLAY TAB PANEL -->
<!-- WAR/DIBS TAB PANEL -->
<div class="tdm-settings-panel ${activeTab === 'wardibs' ? 'tdm-settings-panel--active' : ''}" data-panel="wardibs">
<div class="settings-section" data-section="latest-war">
<div class="settings-header">RW Details: <span id="rw-warstring" style="color:#ffd600;">${warstring}</span></div>
<div>
<div style="margin-top:4px;padding:6px;background:#222;border-radius:5px;">
<div class="tdm-grid-war">
<div id="war-type-container">
<label class="tdm-mini-lbl">War Type</label>
${isAdmin ? `<select id=\"war-type-select\" class=\"settings-input\"><option value=\"\" disabled ${!warType||warType==='War Type Not Set'?'selected':''}>Not Set</option><option value=\"Termed War\" ${warType==='Termed War'?'selected':''}>Termed War</option><option value=\"Ranked War\" ${warType==='Ranked War'?'selected':''}>Ranked War</option></select>`:`<div class=\"settings-input-display\">${warType}</div>`}
</div>
<div id="term-type-container" style="display:${termedWarDisplay};">
<label class="tdm-mini-lbl">Term Type</label>
${isAdmin ? `<select id=\"term-type-select\" class=\"settings-input\"><option value=\"Termed Loss\" ${termType==='Termed Loss'?'selected':''}>Termed Loss</option><option value=\"Termed Win\" ${termType==='Termed Win'?'selected':''}>Termed Win</option></select>`:`<div class=\"settings-input-display\">${termType}</div>`}
</div>
<div id="score-cap-container" style="display:${termedWarDisplay};">
<label class="tdm-mini-lbl">Faction Cap</label>
${isAdmin ? `<input type=\"number\" id=\"faction-score-cap-input\" value=\"${factionScoreCap}\" min=\"0\" class=\"settings-input\" style=\"width:80px;\">`:`<div class=\"settings-input-display\">${factionScoreCap}</div>`}
</div>
<div id="individual-score-type-container" style="display:${termedWarDisplay};">
<label class="tdm-mini-lbl">Indiv Type</label>
${isAdmin ? `<select id="individual-score-type-select" class="settings-input" style="width:130px;"><option value="Attacks" ${individualScoreType==='Attacks'?'selected':''}>Attacks</option><option value="Respect" ${individualScoreType==='Respect'?'selected':''}>Respect</option><option value="Respect (no chain)" ${individualScoreType==='Respect (no chain)'?'selected':''}>Respect No-Chain</option><option value="Respect (no bonus)" ${individualScoreType==='Respect (no bonus)'?'selected':''}>Respect No-Bonus</option></select>`:`<div class="settings-input-display">${individualScoreType}</div>`}
</div>
<div id="individual-score-cap-container" style="display:${termedWarDisplay};">
<label class="tdm-mini-lbl">Indiv Cap</label>
${isAdmin ? `<input type="number" id="individual-score-cap-input" value="${individualScoreCap}" min="0" class="settings-input" style="width:80px;">`:`<div class="settings-input-display">${individualScoreCap}</div>`}
</div>
<!-- Row 2 inputs -->
<div id="opponent-score-cap-container" style="display:${termedWarDisplay};">
<label class="tdm-mini-lbl">Opponent Cap</label>
${isAdmin ? `<input type="number" id="opponent-score-cap-input" value="${state.warData?.opponentScoreCap ?? ''}" min="0" class="settings-input" style="width:100px;">`:`<div class="settings-input-display">${state.warData?.opponentScoreCap ?? ''}</div>`}
</div>
<div id="initial-target-container" style="display:${termedWarDisplay};">
<label class="tdm-mini-lbl">Initial Target</label>
${/* initial target is display-only (never editable) */''}
<div id="initial-target-display" class="settings-input-display">${Number(state.warData?.initialTargetScore ?? ((state.lastRankWar?.war?.target ?? state.lastRankWar?.target) || 0)) || ''}</div>
</div>
<div id="target-end-container" style="display:${termedWarDisplay}; min-width:0; grid-column: span 2;">
<label class="tdm-mini-lbl">Target End (UTC, hour-only)</label>
${isAdmin ? `<div style="display:flex;align-items:center;"><input type="datetime-local" step="3600" id="war-target-end-input" class="settings-input" value="${(function(){try{const t=Number(state.warData?.targetEndTime||0)||0; if(!t) return ''; const d=new Date(t*1000); const pad=(n)=>String(n).padStart(2,'0'); return `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:00`; }catch(_){return ''}})()}" style="width:100%; min-width:220px; box-sizing:border-box;"><span id="war-target-end-utc-preview" style="margin-left:6px; font-size:11px; color:#aaa; white-space:nowrap;"></span></div>`:`<div class="settings-input-display" style="max-width:100%;word-break:break-word;overflow-wrap:break-word;">${state.warData?.targetEndTime ? (new Date(Number(state.warData.targetEndTime||0)*1000).toUTCString() + ' (UTC)') : ''}</div>`}
</div>
<div id="initial-score-clip">
</div>
<div id="initial-score-clip-end" style="display:none"></div>
<div id="initial-score-clip-end2" style="display:none"></div>
<div id="initial-target-display" style="grid-column:span 2; margin-top:6px; color:#fff; font-size:12px;" ></div>
<div id="initial-target-debug" style="display:none"></div>
<div id="initial-target-break" style="display:none"></div>
<div id="initial-target-extra" style="display:none"></div>
<div id="initial-target-traits" style="display:none"></div>
<div id="initial-target-final" style="display:none"></div>
<div id="target-end-display" style="grid-column:span 2; margin-top:4px; color:#fff; font-size:12px;" ></div>
<div id="rw-term-info" style="margin-top:8px;font-size:12px;color:#fff;display:block;width:100%;box-sizing:border-box;grid-column:1 / -1;line-height:1.6"></div>
<div style="grid-column:span 2;min-width:200px;">
<label class="tdm-mini-lbl">Opponent</label>
<div class="settings-input-display" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${opponentFactionName} ${opponentFactionId?`(ID:${opponentFactionId})`:''}</div>
</div>
${isAdmin ? `<label style="display:flex;align-items:center;gap:8px;color:#fff;font-size:12px;margin-left:8px;"><input type="checkbox" id="war-disable-meddeals" ${state.warData?.disableMedDeals ? 'checked' : ''} /> Disable Med Deals (Only show dibs button) in Termed Wars</label>` : ''}
<div style="display:flex;justify-content:center;gap:6px;flex-wrap:wrap;align-self:center;">
<button id="save-war-data-btn" class="settings-btn settings-btn-green" style="display:${storage.get('adminFunctionality', true)?'inline-block':'none'};" title="Persist current war configuration (term caps, opponent, score types)." aria-label="Save war data">Save War Data</button>
<button id="copy-war-details-btn" class="settings-btn settings-btn-blue" title="Copy a shareable war summary to the clipboard." aria-label="Copy war details">Copy War Details</button>
</div>
</div>
<div style="font-size:11px;color:#888;margin-top:4px;">Set Indiv Cap = 0 for unlimited.</div>
<div id="attack-mode-group" style="margin-top:8px;display:${showAttackMode?'block':'none'};">
<div class="settings-subheader" style="margin-bottom:4px;color:#93c5fd;text-align:center;">Attack Mode</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
<div style="font-size:12px;">Current: <span style="color:#ffd600;">${attackMode}</span></div>
${isAdmin?`<select id=\"attack-mode-select\" class=\"settings-input\" style=\"width:120px;\" title=\"Select attack mode (Farming enforces dibs).\">${['Farming','FFA','Turtle'].map(m=>`<option value='${m}' ${attackMode===m?'selected':''}>${m}</option>`).join('')}</select><button id=\"attack-mode-save-btn\" class=\"settings-btn settings-btn-green\" title=\"Save selected attack mode.\" aria-label=\"Save attack mode\">Save</button>`:''}
<div style="font-size:11px;color:#aaa;">Dibs enforced only in Farming.</div>
</div>
</div>
<div class="settings-subheader" style="margin-top:10px;margin-bottom:4px;color:#93c5fd;text-align:center;">Dibs Style</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:8px;">
<label class="tdm-mini-checkbox"><input type="checkbox" id="dibs-keep-inactive" ${dibsStyle.keepTillInactive?'checked':''} ${isAdmin?'':'disabled'} /> Keep Until Inactive</label>
<label class="tdm-mini-checkbox"><input type="checkbox" id="dibs-redib-after-success" ${dibsStyle.mustRedibAfterSuccess?'checked':''} ${isAdmin?'':'disabled'} /> Require Re-dib After Success</label>
<label class="tdm-mini-checkbox">Inactivity (s): <input type="number" id="dibs-inactivity-seconds" min="60" step="30" value="${parseInt(dibsStyle.inactivityTimeoutSeconds||300)}" ${isAdmin?'':'disabled'} class="settings-input" style="width:80px;margin-left:4px;" /></label>
<label class="tdm-mini-checkbox">Max Hosp (m): <input type="number" id="dibs-max-hosp-minutes" min="0" step="1" value="${Number(dibsStyle.maxHospitalReleaseMinutes||0)}" ${isAdmin?'':'disabled'} class="settings-input" style="width:70px;margin-left:4px;" /></label>
<label class="tdm-mini-checkbox">Remove If Opp Travels <input type="checkbox" id="dibs-remove-on-fly" ${dibsStyle.removeOnFly?'checked':''} ${isAdmin?'':'disabled'} style="margin-left:4px;" /></label>
<label class="tdm-mini-checkbox">Remove If You Travel <input type="checkbox" id="dibs-remove-user-travel" ${(dibsStyle.removeWhenUserTravels?'checked':'')} ${isAdmin?'':'disabled'} style="margin-left:4px;" /></label>
<label class="tdm-mini-checkbox"><input type="checkbox" id="dibs-bypass-style" ${dibsStyle.bypassDibStyle ? 'checked' : ''} ${isAdmin ? '' : 'disabled'} /> Bypass Dibs Style (Admins may ignore rules)</label>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-top:10px;">
<div><div class="tdm-mini-lbl">Allowed Opponent Statuses</div><div class="tdm-check-grid">${['Okay','Hospital','Travel','Abroad','Jail'].map(s=>`<label><input type='checkbox' class='dibs-allow-status' data-status='${s}' ${dibsStyle.allowStatuses?.[s]?'checked':''} ${isAdmin?'':'disabled'} /> ${s}</label>`).join('')}</div></div>
<div><div class="tdm-mini-lbl">Allowed Opponent Activity</div><div class="tdm-check-grid">${(()=>{const dflt={Online:true,Idle:true,Offline:true};const ala=dibsStyle.allowLastActionStatuses||{};return ['Online','Idle','Offline'].map(s=>`<label><input type='checkbox' class='dibs-allow-lastaction' data-status='${s}' ${(ala[s]??dflt[s])?'checked':''} ${isAdmin?'':'disabled'} /> ${s}</label>`).join('');})()}</div></div>
<div><div class="tdm-mini-lbl">Allowed User Statuses</div><div class="tdm-check-grid">${(()=>{const dflt={Okay:true,Hospital:true,Travel:false,Abroad:false,Jail:false};const aus=dibsStyle.allowedUserStatuses||{};return ['Okay','Hospital','Travel','Abroad','Jail'].map(s=>`<label><input type='checkbox' class='dibs-allow-user-status' data-status='${s}' ${(aus[s]??dflt[s])?'checked':''} ${isAdmin?'':'disabled'} /> ${s}</label>`).join('');})()}</div></div>
</div>
<div style="display:flex;justify-content:center;gap:6px;flex-wrap:wrap;margin-top:8px;">
<button id="save-dibs-style-btn" class="settings-btn settings-btn-green" style="display:${isAdmin?'inline-block':'none'};" title="Persist dibs style rules (timeouts, allowed statuses)." aria-label="Save dibs style">Save Dibs Style</button>
<button id="copy-dibs-style-btn" class="settings-btn settings-btn-blue" title="Copy a summary of the current dibs style." aria-label="Copy dibs style">Copy Dibs Style</button>
</div>
${!isAdmin?'<div style="text-align:center;color:#aaa;margin-top:4px;">Visible to all members. Only admins can edit.</div>':''}
</div>
</div>
</div>
<div class="settings-section" data-section="ranked-war-tools">
<div class="settings-header">Ranked War Reports</div>
<div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap;">
<select id="ranked-war-id-select" class="settings-input" style="flex-grow: 1;" title="Select a ranked war to analyze."><option value="">Loading wars...</option></select>
<button id="show-ranked-war-summary-btn" class="settings-btn settings-btn-green" title="Generate summary for selected ranked war." aria-label="Show ranked war summary">War Summary</button>
<button id="view-war-attacks-btn" class="settings-btn settings-btn-blue" title="Open detailed attacks list for selected ranked war." aria-label="View war attacks">War Attacks</button>
</div>
</div>
<!-- ChainWatcher Section -->
<div class="settings-section" data-section="chain-watcher">
<div class="settings-header">ChainWatcher: <span id="tdm-chainwatcher-header-names" style="color:#ffd600;">—</span></div>
<div>
<div style="font-size:12px;color:#fff;margin-bottom:6px;">Select current chain watchers (authoritative list stored for the faction). Selected names will appear as a badge in the UI.</div>
<div id="tdm-chainwatcher-meta" style="font-size:11px;color:#fff;margin-bottom:6px;">Last updated: —</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<select id="tdm-chainwatcher-select" class="settings-input" multiple style="min-width:220px;max-width:420px;min-height:120px;">
<!-- options populated dynamically -->
</select>
<div style="display:flex;flex-direction:column;gap:6px;">
<button id="tdm-chainwatcher-save" class="settings-btn settings-btn-green">Save</button>
<button id="tdm-chainwatcher-clear" class="settings-btn">Clear</button>
</div>
</div>
<div style="margin-top:8px;font-size:11px;color:#888;">Selections are authoritative from the server; local UI reflects server state.</div>
</div>
</div>
</div><!-- END WAR/DIBS TAB PANEL -->
<!-- ADVANCED TAB PANEL -->
<div class="tdm-settings-panel ${activeTab === 'advanced' ? 'tdm-settings-panel--active' : ''}" data-panel="advanced">
<div class="settings-section" data-section="api-keys">
<div class="settings-header">API Keys</div>
<div>
<!-- Torn API Key Card -->
<div id="tdm-api-key-card" class="settings-card" data-tone="${apiKeyTone}" style="margin-bottom:12px;border:1px solid var(--tdm-bg-secondary);background:var(--tdm-bg-secondary);padding:10px;border-radius:8px;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;">
<div>
<div style="font-size:12px;color:#93c5fd;font-weight:600;">Torn API Key for TDM Access</div>
<div id="tdm-api-key-status" style="font-size:11px;color:#cbd5f5;">${apiKeyStatus}</div>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<!-- Revalidate removed: we auto-check key when the settings panel opens -->
<button id="tdm-api-key-clear-btn" class="settings-btn settings-btn-red" title="Clear the stored custom API key and fall back to PDA or idle state.">Clear</button>
</div>
</div>
<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<input type="password" id="tdm-api-key-input" class="settings-input" placeholder="Enter custom Torn API key" value="${apiKeyInputValue}" style="min-width:200px;flex:1;letter-spacing:2px;" autocomplete="off" />
<button id="tdm-api-key-save-btn" class="settings-btn settings-btn-green">Save Key</button>
<button id="tdm-generate-key-btn" class="settings-btn settings-btn-blue" title="Open Torn API key creation page" aria-label="Generate custom key">Generate Custom Key</button>
<label style="font-size:11px;color:#fff;display:flex;align-items:center;gap:4px;">
<input type="checkbox" id="tdm-api-key-show" /> Show
</label>
</div>
<div id="tdm-api-key-message" data-tone="${apiKeyTone}" style="font-size:11px;margin-top:6px;color:${apiKeyMessageColor};">${apiKeyMessageHtml}</div>
<div id="tdm-api-key-source" style="font-size:10px;color:#6b7280;margin-top:6px;">${apiKeySourceNoteHtml}</div>
</div>
<!-- FFScouter API Key Card -->
<div id="tdm-ffscouter-key-card" class="settings-card" data-tone="${ffUi.tone}" style="margin-bottom:12px;border:1px solid var(--tdm-bg-secondary);background:var(--tdm-bg-secondary);padding:10px;border-radius:8px;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;">
<div>
<div style="font-size:12px;color:#93c5fd;font-weight:600;">FFScouter API Key (PC Only, PDA uses other scripts key)</div>
<div id="tdm-ffscouter-key-status" style="font-size:11px;color:#cbd5f5;">${ffUi.status}</div>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button id="tdm-ffscouter-key-clear-btn" class="settings-btn settings-btn-red" title="Clear the stored FFScouter API key.">Clear</button>
</div>
</div>
<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<input type="password" id="tdm-ffscouter-key-input" class="settings-input" placeholder="Enter FFScouter API key" value="${ffKeyInputValue}" style="min-width:200px;flex:1;letter-spacing:2px;" autocomplete="off" />
<button id="tdm-ffscouter-key-save-btn" class="settings-btn settings-btn-green">Save Key</button>
<a href="https://ffscouter.com/" target="_blank" rel="noopener" class="settings-btn settings-btn-blue" style="text-decoration:none;line-height:24px;padding:0 12px;display:inline-block;" title="Get key at ffscouter.com">Get Key</a>
<label style="font-size:11px;color:#fff;display:flex;align-items:center;gap:4px;">
<input type="checkbox" id="tdm-ffscouter-key-show" /> Show
</label>
</div>
<div id="tdm-ffscouter-key-message" style="font-size:11px;margin-top:6px;color:${ffKeyMessageColor};">${ffUi.message}</div>
</div>
<!-- BSP API Key Card -->
<div id="tdm-bsp-key-card" class="settings-card" data-tone="${ffUi.tone}" style="margin-bottom:12px;border:1px solid var(--tdm-bg-secondary);background:var(--tdm-bg-secondary);padding:10px;border-radius:8px;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;">
<div>
<div style="font-size:12px;color:#93c5fd;font-weight:600;">Battle Stats Predictor</div>
<div id="tdm-ffscouter-key-status" style="font-size:11px;color:#cbd5f5;">${ffUi.status}</div>
</div>
</div>
<div id="tdm-bsp-key-message" style="font-size:11px;margin-top:6px;color:${ffKeyMessageColor};">No Key Needed. Uses BSP automatically when that userscript is installed and you are an active subscriber.</div>
</div>
</div>
</div>
<!-- API Cadence & Polling -->
<div class="settings-section" data-section="api-cadence">
<div class="settings-header">API Cadence & Polling</div>
<div style="display:flex; flex-direction:column; gap:8px;">
<div style="font-size:12px; color:#fff; line-height:1.4;">
Controls how often TreeDibsMapper pulls faction bundles to keep scores, dibs, and status data fresh.
</div>
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap; background:var(--tdm-bg-secondary); padding:8px; border-radius:6px;">
<label style="display:flex; align-items:center; gap:6px; color:#fff;">
Team refresh interval (seconds):
<input type="number" id="faction-bundle-refresh-seconds" class="settings-input" min="5" step="5" style="width:100px;" value="${Math.round((Number(storage.get('factionBundleRefreshMs', null)) || state.script.factionBundleRefreshMs || config.DEFAULT_FACTION_BUNDLE_REFRESH_MS)/1000)}" />
</label>
<button id="save-faction-bundle-refresh-btn" class="settings-btn settings-btn-green">Apply</button>
<div id="tdm-last-faction-refresh" style="font-size:12px; color:#fff; flex-basis:100%;">Last faction refresh: —</div>
<div id="tdm-polling-status" style="font-size:12px; color:#fff; flex-basis:100%;">Polling status: —</div>
<div id="tdm-opponent-poll-line" style="font-size:12px; color:#fff; flex-basis:100%;">Opponent polling: —</div>
</div>
<div style="background:var(--tdm-bg-secondary); padding:8px; border-radius:6px; display:flex; flex-wrap:wrap; gap:8px; align-items:flex-start;">
<label style="display:flex; flex-direction:column; gap:4px; color:#fff; font-size:12px; flex:1; min-width:240px;">
Extra factions to poll (comma separated IDs):
<input type="text" id="tdm-additional-factions-input" class="settings-input" value="${utils.coerceStorageString(storage.get('tdmExtraFactionPolls',''), '').replace(/"/g,'"')}" placeholder="12345,67890" />
</label>
<button id="tdm-save-additional-factions-btn" class="settings-btn">Save</button>
<div id="tdm-additional-factions-status" style="font-size:12px; color:#fff; align-self:center;">No extra factions configured.</div>
<div id="tdm-additional-factions-summary" style="flex-basis:100%; font-size:12px; color:#777;">—</div>
</div>
</div>
</div>
<!-- Activity Tracking -->
<div class="settings-section" data-section="activity-tracking">
<div class="settings-header">Activity Tracking ${storage.get('tdmActivityTrackingEnabled', false) ? '(Enabled)' : '(Disabled)'}</div>
<div class="activity-tracking-content" style="display:flex; flex-wrap:wrap; gap:12px; width:100%; padding:8px; border-radius:6px;">
<label style="flex:1; min-width:200px; color:#fff; font-size:12px;">
<input type="checkbox" id="tdm-activity-tracking-toggle" ${storage.get('tdmActivityTrackingEnabled', false)?'checked':''} /> Ranked War Faction Members Activity Tracking
</label>
<label style="flex:1; min-width:200px; color:#fff; font-size:12px;">
<input type="checkbox" id="tdm-activity-track-idle" ${storage.get('tdmActivityTrackWhileIdle', false)?'checked':''} /> Track while idle (higher resource usage)
</label>
<label style="display:flex; align-items:center; gap:6px; color:#fff; font-size:12px;">Activity cadence (seconds):
<input type="number" id="tdm-activity-cadence-seconds" min="5" max="60" step="1" value="${Math.min(60,Math.max(5, Number(storage.get('tdmActivityCadenceMs',10000))/1000 || 10))}" class="settings-input" style="width:70px;" />s
</label>
<button id="tdm-apply-activity-cadence-btn" class="settings-btn settings-btn-green">Apply</button>
<div style="flex-basis:100%; font-size:12px; color:#888;">Runs on top of the Torn API cadence, diffing each pull to expose live status & travel transitions.</div>
<div style="flex-basis:100%; margin-top:4px; display:flex; flex-wrap:wrap; gap:12px; align-items:center; background:var(--tdm-bg-secondary); padding:8px; border-radius:6px;">
<label style="display:flex; align-items:center; gap:6px; color:#fff; font-size:12px;">IDB Max Size
<select id="tdm-idb-maxsize-select" class="settings-input" style="width:140px;">
${(()=>{const cur=Number(storage.get('tdmIdbMaxSizeMB',''))||0;const opts=[0,5,10,20,64,96];return opts.map(v=>`<option value='${v}' ${cur===v?'selected':''}>${v? v+' MB':'Auto (Browser)'}</option>`).join('');})()}
</select>
</label>
<div id="tdm-idb-usage-line" style="font-size:12px; color:#fff;">IDB Usage: —</div>
<button id="tdm-flush-activity-cache-btn" class="settings-btn">Flush Activity Cache</button>
<button id="tdm-clear-idb-btn" class="settings-btn settings-btn-red">Clear IDB Storage</button>
</div>
<label style="flex:1; min-width:160px; color:#fff; font-size:12px;">
<input type="checkbox" id="tdm-debug-overlay-toggle" ${storage.get('liveTrackDebugOverlayEnabled', false)?'checked':''} /> Debug Overlay
</label>
</div>
</div>
<!-- Admin Settings -->
${state.script.canAdministerMedDeals ? `
<div class="settings-section" data-section="Admin-Settings">
<div class="settings-header">Admin Settings</div>
<div style="display:flex; flex-direction:column; gap:8px;">
<button id="admin-functionality-btn" class="settings-btn ${storage.get('adminFunctionality', true) ? 'settings-btn-green' : 'settings-btn-red'}">Manage Others Dibs/Deals: ${storage.get('adminFunctionality', true) ? 'On' : 'Off'}</button>
${state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true ? `
<button id="view-unauthorized-attacks-btn" class="settings-btn">View Unauthorized Attacks</button>
<button id="tdm-adoption-btn" class="settings-btn ${storage.get('debugAdoptionInfo', false) ? 'settings-btn-green' : ''}">TDM Adoption Info</button>
<div style="padding:8px; border:1px solid #444; border-radius:6px;">
<div style="margin-bottom:6px; color:#fca5a5; font-weight:bold;">Bulk Cleanup (current enemy faction)</div>
<div style="display:flex; gap:12px; flex-wrap:wrap; align-items:center;">
<label style="color:#fff; font-size:12px;"><input type="checkbox" id="cleanup-notes" checked /> Notes</label>
<label style="color:#fff; font-size:12px;"><input type="checkbox" id="cleanup-dibs" checked /> Dibs</label>
<label style="color:#fff; font-size:12px;"><input type="checkbox" id="cleanup-meddeals" checked /> Med Deals</label>
<input type="text" id="cleanup-faction-id" placeholder="Enemy faction ID (optional)" class="settings-input" style="width:160px;" />
<input type="text" id="cleanup-reason" placeholder="Reason (required)" class="settings-input" style="min-width:240px;" />
<button id="run-admin-cleanup-btn" class="settings-btn settings-btn-red">Run Cleanup</button>
</div>
<div style="font-size:12px; color:#aaa; margin-top:4px;">Cleans data for opponents visible in the current Ranked War table.</div>
<div id="cleanup-results-line" style="font-size:12px; color:#fff; margin-top:4px; display:none;"></div>
</div>
` : ''}
</div>
</div>
` : ''}
<!-- Dev Toggles -->
<div class="settings-section" data-section="dev-settings">
<div class="settings-header">Dev Toggles</div>
<div style="display:flex; flex-direction:column; gap:8px;">
<div style="display:flex; gap:12px; flex-wrap:wrap; align-items:center;">
<label style="display:flex; align-items:center; gap:6px; color:#fff; font-size:12px;">
<div class="tdm-toggle-switch ${storage.get('debugRowLogs', false) ? 'active' : ''}" id="verbose-row-logs-toggle" data-key="debugRowLogs"></div>
Verbose Row Logs
</label>
<label style="display:flex; align-items:center; gap:6px; color:#fff; font-size:12px;">
<div class="tdm-toggle-switch ${storage.get('debugStatusWatch', false) ? 'active' : ''}" id="status-watch-toggle" data-key="debugStatusWatch"></div>
Status Watch Logs
</label>
<label style="display:flex; align-items:center; gap:6px; color:#fff; font-size:12px;">
<div class="tdm-toggle-switch ${storage.get('debugPointsParseLogs', false) ? 'active' : ''}" id="points-parse-logs-toggle" data-key="debugPointsParseLogs"></div>
Points Parse Logs
</label>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<label style="color:#fff; font-size:12px;">Log Level</label>
<select id="tdm-log-level-select" class="settings-input" style="width:140px;">
${logLevels.map(l=>`<option value="${l}" ${storage.get('logLevel','warn')===l?'selected':''}>${l}</option>`).join('')}
</select>
</div>
</div>
</div>
<!-- Maintenance -->
<div class="settings-section" data-section="maintenance">
<div class="settings-header">Maintenance</div>
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<button id="reset-settings-btn" class="settings-btn settings-btn-red">Reset All Settings</button>
</div>
</div>
</div><!-- END ADVANCED TAB PANEL -->
`;
renderPhaseComplete = true;
perf?.stop?.('ui.updateSettingsContent.render');
tdmlogger('debug', 'UI: Settings content rendered');
perf?.start?.('ui.updateSettingsContent.bind');
bindPhaseStarted = true;
// Tab switching handler
try {
const tabs = content.querySelectorAll('.tdm-settings-tab');
const panels = content.querySelectorAll('.tdm-settings-panel');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetTab = tab.dataset.tab;
// Update active tab
tabs.forEach(t => t.classList.remove('tdm-settings-tab--active'));
tab.classList.add('tdm-settings-tab--active');
// Update active panel
panels.forEach(p => {
if (p.dataset.panel === targetTab) {
p.classList.add('tdm-settings-panel--active');
} else {
p.classList.remove('tdm-settings-panel--active');
}
});
// Persist active tab
storage.set('tdm_settings_active_tab', targetTab);
});
});
} catch(_) {}
// API Key card handlers
try {
const apiKeyCard = document.getElementById('tdm-api-key-card');
if (apiKeyCard) {
const apiInput = document.getElementById('tdm-api-key-input');
const saveBtn = document.getElementById('tdm-api-key-save-btn');
const clearBtn = document.getElementById('tdm-api-key-clear-btn');
// refreshBtn intentionally removed — we auto-check stored key when settings open
const refreshBtn = null;
const showToggle = document.getElementById('tdm-api-key-show');
const messageEl = document.getElementById('tdm-api-key-message');
const statusEl = document.getElementById('tdm-api-key-status');
const sourceEl = document.getElementById('tdm-api-key-source');
const toneColors = { success:'#86efac', warning:'#facc15', info:'#93c5fd', error:'#fca5a5' };
const setTone = (tone) => {
const t = tone || 'error';
apiKeyCard.dataset.tone = t;
if (messageEl) messageEl.style.color = toneColors[t] || toneColors.error;
};
const setStatus = (text) => {
if (statusEl) statusEl.textContent = text || 'Unverified';
};
const setSource = (text) => {
if (!sourceEl) return;
sourceEl.textContent = text || '';
};
const setMessage = (tone, text, reason = 'api-key-ui') => {
const msg = text || '';
const toneKey = tone || 'info';
setTone(toneKey);
if (messageEl) messageEl.textContent = msg;
state.user.apiKeyUiMessage = { tone: toneKey, text: msg, ts: Date.now(), reason };
};
const refreshFromState = () => {
const uiState = computeApiKeyUi();
if (apiInput && typeof uiState.storedKey === 'string') {
// Don't clobber unsaved user input. Only replace input if empty
// or it already matches the stored value (safe to sync).
const cur = (apiInput.value || '').trim();
const storedVal = String(uiState.storedKey || '').trim();
if (!cur || cur === storedVal) apiInput.value = storedVal;
}
setTone(uiState.tone);
if (messageEl) messageEl.textContent = uiState.message;
setStatus(uiState.status);
setSource(uiState.sourceNote || '');
if (showToggle && apiInput) {
apiInput.type = showToggle.checked ? 'text' : 'password';
}
};
// Refresh UI from state first, then attempt a silent revalidation of the stored key
// to keep helper text accurate when the settings panel opens.
refreshFromState();
(async () => {
try {
// Only revalidate if there is a stored or PDA-provided key; avoid toasts
// and protect any unsaved input value.
await main.revalidateStoredApiKey({ showToasts: false });
} catch(_) { /* ignore */ }
try { refreshFromState(); } catch(_) { /* noop */ }
})();
showToggle?.addEventListener('change', (e) => {
if (!apiInput) return;
apiInput.type = e.target.checked ? 'text' : 'password';
if (e.target.checked) apiInput.select?.();
});
saveBtn?.addEventListener('click', async () => {
if (!apiInput) return;
const value = (apiInput.value || '').trim();
if (!value) {
setMessage('error', 'Enter a Torn API key before saving.', 'api-key-empty');
apiInput.focus();
return;
}
const originalLabel = saveBtn.textContent;
saveBtn.disabled = true;
saveBtn.textContent = 'Verifying...';
setStatus('Verifying...');
setMessage('info', 'Verifying API key...', 'api-key-verifying');
try {
const verification = await main.verifyApiKey({ key: value });
if (!verification.ok) {
// Not valid -> do not reload, show error and keep user's input intact
setMessage('error', verification.message, verification.reason || 'api-key-verify-failed');
setStatus('Unverified');
return;
}
// If key verified but flagged as LIMITED, save it but DO NOT reload automatically.
// This prevents a premature reload and helps the user inspect the diagnostics.
if (verification.validation?.isLimited) {
setMessage('warning', verification.message + ' Saved (limited access) — not reloading.', 'api-key-limited');
await main.storeCustomApiKey(value, { reload: false, validation: verification.validation, keyInfo: verification.keyInfo });
// Also show missing scopes/details in the helper text for clarity
const missing = verification.validation?.missing || [];
if (missing.length) {
const friendly = missing.map(s => s.replace('.', ' -> ')).join(', ');
setMessage('warning', `Limited key saved. Missing selections: ${friendly}`, 'api-key-missing-scopes');
}
return;
}
// Fully validated custom key — store and reload as before to reflect verified state everywhere.
setMessage('info', 'Custom API key verified. Saving and reloading…', 'api-key-verified-reload');
await main.storeCustomApiKey(value, { reload: true, validation: verification.validation, keyInfo: verification.keyInfo });
} catch (error) {
setMessage('error', error?.message || 'Failed to save API key.', 'api-key-store-error');
} finally {
if (state.user.apiKeyUiMessage?.reason !== 'stored' && saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = originalLabel;
}
}
});
clearBtn?.addEventListener('click', async () => {
if (!clearBtn) return;
const originalLabel = clearBtn.textContent;
clearBtn.disabled = true;
clearBtn.textContent = 'Clearing...';
try {
const result = await main.clearStoredApiKey({ confirm: true });
if (result && result.cancelled) {
clearBtn.disabled = false;
clearBtn.textContent = originalLabel;
refreshFromState();
}
} catch (error) {
setMessage('error', error?.message || 'Failed to clear API key.', 'api-key-clear-error');
clearBtn.disabled = false;
clearBtn.textContent = originalLabel;
}
});
// Open the Torn custom API key creator in a new tab (configurable fallback)
try {
const generateBtn = document.getElementById('tdm-generate-key-btn');
generateBtn?.addEventListener('click', () => {
const customUrl = config.customKeyUrl || 'https://www.torn.com/preferences.php#tab=api?step=addNewKey&title=TreeDibsMapper&user=basic,profile,faction,job&faction=rankedwars,members,attacks,attacksfull,basic,chain,chains,positions,warfare,wars&torn=rankedwars,rankedwarreport';
try {
window.open(customUrl, '_blank', 'noopener');
} catch (err) {
// Fallback: surface the URL so the user can copy it manually
ui.showMessageBox(`Unable to open key generator. Copy this URL: ${customUrl}`, 'error');
}
});
} catch (_) { /* noop */ }
// Revalidate button removed; we revalidate silently when the panel opens.
}
} catch(_) {}
// FFScouter Key Handlers
try {
const ffInput = document.getElementById('tdm-ffscouter-key-input');
const ffSaveBtn = document.getElementById('tdm-ffscouter-key-save-btn');
const ffClearBtn = document.getElementById('tdm-ffscouter-key-clear-btn');
const ffShowToggle = document.getElementById('tdm-ffscouter-key-show');
const ffMessageEl = document.getElementById('tdm-ffscouter-key-message');
const ffStatusEl = document.getElementById('tdm-ffscouter-key-status');
const ffCard = document.getElementById('tdm-ffscouter-key-card');
const setFFMessage = (tone, text) => {
if (ffMessageEl) {
ffMessageEl.textContent = text;
ffMessageEl.style.color = (tone === 'success' ? '#86efac' : tone === 'info' ? '#93c5fd' : '#fca5a5');
}
if (ffCard) ffCard.dataset.tone = tone;
if (ffStatusEl) ffStatusEl.textContent = (tone === 'success' ? 'Saved' : 'Unverified');
};
ffShowToggle?.addEventListener('change', (e) => {
if (ffInput) ffInput.type = e.target.checked ? 'text' : 'password';
});
ffSaveBtn?.addEventListener('click', async () => {
if (!ffInput) return;
const val = (ffInput.value || '').trim();
if (!val) {
setFFMessage('error', 'Enter a key first.');
return;
}
const orig = ffSaveBtn.textContent;
ffSaveBtn.disabled = true;
ffSaveBtn.textContent = 'Verifying...';
setFFMessage('info', 'Verifying key with FFScouter...');
try {
const res = await api.verifyFFScouterKey(val);
if (res.ok) {
storage.set('ffscouterApiKey', val);
setFFMessage('success', 'Key verified and saved.');
// Trigger an immediate fetch to populate cache
try {
const visibleIds = utils.getVisibleRankedWarFactionIds?.()?.ids || [];
if (visibleIds.length) api.fetchFFScouterStats(visibleIds);
} catch(_) {}
} else {
setFFMessage('error', res.message || 'Verification failed.');
}
} catch (e) {
setFFMessage('error', 'Error: ' + e.message);
} finally {
ffSaveBtn.disabled = false;
ffSaveBtn.textContent = orig;
}
});
ffClearBtn?.addEventListener('click', () => {
if (confirm('Clear FFScouter API key?')) {
storage.remove('ffscouterApiKey');
if (ffInput) ffInput.value = '';
setFFMessage('error', 'Key cleared.');
if (ffStatusEl) ffStatusEl.textContent = 'Not Set';
}
});
} catch(_) {}
// Activity Tracking Toggle & Cadence
try {
const toggle = document.getElementById('tdm-activity-tracking-toggle');
const idleToggle = document.getElementById('tdm-activity-track-idle');
const cadenceInput = document.getElementById('tdm-activity-cadence-seconds');
const applyStatusLine = () => {
// Update the header to show current status
const section = document.querySelector('[data-section="activity-tracking"]');
if (section) {
const header = section.querySelector('.settings-header');
if (header) {
const enabled = storage.get('tdmActivityTrackingEnabled', false);
header.innerHTML = `Activity Tracking ${enabled ? 'Enabled' : 'Disabled'} <span class="chevron">▾</span>`;
}
}
};
const applyKeepActivePreference = () => {
try {
const keepActive = utils.isActivityKeepActiveEnabled();
state.script.idleTrackingOverride = keepActive;
state.script.isWindowActive = !document.hidden;
if (keepActive) {
if (!state.script.mainRefreshIntervalId || document.hidden) {
main.startPolling();
}
} else if (document.hidden) {
if (state.script.mainRefreshIntervalId) { try { utils.unregisterInterval(state.script.mainRefreshIntervalId); } catch(_) {} state.script.mainRefreshIntervalId = null; }
if (state.script.activityTimeoutId) { try { utils.unregisterTimeout(state.script.activityTimeoutId); } catch(_) {} state.script.activityTimeoutId = null; }
if (state.script.factionBundleRefreshIntervalId) { try { utils.unregisterInterval(state.script.factionBundleRefreshIntervalId); } catch(_) {} state.script.factionBundleRefreshIntervalId = null; }
if (state.script.fetchWatchdogIntervalId) { try { utils.unregisterInterval(state.script.fetchWatchdogIntervalId); } catch(_) {} state.script.fetchWatchdogIntervalId = null; }
if (state.script.lightPingIntervalId) { try { utils.unregisterInterval(state.script.lightPingIntervalId); } catch(_) {} state.script.lightPingIntervalId = null; }
}
try { ui.updateApiCadenceInfo?.(); } catch(_) {}
} catch(_) { /* ignore */ }
};
const startTracking = () => {
if (!state._activityTracking) {
state._activityTracking = {
prevById: {},
metrics: { transitions:0, lastPoll:0, lastDiffMs:0 },
cadenceMs: Number(storage.get('tdmActivityCadenceMs', 10000)) || 10000,
lastSig: null
};
} else {
state._activityTracking.cadenceMs = Number(storage.get('tdmActivityCadenceMs', 10000)) || 10000;
}
handlers._initActivityTracking?.();
applyKeepActivePreference();
};
const stopTracking = () => {
handlers._teardownActivityTracking?.();
applyKeepActivePreference();
};
toggle?.addEventListener('change', e => {
const enabled = !!e.target.checked;
storage.set('tdmActivityTrackingEnabled', enabled);
if (enabled) startTracking(); else stopTracking();
applyStatusLine();
applyKeepActivePreference();
});
idleToggle?.addEventListener('change', e => {
const enabled = !!e.target.checked;
storage.set('tdmActivityTrackWhileIdle', enabled);
applyKeepActivePreference();
});
document.getElementById('tdm-apply-activity-cadence-btn')?.addEventListener('click', () => {
try {
let sec = Math.round(Number(cadenceInput.value)||10);
if (sec < 5) sec = 5; if (sec > 60) sec = 60;
cadenceInput.value = String(sec);
const ms = sec*1000;
storage.set('tdmActivityCadenceMs', ms);
if (storage.get('tdmActivityTrackingEnabled', false)) {
if (state._activityTracking) state._activityTracking.cadenceMs = ms;
handlers._updateActivityCadence?.(ms);
}
ui.showMessageBox(`Activity cadence set to ${sec}s.`, 'success');
} catch(err) { ui.showMessageBox('Invalid cadence.', 'error'); }
});
document.getElementById('tdm-flush-activity-cache-btn')?.addEventListener('click', async () => {
try {
if (!(await ui.showConfirmationBox('Flush activity cache? Confidence resets & previous states cleared.'))) return;
await handlers._flushActivityCache?.();
ui.showMessageBox('Activity cache flushed.', 'info');
} catch(err) { ui.showMessageBox('Flush failed','error'); }
});
document.getElementById('tdm-clear-idb-btn')?.addEventListener('click', async () => {
try {
if (!(await ui.showConfirmationBox('Clear entire IDB Storage? This will wipe all cached data (tdm-store).'))) return;
if (ui._kv && typeof ui._kv.deleteDb === 'function') {
const ok = await ui._kv.deleteDb();
if (ok) {
ui.showMessageBox('IDB Storage cleared.', 'success');
setTimeout(() => { try { ui.updateIdbUsageLine?.(); } catch(_) {} }, 500);
} else {
ui.showMessageBox('Failed to clear IDB.', 'error');
}
} else {
ui.showMessageBox('IDB interface not available.', 'error');
}
} catch(err) { ui.showMessageBox('Clear failed: ' + err, 'error'); }
});
document.getElementById('tdm-debug-overlay-toggle')?.addEventListener('change', e => {
const v = !!e.target.checked;
handlers.toggleLiveTrackDebugOverlay(v);
});
applyStatusLine();
applyKeepActivePreference();
} catch(_) {}
tdmlogger('debug', 'UI: Settings content rendered');
// IDB usage helpers
if (!ui.updateIdbUsageLine) {
ui.updateIdbUsageLine = async () => {
try {
const line = document.getElementById('tdm-idb-usage-line');
if (!line) return;
let used = ui._kv ? (ui._kv._approxBytes || 0) : 0;
let isEstimate = false;
// Try to get accurate origin usage
if (navigator.storage && navigator.storage.estimate) {
try {
const estimate = await navigator.storage.estimate();
if (estimate && typeof estimate.usage === 'number') {
used = estimate.usage;
isEstimate = true;
}
} catch(_) {}
}
const maxBytes = ui._kv ? ui._kv._maxBytes() : 0;
let usageStr = '';
if (used < 1024 * 1024) {
usageStr = (used / 1024).toFixed(2) + ' KB';
} else {
const mb = used / 1024 / 1024;
usageStr = mb.toFixed(mb < 10 ? 2 : 1) + ' MB';
}
const pct = maxBytes ? Math.min(100, (used / maxBytes)*100) : null;
const maxStr = maxBytes ? (maxBytes/1024/1024).toFixed(0) + ' MB' : 'auto';
line.textContent = `IDB Usage: ${usageStr}${maxBytes ? ` / ${maxStr} (${pct.toFixed(1)}%)` : ` (${isEstimate ? 'Origin' : 'Est.'})`}`;
} catch(_) {}
};
setTimeout(()=>{ try { ui.updateIdbUsageLine(); } catch(_) {} }, 500);
}
try {
document.getElementById('tdm-idb-maxsize-select')?.addEventListener('change', e => {
try {
const mb = Number(e.target.value)||0;
storage.set('tdmIdbMaxSizeMB', mb);
ui.showMessageBox(`IDB max size set to ${mb? mb+' MB (eviction applies)':'Auto (browser managed)'}.`, 'info');
ui._kv?._maybeEvict?.();
ui.updateIdbUsageLine?.();
} catch(err) { tdmlogger('error', `[Failed to apply IDB max size] ${err}`); }
});
} catch(_) {}
// Re-attach all event listeners
try { ui.updateApiCadenceInfo?.(); } catch(_) {}
// Ensure diagnostics auto-update interval is running while settings are visible
try {
if (document.getElementById('tdm-settings-popup')) {
if (!state.ui) state.ui = {};
if (!state.ui.apiCadenceInfoIntervalId) {
state.ui.apiCadenceInfoIntervalId = utils.registerInterval(setInterval(() => {
try { if (document.getElementById('tdm-settings-popup')) ui.updateApiCadenceInfo?.(); else { try { utils.unregisterInterval(state.ui.apiCadenceInfoIntervalId); } catch(_) {} state.ui.apiCadenceInfoIntervalId = null; } } catch(_) {}
}, 1000));
}
}
} catch(_) {}
const recomputeWarString = () => {
const wt = document.getElementById('war-type-select')?.value || warType;
const tt = document.getElementById('term-type-select')?.value || termType;
const fsc = parseInt(document.getElementById('faction-score-cap-input')?.value || factionScoreCap) || 0;
const isc = parseInt(document.getElementById('individual-score-cap-input')?.value || individualScoreCap) || 0;
const ist = document.getElementById('individual-score-type-select')?.value || individualScoreType;
return wt === 'Termed War' ? `${tt} to Total ${fsc}, ${isc} ${ist} Each` : (wt || 'War Type Not Set');
};
const applyWarString = () => {
const span = document.getElementById('rw-warstring');
if (span) span.textContent = recomputeWarString();
};
const copyTextToClipboard = async (text, label) => {
const trimmed = (text || '').trim();
if (!trimmed) {
ui.showMessageBox(`Nothing to copy for ${label.toLowerCase()}.`, 'info');
return;
}
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(trimmed);
ui.showTransientMessage(`${label} copied to clipboard.`, { type: 'success', timeout: 3200 });
} else {
throw new Error('clipboard unavailable');
}
} catch (_) {
ui.fallbackCopyToClipboard(trimmed, `${label}`);
}
};
const buildWarDetailsSummary = () => {
const formatWarStartTct = (value) => {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) return null;
const epochMs = numeric < 1e12 ? numeric * 1000 : numeric;
const dt = new Date(epochMs);
if (Number.isNaN(dt.getTime())) return null;
const pad = utils.pad2;
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return `${pad(dt.getUTCHours())}:${pad(dt.getUTCMinutes())} TCT (UTC) on ${pad(dt.getUTCDate())} ${months[dt.getUTCMonth()]} ${dt.getUTCFullYear()}`;
};
const resolveWarStart = () => {
const candidates = [];
try {
if (state?.warData) {
candidates.push(state.warData.warStart, state.warData.start, state.warData.warBegin);
}
} catch (_) { /* ignore */ }
try {
if (state?.lastRankWar) {
candidates.push(state.lastRankWar.warStart, state.lastRankWar.start, state.lastRankWar.warBegin);
}
} catch (_) { /* ignore */ }
try {
if (Array.isArray(state?.rankWars) && state.rankWars.length > 0) {
const latest = state.rankWars[0];
candidates.push(latest?.warStart, latest?.start, latest?.warBegin);
}
} catch (_) { /* ignore */ }
for (const candidate of candidates) {
const asNumber = Number(candidate);
if (Number.isFinite(asNumber) && asNumber > 0) return asNumber;
}
return null;
};
const readNumber = (id, fallback) => {
const input = document.getElementById(id);
if (!input) return Number(fallback) || 0;
const raw = input.value ?? input.textContent;
if (raw == null || String(raw).trim() === '') return 0;
const num = Number(raw);
return Number.isFinite(num) ? num : Number(fallback) || 0;
};
const currentWarType = document.getElementById('war-type-select')?.value || warType || 'War Type Not Set';
const currentTermType = document.getElementById('term-type-select')?.value || termType || '';
const currentFactionCap = currentWarType === 'Termed War' ? readNumber('faction-score-cap-input', factionScoreCap) : 0;
const currentIndivCap = currentWarType === 'Termed War' ? readNumber('individual-score-cap-input', individualScoreCap) : 0;
const currentIndivType = document.getElementById('individual-score-type-select')?.value || individualScoreType || 'Respect';
const currentAttackMode = document.getElementById('attack-mode-select')?.value || attackMode || 'Mode Not Set';
const opponentNameText = opponentFactionName || 'opponent TBD';
const opponentLink = opponentFactionId
? `<a href="/factions.php?step=profile&ID=${opponentFactionId}" class="t-blue"><b>${opponentNameText}</b></a>`
: opponentNameText;
const statements = [`Our opponent for this war is ${opponentLink}.`];
const warStartText = formatWarStartTct(resolveWarStart());
if (warStartText) statements.push(`War started at ${warStartText}.`);
if (currentWarType === 'Termed War') {
const outcome = (() => {
const lower = (currentTermType || '').toLowerCase();
if (lower.includes('win')) return 'Win';
if (lower.includes('loss')) return 'Lose';
if (lower.includes('draw')) return 'Draw';
return currentTermType || 'Play it out';
})();
statements.push(`This will be a <b>Termed War and we will ${outcome}</b>.`);
statements.push(currentFactionCap > 0 ? `Our Faction Score Cap is ${currentFactionCap}.` : 'Faction score is uncapped.');
statements.push(currentIndivCap > 0 ? `Individual Score Cap is ${currentIndivCap} ${currentIndivType}.` : 'Individual score is uncapped.');
const activeCaps = [currentFactionCap > 0, currentIndivCap > 0].filter(Boolean).length;
if (activeCaps === 2) {
statements.push('<u><b>Stop hitting once either cap is reached.</b></u>');
} else if (activeCaps === 1) {
statements.push('<u><b>Stop hitting once cap is reached.</b></u>');
}
} else if (currentWarType === 'Ranked War') {
const modeText = currentAttackMode && currentAttackMode !== 'Mode Not Set' ? `${currentAttackMode} attack mode` : 'the current attack mode';
statements.push(`This is a <b>Real Ranked War</b>! We are operating in <u>${modeText}</u>; watch for leadership updates if the attack mode changes mid-war.`);
} else {
statements.push('War type is not set yet; hold for leadership instructions.');
}
return statements.join(' ').replace(/\s+/g, ' ').trim();
};
const buildDibsStyleSummary = () => {
const checked = (id, fallback) => {
const el = document.getElementById(id);
if (el) return !!el.checked;
return !!fallback;
};
const numeric = (id, fallback) => {
const el = document.getElementById(id);
if (!el) return Number(fallback) || 0;
const raw = el.value;
if (raw == null || raw === '') return 0;
const num = Number(raw);
return Number.isFinite(num) ? num : Number(fallback) || 0;
};
const keepInactive = checked('dibs-keep-inactive', dibsStyle.keepTillInactive);
const inactivitySeconds = numeric('dibs-inactivity-seconds', dibsStyle.inactivityTimeoutSeconds || 0);
const inactivityMinutes = inactivitySeconds > 0 ? Math.round(inactivitySeconds / 60) : 0;
const mustRedib = checked('dibs-redib-after-success', dibsStyle.mustRedibAfterSuccess);
const maxHospitalMinutes = numeric('dibs-max-hosp-minutes', dibsStyle.maxHospitalReleaseMinutes || 0);
const removeOnFly = checked('dibs-remove-on-fly', dibsStyle.removeOnFly);
const removeUserTravel = checked('dibs-remove-user-travel', dibsStyle.removeWhenUserTravels);
const bypassDibStyle = checked('dibs-bypass-style', dibsStyle.bypassDibStyle);
const allowStatusNodes = Array.from(document.querySelectorAll('.dibs-allow-status'));
const allowStatuses = allowStatusNodes.length
? allowStatusNodes.map(cb => ({ status: cb.dataset.status, allowed: cb.checked }))
: Object.entries(dibsStyle.allowStatuses || {}).map(([status, allowed]) => ({ status, allowed: !!allowed }));
const allowActivityNodes = Array.from(document.querySelectorAll('.dibs-allow-lastaction'));
const allowActivities = allowActivityNodes.length
? allowActivityNodes.map(cb => ({ status: cb.dataset.status, allowed: cb.checked }))
: Object.entries(dibsStyle.allowLastActionStatuses || {}).map(([status, allowed]) => ({ status, allowed: !!allowed }));
const allowUserStatusNodes = Array.from(document.querySelectorAll('.dibs-allow-user-status'));
const userStatuses = allowUserStatusNodes.length
? allowUserStatusNodes.map(cb => ({ status: cb.dataset.status, allowed: cb.checked }))
: Object.entries(dibsStyle.allowedUserStatuses || {}).map(([status, allowed]) => ({ status, allowed: !!allowed }));
const sentences = [];
const firstFragments = [];
if (keepInactive) {
if (inactivityMinutes > 0) firstFragments.push(`Dib <b>stay until member is inactive for ~${inactivityMinutes} min</b>`);
else firstFragments.push('<b>dibs stay active until manually cleared</b>');
} else {
firstFragments.push('dibs clear immediately after a successful hit');
}
if (mustRedib) firstFragments.push('Dibs automatically removed after successful hit');
sentences.push(`<u><b>Dibs Style</b></u>: ${firstFragments.join('. ')}.`);
if (maxHospitalMinutes > 0) {
sentences.push(`Wait until hospital release is within ${maxHospitalMinutes} minute${maxHospitalMinutes === 1 ? '' : 's'} before dibbing hospitalized targets.`);
}
if (removeOnFly) sentences.push('Dibs automatically remove when opponents travel.');
if (removeUserTravel) sentences.push('Your dibs clear if you travel.');
if (allowStatuses.length) {
const allowedStatusesList = allowStatuses.filter(item => item.allowed).map(item => item.status);
const blockedStatusesList = allowStatuses.filter(item => !item.allowed).map(item => item.status);
if (blockedStatusesList.length && blockedStatusesList.length < allowStatuses.length) {
sentences.push(`Skip targets marked ${blockedStatusesList.join(', ')}.`);
}
if (allowedStatusesList.length && allowedStatusesList.length < allowStatuses.length) {
sentences.push(`Allowed opponent statuses: ${allowedStatusesList.join(', ')}.`);
}
}
if (allowActivities.length) {
const allowedActivities = allowActivities.filter(item => item.allowed).map(item => item.status);
const blockedActivities = allowActivities.filter(item => !item.allowed).map(item => item.status);
if (blockedActivities.includes('Online')) {
sentences.push('<u>Don\'t Hit Online Opponents.</u>');
} else if (allowedActivities.length && allowedActivities.length < allowActivities.length) {
sentences.push(`Activity allowed: ${allowedActivities.join(', ')}.`);
}
}
if (userStatuses.length) {
const blockedUserStatuses = userStatuses.filter(item => !item.allowed).map(item => item.status);
if (blockedUserStatuses.length) {
const statusLabelMap = { Travel: 'Travelling' };
const formattedStatuses = blockedUserStatuses.map(status => statusLabelMap[status] || status);
sentences.push(`Can't place dibs if you are: ${formattedStatuses.join(', ')}.`);
}
}
if (bypassDibStyle) sentences.push('<b>Admin bypass enabled:</b> admins may ignore dib style rules for this faction.');
return sentences.join(' ').replace(/\s+/g, ' ').trim();
};
document.getElementById('war-type-select')?.addEventListener('change', (e) => {
const val = e.currentTarget.value;
const isTermed = val === 'Termed War';
document.getElementById('term-type-container').style.display = isTermed ? 'block' : 'none';
document.getElementById('score-cap-container').style.display = isTermed ? 'block' : 'none';
document.getElementById('individual-score-cap-container').style.display = isTermed ? 'block' : 'none';
document.getElementById('individual-score-type-container').style.display = isTermed ? 'block' : 'none';
// Also show the second-row termed war controls when switching
try { document.getElementById('opponent-score-cap-container').style.display = isTermed ? 'block' : 'none'; } catch(_) {}
try { document.getElementById('initial-target-container').style.display = isTermed ? 'block' : 'none'; } catch(_) {}
try { document.getElementById('target-end-container').style.display = isTermed ? 'block' : 'none'; } catch(_) {}
try { document.getElementById('initial-target-display').style.display = isTermed ? 'block' : 'none'; } catch(_) {}
try { document.getElementById('target-end-display').style.display = isTermed ? 'block' : 'none'; } catch(_) {}
const amg = document.getElementById('attack-mode-group');
if (amg) amg.style.display = val === 'Ranked War' ? 'block' : 'none';
applyWarString();
try { updateWarTermInfo(); } catch(_) {}
});
document.getElementById('term-type-select')?.addEventListener('change', applyWarString);
document.getElementById('faction-score-cap-input')?.addEventListener('input', applyWarString);
document.getElementById('opponent-score-cap-input')?.addEventListener('input', applyWarString);
// initial-target is display-only now; do not listen for input events
document.getElementById('war-target-end-input')?.addEventListener('change', applyWarString);
document.getElementById('individual-score-cap-input')?.addEventListener('input', applyWarString);
document.getElementById('individual-score-type-select')?.addEventListener('change', applyWarString);
// Helpers for termed-war decay math + two-of-three solver
// Parse a datetime-local string but treat the value as UTC (skip minutes by rounding to hour if needed)
const parseDateTimeLocalToEpochSecAssumeUTC = (str) => {
if (!str) return 0;
try {
// Treat the provided local-style string as a UTC timestamp by appending 'Z'
// e.g. '2025-11-27T16:00' -> '2025-11-27T16:00Z' and parse as UTC.
// Also normalize to hour precision by clearing minutes/seconds if present.
// If string includes minutes, we ignore them and use the hour component.
const match = String(str).match(/^(\d{4}-\d{2}-\d{2}T\d{2})(:?\d{2})?(:?\d{2})?$/);
let base;
if (match && match[1]) {
base = match[1]; // keep only YYYY-MM-DDTHH
} else {
// Try to support a couple of common non-ISO formats users might type
const sl = String(str).trim();
// MM/DD/YYYY HHmm or M/D/YYYY H:mm or M/D/YYYY HHmm
const us = sl.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2})(:?)(\d{2})?$/);
if (us) {
const mm = us[1].padStart(2,'0');
const dd = us[2].padStart(2,'0');
const yyyy = us[3];
const hh = us[4].padStart(2,'0');
const mins = us[6] ? us[6].padStart(2,'0') : '00';
base = `${yyyy}-${mm}-${dd}T${hh}:${mins}`;
} else {
// Try YYYY-MM-DD HH:MM
const isoLike = sl.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{1,2})(:?)(\d{2})?$/);
if (isoLike) {
const yyyyMmDd = isoLike[1];
const hh = isoLike[2].padStart(2,'0');
const mins = isoLike[4] ? isoLike[4].padStart(2,'0') : '00';
base = `${yyyyMmDd}T${hh}:${mins}`;
} else {
base = str;
}
}
}
let d = new Date(base + 'Z');
if (!Number.isNaN(d.getTime())) return Math.floor(d.getTime() / 1000);
// Fallback attempts: try loose parsing of the original string
// - Date.parse with appended Z
let parsed = Date.parse(String(str) + 'Z');
if (!Number.isNaN(parsed)) return Math.floor(parsed/1000);
// - Try Date.parse on the raw string (some browsers accept different formats)
parsed = Date.parse(String(str));
if (!Number.isNaN(parsed)) return Math.floor(parsed/1000);
// - Try replacing space with 'T' and append Z
const maybeT = String(str).trim().replace(' ', 'T');
parsed = Date.parse(maybeT + 'Z');
if (!Number.isNaN(parsed)) return Math.floor(parsed/1000);
// As a last resort, attempt to coerce an ISO-like value with minutes if the hour-only base was produced
if (/^\d{4}-\d{2}-\d{2}T\d{2}$/.test(base)) {
// append :00 minutes
d = new Date(base + ':00Z');
if (!Number.isNaN(d.getTime())) return Math.floor(d.getTime() / 1000);
}
return 0;
} catch (_) { return 0; }
};
// Convert epoch seconds to an input-friendly datetime-local string but using UTC hour (minutes cleared)
const epochSecToUtcDatetimeHourValue = (sec) => {
if (!sec) return '';
try {
const d = new Date(Number(sec) * 1000);
if (Number.isNaN(d.getTime())) return '';
const pad = utils.pad2;
// Hour-only UTC output (minutes set to 00)
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:00`;
} catch (_) { return ''; }
};
// Decay model (matches Torn Ranked War Calculator sample):
// - No decay during first 24 hours after war start
// - After that, once per hour, target reduces by 1% of the original initialTargetScore (linear hourly), capped at 99%
const computeTargetAfterDecay = (initialTarget, warStartSec, atSec) => {
initialTarget = Number(initialTarget) || 0;
if (!initialTarget || !warStartSec || !atSec) return initialTarget;
const now = Number(atSec);
const startMs = Number(warStartSec) * 1000;
const atMs = Number(now) * 1000;
const msSinceStart = atMs - startMs;
if (msSinceStart < 24*60*60*1000) return initialTarget; // still pre-decay
let hoursIntoDecay = Math.floor((msSinceStart - 24*60*60*1000) / (60*60*1000));
hoursIntoDecay = Math.max(0, hoursIntoDecay);
const pct = Math.min(99, hoursIntoDecay * 1); // 1% per hour
const factor = Math.max(0, 1 - pct/100);
return initialTarget * factor;
};
const formatMsRelative = (ms) => {
if (!Number.isFinite(ms)) return 'N/A';
const abs = Math.abs(ms);
const s = Math.floor(abs/1000); const d = Math.floor(s / (24*3600)); let r = [];
let rem = s % (24*3600); const h = Math.floor(rem/3600); rem %= 3600; const m = Math.floor(rem/60); const sec = rem % 60;
if (d) r.push(`${d}d`); if (h) r.push(`${h}h`); if (m) r.push(`${m}m`); if (sec) r.push(`${sec}s`);
return r.length ? (ms<0?`-${r.join(' ')}`:r.join(' ')) : '0s';
};
const updateWarTermInfo = () => {
try {
const infoEl = document.getElementById('rw-term-info'); if (!infoEl) return;
const initial = Number(state.warData?.initialTargetScore || (state.lastRankWar?.war?.target ?? state.lastRankWar?.target) || 0) || 0;
const rawFactionVal = document.getElementById('faction-score-cap-input')?.value ?? '';
const rawOpponentVal = document.getElementById('opponent-score-cap-input')?.value ?? '';
const rawEndVal = document.getElementById('war-target-end-input')?.value ?? '';
let factionCap = Number(rawFactionVal === '' ? (state.warData?.factionScoreCap || 0) : rawFactionVal) || 0;
let opponentCap = Number(rawOpponentVal === '' ? (state.warData?.opponentScoreCap || 0) : rawOpponentVal) || 0;
// Ensure non-negative
factionCap = Math.max(0, factionCap);
opponentCap = Math.max(0, opponentCap);
const endInput = rawEndVal || '';
// parse DateTime-local but we treat the value as UTC hour-only
// Input-provided end (explicit target end time) vs authoritative backend end
const endSecInput = parseDateTimeLocalToEpochSecAssumeUTC(endInput) || Number(state.warData?.targetEndTime || 0) || 0;
// Backend-provided end (e.g., ranked war's end timestamp) should be considered authoritative
const endSecApi = Number(state.lastRankWar?.war?.end || state.lastRankWar?.end || 0) || 0;
// Effective end we consider for display/logic: prefer backend API end when present
const endSec = endSecApi || endSecInput || 0;
// Resolve war start if available
const resolveStart = () => {
const candidates = [state.warData?.warStart, state.warData?.start, state.lastRankWar?.war?.start, state.lastRankWar?.start];
for (const c of candidates) {
const n = Number(c);
if (Number.isFinite(n) && n > 0) return n;
}
return 0;
};
const startSec = resolveStart();
const nowSec = Math.floor(Date.now()/1000);
// Quick-change guard: skip heavy recomputation if nothing meaningful changed
try {
if (!state.ui) state.ui = {};
const last = state.ui._lastWarTermInputs || null;
// normalize raw strings (trim) for comparison
// treat a literal '0' as equivalent to empty for change-detection so 0 and '' compare equal
const norm = (s) => {
if (typeof s === 'string') {
const t = s.trim();
if (t === '0') return '';
return t;
}
return String(s);
};
const snapshot = {
rawFactionVal: norm(rawFactionVal),
rawOpponentVal: norm(rawOpponentVal),
rawEndVal: norm(rawEndVal),
// numeric-derived values used by solver
factionCap: Number(factionCap) || 0,
opponentCap: Number(opponentCap) || 0,
endSec: Number(endSec) || 0,
startSec: Number(startSec) || 0,
initial: Number(initial) || 0,
currentTermType: (document.getElementById('term-type-select')?.value || state.warData?.termType || '')
};
// shallow-compare snapshot to last
let changed = false;
if (!last) changed = true; else {
for (const k of Object.keys(snapshot)) {
if (String(last[k]) !== String(snapshot[k])) { changed = true; break; }
}
}
if (!changed) return;
state.ui._lastWarTermInputs = snapshot;
} catch(_) {}
// Logging helper: only emit noisy debug output when explicitly enabled via state.debug?.rwTermInfo
// or when the snapshot meaningfully changed since last log. This prevents console spam during rapid typing.
const _rwLogEnabled = !!(state.debug?.rwTermInfo);
const _rwLogSnapshotKey = JSON.stringify({ rawFactionVal: String(rawFactionVal||''), rawOpponentVal: String(rawOpponentVal||''), rawEndVal: String(rawEndVal||''), factionCap: Number(factionCap)||0, opponentCap: Number(opponentCap)||0, endSec: Number(endSec)||0, startSec: Number(startSec)||0 });
if (_rwLogEnabled) {
try { console.debug('[updateWarTermInfo] inputs', { rawFactionVal, rawOpponentVal, rawEndVal, factionCap, opponentCap, endSec, startSec, initial, currentTermType: (document.getElementById('term-type-select')?.value || state.warData?.termType || '') }); } catch(_) {}
state.ui._lastWarTermInfoLogSnapshot = { key: _rwLogSnapshotKey, ts: Date.now() };
} else {
try {
const lastLog = state.ui._lastWarTermInfoLogSnapshot || {};
// Only log when the snapshot changes (or if we haven't logged before)
if (!lastLog.key || lastLog.key !== _rwLogSnapshotKey) {
try { console.debug('[updateWarTermInfo] inputs (changed)', { rawFactionVal, rawOpponentVal, rawEndVal, factionCap, opponentCap, endSec, startSec, initial, currentTermType: (document.getElementById('term-type-select')?.value || state.warData?.termType || '') }); } catch(_) {}
state.ui._lastWarTermInfoLogSnapshot = { key: _rwLogSnapshotKey, ts: Date.now() };
}
} catch(_) {}
}
// If the user supplied an end value string but parsing yielded 0, emit a focused debug so we can see why
try { if (rawEndVal && !endSec) {
// only emit parse-end-failed when enabled or when the rawEndVal changed since last log
const _pKey = String(rawEndVal||'');
const _last = (state.ui._lastWarTermInfoParseFailed || {});
if (state.debug?.rwTermInfo || !_last.key || _last.key !== _pKey) {
console.debug('[updateWarTermInfo] parse-end-failed', { rawEndVal });
state.ui._lastWarTermInfoParseFailed = { key: _pKey, ts: Date.now() };
}
} } catch(_) {}
let lines = [];
const isTermedFromSelect = (document.getElementById('war-type-select')?.value || state.warData?.warType) === 'Termed War';
// Update initial target display (read-only)
try { const it = document.getElementById('initial-target-display'); if (it) it.textContent = initial ? String(initial) : ''; if (it) it.classList.add('tdm-initial-readonly'); } catch(_) {}
// UI hints: mark required / calculated fields
try {
const elFac = document.getElementById('faction-score-cap-input');
const elOpp = document.getElementById('opponent-score-cap-input');
const elEnd = document.getElementById('war-target-end-input');
// Treat both empty string and '0' (string form) as "not manually provided" — 0 is typically the default
const rawTrim = utils.rawTrim;
const rawHasValue = utils.rawHasValue;
const fallbackNumIsPositive = utils.fallbackNumIsPositive;
const providedFac = utils.rawHasValue(rawFactionVal) || utils.fallbackNumIsPositive(state.warData?.factionScoreCap);
const providedOpp = utils.rawHasValue(rawOpponentVal) || utils.fallbackNumIsPositive(state.warData?.opponentScoreCap);
const providedEnd = utils.rawHasValue(rawEndVal) || !!(state.warData?.targetEndTime);
const presentCount = [providedFac, providedOpp, providedEnd].filter(Boolean).length;
const clearClasses = (el) => { if (!el) return; el.classList.remove('tdm-term-required'); el.classList.remove('tdm-term-calculated'); el.classList.remove('tdm-term-error'); };
[elFac, elOpp, elEnd].forEach(clearClasses);
if (presentCount < 2) {
// Highlight missing fields as required
if (!providedFac && elFac) elFac.classList.add('tdm-term-required');
if (!providedOpp && elOpp) elOpp.classList.add('tdm-term-required');
if (!providedEnd && elEnd) elEnd.classList.add('tdm-term-required');
} else if (presentCount === 2) {
// The missing one is calculated and should be marked as such
if (!providedFac && elFac) elFac.classList.add('tdm-term-calculated');
if (!providedOpp && elOpp) elOpp.classList.add('tdm-term-calculated');
if (!providedEnd && elEnd) elEnd.classList.add('tdm-term-calculated');
// Mark the provided inputs as required (the two inputs)
if (providedFac && elFac) elFac.classList.add('tdm-term-required');
if (providedOpp && elOpp) elOpp.classList.add('tdm-term-required');
if (providedEnd && elEnd) elEnd.classList.add('tdm-term-required');
}
// If any input was auto-filled previously, preserve the visual calculated mark for it
try {
const elFac3 = document.getElementById('faction-score-cap-input');
const elOpp3 = document.getElementById('opponent-score-cap-input');
const elEnd3 = document.getElementById('war-target-end-input');
if (elFac3 && elFac3.dataset && elFac3.dataset.autofilled === 'true') elFac3.classList.add('tdm-term-calculated');
if (elOpp3 && elOpp3.dataset && elOpp3.dataset.autofilled === 'true') elOpp3.classList.add('tdm-term-calculated');
if (elEnd3 && elEnd3.dataset && elEnd3.dataset.autofilled === 'true') elEnd3.classList.add('tdm-term-calculated');
} catch(_) {}
// If both caps are present, enforce term-type ordering (and show inline error if violated)
try {
const termTypeVal = (document.getElementById('term-type-select')?.value || state.warData?.termType || '').toLowerCase();
const elFac2 = document.getElementById('faction-score-cap-input');
const elOpp2 = document.getElementById('opponent-score-cap-input');
if (elFac2) elFac2.classList.remove('tdm-term-error');
if (elOpp2) elOpp2.classList.remove('tdm-term-error');
if ((providedFac && providedOpp) || (Number(factionCap) > 0 && Number(opponentCap) > 0)) {
if (termTypeVal.includes('win') && factionCap <= opponentCap) {
if (elFac2) elFac2.classList.add('tdm-term-error');
if (elOpp2) elOpp2.classList.add('tdm-term-error');
} else if (termTypeVal.includes('loss') && opponentCap <= factionCap) {
if (elFac2) elFac2.classList.add('tdm-term-error');
if (elOpp2) elOpp2.classList.add('tdm-term-error');
}
}
} catch (_) {}
} catch(_) {}
if (!startSec) {
// War-start is unknown — we still attempt best-effort solver autofill when the user provides an end time + one cap.
lines.push('<b>War Start:</b> not available (start time unknown). Decay calculations normally require a known war start; best-effort guesses will assume no decay (initial target used) where needed.');
if (initial) lines.push(`<b>Initial Target:</b> ${initial.toLocaleString()}`);
if (endSec) lines.push(`<b>Target End:</b> ${new Date(endSec*1000).toLocaleString()} (local) / ${new Date(endSec*1000).toUTCString()} (UTC)`);
if (factionCap || opponentCap) {
const diff = Math.abs(factionCap - opponentCap);
lines.push(`<b>Score Cap Diff:</b> ${diff || 'N/A'} — if start is unknown decay is ignored and initial target is used for estimation.`);
}
// If it's a termed war and we haven't provided two of the three key inputs (faction, opponent, end), show helper text
if (isTermedFromSelect && [!!factionCap, !!opponentCap, !!endSec].filter(Boolean).length < 2) {
lines.push(`<i>Tip: fill out any 2 of these 3 fields to generate an estimate for the third: Faction Cap, Opponent Cap, Target End (UTC hour-only).</i>`);
}
// don't return here — allow the solver below to make best-effort autofills (using initial target when start is unknown)
infoEl.innerHTML = lines.join('<br>');
}
// War start available: show countdown to start or active/ended status
const startMs = startSec * 1000;
const elapsedMs = Date.now() - startMs;
const maxMs = 123*60*60*1000; // 123 hours max per sample
// --- LIVE SCORES BLOCK ---
let liveLead = 0;
let liveScoresAvailable = false;
try {
const lr = state.lastRankWar || {};
const warObj = lr.war || lr;
const factions = Array.isArray(warObj.factions) ? warObj.factions : (Array.isArray(lr.factions) ? lr.factions : []);
if (factions && factions.length >= 2) {
const sorted = factions.slice().sort((x,y)=>Number(y.score||0) - Number(x.score||0));
const [leadF, trailF] = sorted.slice(0,2);
const scoreA = Number(leadF.score || 0); const scoreB = Number(trailF.score || 0);
liveLead = Math.abs(scoreA - scoreB);
liveScoresAvailable = true;
lines.push(`<div style="margin-bottom: 6px; padding-bottom: 6px; border-bottom: 1px solid #444;">
<b>Live Scores:</b> <span style="color:#8bc34a">${leadF.name}: ${scoreA.toLocaleString()}</span> vs <span style="color:#ff5722">${trailF.name}: ${scoreB.toLocaleString()}</span><br>
<b>Current Lead:</b> <span style="color:#fff; font-weight:bold">${liveLead.toLocaleString()}</span>
</div>`);
}
} catch(_) {}
// -------------------------
if (elapsedMs < 0) {
lines.push(`<b>War starts in:</b> ${formatMsRelative(-elapsedMs)} — starts ${new Date(startMs).toLocaleString()} (local) / ${new Date(startMs).toUTCString()} (UTC)`);
lines.push(`<b>Initial Target:</b> ${initial.toLocaleString()}`);
if (endSec) {
// Compute final target at end
const final = computeTargetAfterDecay(initial, startSec, endSec);
lines.push(`<b>Predicted Final Target at End:</b> ${Math.round(final).toLocaleString()} (${((final/initial||0)*100).toFixed(1)}% of initial)`);
}
} else if (endSec > 0 && endSec <= nowSec) {
// War *has* ended per backend (ranked war canonical end)
const endMs = endSec * 1000;
const durMs = endMs - startMs;
// Try to show results from lastRankWar if available
try {
const lr = state.lastRankWar || {};
const warObj = lr.war || lr;
const factions = Array.isArray(warObj.factions) ? warObj.factions : (Array.isArray(lr.factions) ? lr.factions : []);
const winnerId = Number(warObj.winner || lr.winner || 0) || 0;
if (factions && factions.length > 0) {
const left = factions.map(f => `${f.name}: ${Number(f.score||0).toLocaleString()}`).join(' — ');
lines.push(`<b>War Status:</b> Ended — duration ${formatMsRelative(durMs)} (started ${new Date(startMs).toLocaleString()}, ended ${new Date(endMs).toLocaleString()}).`);
lines.push(`<b>Result:</b> ${left}${winnerId ? ` — winner: ${factions.find(ff => String(ff.id)===String(winnerId))?.name || winnerId}` : ''}`);
} else {
lines.push(`<b>War Status:</b> Ended — duration ${formatMsRelative(durMs)} (started ${new Date(startMs).toLocaleString()}, ended ${new Date(endMs).toLocaleString()}).`);
}
} catch(_) {
lines.push(`<b>War Status:</b> Ended — ended at ${new Date(endSec*1000).toLocaleString()} / ${new Date(endSec*1000).toUTCString()}.`);
}
} else {
// Active
const currentTarget = computeTargetAfterDecay(initial, startSec, nowSec);
const distToTarget = Math.max(0, Math.round(currentTarget) - liveLead);
lines.push(`<b>War Status:</b> <span style="color:#4caf50">Active</span> — elapsed ${formatMsRelative(elapsedMs)} (started ${new Date(startMs).toLocaleString()}).`);
lines.push(`<b>Targets:</b> Initial: ${initial.toLocaleString()} — <span style="color:#2196f3">Current: ${Math.round(currentTarget).toLocaleString()}</span>`);
if (liveScoresAvailable) {
if (liveLead >= currentTarget) {
lines.push(`<span style="color:#4caf50"><b>Target Met!</b> Current lead exceeds target by ${(liveLead - currentTarget).toLocaleString()}. War should end.</span>`);
} else {
lines.push(`<b>Distance to Target:</b> ${distToTarget.toLocaleString()} (Lead needs to increase or target needs to decay)`);
}
}
// Next decay drop info
if (elapsedMs < 24*60*60*1000) {
const msUntilDecay = 24*60*60*1000 - elapsedMs;
lines.push(`<b>Decay:</b> Not started — will begin in ${formatMsRelative(msUntilDecay)} (at ${new Date(startMs + 24*60*60*1000).toLocaleString()})`);
} else {
const hoursSinceDecay = Math.floor((elapsedMs - 24*60*60*1000) / (60*60*1000));
const pct = Math.min(99, hoursSinceDecay * 1);
const nextDropMs = (60*60*1000) - ((elapsedMs - 24*60*60*1000) % (60*60*1000));
lines.push(`<b>Decay:</b> In effect — ${pct}% total applied. Next hourly drop in ${formatMsRelative(nextDropMs)}.`);
}
// Always show Live Estimator if scores are available, regardless of whether an end time is specified
if (liveScoresAvailable) {
try {
// If lead already >= current target then war should end; otherwise estimate when decay causes target <= lead
if (liveLead >= Math.round(currentTarget)) {
lines.push(`<b>Live Estimator:</b> Current lead (${liveLead.toLocaleString()}) already exceeds current target (${Math.round(currentTarget).toLocaleString()}) — war would be expected to end now.`);
} else {
// Solve for hoursIntoDecay needed where initial*(1 - h/100) <= lead => h >= 100*(1 - lead/initial)
const neededPct = Math.max(0, 1 - (liveLead / initial));
const hoursNeededTotal = Math.ceil(neededPct * 100);
// Hours into decay = hoursNeededTotal; but some hours already elapsed
const hoursSinceDecay = Math.max(0, Math.floor((elapsedMs - 24*60*60*1000)/(60*60*1000)));
const hoursRemaining = Math.max(0, hoursNeededTotal - hoursSinceDecay);
const estEndMs = startMs + 24*60*60*1000 + (hoursNeededTotal * 60*60*1000);
if (hoursNeededTotal === 0) {
lines.push(`<b>Live Estimator:</b> Lead (${liveLead.toLocaleString()}) implies war could end shortly (no further decay required).`);
} else {
lines.push(`<b>Live Estimator (Decay to Score Diff ${liveLead.toLocaleString()}):</b> In ${formatMsRelative(estEndMs - Date.now())} (at ${new Date(estEndMs).toUTCString()})`);
}
}
} catch(_) {}
}
// If there's an authoritative API end time (ranked war ended/declared end), show final target at end
if (endSecApi) {
const finalAtEnd = computeTargetAfterDecay(initial, startSec, endSecApi);
lines.push(`<b>Target at Official End:</b> ${Math.round(finalAtEnd).toLocaleString()} (end ${new Date(endSecApi*1000).toLocaleString()})`);
} else if (endSecInput) {
// User-specified end
const finalAtEnd = computeTargetAfterDecay(initial, startSec, endSecInput);
lines.push(`<b>Target at Specified End:</b> ${Math.round(finalAtEnd).toLocaleString()} (end ${new Date(endSecInput*1000).toLocaleString()})`);
}
}
// Two-of-three solver: only for Termed Wars. If the war is Ranked, use live-ranked estimators instead.
// If the war is Ranked but we still have Termed-style fields in warData (legacy), do NOT run the Termed solver.
// For Ranked wars we'll compute separate live estimations below using `state.lastRankWar`.
// Guard: if the user is currently editing one of the three inputs we *must not* run the
// autofill solver — only run solver when the user has left the field (blur / Enter)
try {
const activeId = (document.activeElement && document.activeElement.id) ? document.activeElement.id : '';
const editingField = (state.ui && state.ui._warTermEditing) || ['faction-score-cap-input', 'opponent-score-cap-input', 'war-target-end-input'].includes(activeId);
if (editingField) {
// Skip solver while user is typing; ensure the UI status text still updates
try { const solverSnap = JSON.stringify({ presentCount: [utils.rawHasValue(rawOpponentVal), utils.rawHasValue(rawFactionVal), utils.rawHasValue(rawEndVal)].filter(Boolean).length, factionCap: Number(factionCap)||0, opponentCap: Number(opponentCap)||0, endSec: Number(endSec)||0, startSec: Number(startSec)||0, termType: String((document.getElementById('term-type-select')?.value || state.warData?.termType || '')||'') });
const _lastSolver = state.ui._lastWarTermInfoSolverSnap || {};
if (state.debug?.rwTermInfo || !_lastSolver.key || _lastSolver.key !== solverSnap) {
// indicate we skipped solver due to editing
console.debug('[updateWarTermInfo] solver skipped because user is editing', { activeId, presentCount: [utils.rawHasValue(rawOpponentVal), utils.rawHasValue(rawFactionVal), utils.rawHasValue(rawEndVal)].filter(Boolean).length });
state.ui._lastWarTermInfoSolverSnap = { key: solverSnap, ts: Date.now() };
}
} catch(_) {}
} else {
// term type matters (Termed Loss vs Termed Win semantics)
const currentTermType = (document.getElementById('term-type-select')?.value || state.warData?.termType || '').toLowerCase();
const isLoss = currentTermType.includes('loss');
// Use the normalized rawHasValue helper here too, so '0' behaves like empty
const presentCount = [utils.rawHasValue(rawOpponentVal), utils.rawHasValue(rawFactionVal), utils.rawHasValue(rawEndVal)].filter(Boolean).length;
try {
// similar duplicate-suppression for solver state: either enabled via debug flag or only logged when changed
const solverSnap = JSON.stringify({ presentCount, factionCap: Number(factionCap)||0, opponentCap: Number(opponentCap)||0, endSec: Number(endSec)||0, startSec: Number(startSec)||0, termType: String(currentTermType||'') });
const _lastSolver = state.ui._lastWarTermInfoSolverSnap || {};
if (state.debug?.rwTermInfo || !_lastSolver.key || _lastSolver.key !== solverSnap) {
console.debug('[updateWarTermInfo] solver state', { presentCount, providedFlags: { faction: utils.rawHasValue(rawFactionVal), opponent: utils.rawHasValue(rawOpponentVal), end: utils.rawHasValue(rawEndVal) }, numericCaps: { factionCap, opponentCap }, endSec, startSec, initial, termType: currentTermType });
state.ui._lastWarTermInfoSolverSnap = { key: solverSnap, ts: Date.now() };
}
} catch(_) {}
let didAutoFill = false;
const setInput = (id, val) => { try { const el = document.getElementById(id); if (el) { el.value = String(val); el.dataset.autofilled = 'true'; el.classList.remove('tdm-term-required'); el.classList.remove('tdm-term-error'); el.classList.add('tdm-term-calculated'); didAutoFill = true; /* do NOT dispatch an input event to avoid re-entrancy */ } } catch(_) {} };
// Only run the Termed solver when this is a Termed War
if (isTermedFromSelect) {
// If endTime + factionCap -> compute opponentCap if the opponent input
// has not been provided by the user. We DO NOT overwrite a user-supplied
// value for opponent cap.
// Only compute when opponent raw value is not present.
if (endSec && factionCap && !utils.rawHasValue(rawOpponentVal)) {
const finalT = Math.round(computeTargetAfterDecay(initial, startSec, endSec));
// For Termed Loss: final = opponent - faction -> opponent = faction + final
// For Termed Win: final = faction - opponent -> opponent = faction - final
const guessedOppCap = isLoss ? Math.max(0, Math.round(factionCap + finalT)) : Math.max(0, Math.round(factionCap - finalT));
lines.push(`<b>Solver:</b> Given end time + faction cap, opponent cap ≈ ${guessedOppCap} (final target ≈ ${finalT}).`);
// apply computed value and refresh UI state
setInput('opponent-score-cap-input', guessedOppCap);
try {
// debug: ensure the input element value was set (helps identify race/DOM issues)
const debugEl = document.getElementById('opponent-score-cap-input');
if (debugEl && debugEl.value !== String(guessedOppCap)) {
// Mismatch here often indicates a race/another handler overwrote us — keep this log but amount-limited
const tkey = JSON.stringify({ guessedOppCap: guessedOppCap, value: debugEl.value });
const lastMiss = state.ui._lastWarTermInfoMismatch || {};
if (state.debug?.rwTermInfo || !lastMiss.key || lastMiss.key !== tkey) {
try { console.debug('[updateWarTermInfo] guessedOppCap set but input value did not match', { guessedOppCap, value: debugEl.value }); } catch(_) {}
state.ui._lastWarTermInfoMismatch = { key: tkey, ts: Date.now() };
}
}
} catch(_) {}
/* scheduled later if needed (didAutoFill) to avoid re-entrancy */
}
// If endTime + opponentCap -> compute factionCap only when faction
// input was not provided by the user. Avoid overwriting a user value.
if (endSec && opponentCap && !utils.rawHasValue(rawFactionVal)) {
const finalT = Math.round(computeTargetAfterDecay(initial, startSec, endSec));
// For Termed Loss: final = opponent - faction -> faction = opponent - final
// For Termed Win: final = faction - opponent -> faction = opponent + final
const guessedFacCap = isLoss ? Math.max(0, Math.round(opponentCap - finalT)) : Math.max(0, Math.round(opponentCap + finalT));
lines.push(`<b>Solver:</b> Given end time + opponent cap, faction cap ≈ ${guessedFacCap} (final target ≈ ${finalT}).`);
// apply computed value and refresh UI state
setInput('faction-score-cap-input', guessedFacCap);
}
// If both caps provided, compute needed decay hours to reach target difference and the end time
if (isTermedFromSelect && factionCap && opponentCap && initial) {
// For Termed Loss/Win, desired final is opponent - faction (loss) or faction - opponent (win)
const desiredFinal = isLoss ? Math.max(0, opponentCap - factionCap) : Math.max(0, factionCap - opponentCap);
if (desiredFinal >= initial) {
lines.push(`<b>Solver:</b> Desired final (${desiredFinal}) ≥ initial (${initial}) — no decay required (end would be immediate or earlier).`);
} else {
// percent reduction required
const reduction = 1 - (desiredFinal / initial);
const hoursNeeded = Math.ceil(reduction * 100); // 1% per hour
if (!startSec) {
lines.push(`<b>Solver:</b> Cannot estimate end time from both caps because war start is unknown.`);
} else {
const endCandidateMs = (startSec * 1000) + 24*60*60*1000 + (hoursNeeded * 60*60*1000);
lines.push(`<b>Solver:</b> To reach final target ${desiredFinal}, decay needs ~${hoursNeeded}h after 24h. Estimated end: ${new Date(endCandidateMs).toLocaleString()} / ${new Date(endCandidateMs).toUTCString()}`);
// Auto-fill target end only when the user did not provide an end value.
// If the user supplied an explicit Target End, do not overwrite it.
if (!utils.rawHasValue(rawEndVal)) {
setInput('war-target-end-input', epochSecToUtcDatetimeHourValue(Math.round(endCandidateMs/1000)));
}
}
}
}
// End Termed solver section
} // end if (isTermedFromSelect)
// If this is a Ranked War then add a live-ranked estimator (based on lastRankWar data)
if (!isTermedFromSelect && (((document.getElementById('war-type-select')?.value || state.warData?.warType) === 'Ranked War') || (state.lastRankWar && state.lastRankWar.id))) {
try {
const lr = state.lastRankWar || {};
// only estimate if war is active (no end or end in future)
const lrEnd = Number(lr.end || (lr.war && lr.war.end) || 0) || 0;
const lrStart = Number(lr.start || (lr.war && lr.war.start) || 0) || startSec || 0;
if (lrStart) {
const factions = Array.isArray(lr.factions) ? lr.factions : (lr.war && Array.isArray(lr.war.factions) ? lr.war.factions : []);
if (factions.length >= 2) {
const sorted = factions.slice().sort((x,y)=>Number(y.score||0)-Number(x.score||0));
const [lead, trail] = sorted.slice(0,2);
const leadScore = Number(lead.score||0), trailScore = Number(trail.score||0);
const liveLead = Math.abs(leadScore - trailScore);
// Use lr.target when available, otherwise initial
const targetAtStart = Number(lr.target || initial || 0) || 0;
// Current target using decay model
const curTarget = computeTargetAfterDecay(initial, startSec, nowSec);
// If end exists in the past, war ended — no estimator, but show final target
if (lrEnd && lrEnd <= nowSec) {
// ended — nothing to estimate
} else {
// Estimate when liveLead >= decayed target
if (leadScore >= curTarget) {
lines.push(`<b>Ranked Estimator:</b> Current live lead (${liveLead.toLocaleString()}) already meets current target (${Math.round(curTarget).toLocaleString()}).`);
} else {
const neededPct = Math.max(0, 1 - (liveLead / (initial || targetAtStart || 1)));
const hoursNeeded = Math.ceil(neededPct * 100);
const decayStartMs = startMs + (24*60*60*1000);
const estEndMs = decayStartMs + (hoursNeeded * 60*60*1000);
const hoursSinceDecay = Math.max(0, Math.floor((elapsedMs - 24*60*60*1000) / (60*60*1000)));
const hoursRemaining = Math.max(0, hoursNeeded - hoursSinceDecay);
lines.push(`<b>Ranked Estimator:</b> If scores hold, lead ${liveLead.toLocaleString()} meets decayed target after ~${hoursNeeded}h of decay (${hoursRemaining}h left). Estimated end: ${new Date(estEndMs).toLocaleString()} / ${new Date(estEndMs).toUTCString()}`);
}
}
}
}
} catch (_) {}
}
// If we autofilled any inputs above, schedule a single follow-up refresh
if (didAutoFill) {
try { setTimeout(() => { try { updateWarTermInfo(); applyWarString(); } catch(_) {} }, 0); } catch(_) {}
}
// For Termed wars we also present a live-score based estimate using lastRankWar scores
try {
if (isTermedFromSelect && state.lastRankWar && state.lastRankWar.war) {
const lr = state.lastRankWar.war || state.lastRankWar;
const factions = Array.isArray(lr.factions) ? lr.factions : [];
if (factions.length >= 2 && initial && startSec) {
const sorted = factions.slice().sort((x,y) => Number(y.score||0) - Number(x.score||0));
const [leadFaction, trailFaction] = sorted.slice(0,2);
const liveLead = Math.abs(Number(leadFaction.score||0) - Number(trailFaction.score||0));
lines.push(`<b>Live Scores:</b> ${leadFaction.name || leadFaction.id}: ${Number(leadFaction.score||0).toLocaleString()} — ${trailFaction.name || trailFaction.id}: ${Number(trailFaction.score||0).toLocaleString()} (lead ${liveLead.toLocaleString()})`);
// If two caps provided in UI, show differences between entered caps and live scores
if ((factionCap || opponentCap)) {
const enteredFacName = (state.warData?.factionName || (factions[0] && factions[0].name) || 'Faction');
const enteredOppName = (state.warData?.opponentFactionName || (factions[1] && factions[1].name) || 'Opponent');
lines.push(`<b>Entered Caps (warData):</b> ${enteredFacName}: ${Number(factionCap).toLocaleString()} — ${enteredOppName}: ${Number(opponentCap).toLocaleString()}`);
try {
const diffFacVsLive = (Number(factionCap) || 0) - Number(leadFaction.score||0);
const diffOppVsLive = (Number(opponentCap) || 0) - Number(trailFaction.score||0);
lines.push(`<b>Diff (entered - live):</b> ${enteredFacName}: ${diffFacVsLive>=0?'+':''}${diffFacVsLive.toLocaleString()} — ${enteredOppName}: ${diffOppVsLive>=0?'+':''}${diffOppVsLive.toLocaleString()}`);
} catch(_) {}
}
// Estimate end from live scores: find when decayed target <= live lead
const currentTarget = Math.round(computeTargetAfterDecay(initial, startSec, nowSec));
if (liveLead >= currentTarget) {
lines.push(`<b>Live-based Estimator:</b> Current lead (${liveLead.toLocaleString()}) already meets current target (${currentTarget.toLocaleString()}) — war would be expected to end now if scores hold.`);
} else {
const neededPctLive = Math.max(0, 1 - (liveLead / initial));
const hoursNeededLiveTotal = Math.ceil(neededPctLive * 100);
const hoursSinceDecay = Math.max(0, Math.floor((elapsedMs - 24*60*60*1000) / (60*60*1000)));
const hoursRemainingLive = Math.max(0, hoursNeededLiveTotal - hoursSinceDecay);
const estEndLiveMs = startMs + 24*60*60*1000 + (hoursNeededLiveTotal * 60*60*1000);
lines.push(`<b>Live-based Estimator:</b> If current scores hold, lead ${liveLead.toLocaleString()} will meet decayed target after ~${hoursNeededLiveTotal}h of decay (${hoursRemainingLive}h left). Estimated end: ${new Date(estEndLiveMs).toLocaleString()} / ${new Date(estEndLiveMs).toUTCString()}`);
}
}
}
} catch(_) {}
}
} catch (_) { /* ignore solver error */ }
// If this is a termed war and user hasn't provided at least 2 of the 3 inputs, offer the quick helper hint
if (isTermedFromSelect && [!!factionCap, !!opponentCap, !!endSec].filter(Boolean).length < 2) {
lines.push(`<i>Tip: fill out any 2 of these 3 fields to generate an estimate for the third: Faction Cap, Opponent Cap, Target End.</i>`);
}
infoEl.innerHTML = lines.join('<br>');
} catch (err) { /* non-fatal — don't stop settings build */ }
};
// Wire realtime updates for the term info area
// Debounced update helper so fast typing (eg. 4-digit caps) doesn't recalc every keystroke
try {
if (!state.ui) state.ui = {};
state.ui._rwTermInfoDebounceTimeout = state.ui._rwTermInfoDebounceTimeout || null;
const debouncedUpdateWarTermInfo = () => {
try { if (state.ui._rwTermInfoDebounceTimeout) utils.unregisterTimeout(state.ui._rwTermInfoDebounceTimeout); } catch(_) {}
state.ui._rwTermInfoDebounceTimeout = utils.registerTimeout(setTimeout(() => {
try { updateWarTermInfo(); } catch(_) {}
state.ui._rwTermInfoDebounceTimeout = null;
}, 600));
};
// Also expose for outside callers (tests / other codepaths)
state.ui.debouncedUpdateWarTermInfo = debouncedUpdateWarTermInfo;
} catch(_) {}
// initial-target is display-only now; do not listen for input events
// Debounced handlers: wait until user pauses typing to trigger solver
const _winTermImmediate = () => { try { if (state.ui && state.ui._rwTermInfoDebounceTimeout) { utils.unregisterTimeout(state.ui._rwTermInfoDebounceTimeout); state.ui._rwTermInfoDebounceTimeout = null; } if (!state.ui) state.ui = {}; state.ui._lastWarTermInputs = null; state.ui._lastWarTermInfoSolverSnap = null; updateWarTermInfo(); } catch(_) {} };
// Only clear autofill marker while typing; do NOT trigger solver until user leaves field
document.getElementById('opponent-score-cap-input')?.addEventListener('input', (e) => { try { e.currentTarget.removeAttribute('data-autofilled'); if (!state.ui) state.ui = {}; state.ui._warTermEditing = true; } catch(_) {} ; /* no auto-solve on input */ });
document.getElementById('opponent-score-cap-input')?.addEventListener('blur', (e) => { try { e.currentTarget.removeAttribute('data-autofilled'); if (!state.ui) state.ui = {}; state.ui._warTermEditing = false; } catch(_) {} ; _winTermImmediate(); });
document.getElementById('opponent-score-cap-input')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { try { e.currentTarget.removeAttribute('data-autofilled'); if (!state.ui) state.ui = {}; state.ui._warTermEditing = false; } catch(_) {} ; _winTermImmediate(); } });
// Only clear autofill marker while typing; do NOT trigger solver until user leaves field
document.getElementById('faction-score-cap-input')?.addEventListener('input', (e) => { try { e.currentTarget.removeAttribute('data-autofilled'); if (!state.ui) state.ui = {}; state.ui._warTermEditing = true; } catch(_) {} ; /* no auto-solve on input */ });
document.getElementById('faction-score-cap-input')?.addEventListener('blur', (e) => { try { e.currentTarget.removeAttribute('data-autofilled'); if (!state.ui) state.ui = {}; state.ui._warTermEditing = false; } catch(_) {} ; _winTermImmediate(); });
document.getElementById('faction-score-cap-input')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { try { e.currentTarget.removeAttribute('data-autofilled'); if (!state.ui) state.ui = {}; state.ui._warTermEditing = false; } catch(_) {} ; _winTermImmediate(); } });
// target end is a date/time input; use both input & change to be responsive to typing or picker selection
// For the date/time picker we clear autofill when the user edits, but we only
// run the solver when they exit the control (blur) or press Enter.
document.getElementById('war-target-end-input')?.addEventListener('input', (e) => { try { e.currentTarget.removeAttribute('data-autofilled'); if (!state.ui) state.ui = {}; state.ui._warTermEditing = true; } catch(_) {} ; /* no auto-solve on input */ });
document.getElementById('war-target-end-input')?.addEventListener('change', (e) => { try { e.currentTarget.removeAttribute('data-autofilled'); if (!state.ui) state.ui = {}; state.ui._warTermEditing = false; } catch(_) {} ; /* no auto-solve on change */ });
document.getElementById('war-target-end-input')?.addEventListener('blur', (e) => { try { e.currentTarget.removeAttribute('data-autofilled'); if (!state.ui) state.ui = {}; state.ui._warTermEditing = false; } catch(_) {} ; _winTermImmediate(); });
document.getElementById('war-target-end-input')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { try { e.currentTarget.removeAttribute('data-autofilled'); if (!state.ui) state.ui = {}; state.ui._warTermEditing = false; } catch(_) {} ; _winTermImmediate(); } });
// Run a first pass to populate the info area and keep it live-updating while panel is open
try {
if (!state.ui) state.ui = {};
state.ui._lastWarTermInputs = null;
state.ui._lastWarTermInfoLogSnapshot = null;
} catch(_) {}
try { updateWarTermInfo(); } catch(_) {}
try {
if (!state.ui) state.ui = {};
if (state.ui.rwTermInfoIntervalId) { try { utils.unregisterInterval(state.ui.rwTermInfoIntervalId); } catch(_) {} state.ui.rwTermInfoIntervalId = null; }
state.ui.rwTermInfoIntervalId = utils.registerInterval(setInterval(() => {
try { if (document.getElementById('tdm-settings-popup')) updateWarTermInfo(); else { try { utils.unregisterInterval(state.ui.rwTermInfoIntervalId); } catch(_) {} state.ui.rwTermInfoIntervalId = null; } } catch(_) {}
}, 1000));
} catch(_) {}
document.getElementById('save-war-data-btn')?.addEventListener('click', async (e) => {
const warDataToSave = { ...state.warData };
warDataToSave.warType = document.getElementById('war-type-select').value;
if (warDataToSave.warType === 'Termed War') {
warDataToSave.termType = document.getElementById('term-type-select').value;
warDataToSave.factionScoreCap = parseInt(document.getElementById('faction-score-cap-input').value) || 0;
// New fields: opponent cap, initial target, target end time
try { warDataToSave.opponentScoreCap = parseInt(document.getElementById('opponent-score-cap-input')?.value) || 0; } catch(_) { warDataToSave.opponentScoreCap = warDataToSave.opponentScoreCap || 0; }
try { warDataToSave.initialTargetScore = Number(state.warData?.initialTargetScore || (state.lastRankWar?.war?.target ?? state.lastRankWar?.target) || 0) || 0; } catch(_) { warDataToSave.initialTargetScore = warDataToSave.initialTargetScore || 0; }
try { const dt = document.getElementById('war-target-end-input')?.value || ''; if (dt) warDataToSave.targetEndTime = parseDateTimeLocalToEpochSecAssumeUTC(dt) || warDataToSave.targetEndTime || 0; } catch(_) {}
warDataToSave.individualScoreCap = parseInt(document.getElementById('individual-score-cap-input').value) || 0;
warDataToSave.individualScoreType = document.getElementById('individual-score-type-select').value;
// Backward-compat fields
warDataToSave.scoreCap = warDataToSave.factionScoreCap;
warDataToSave.scoreType = warDataToSave.individualScoreType;
}
else {
// Switching away from Termed War: clear Termed-specific fields so Ranked War
// doesn't keep stale/invalid data.
try {
delete warDataToSave.termType;
delete warDataToSave.factionScoreCap;
delete warDataToSave.opponentScoreCap;
delete warDataToSave.initialTargetScore;
delete warDataToSave.targetEndTime;
delete warDataToSave.individualScoreCap;
delete warDataToSave.individualScoreType;
// Backwards-compat / aliases
delete warDataToSave.scoreCap;
delete warDataToSave.scoreType;
// Optional Term-specific flag
delete warDataToSave.disableMedDeals;
} catch(_) {}
}
// Optional: disable med deals during this war (only show dibs button)
try { warDataToSave.disableMedDeals = !!document.getElementById('war-disable-meddeals')?.checked; } catch(_) { /* ignore */ }
// Validation: for Termed War, ensure we have two-of-three inputs (faction cap, opponent cap, target end)
if (warDataToSave.warType === 'Termed War') {
try {
const rawFac = document.getElementById('faction-score-cap-input')?.value?.trim() || '';
const rawOpp = document.getElementById('opponent-score-cap-input')?.value?.trim() || '';
const rawEnd = document.getElementById('war-target-end-input')?.value?.trim() || '';
const initial = warDataToSave.initialTargetScore || Number(state.warData?.initialTargetScore || (state.lastRankWar?.war?.target ?? state.lastRankWar?.target) || 0) || 0;
const present = [rawFac !== '', rawOpp !== '', rawEnd !== ''].filter(Boolean).length;
if (present < 2) {
try { const infoEl = document.getElementById('rw-term-info'); if (infoEl) { infoEl.innerHTML = `<b style="color:#f87171">Please provide at least <u>TWO</u> of these fields: Faction Cap, Opponent Cap, or Target End Time (UTC, hour-only) so the solver can estimate the third.</b>`; } } catch(_) {}
return;
}
// Resolve war start if available (needed to estimate end)
const resolveStart = () => {
const candidates = [state.warData?.warStart, state.warData?.start, state.lastRankWar?.war?.start, state.lastRankWar?.start];
for (const c of candidates) {
const n = Number(c);
if (Number.isFinite(n) && n > 0) return n;
}
return 0;
};
const startSec = resolveStart();
const currentTermType = (document.getElementById('term-type-select')?.value || warDataToSave.termType || '').toLowerCase();
const isLoss = currentTermType.includes('loss');
// Compute missing third value
const fac = rawFac !== '' ? (parseInt(rawFac) || 0) : (warDataToSave.factionScoreCap || 0);
const opp = rawOpp !== '' ? (parseInt(rawOpp) || 0) : (warDataToSave.opponentScoreCap || 0);
const endVal = rawEnd !== '' ? parseDateTimeLocalToEpochSecAssumeUTC(rawEnd) || 0 : (warDataToSave.targetEndTime || 0);
// Helper: compute final target at end
const finalFromEnd = (endSec) => Math.round(computeTargetAfterDecay(initial, startSec, endSec));
if (rawEnd !== '' && rawFac !== '' && rawOpp === '') {
const finalT = finalFromEnd(endVal);
const guessedOpp = isLoss ? Math.max(0, Math.round(fac + finalT)) : Math.max(0, Math.round(fac - finalT));
warDataToSave.opponentScoreCap = guessedOpp;
} else if (rawEnd !== '' && rawOpp !== '' && rawFac === '') {
const finalT = finalFromEnd(endVal);
const guessedFac = isLoss ? Math.max(0, Math.round(opp - finalT)) : Math.max(0, Math.round(opp + finalT));
warDataToSave.factionScoreCap = guessedFac;
} else if (rawFac !== '' && rawOpp !== '' && rawEnd === '') {
// Need start time to estimate end
if (!startSec) {
try { const infoEl = document.getElementById('rw-term-info'); if (infoEl) { infoEl.innerHTML = `<b style="color:#f87171">Cannot estimate end time without a known war start time. Add an end time or ensure war start is available.</b>`; } } catch(_) {}
return;
}
const desiredFinal = isLoss ? Math.max(0, opp - fac) : Math.max(0, fac - opp);
if (desiredFinal >= initial) {
// end is effectively immediate (start)
warDataToSave.targetEndTime = Math.floor(Date.now() / 1000);
} else {
const reduction = 1 - (desiredFinal / initial);
const hoursNeeded = Math.ceil(reduction * 100);
const endCandidateMs = (startSec * 1000) + 24*60*60*1000 + (hoursNeeded * 60*60*1000);
warDataToSave.targetEndTime = Math.round(endCandidateMs / 1000);
}
}
// Final validation: ensure non-negative and term-type ordering is satisfied before saving
try {
const finalFac = Number(warDataToSave.factionScoreCap || 0);
const finalOpp = Number(warDataToSave.opponentScoreCap || 0);
// clamp
warDataToSave.factionScoreCap = Math.max(0, finalFac);
warDataToSave.opponentScoreCap = Math.max(0, finalOpp);
const infoEl = document.getElementById('rw-term-info');
const termTypeVal2 = (document.getElementById('term-type-select')?.value || warDataToSave.termType || '').toLowerCase();
if (termTypeVal2.includes('win') && warDataToSave.factionScoreCap <= warDataToSave.opponentScoreCap) {
if (infoEl) infoEl.innerHTML = `<b style="color:#fb7185">Error: Termed Win requires Faction Cap > Opponent Cap.</b>`;
return;
}
if (termTypeVal2.includes('loss') && warDataToSave.opponentScoreCap <= warDataToSave.factionScoreCap) {
if (infoEl) infoEl.innerHTML = `<b style="color:#fb7185">Error: Termed Loss requires Opponent Cap > Faction Cap.</b>`;
return;
}
} catch (_) {}
} catch (err) {
tdmlogger('warn', `[save-war-data] solver validation failed: ${err?.message || err}`);
}
}
await handlers.debouncedSetFactionWarData(warDataToSave, e.currentTarget);
});
document.getElementById('copy-war-details-btn')?.addEventListener('click', async () => {
const summary = buildWarDetailsSummary();
await copyTextToClipboard(summary, 'War details');
});
const rwButtonsContainer = document.getElementById('column-visibility-rw');
const mlButtonsContainer = document.getElementById('column-visibility-ml');
if (rwButtonsContainer || mlButtonsContainer) {
const rankedWarColumns = [
{ key: 'lvl', label: 'Level' },
{ key: 'members', label: 'Member' },
{ key: 'points', label: 'Points' },
{ key: 'status', label: 'Status' },
{ key: 'attack', label: 'Attack' },
{ key: 'factionIcon', label: 'Faction Icon' }
];
const membersListColumns = [
{ key: 'lvl', label: 'Level' },
{ key: 'member', label: 'Member' },
{ key: 'memberIcons', label: 'Member Icons' },
{ key: 'position', label: 'Position' },
{ key: 'days', label: 'Days' },
{ key: 'status', label: 'Status' },
{ key: 'factionIcon', label: 'Faction Icon' },
{ key: 'dibsDeals', label: 'Dibs/Deals' },
{ key: 'notes', label: 'Notes' }
];
// Ranked War Buttons (toggle + width control)
if (rwButtonsContainer) {
let deferredRWFactionWrapper = null;
rankedWarColumns.forEach(col => {
if (!rwButtonsContainer.querySelector(`button[data-column="${col.key}"][data-table="rankedWar"]`)) {
const vis = storage.get('columnVisibility', config.DEFAULT_COLUMN_VISIBILITY);
const active = vis.rankedWar?.[col.key] !== false ? 'active' : 'inactive';
const widths = storage.get('columnWidths', config.DEFAULT_COLUMN_WIDTHS);
const curW = (widths && widths.rankedWar && typeof widths.rankedWar[col.key] === 'number') ? widths.rankedWar[col.key] : (config.DEFAULT_COLUMN_WIDTHS.rankedWar[col.key] || 6);
const wrapper = utils.createElement('div', { className: 'column-control tdm-toggle-container', style: { display: 'flex', gap: '8px', alignItems: 'center' } });
// Create toggle switch
const toggleSwitch = utils.createElement('div', {
className: `tdm-toggle-switch ${active}`,
dataset: { column: col.key, table: 'rankedWar' },
onclick: () => {
const vis = storage.get('columnVisibility', config.DEFAULT_COLUMN_VISIBILITY) || {};
if (!vis.rankedWar) vis.rankedWar = {};
const cur = (typeof vis.rankedWar[col.key] !== 'undefined')
? vis.rankedWar[col.key]
: (config.DEFAULT_COLUMN_VISIBILITY?.rankedWar?.[col.key] ?? true);
vis.rankedWar[col.key] = !cur;
storage.set('columnVisibility', vis);
ui.updateColumnVisibilityStyles();
ui.updateSettingsContent();
}
});
const label = utils.createElement('span', {
className: 'tdm-toggle-label',
textContent: col.label,
style: { minWidth: '70px', fontSize: '12px', color: '#fff' }
});
// Keep button reference for backward compatibility
const button = toggleSwitch;
// don't offer width adjustment for small icon columns (factionIcon)
let widthInput;
if (col.key !== 'factionIcon') {
widthInput = utils.createElement('input', { type: 'number', className: 'settings-input-display column-width-input', dataset: { column: col.key, table: 'rankedWar' }, min: '1', max: '99', step: '1', value: String(curW), title: 'Column width percentage' });
widthInput.style.width = '64px';
widthInput.addEventListener('change', (e) => {
try {
const v = Math.max(1, Math.min(99, Math.round(Number(e.target.value) || 0)));
const w = storage.get('columnWidths', config.DEFAULT_COLUMN_WIDTHS) || {};
if (!w.rankedWar) w.rankedWar = {};
w.rankedWar[col.key] = v;
storage.set('columnWidths', w);
ui.updateColumnVisibilityStyles();
ui.updateSettingsContent();
} catch(_) {}
});
}
wrapper.appendChild(toggleSwitch);
wrapper.appendChild(label);
if (widthInput) {
wrapper.appendChild(widthInput);
wrapper.appendChild(utils.createElement('div', { textContent: '%', style: { color: '#fff', fontSize: '0.85em' } }));
}
if (col.key === 'factionIcon') {
// Defer appending factionIcon toggle - it should appear below the other controls
deferredRWFactionWrapper = wrapper;
} else {
rwButtonsContainer.appendChild(wrapper);
}
}
});
if (deferredRWFactionWrapper) rwButtonsContainer.appendChild(deferredRWFactionWrapper);
}
// Members List Buttons (toggle + width control)
if (mlButtonsContainer) {
let deferredMLFactionWrapper = null;
membersListColumns.forEach(col => {
if (!mlButtonsContainer.querySelector(`button[data-column="${col.key}"][data-table="membersList"]`)) {
const vis = storage.get('columnVisibility', config.DEFAULT_COLUMN_VISIBILITY);
const active = vis.membersList?.[col.key] !== false ? 'active' : 'inactive';
const widths = storage.get('columnWidths', config.DEFAULT_COLUMN_WIDTHS);
const curW = (widths && widths.membersList && typeof widths.membersList[col.key] === 'number') ? widths.membersList[col.key] : (config.DEFAULT_COLUMN_WIDTHS.membersList[col.key] || 8);
const wrapper = utils.createElement('div', { className: 'column-control tdm-toggle-container', style: { display: 'flex', gap: '8px', alignItems: 'center' } });
// Create toggle switch
const toggleSwitch = utils.createElement('div', {
className: `tdm-toggle-switch ${active}`,
dataset: { column: col.key, table: 'membersList' },
onclick: () => {
const vis = storage.get('columnVisibility', config.DEFAULT_COLUMN_VISIBILITY) || {};
if (!vis.membersList) vis.membersList = {};
const cur = (typeof vis.membersList[col.key] !== 'undefined')
? vis.membersList[col.key]
: (config.DEFAULT_COLUMN_VISIBILITY?.membersList?.[col.key] ?? true);
vis.membersList[col.key] = !cur;
storage.set('columnVisibility', vis);
ui.updateColumnVisibilityStyles();
ui.updateSettingsContent();
}
});
const label = utils.createElement('span', {
className: 'tdm-toggle-label',
textContent: col.label,
style: { minWidth: '70px', fontSize: '12px', color: '#fff' }
});
// Keep button reference for backward compatibility
const button = toggleSwitch;
// don't offer width adjustment for small icon columns (factionIcon)
let widthInput;
if (col.key !== 'factionIcon') {
widthInput = utils.createElement('input', { type: 'number', className: 'settings-input-display column-width-input', dataset: { column: col.key, table: 'membersList' }, min: '1', max: '99', step: '1', value: String(curW), title: 'Column width percentage' });
widthInput.style.width = '64px';
widthInput.addEventListener('change', (e) => {
try {
const v = Math.max(1, Math.min(99, Math.round(Number(e.target.value) || 0)));
const w = storage.get('columnWidths', config.DEFAULT_COLUMN_WIDTHS) || {};
if (!w.membersList) w.membersList = {};
w.membersList[col.key] = v;
storage.set('columnWidths', w);
ui.updateColumnVisibilityStyles();
ui.updateSettingsContent();
} catch(_) {}
});
}
wrapper.appendChild(toggleSwitch);
wrapper.appendChild(label);
if (widthInput) {
wrapper.appendChild(widthInput);
wrapper.appendChild(utils.createElement('div', { textContent: '%', style: { color: '#fff', fontSize: '0.85em' } }));
}
if (col.key === 'factionIcon') {
// Defer appending factionIcon toggle - place below other members controls
deferredMLFactionWrapper = wrapper;
} else {
mlButtonsContainer.appendChild(wrapper);
}
}
});
if (deferredMLFactionWrapper) mlButtonsContainer.appendChild(deferredMLFactionWrapper);
}
}
document.getElementById('reset-column-widths-btn')?.addEventListener('click', async () => {
const ok = await ui.showConfirmationBox('Reset all column widths back to defaults?');
if (!ok) return;
try {
storage.set('columnWidths', config.DEFAULT_COLUMN_WIDTHS);
ui.updateColumnVisibilityStyles();
ui.updateSettingsContent();
ui.showMessageBox('Column widths reset to defaults.', 'info');
} catch (e) { ui.showMessageBox('Failed to reset column widths.', 'error'); }
});
// Add recommended presets for PC and PDA after the reset button
try {
const resetBtn = document.getElementById('reset-column-widths-btn');
if (resetBtn && !document.getElementById('apply-recommendation-pc')) {
const pcBtn = utils.createElement('button', { id: 'apply-recommendation-pc', className: 'settings-btn', style: { marginLeft: '8px' }, textContent: 'PC Rec', title: 'Apply recommended settings for PC (desktop) interfaces' });
const pdaBtn = utils.createElement('button', { id: 'apply-recommendation-pda', className: 'settings-btn', style: { marginLeft: '6px' }, textContent: 'PDA Rec', title: 'Apply recommended settings for PDA (mobile) interfaces' });
resetBtn.insertAdjacentElement('afterend', pdaBtn);
resetBtn.insertAdjacentElement('afterend', pcBtn);
pcBtn.addEventListener('click', async () => {
if (!(await ui.showConfirmationBox('Apply PC recommended column visibility and widths? This will overwrite current settings.'))) return;
try {
storage.set('columnVisibility', config.DEFAULT_COLUMN_VISIBILITY);
storage.set('columnWidths', config.DEFAULT_COLUMN_WIDTHS);
ui.updateColumnVisibilityStyles();
ui.updateSettingsContent();
ui.showMessageBox('Applied PC recommended column settings.', 'success');
} catch (e) { ui.showMessageBox('Failed to apply PC recommended settings.', 'error'); }
});
pdaBtn.addEventListener('click', async () => {
if (!(await ui.showConfirmationBox('Apply PDA recommended column visibility and widths? This will overwrite current settings.'))) return;
try {
storage.set('columnVisibility', config.DEFAULT_COLUMN_VISIBILITY_PDA);
storage.set('columnWidths', config.DEFAULT_COLUMN_WIDTHS_PDA);
ui.updateColumnVisibilityStyles();
ui.updateSettingsContent();
ui.showMessageBox('Applied PDA recommended column settings.', 'success');
} catch (e) { ui.showMessageBox('Failed to apply PDA recommended settings.', 'error'); }
});
}
} catch(_) { /* noop */ }
document.getElementById('admin-functionality-btn')?.addEventListener('click', () => {
storage.set('adminFunctionality', !storage.get('adminFunctionality', true));
ui.updateSettingsContent();
});
// Timeline (legacy) handlers removed – replaced by unified activity tracking
document.getElementById('save-faction-bundle-refresh-btn')?.addEventListener('click', () => {
try {
const sec = Math.max(5, Math.round(Number(document.getElementById('faction-bundle-refresh-seconds').value || '0')));
const ms = sec * 1000;
storage.set('factionBundleRefreshMs', ms);
state.script.factionBundleRefreshMs = ms;
// Restart decoupled interval to apply new cadence
try {
if (state.script.factionBundleRefreshIntervalId) try { utils.unregisterInterval(state.script.factionBundleRefreshIntervalId); } catch(_) {}
state.script.factionBundleRefreshIntervalId = utils.registerInterval(setInterval(() => {
try { if ((state.script.isWindowActive !== false) || utils.isActivityKeepActiveEnabled()) api.refreshFactionBundles?.().catch(() => {}); } catch(_) {}
}, ms));
} catch(_) {}
ui.showMessageBox(`Faction bundle refresh set to ${sec}s.`);
ui.updateApiCadenceInfo?.({ force: true });
} catch(_) { ui.showMessageBox('Invalid refresh interval.'); }
});
document.getElementById('tdm-save-additional-factions-btn')?.addEventListener('click', () => {
const input = document.getElementById('tdm-additional-factions-input');
if (!input) return;
try {
const rawValue = String(input.value || '');
const meta = {};
const parsed = utils.parseFactionIdList(rawValue, meta);
const serialized = parsed.join(',');
if (serialized) storage.set('tdmExtraFactionPolls', serialized); else storage.remove('tdmExtraFactionPolls');
state.script.additionalFactionPolls = parsed;
input.value = serialized;
ui.updateApiCadenceInfo?.({ force: true });
try { api.refreshFactionBundles?.({ force: true, source: 'settings-extra-factions' }).catch(() => {}); } catch(_) { /* noop */ }
const notes = [];
if (meta.duplicateCount > 0) {
notes.push(`Removed ${meta.duplicateCount} duplicate id${meta.duplicateCount === 1 ? '' : 's'}.`);
}
if (meta.hadInvalid) {
const bad = Array.isArray(meta.invalidTokens) ? meta.invalidTokens.filter(Boolean) : [];
if (bad.length) {
notes.push(`Ignored invalid entries (${bad.join(', ')}). Only numbers and commas are allowed.`);
} else {
notes.push('Ignored invalid entries. Only numbers and commas are allowed.');
}
}
if (parsed.length) {
notes.push(`Extra faction polling saved (${parsed.length} id${parsed.length === 1 ? '' : 's'}).`);
} else {
notes.push('Extra faction polling cleared.');
}
const level = meta.hadInvalid ? 'warning' : 'info';
ui.showMessageBox(notes.join(' '), level);
} catch(_) {
ui.showMessageBox('Failed to save extra faction list.', 'error');
}
});
// Legacy purge buttons removed; replaced by single Flush Activity Cache
document.getElementById('points-parse-logs-btn')?.addEventListener('click', () => {
const cur = !!storage.get('debugPointsParseLogs', false);
storage.set('debugPointsParseLogs', !cur);
state.debug = state.debug || {}; state.debug.pointsParseLogs = !cur;
ui.updateSettingsContent();
});
// Wire Dev: Log level selector (persisted)
try {
const logSelect = document.getElementById('tdm-log-level-select');
if (logSelect) {
try { logSelect.value = storage.get('logLevel', 'warn') || 'warn'; } catch(_) {}
logSelect.addEventListener('change', (e) => {
try {
const v = e.target.value;
storage.set('logLevel', v);
ui.showMessageBox(`Log level set to ${v}`, 'info');
} catch(_) {}
});
}
} catch(_) {}
document.getElementById('view-unauthorized-attacks-btn')?.addEventListener('click', () => ui.showUnauthorizedAttacksModal());
document.getElementById('tdm-adoption-btn')?.addEventListener('click', () => {
const cur = !!storage.get('debugAdoptionInfo', false);
storage.set('debugAdoptionInfo', !cur);
state.debug = state.debug || {}; state.debug.adoptionInfo = !cur;
// Show info modal if turning on
if (!cur) ui.showAdoptionInfo?.();
ui.updateSettingsContent();
});
document.getElementById('attack-mode-save-btn')?.addEventListener('click', async (e) => {
e?.preventDefault?.();
const select = document.getElementById('attack-mode-select');
if (!select) return;
const next = select.value;
const cur = (state.script.factionSettings?.options?.attackMode || state.script.factionSettings?.attackMode || 'Farming');
if (next === cur) { ui.showMessageBox('Attack mode unchanged.', 'info'); return; }
if (!(await ui.showConfirmationBox(`Set faction attack mode to ${next}?`))) return;
const btnStart = document.getElementById('attack-mode-save-btn');
if (btnStart) { btnStart.disabled = true; btnStart.textContent = 'Saving...'; }
try {
const res = await api.post('updateAttackMode', { factionId: state.user.factionId, attackMode: next });
if (res?.settings) state.script.factionSettings = res.settings; else if (res?.attackMode) {
const fs = state.script.factionSettings || {}; state.script.factionSettings = { ...fs, options: { ...(fs.options||{}), attackMode: res.attackMode } };
}
// Normalize for readers that use either field
try {
const fsn = state.script.factionSettings || {};
state.script.factionSettings = { ...fsn, options: { ...(fsn.options||{}), attackMode: next }, attackMode: next };
} catch(_) { /* noop */ }
ui.showMessageBox(`Attack mode set to ${next}.`, 'success');
ui.ensureAttackModeBadge();
// Sync the header select if present
const headerSel = document.getElementById('tdm-attack-mode-select');
if (headerSel) headerSel.value = next;
ui.updateAllPages?.();
} catch (err) {
ui.showMessageBox(`Failed to set mode: ${err.message || 'Unknown error'}`, 'error');
} finally {
const btnEnd = document.getElementById('attack-mode-save-btn');
if (btnEnd) { btnEnd.disabled = false; btnEnd.textContent = 'Save'; }
ui.updateSettingsContent();
}
});
// Unified status debug toggle
document.getElementById('tdm-debug-unified-status')?.addEventListener('change', (e) => {
const on = !!e.target.checked;
storage.set('debugUnifiedStatus', on);
ui.showMessageBox(`Unified status debug ${on ? 'enabled' : 'disabled'}.`, 'info');
});
// Disable legacy segments toggle (immediate effect; purging left to user)
document.getElementById('tdm-disable-legacy-status')?.addEventListener('change', (e) => {
const on = !!e.target.checked;
storage.set('disableLegacyStatusSegments', on);
ui.showMessageBox(`Legacy status segments ${on ? 'disabled' : 'enabled'}.`, 'info');
});
document.getElementById('run-admin-cleanup-btn')?.addEventListener('click', async (e) => {
const reason = (document.getElementById('cleanup-reason')?.value || '').trim();
if (!reason) { ui.showMessageBox('Reason is required.', 'error'); return; }
const types = [];
if (document.getElementById('cleanup-notes')?.checked) types.push('notes');
if (document.getElementById('cleanup-dibs')?.checked) types.push('dibs');
if (document.getElementById('cleanup-meddeals')?.checked) types.push('medDeals');
if (types.length === 0) { ui.showMessageBox('Select at least one data type.', 'error'); return; }
const factionOverrideRaw = (document.getElementById('cleanup-faction-id')?.value || '').trim();
let factionOverride = null;
if (factionOverrideRaw) {
if (!/^[0-9]+$/.test(factionOverrideRaw)) { ui.showMessageBox('Faction ID must be numeric.', 'error'); return; }
factionOverride = factionOverrideRaw;
}
// Opponent ID enumeration removed: backend interprets empty targetOpponentIds as FULL cleanup.
const opponentIds = []; // always full-faction scope
// Build friendly type list (human readable) and confirmation text.
const humanMap = { notes: 'Notes', dibs: 'Dibs', medDeals: 'Med Deals' };
const selectedTypes = types.slice();
const typesHuman = selectedTypes.map(t => humanMap[t] || t).join(', ');
const isAllTypes = selectedTypes.length === 3;
const targetText = factionOverride ? `opponents in faction ${factionOverride}` : 'ALL opponents';
let confirmText;
if (isAllTypes && !factionOverride) {
confirmText = `Confirm FULL cleanup for ${targetText}? This cannot be undone.`;
} else if (isAllTypes && factionOverride) {
confirmText = `Confirm cleanup of ALL data types for ${targetText}? This cannot be undone.`;
} else {
confirmText = `Confirm cleanup of ${typesHuman} for ${targetText}? This cannot be undone.`;
}
const ok = await ui.showConfirmationBox(confirmText);
if (!ok) return;
const btn = (e && e.currentTarget) ? e.currentTarget : document.getElementById('run-admin-cleanup-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Cleaning...'; }
try {
const res = await api.post('adminCleanupFactionData', {
factionId: state.user.factionId,
targetOpponentIds: opponentIds,
targetOpponentFactionId: factionOverride || null,
types,
reason,
debug: true // temporary: enable backend verbose logging; remove or gate by UI toggle later
});
const msg = `Cleanup done. Notes Deleted: ${res?.notesDeleted||0} | Dibs Deactivated: ${res?.dibsDeactivated||0} | Med Deals Unset: ${res?.medDealsRemoved||0}`;
tdmlogger('info', msg);
ui.showMessageBox(msg, 'success');
const line = document.getElementById('cleanup-results-line');
if (line) { line.textContent = msg; line.style.display = 'block'; }
handlers.debouncedFetchGlobalData?.();
} catch (err) {
ui.showMessageBox(`Cleanup failed: ${err.message || 'Unknown error'}`, 'error');
const line = document.getElementById('cleanup-results-line');
if (line) { line.textContent = `Cleanup failed.`; line.style.display = 'block'; }
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Run Cleanup'; }
}
});
// Collapsible toggles: attach per-header listeners for reliable toggling
try {
const container = document.getElementById('tdm-settings-content');
if (container) {
// Remove any delegated handler we might have set previously
try { if (window._tdm_settings_click_handler && typeof window._tdm_settings_click_handler === 'function') { container.removeEventListener('click', window._tdm_settings_click_handler); } } catch(_) {}
window._tdm_settings_click_handler = null;
// Attach a click handler to each header. Remove prior handlers if present to avoid duplicates.
const headers = container.querySelectorAll('.collapsible-header');
headers.forEach(h => {
try {
if (h._tdm_click_handler) h.removeEventListener('click', h._tdm_click_handler);
} catch(_) {}
const handler = (e) => {
try {
// Ignore clicks that originate on interactive controls inside the header
if (e.target && e.target.closest && e.target.closest('input,button,a,select,textarea,label')) return;
const sec = h.closest('.collapsible');
if (!sec) return;
const isCollapsed = sec.classList.toggle('collapsed');
const key = sec.getAttribute('data-section');
const stateMap = storage.get('settings_collapsed', {});
stateMap[key] = isCollapsed;
storage.set('settings_collapsed', stateMap);
} catch(_) {}
};
h._tdm_click_handler = handler;
h.addEventListener('click', handler);
});
}
} catch(_) {}
// Attach event listeners for Badges Dock toggle switches
document.getElementById('chain-timer-toggle')?.addEventListener('click', function() {
const cur = storage.get('chainTimerEnabled', true);
storage.set('chainTimerEnabled', !cur);
this.classList.toggle('active', !cur);
if (!cur) ui.ensureChainTimer(); else ui.removeChainTimer();
});
document.getElementById('inactivity-timer-toggle')?.addEventListener('click', function() {
const cur = storage.get('inactivityTimerEnabled', false);
storage.set('inactivityTimerEnabled', !cur);
this.classList.toggle('active', !cur);
if (!cur) ui.ensureInactivityTimer(); else ui.removeInactivityTimer();
});
document.getElementById('opponent-status-toggle')?.addEventListener('click', function() {
const cur = storage.get('opponentStatusTimerEnabled', true);
storage.set('opponentStatusTimerEnabled', !cur);
this.classList.toggle('active', !cur);
if (!cur) ui.ensureOpponentStatus(); else ui.removeOpponentStatus();
});
document.getElementById('api-usage-toggle')?.addEventListener('click', function() {
const cur = storage.get('apiUsageCounterEnabled', false);
storage.set('apiUsageCounterEnabled', !cur);
this.classList.toggle('active', !cur);
if (!cur) ui.ensureApiUsageBadge();
if (handlers?.debouncedUpdateApiUsageBadge) { handlers.debouncedUpdateApiUsageBadge(); } else { ui.updateApiUsageBadge(); }
});
document.getElementById('attack-mode-badge-toggle')?.addEventListener('click', function() {
const cur = storage.get('attackModeBadgeEnabled', true);
storage.set('attackModeBadgeEnabled', !cur);
this.classList.toggle('active', !cur);
if (!cur) ui.ensureAttackModeBadge(); else ui.removeAttackModeBadge?.();
});
document.getElementById('chainwatcher-badge-toggle')?.addEventListener('click', function() {
const cur = storage.get('chainWatcherBadgeEnabled', true);
storage.set('chainWatcherBadgeEnabled', !cur);
this.classList.toggle('active', !cur);
if (!cur) ui.ensureChainWatcherBadge(); else ui.removeChainWatcherBadge?.();
});
document.getElementById('alert-buttons-toggle')?.addEventListener('click', function() {
const cur = storage.get('alertButtonsEnabled', true);
storage.set('alertButtonsEnabled', !cur);
this.classList.toggle('active', !cur);
// Observer will respect this; force a UI refresh
ui.updateAllPages?.();
});
document.getElementById('user-score-badge-toggle')?.addEventListener('click', function() {
const cur = storage.get('userScoreBadgeEnabled', true);
storage.set('userScoreBadgeEnabled', !cur);
this.classList.toggle('active', !cur);
if (!cur) ui.ensureUserScoreBadge(); else ui.removeUserScoreBadge?.();
ui.updateUserScoreBadge?.();
});
// Removed: refreshing shimmer toggle and related CSS injection
document.getElementById('faction-score-badge-toggle')?.addEventListener('click', function() {
const cur = storage.get('factionScoreBadgeEnabled', true);
storage.set('factionScoreBadgeEnabled', !cur);
this.classList.toggle('active', !cur);
if (!cur) ui.ensureFactionScoreBadge(); else ui.removeFactionScoreBadge?.();
ui.updateFactionScoreBadge?.();
});
document.getElementById('dibs-deals-badge-toggle')?.addEventListener('click', function() {
const cur = storage.get('dibsDealsBadgeEnabled', true);
storage.set('dibsDealsBadgeEnabled', !cur);
this.classList.toggle('active', !cur);
if (!cur) ui.ensureDibsDealsBadge(); else ui.removeDibsDealsBadge?.();
ui.updateDibsDealsBadge?.();
});
document.getElementById('paste-messages-toggle')?.addEventListener('click', function() {
const cur = storage.get('pasteMessagesToChatEnabled', true);
storage.set('pasteMessagesToChatEnabled', !cur);
this.classList.toggle('active', !cur);
});
document.getElementById('oc-reminder-toggle')?.addEventListener('click', function() {
const currentState = storage.get('ocReminderEnabled', true);
storage.set('ocReminderEnabled', !currentState);
this.classList.toggle('active', !currentState);
});
document.getElementById('verbose-row-logs-toggle')?.addEventListener('click', function() {
const current = storage.get('debugRowLogs', false);
storage.set('debugRowLogs', !current);
this.classList.toggle('active', !current);
if (!state.debug) state.debug = {};
state.debug.rowLogs = !current;
ui.showMessageBox(`Verbose Row Logs ${!current ? 'Enabled' : 'Disabled'}`, !current ? 'success' : 'info', 2000);
});
document.getElementById('status-watch-toggle')?.addEventListener('click', function() {
const current = storage.get('debugStatusWatch', false);
storage.set('debugStatusWatch', !current);
this.classList.toggle('active', !current);
if (!state.debug) state.debug = {};
state.debug.statusWatch = !current;
ui.showMessageBox(`Status Watch Logs ${!current ? 'Enabled' : 'Disabled'}`,'success',1500);
});
document.getElementById('points-parse-logs-toggle')?.addEventListener('click', function() {
const current = storage.get('debugPointsParseLogs', false);
storage.set('debugPointsParseLogs', !current);
this.classList.toggle('active', !current);
if (!state.debug) state.debug = {};
state.debug.pointsParseLogs = !current;
ui.showMessageBox(`Points Parse Logs ${!current ? 'Enabled' : 'Disabled'}`,'success',1500);
});
document.getElementById('reset-api-counters-btn')?.addEventListener('click', async () => {
if (!(await ui.showConfirmationBox('Reset API counters for this tab/session?'))) return;
try {
sessionStorage.removeItem('api_calls');
sessionStorage.removeItem('api_calls_client');
sessionStorage.removeItem('api_calls_backend');
} catch(_) {}
try {
state.session.apiCalls = 0;
state.session.apiCallsClient = 0;
state.session.apiCallsBackend = 0;
} catch(_) {}
if (handlers?.debouncedUpdateApiUsageBadge) { handlers.debouncedUpdateApiUsageBadge(); } else { ui.updateApiUsageBadge?.(); }
ui.showMessageBox('API counters reset.', 'success', 1500);
});
document.getElementById('reset-settings-btn')?.addEventListener('click', async () => {
// Custom confirmation with Normal / Factory / Cancel buttons
const modal = ui.showMessageBox ? null : null; // placeholder to satisfy linter if showMessageBox tree-shaken
const wrapper = document.createElement('div');
wrapper.style.padding='4px 2px';
wrapper.innerHTML = `
<div style="font-size:12px;line-height:1.5;color:#e2e8f0;max-width:480px;">
<strong>Reset TreeDibsMapper Data</strong><br><br>
<div style="margin-bottom:6px;">
<span style="color:#93c5fd;">Normal Reset</span> – Purges caches, history, IndexedDB, unified status. Preserves API key, column settings.
</div>
<div style="margin-bottom:6px;">
<span style="color:#fca5a5;">Factory Reset</span> – Everything above <em>plus</em> removes API key, IndexedDB, and all TDM localStorage. Script reload acts like first install.
</div>
<div style="margin-bottom:8px;opacity:.8;">Choose reset scope:</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button id="tdm-reset-normal" class="settings-btn" style="flex:1;min-width:120px;">Normal Reset</button>
<button id="tdm-reset-factory" class="settings-btn settings-btn-red" style="flex:1;min-width:120px;">Factory Reset</button>
<button id="tdm-reset-cancel" class="settings-btn" style="flex:1;min-width:90px;">Cancel</button>
</div>
</div>`;
const host = document.createElement('div');
host.style.position='fixed'; host.style.top='50%'; host.style.left='50%'; host.style.transform='translate(-50%,-50%)';
host.style.background='#111827'; host.style.border='1px solid #1f2937'; host.style.padding='14px 16px'; host.style.borderRadius='8px'; host.style.zIndex='10050'; host.style.boxShadow='0 10px 24px -4px rgba(0,0,0,.55)';
host.appendChild(wrapper);
document.body.appendChild(host);
const closeHost = ()=>{ try { host.remove(); } catch(_) {} };
const run = async (factory=false) => {
closeHost();
try { await handlers.performHardReset({ factory }); } catch(e) { tdmlogger('error', '[Reset] error', e); }
setTimeout(()=>location.reload(), 1200);
};
host.querySelector('#tdm-reset-normal')?.addEventListener('click', ()=> run(false));
host.querySelector('#tdm-reset-factory')?.addEventListener('click', ()=> run(true));
host.querySelector('#tdm-reset-cancel')?.addEventListener('click', closeHost);
});
document.getElementById('tdm-adoption-btn')?.addEventListener('click', ui.showTDMAdoptionModal);
document.getElementById('copy-dibs-style-btn')?.addEventListener('click', async () => {
const summary = buildDibsStyleSummary();
await copyTextToClipboard(summary, 'Dibs style');
});
// Dibs Style save
const saveDibsBtn = document.getElementById('save-dibs-style-btn');
if (saveDibsBtn) {
saveDibsBtn.addEventListener('click', async (e) => {
const btn = e.currentTarget;
btn.disabled = true; btn.innerHTML = '<span class="dibs-spinner"></span> Saving...';
try {
const allowStatuses = {};
document.querySelectorAll('.dibs-allow-status').forEach(cb => {
allowStatuses[cb.dataset.status] = cb.checked;
});
const allowLastActionStatuses = {};
document.querySelectorAll('.dibs-allow-lastaction').forEach(cb => {
allowLastActionStatuses[cb.dataset.status] = cb.checked;
});
const allowedUserStatuses = {};
document.querySelectorAll('.dibs-allow-user-status').forEach(cb => {
allowedUserStatuses[cb.dataset.status] = cb.checked;
});
const maxHospMinsRaw = document.getElementById('dibs-max-hosp-minutes')?.value;
const maxHospMins = maxHospMinsRaw === '' ? 0 : Math.max(0, parseInt(maxHospMinsRaw));
const payload = {
options: {
dibsStyle: {
keepTillInactive: document.getElementById('dibs-keep-inactive').checked,
mustRedibAfterSuccess: document.getElementById('dibs-redib-after-success').checked,
bypassDibStyle: document.getElementById('dibs-bypass-style')?.checked === true,
removeOnFly: document.getElementById('dibs-remove-on-fly').checked,
removeWhenUserTravels: document.getElementById('dibs-remove-user-travel')?.checked || false,
inactivityTimeoutSeconds: parseInt(document.getElementById('dibs-inactivity-seconds').value) || 300,
maxHospitalReleaseMinutes: maxHospMins,
allowStatuses,
allowLastActionStatuses,
allowedUserStatuses
}
}
};
const res = await api.post('updateFactionSettings', { factionId: state.user.factionId, ...payload });
state.script.factionSettings = res?.settings || state.script.factionSettings;
ui.showMessageBox('Dibs Style saved.', 'success');
// Immediately reflect new rules in UI without page reload
ui.updateAllPages();
handlers.debouncedFetchGlobalData?.();
} catch (err) {
ui.showMessageBox(`Failed to save Dibs Style: ${err.message || 'Unknown error'}`, 'error');
} finally {
btn.disabled = false; btn.textContent = 'Save Dibs Style';
ui.updateSettingsContent();
}
});
}
const rankedWarSelect = document.getElementById('ranked-war-id-select');
const showRankedWarSummaryBtn = document.getElementById('show-ranked-war-summary-btn');
const viewWarAttacksBtn = document.getElementById('view-war-attacks-btn');
// Note tag presets elements
const noteTagsInput = document.getElementById('note-tags-input');
const noteTagsPreview = document.getElementById('note-tags-preview');
const noteTagsSaveBtn = document.getElementById('note-tags-save-btn');
const noteTagsResetBtn = document.getElementById('note-tags-reset-btn');
const noteTagsStatus = document.getElementById('note-tags-status');
const renderNoteTagsPreview = () => {
if (!noteTagsPreview || !noteTagsInput) return;
noteTagsPreview.innerHTML='';
const raw = (noteTagsInput.value||'').trim();
const tags = raw.split(/[\s,]+/).filter(Boolean).map(t=>t.slice(0,24)).map(t=>t.replace(/[^a-z0-9_\-]/gi,''));
const uniq=[]; const seen=new Set();
for (const t of tags) { if (!t) continue; const l=t.toLowerCase(); if (seen.has(l)) continue; seen.add(l); uniq.push(t); if (uniq.length>=12) break; }
if (uniq.length===0) { noteTagsPreview.appendChild(utils.createElement('div',{ style:{fontSize:'11px',color:'#666'}, textContent:'No valid tags'})); }
uniq.forEach(t=>{ noteTagsPreview.appendChild(utils.createElement('span',{ textContent:'#'+t, style:{ background:'#333', padding:'2px 6px', borderRadius:'4px', fontSize:'11px', color:'#ddd' }})); });
};
if (noteTagsInput) { noteTagsInput.addEventListener('input', utils.debounce(renderNoteTagsPreview, 120)); renderNoteTagsPreview(); }
const saveNoteTags = () => {
if (!noteTagsInput || !noteTagsStatus) return;
const raw = (noteTagsInput.value||'').trim();
const tags = raw.split(/[\s,]+/).filter(Boolean).map(t=>t.slice(0,24)).map(t=>t.replace(/[^a-z0-9_\-]/gi,''));
const uniq=[]; const seen=new Set();
for (const t of tags) { if (!t) continue; const l=t.toLowerCase(); if (seen.has(l)) continue; seen.add(l); uniq.push(t); if (uniq.length>=12) break; }
storage.set('tdmNoteQuickTags', uniq.join(','));
noteTagsStatus.textContent = 'Saved'; noteTagsStatus.style.color = '#10b981';
setTimeout(()=>{ noteTagsStatus.textContent='Idle'; noteTagsStatus.style.color='#9ca3af'; }, 1800);
renderNoteTagsPreview();
};
noteTagsSaveBtn?.addEventListener('click', saveNoteTags);
noteTagsResetBtn?.addEventListener('click', ()=>{ if (!noteTagsInput) return; noteTagsInput.value='dex+,def+,str+,spd+,hosp,retal'; saveNoteTags(); });
// ChainWatcher settings wiring
const chainSelect = document.getElementById('tdm-chainwatcher-select');
const chainSaveBtn = document.getElementById('tdm-chainwatcher-save');
const chainClearBtn = document.getElementById('tdm-chainwatcher-clear');
const isTouchDevice = () => ('ontouchstart' in window) || navigator.maxTouchPoints > 0 || window.matchMedia && window.matchMedia('(pointer:coarse)').matches;
const renderChainCheckboxList = (members, preselectedIds) => {
// Create a scrollable list of checkboxes for mobile-friendly selection
let container = document.getElementById('tdm-chainwatcher-checkbox-list');
if (!container) {
container = document.createElement('div');
container.id = 'tdm-chainwatcher-checkbox-list';
container.style.maxHeight = '160px';
container.style.overflowY = 'auto';
container.style.padding = '6px';
container.style.border = '1px solid #444';
container.style.background = '#111';
container.style.marginTop = '6px';
chainSelect.parentElement?.appendChild(container);
}
container.innerHTML = '';
for (const m of members) {
const row = document.createElement('label');
row.style.display = 'block';
row.style.padding = '6px';
row.style.cursor = 'pointer';
row.style.color = '#ddd';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = String(m.id);
// store canonical member name on the input so we don't accidentally include status suffixes
try { cb.dataset.memberName = String(m.name || ''); } catch(_) {}
cb.style.marginRight = '8px';
if (preselectedIds.has(String(m.id))) cb.checked = true;
// Name span
const nameSpan = document.createElement('span');
nameSpan.className = 'tdm-chainwatcher-name';
nameSpan.textContent = `${m.name} [${m.id}]`;
nameSpan.style.marginRight = '6px';
nameSpan.dataset.memberId = String(m.id);
// Status span (dynamic)
const statusSpan = document.createElement('span');
statusSpan.className = 'tdm-chainwatcher-status tdm-last-action-inline';
statusSpan.dataset.memberId = String(m.id);
// Start without textual status; coloring is applied to nameSpan instead
statusSpan.textContent = '';
row.appendChild(cb);
row.appendChild(nameSpan);
row.appendChild(statusSpan);
// Apply initial coloring to the name span based on last_action.status
try { utils.addLastActionStatusColor && utils.addLastActionStatusColor(nameSpan, m); } catch(_) {}
container.appendChild(row);
}
return container;
};
// Retry counter to handle cases where factionMembers aren't loaded yet
let _tdm_chainPopulateRetries = 0;
const populateChainSelect = () => {
if (!chainSelect) return;
chainSelect.innerHTML = '';
// Use factionMembers if available
const members = Array.isArray(state.factionMembers) ? state.factionMembers : [];
// Sort alphabetically (case-insensitive), but keep current user on top
const sorted = [...members].filter(m => m && m.id && m.name).sort((a, b) => {
const idA = String(a.id);
const idB = String(b.id);
if (idA === String(state.user.tornId)) return -1;
if (idB === String(state.user.tornId)) return 1;
return (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase());
});
// If we have no members yet (fresh reset/cache cleared), try to fetch and retry a few times
if ((!Array.isArray(members) || members.length === 0) && _tdm_chainPopulateRetries < 5) {
_tdm_chainPopulateRetries++;
// Try to trigger a fetch of faction members if available
try { if (handlers && typeof handlers.fetchFactionMembers === 'function') handlers.fetchFactionMembers(); } catch(_) {}
// Schedule a retry after a short delay to allow fetch to populate state.factionMembers
setTimeout(() => { try { populateChainSelect(); } catch(_) {} }, 700 + (_tdm_chainPopulateRetries * 200));
// Clear any existing checkbox list to avoid showing an empty box
const existingBox = document.getElementById('tdm-chainwatcher-checkbox-list'); if (existingBox) existingBox.remove();
// Keep native select hidden until we populate
chainSelect.style.display = 'none';
return;
}
// Build options for desktop multi-select
for (const m of sorted) {
const opt = document.createElement('option');
opt.value = String(m.id);
try { opt.dataset.memberName = String(m.name || ''); } catch(_) {}
const statusText = (m && m.last_action && m.last_action.status) ? String(m.last_action.status) : '';
opt.textContent = statusText ? `${m.name} [${m.id}] - ${statusText}` : `${m.name} [${m.id}]`;
chainSelect.appendChild(opt);
}
// Load stored values (authoritative server will overwrite on next fetch)
let preselected = new Set();
try {
const stored = storage.get('chainWatchers', []);
if (Array.isArray(stored) && stored.length) preselected = new Set(stored.map(s => String(s.id || s)));
} catch(_) { preselected = new Set(); }
// Always render the checkbox list for easier multi-selection on all devices
// Remove existing checkbox list if any
const existing = document.getElementById('tdm-chainwatcher-checkbox-list');
if (existing) existing.remove();
renderChainCheckboxList(sorted, preselected);
// Hide the native select to avoid confusion
chainSelect.style.display = 'none';
};
// Initial populate
populateChainSelect();
// Also repopulate when factionMembers update and refresh displayed statuses
try {
handlers.addObserver && handlers.addObserver('factionMembers', populateChainSelect);
handlers.addObserver && handlers.addObserver('factionMembers', ui.updateChainWatcherBadge);
handlers.addObserver && handlers.addObserver('factionMembers', ui.updateChainWatcherDisplayedStatuses);
} catch(_) {}
chainSaveBtn?.addEventListener('click', async () => {
if (!chainSelect) return;
// Collect selections from either checkbox list (mobile) or native multi-select
let selected = [];
const checkboxContainer = document.getElementById('tdm-chainwatcher-checkbox-list');
if (checkboxContainer) {
const checks = checkboxContainer.querySelectorAll('input[type="checkbox"]');
for (const cb of checks) if (cb.checked) {
const name = cb.dataset.memberName || cb.parentElement?.querySelector('.tdm-chainwatcher-name')?.textContent?.split(' [')[0]?.trim() || String(cb.value);
selected.push({ id: String(cb.value), name });
}
} else {
selected = Array.from(chainSelect.selectedOptions || []).map(o => {
const name = o.dataset.memberName || (o.textContent || '').split(' - ')[0].split(' [')[0].trim();
return { id: String(o.value), name };
});
}
if (selected.length > 3) { ui.showMessageBox('Select at most 3 watchers.', 'error'); return; }
// Persist to backend so selection is shared across faction
try {
const res = await api.post('setChainWatchers', { factionId: state.user.factionId, watchers: selected });
// Accept either shape: { ok: true, watchers, meta } OR { status:'success', data: { watchers, meta } }
let remoteWatchers = null; let remoteMeta = null; let ok = false;
if (res && (res.ok === true || res.status === 'success')) {
ok = true;
if (res.data && res.data.watchers) remoteWatchers = res.data.watchers;
if (res.data && ('meta' in res.data)) remoteMeta = res.data.meta;
if (!remoteWatchers && Array.isArray(res.watchers)) remoteWatchers = res.watchers;
if (!remoteMeta && ('meta' in res)) remoteMeta = res.meta;
}
if (ok && Array.isArray(remoteWatchers)) {
storage.set('chainWatchers', remoteWatchers);
try { storage.set('chainWatchers_meta', remoteMeta || null); } catch(_) {}
ui.ensureChainWatcherBadge();
ui.updateChainWatcherMeta && ui.updateChainWatcherMeta(remoteMeta || null);
ui.showMessageBox('ChainWatcher saved for faction.', 'success');
// Update in-modal selections to match authoritative list
populateChainSelect();
} else {
ui.showMessageBox('Unexpected backend response; please refresh to see authoritative state.', 'warn');
}
} catch (err) {
ui.showMessageBox('Failed to save on server; please try again.', 'error');
}
});
chainClearBtn?.addEventListener('click', async () => {
try {
const res = await api.post('setChainWatchers', { factionId: state.user.factionId, watchers: [] });
// Accept both shapes
const ok = !!(res && (res.ok === true || res.status === 'success'));
if (ok) {
storage.set('chainWatchers', []);
try { storage.set('chainWatchers_meta', null); } catch(_) {}
// Unselect options and remove checkboxes
if (chainSelect) Array.from(chainSelect.options).forEach(o=>o.selected=false);
const existing = document.getElementById('tdm-chainwatcher-checkbox-list'); if (existing) existing.querySelectorAll('input[type="checkbox"]').forEach(cb=>cb.checked=false);
ui.updateChainWatcherBadge();
ui.updateChainWatcherMeta && ui.updateChainWatcherMeta(null);
ui.showMessageBox('ChainWatcher cleared.', 'info');
} else {
ui.showMessageBox('Failed to clear ChainWatcher on server.', 'error');
}
} catch(e) {
ui.showMessageBox('Failed to clear ChainWatcher on server.', 'error');
}
});
// Ensure badge exists if stored selections present
try { ui.ensureChainWatcherBadge(); } catch(_) {}
if (rankedWarSelect && showRankedWarSummaryBtn && viewWarAttacksBtn) {
showRankedWarSummaryBtn.disabled = true;
viewWarAttacksBtn.disabled = true;
// Dev button for forcing backend war attacks refresh (only show when debug enabled)
try {
if (state.debug?.apiLogs && !document.getElementById('force-backend-war-attacks-refresh-btn')) {
const devBtn = utils.createElement('button', {
id: 'force-backend-war-attacks-refresh-btn',
className: 'settings-btn settings-btn-yellow',
textContent: 'Force Backend Attacks Refresh',
title: 'Force backend to pull latest ranked war attacks manifest and chunks, then refresh local cache.'
});
devBtn.addEventListener('click', async () => {
const warId = rankedWarSelect.value;
if (!warId) { ui.showMessageBox('Select a war first.', 'error'); return; }
devBtn.disabled = true; const old = devBtn.textContent; devBtn.textContent = 'Refreshing...';
try {
const ok = await api.forceBackendWarAttacksRefresh(warId, state.user.factionId);
if (ok) {
ui.showTransientMessage('Backend refresh triggered; refetching manifest in 2s...', { type: 'success' });
setTimeout(async ()=>{
try { await api.fetchWarManifestV2(warId, state.user.factionId, { force: true }); await api.assembleAttacksFromV2(warId, state.user.factionId, { forceWindowBootstrap: false }); } catch(_) {}
}, 2000);
} else {
ui.showTransientMessage('Backend refresh callable unavailable.', { type: 'error' });
}
} catch(e){ ui.showMessageBox('Refresh failed: '+(e.message||e), 'error'); }
finally { devBtn.disabled=false; devBtn.textContent=old; }
});
// Insert near existing war buttons
viewWarAttacksBtn.parentElement?.insertBefore(devBtn, viewWarAttacksBtn.nextSibling);
// (Removed) escalate truncated attacks button
}
} catch(_) { /* noop */ }
const prefetchRankedWars = async () => {
perf?.start?.('ui.updateSettingsContent.loadRankedWars');
try {
showRankedWarSummaryBtn.disabled = true;
viewWarAttacksBtn.disabled = true;
rankedWarSelect.innerHTML = '<option value="">Loading...</option>';
let rankedWars = Array.isArray(state.rankWars) ? state.rankWars : [];
if (rankedWars.length === 0) {
// Try direct Torn fetch if cache is empty (respects 1-hour init fetch, but as a fallback)
try {
const warsUrl = `https://api.torn.com/v2/faction/rankedwars?key=${state.user.actualTornApiKey}&comment=TDM_FEgRW2×tamp=${Math.floor(Date.now()/1000)}`;
const warsResp = await fetch(warsUrl);
const warsData = await warsResp.json();
utils.incrementClientApiCalls(1);
if (!warsData.error) {
const list = Array.isArray(warsData.rankedwars) ? warsData.rankedwars : (Array.isArray(warsData) ? warsData : []);
rankedWars = list;
storage.updateStateAndStorage('rankWars', rankedWars);
}
} catch (_) { /* ignore */ }
}
if (Array.isArray(rankedWars) && rankedWars.length > 0) {
rankedWarSelect.innerHTML = '';
rankedWars.forEach(war => {
if (war && war.id && war.factions) {
const opponentFaction = Object.values(war.factions).find(f => f.id !== parseInt(state.user.factionId));
const opponentName = opponentFaction ? opponentFaction.name : 'Unknown';
const option = utils.createElement('option', { value: war.id, textContent: `${war.id} - ${opponentName}` });
rankedWarSelect.appendChild(option);
}
});
showRankedWarSummaryBtn.disabled = false;
viewWarAttacksBtn.disabled = false;
} else {
rankedWarSelect.innerHTML = '<option value="">No ranked wars found</option>';
}
} catch (error) {
tdmlogger('error', `[Error loading ranked wars into settings panel] ${error}`);
rankedWarSelect.innerHTML = '<option value="">Error loading wars</option>';
} finally {
perf?.stop?.('ui.updateSettingsContent.loadRankedWars');
}
};
runRankedWarPrefetch = prefetchRankedWars;
prefetchRankedWars();
showRankedWarSummaryBtn.addEventListener('click', async () => {
const selectedWarId = rankedWarSelect.value;
if (!selectedWarId) { ui.showMessageBox('Please select a ranked war first', 'error'); return; }
showRankedWarSummaryBtn.disabled = true;
showRankedWarSummaryBtn.innerHTML = '<span class="dibs-spinner"></span>';
try {
// Use the freshest source (local vs server) - getRankedWarSummaryFreshest handles smart fallback
const summary = await api.getRankedWarSummaryFreshest(selectedWarId, state.user.factionId);
ui.showRankedWarSummaryModal(summary, selectedWarId);
} catch (error) {
ui.showMessageBox(`Error fetching war summary: ${error.message || 'Unknown error'}`, 'error');
tdmlogger('error', `[War Summary Error] ${error}`);
} finally {
showRankedWarSummaryBtn.disabled = false;
showRankedWarSummaryBtn.textContent = 'War Summary';
}
});
viewWarAttacksBtn.addEventListener('click', async () => {
const selectedWarId = rankedWarSelect.value;
if (!selectedWarId) { ui.showMessageBox('Please select a ranked war first', 'error'); return; }
viewWarAttacksBtn.disabled = true;
viewWarAttacksBtn.innerHTML = '<span class="dibs-spinner"></span>';
try {
// First, tell the backend to update the raw attack logs from the API
await api.post('updateRankedWarAttacks', { rankedWarId: selectedWarId, factionId: state.user.factionId });
// Then, show the modal which reads that raw data from Firestore
ui.showCurrentWarAttacksModal(selectedWarId);
} catch (error) {
ui.showMessageBox(`Error preparing war attacks: ${error.message || 'Unknown error'}`, 'error');
tdmlogger('error', `[War Attacks Error] ${error}`);
} finally {
viewWarAttacksBtn.disabled = false;
viewWarAttacksBtn.textContent = 'War Attacks';
}
});
}
// Sanitize native tooltips on touch devices to prevent sticky titles (PDA)
try { ui._sanitizeTouchTooltips(content); } catch(_) {}
} catch (error) {
tdmlogger('error', '[Settings UI] updateSettingsContent failed', error);
try {
const fallbackContent = document.getElementById('tdm-settings-content');
if (fallbackContent) {
fallbackContent.innerHTML = `<div style="padding:12px;background:#1f2937;border:1px solid #ef4444;border-radius:6px;color:#f87171;font-size:12px;">Failed to render settings panel. Check console for details.</div>`;
}
} catch(_) {/* noop */}
} finally {
if (!renderPhaseComplete) perf?.stop?.('ui.updateSettingsContent.render');
if (bindPhaseStarted) perf?.stop?.('ui.updateSettingsContent.bind');
perf?.stop?.('ui.updateSettingsContent.total');
tdmlogger('info', `[Perf] ui.updateSettingsContent: ${perf?.toString?.() || 'no perf data'}`);
}
},
applyGeneralStyles: () => {
// Ensure scope attribute is present and idempotent
try { document.body.setAttribute('data-tdm-root','true'); } catch(_) {}
const ensureDibsClickListener = () => {
try {
if (window.__tdm_dibs_click_listener_added) return;
document.addEventListener('click', (evt) => {
try {
const btn = evt.target && evt.target.closest && evt.target.closest('.dibs-btn');
if (!btn) return;
btn.classList.add('tdm-dibs-clicked');
setTimeout(() => { try { btn.classList.remove('tdm-dibs-clicked'); } catch(_) {} }, 220);
} catch(_) { /* noop */ }
});
window.__tdm_dibs_click_listener_added = true;
} catch(_) { /* noop */ }
};
const existingStyle = document.getElementById('dibs-general-styles');
if (existingStyle) { ensureDibsClickListener(); return; }
const styleTag = utils.createElement('style', {
type: 'text/css',
id: 'dibs-general-styles',
textContent: `
/* ============================================
CSS VARIABLES - Design System Foundation
============================================ */
#tdm-root, [data-tdm-root] {
/* Background Colors */
--tdm-bg-main: #666;
--tdm-bg-secondary: #333;
--tdm-bg-tertiary: #1a1a1a;
--tdm-bg-card: #2c2c2c;
/* Semantic Colors (mirrors config.CSS.colors) */
--tdm-color-success: ${config.CSS.colors.success};
--tdm-color-error: ${config.CSS.colors.error};
--tdm-color-warning: ${config.CSS.colors.warning};
--tdm-color-info: ${config.CSS.colors.info};
/* Dibs System Colors (from config.CSS.colors) */
--tdm-dibs-success: ${config.CSS.colors.dibsSuccess};
--tdm-dibs-success-hover: ${config.CSS.colors.dibsSuccessHover};
--tdm-dibs-other: ${config.CSS.colors.dibsOther};
--tdm-dibs-other-hover: ${config.CSS.colors.dibsOtherHover};
--tdm-dibs-inactive: ${config.CSS.colors.dibsInactive};
--tdm-dibs-inactive-hover: ${config.CSS.colors.dibsInactiveHover};
/* Note Button Colors */
--tdm-note-inactive: ${config.CSS.colors.noteInactive};
--tdm-note-inactive-hover: ${config.CSS.colors.noteInactiveHover};
--tdm-note-active: ${config.CSS.colors.noteActive};
--tdm-note-active-hover: ${config.CSS.colors.noteActiveHover};
/* Text colors for note buttons (use for strong contrast when active) */
--tdm-note-inactive-text: ${config.CSS.colors.noteInactiveText || '#ffffff'};
--tdm-note-active-text: ${config.CSS.colors.noteActiveText || '#ffffff'};
/* Med Deal Colors */
--tdm-med-inactive: ${config.CSS.colors.medDealInactive};
--tdm-med-inactive-hover: ${config.CSS.colors.medDealInactiveHover};
--tdm-med-set: ${config.CSS.colors.medDealSet};
--tdm-med-set-hover: ${config.CSS.colors.medDealSetHover};
--tdm-med-mine: ${config.CSS.colors.medDealMine};
--tdm-med-mine-hover: ${config.CSS.colors.medDealMineHover};
/* Assist Button Colors */
--tdm-assist: ${config.CSS.colors.assistButton};
--tdm-assist-hover: ${config.CSS.colors.assistButtonHover};
/* Modal Colors */
--tdm-modal-bg: ${config.CSS.colors.modalBg};
--tdm-modal-border: ${config.CSS.colors.modalBorder};
--tdm-modal-header: ${config.CSS.colors.mainColor};
/* Text Colors */
--tdm-text-primary: #ffffff;
--tdm-text-secondary: #9ca3af;
--tdm-text-muted: #6b7280;
--tdm-text-accent: #93c5fd;
/* Spacing System (4px base grid) */
--tdm-space-xs: 2px;
--tdm-space-sm: 4px;
--tdm-space-md: 8px;
--tdm-space-lg: 12px;
--tdm-space-xl: 16px;
--tdm-space-2xl: 24px;
/* Typography */
--tdm-font-size-xs: 10px;
--tdm-font-size-sm: 11px;
--tdm-font-size-base: 13px;
--tdm-font-size-lg: 16px;
--tdm-font-size-xl: 18px;
/* Border Radius */
--tdm-radius-sm: 3px;
--tdm-radius-md: 4px;
--tdm-radius-lg: 8px;
--tdm-radius-xl: 14px;
--tdm-radius-full: 9999px;
/* Z-Index Scale */
--tdm-z-dropdown: 1000;
--tdm-z-modal: 10000;
--tdm-z-modal-backdrop: 9999;
--tdm-z-tooltip: 10010;
--tdm-z-toast: 10020;
/* Transitions */
--tdm-transition-fast: 0.15s ease;
--tdm-transition-normal: 0.25s ease;
--tdm-transition-slow: 0.35s ease;
/* Shadows */
--tdm-shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
--tdm-shadow-md: 0 4px 8px rgba(0,0,0,0.4);
--tdm-shadow-lg: 0 8px 16px rgba(0,0,0,0.5);
--tdm-shadow-modal: 0 22px 46px -12px rgba(15,23,42,0.75);
/* Button Heights */
--tdm-btn-height-sm: 20px;
--tdm-btn-height-md: 24px;
--tdm-btn-height-lg: 32px;
}
/* ============================================
BEM BUTTON SYSTEM - Base & Modifiers
============================================ */
/* Base Button */
.tdm-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--tdm-space-sm) var(--tdm-space-md);
font-size: var(--tdm-font-size-sm);
font-weight: 500;
line-height: 1.2;
color: var(--tdm-text-primary);
background-color: var(--tdm-bg-card);
border: 1px solid var(--tdm-bg-secondary);
border-radius: var(--tdm-radius-sm);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background-color var(--tdm-transition-fast),
border-color var(--tdm-transition-fast),
transform var(--tdm-transition-fast);
box-sizing: border-box;
user-select: none;
}
.tdm-btn:hover {
background-color: var(--tdm-bg-secondary);
border-color: var(--tdm-bg-main);
}
.tdm-btn:active {
transform: scale(0.98);
}
.tdm-btn:disabled, .tdm-btn.disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
/* Size Modifiers */
.tdm-btn--sm {
height: var(--tdm-btn-height-sm);
padding: var(--tdm-space-xs) var(--tdm-space-sm);
font-size: var(--tdm-font-size-xs);
}
.tdm-btn--md {
height: var(--tdm-btn-height-md);
padding: var(--tdm-space-sm) var(--tdm-space-md);
font-size: var(--tdm-font-size-sm);
}
.tdm-btn--lg {
height: var(--tdm-btn-height-lg);
padding: var(--tdm-space-md) var(--tdm-space-lg);
font-size: var(--tdm-font-size-base);
}
/* Color Modifiers */
.tdm-btn--primary {
background-color: var(--tdm-color-info);
border-color: var(--tdm-color-info);
}
.tdm-btn--primary:hover {
background-color: #1976D2;
border-color: #1976D2;
}
.tdm-btn--success {
background-color: var(--tdm-color-success);
border-color: var(--tdm-color-success);
}
.tdm-btn--success:hover {
background-color: #45a049;
border-color: #45a049;
}
.tdm-btn--danger {
background-color: var(--tdm-color-error);
border-color: var(--tdm-color-error);
}
.tdm-btn--danger:hover {
background-color: #e53935;
border-color: #e53935;
}
.tdm-btn--warning {
background-color: var(--tdm-color-warning);
border-color: var(--tdm-color-warning);
color: #000;
}
.tdm-btn--warning:hover {
background-color: #f57c00;
border-color: #f57c00;
}
/* State Modifiers for Dibs System */
.tdm-btn--dibs-inactive {
background-color: var(--tdm-dibs-inactive);
border-color: var(--tdm-dibs-inactive);
}
.tdm-btn--dibs-inactive:hover {
background-color: var(--tdm-dibs-inactive-hover);
border-color: var(--tdm-dibs-inactive-hover);
}
.tdm-btn--dibs-yours {
background-color: var(--tdm-dibs-success);
border-color: var(--tdm-dibs-success);
}
.tdm-btn--dibs-yours:hover {
background-color: var(--tdm-dibs-success-hover);
border-color: var(--tdm-dibs-success-hover);
}
.tdm-btn--dibs-other {
background-color: var(--tdm-dibs-other);
border-color: var(--tdm-dibs-other);
}
.tdm-btn--dibs-other:hover {
background-color: var(--tdm-dibs-other-hover);
border-color: var(--tdm-dibs-other-hover);
}
/* State Modifiers for Notes */
.tdm-btn--note-inactive {
background-color: var(--tdm-note-inactive);
border-color: var(--tdm-note-inactive);
}
.tdm-btn--note-inactive:hover {
background-color: var(--tdm-note-inactive-hover);
border-color: var(--tdm-note-inactive-hover);
}
.tdm-btn--note-active {
background-color: var(--tdm-note-active);
border-color: var(--tdm-note-active);
}
.tdm-btn--note-active:hover {
background-color: var(--tdm-note-active-hover);
border-color: var(--tdm-note-active-hover);
}
/* State Modifiers for Med Deals */
.tdm-btn--med-inactive {
background-color: var(--tdm-med-inactive);
border-color: var(--tdm-med-inactive);
}
.tdm-btn--med-inactive:hover {
background-color: var(--tdm-med-inactive-hover);
border-color: var(--tdm-med-inactive-hover);
}
.tdm-btn--med-set {
background-color: var(--tdm-med-set);
border-color: var(--tdm-med-set);
}
.tdm-btn--med-set:hover {
background-color: var(--tdm-med-set-hover);
border-color: var(--tdm-med-set-hover);
}
.tdm-btn--med-mine {
background-color: var(--tdm-med-mine);
border-color: var(--tdm-med-mine);
}
.tdm-btn--med-mine:hover {
background-color: var(--tdm-med-mine-hover);
border-color: var(--tdm-med-mine-hover);
}
/* Assist Button */
.tdm-btn--assist {
background-color: var(--tdm-assist);
border-color: var(--tdm-assist);
}
.tdm-btn--assist:hover {
background-color: var(--tdm-assist-hover);
border-color: var(--tdm-assist-hover);
}
/* Ghost/Outline Variant */
.tdm-btn--ghost {
background-color: transparent;
border-color: var(--tdm-bg-secondary);
color: var(--tdm-text-secondary);
}
.tdm-btn--ghost:hover {
background-color: var(--tdm-bg-secondary);
color: var(--tdm-text-primary);
}
/* Full Width Modifier */
.tdm-btn--full {
width: 100%;
}
/* Icon Button (square) */
.tdm-btn--icon {
padding: var(--tdm-space-sm);
min-width: var(--tdm-btn-height-md);
}
/* ============================================
LEGACY .btn CLASS COMPATIBILITY
Maps old class names to new CSS variable system
============================================ */
/* Base .btn inherits modern button styles */
.dibs-notes-subrow .btn,
.tdm-dibs-deals-container .btn,
.tdm-notes-container .btn,
.dibs-cell .btn,
.notes-cell .btn,
button.dibs-btn,
button.med-deal-btn,
button.retal-btn,
button.req-assist-button {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 500;
line-height: 1.2;
color: var(--tdm-text-primary);
border-radius: var(--tdm-radius-sm);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background-color var(--tdm-transition-fast),
border-color var(--tdm-transition-fast),
transform var(--tdm-transition-fast);
box-sizing: border-box;
user-select: none;
}
.dibs-notes-subrow .btn:active,
button.dibs-btn:active,
button.med-deal-btn:active,
button.retal-btn:active {
transform: scale(0.98);
}
/* Dibs Button States - Map old classes to new variables */
.btn-dibs-inactive,
.btn.btn-dibs-inactive {
background-color: var(--tdm-dibs-inactive) !important;
border-color: var(--tdm-dibs-inactive) !important;
color: var(--tdm-text-primary) !important;
}
.btn-dibs-inactive:hover,
.btn.btn-dibs-inactive:hover {
background-color: var(--tdm-dibs-inactive-hover) !important;
border-color: var(--tdm-dibs-inactive-hover) !important;
}
.btn-dibs-success-you,
.btn.btn-dibs-success-you {
background-color: var(--tdm-dibs-success) !important;
border-color: var(--tdm-dibs-success) !important;
color: var(--tdm-text-primary) !important;
}
.btn-dibs-success-you:hover,
.btn.btn-dibs-success-you:hover {
background-color: var(--tdm-dibs-success-hover) !important;
border-color: var(--tdm-dibs-success-hover) !important;
}
.btn-dibs-success-other,
.btn.btn-dibs-success-other {
background-color: var(--tdm-dibs-other) !important;
border-color: var(--tdm-dibs-other) !important;
color: var(--tdm-text-primary) !important;
}
.btn-dibs-success-other:hover,
.btn.btn-dibs-success-other:hover {
background-color: var(--tdm-dibs-other-hover) !important;
border-color: var(--tdm-dibs-other-hover) !important;
}
.btn-dibs-disabled,
.btn.btn-dibs-disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
/* Med Deal Button States */
.btn-med-deal-default,
.btn.btn-med-deal-default,
.med-deal-btn.btn-med-deal-default {
background-color: var(--tdm-med-inactive) !important;
border-color: var(--tdm-med-inactive) !important;
color: var(--tdm-text-primary) !important;
}
.btn-med-deal-default:hover,
.btn.btn-med-deal-default:hover {
background-color: var(--tdm-med-inactive-hover) !important;
border-color: var(--tdm-med-inactive-hover) !important;
}
.btn-med-deal-set,
.btn.btn-med-deal-set,
.med-deal-btn.btn-med-deal-set {
background-color: var(--tdm-med-set) !important;
border-color: var(--tdm-med-set) !important;
color: var(--tdm-text-primary) !important;
}
.btn-med-deal-set:hover,
.btn.btn-med-deal-set:hover {
background-color: var(--tdm-med-set-hover) !important;
border-color: var(--tdm-med-set-hover) !important;
}
.btn-med-deal-mine,
.btn.btn-med-deal-mine,
.med-deal-btn.btn-med-deal-mine {
background-color: var(--tdm-med-mine) !important;
border-color: var(--tdm-med-mine) !important;
color: #000 !important;
}
.btn-med-deal-mine:hover,
.btn.btn-med-deal-mine:hover {
background-color: var(--tdm-med-mine-hover) !important;
border-color: var(--tdm-med-mine-hover) !important;
}
/* Retal Button States */
.btn-retal-inactive,
.btn.btn-retal-inactive,
.retal-btn.btn-retal-inactive {
background-color: var(--tdm-bg-card) !important;
border-color: var(--tdm-bg-secondary) !important;
color: var(--tdm-text-secondary) !important;
}
.btn-retal-active,
.btn.btn-retal-active,
.retal-btn.btn-retal-active {
background-color: var(--tdm-color-warning) !important;
border-color: var(--tdm-color-warning) !important;
color: #000 !important;
}
.btn-retal-active:hover,
.btn.btn-retal-active:hover {
background-color: #f57c00 !important;
border-color: #f57c00 !important;
}
/* Assist Request Button */
.req-assist-button,
.btn.req-assist-button {
background-color: var(--tdm-assist) !important;
border-color: var(--tdm-assist) !important;
color: var(--tdm-text-primary) !important;
}
.req-assist-button:hover,
.btn.req-assist-button:hover {
background-color: var(--tdm-assist-hover) !important;
border-color: var(--tdm-assist-hover) !important;
}
/* Note Button States */
.inactive-note-button,
.btn.inactive-note-button {
background-color: transparent !important;
border-color: var(--tdm-note-inactive) !important;
}
.inactive-note-button:hover,
.btn.inactive-note-button:hover {
border-color: var(--tdm-note-inactive-hover) !important;
}
.active-note-button:hover,
.btn.active-note-button:hover {
border-color: var(--tdm-note-active-hover) !important;
}
/* Settings Panel Buttons */
.tdm-note-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--tdm-space-sm) var(--tdm-space-md);
font-size: var(--tdm-font-size-sm);
font-weight: 500;
color: var(--tdm-text-primary);
background-color: var(--tdm-bg-card);
border: 1px solid var(--tdm-bg-secondary);
border-radius: var(--tdm-radius-sm);
cursor: pointer;
transition: background-color var(--tdm-transition-fast);
}
.tdm-note-btn:hover {
background-color: var(--tdm-bg-secondary);
}
.tdm-note-save {
background-color: var(--tdm-color-success) !important;
border-color: var(--tdm-color-success) !important;
}
.tdm-note-save:hover {
background-color: #45a049 !important;
}
.tdm-note-cancel {
background-color: var(--tdm-bg-secondary) !important;
border-color: var(--tdm-bg-secondary) !important;
}
.tdm-note-cancel:hover {
background-color: var(--tdm-bg-main) !important;
}
/* ============================================
TABLE LAYOUT - Column alignment fixes
============================================ */
/* Ensure header and body columns align */
.f-war-list .table-header,
.f-war-list .table-body > li {
display: flex;
align-items: stretch;
width: 100%;
box-sizing: border-box;
}
/* Table cell base styling */
.f-war-list .table-cell,
.f-war-list .table-header > li {
display: flex;
align-items: center;
justify-content: center;
padding: var(--tdm-space-xs) var(--tdm-space-sm);
box-sizing: border-box;
min-height: 28px;
}
/* Consistent borders for visual column separation */
.f-war-list .table-cell:not(:last-child),
.f-war-list .table-header > li:not(:last-child) {
border-right: 1px solid rgba(255, 255, 255, 0.05);
}
/* Header styling */
.f-war-list .table-header {
background: var(--tdm-bg-secondary);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
/* Body row alternate styling */
.f-war-list .table-body > li:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
}
/* Flex grow for variable width columns */
.f-war-list .table-cell.flex-grow,
.f-war-list .table-header > li.flex-grow {
flex: 1 1 auto;
}
/* Fixed width columns */
.f-war-list .table-cell.flex-fixed,
.f-war-list .table-header > li.flex-fixed {
flex: 0 0 auto;
}
/* TDM column containers alignment */
.tdm-dibs-deals-container,
.tdm-notes-container {
display: flex;
flex-direction: row;
align-items: stretch;
gap: var(--tdm-space-xs);
}
/* ============================================
MODAL SYSTEM - Unified modals with sizes
============================================ */
/* Modal Backdrop */
.tdm-modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: var(--tdm-z-modal-backdrop);
opacity: 0;
pointer-events: none;
transition: opacity var(--tdm-transition-normal);
}
.tdm-modal-backdrop.visible {
opacity: 1;
pointer-events: auto;
}
/* Base Modal */
.tdm-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.95);
background: var(--tdm-modal-bg);
border: 1px solid var(--tdm-modal-border);
border-radius: var(--tdm-radius-lg);
z-index: var(--tdm-z-modal);
color: var(--tdm-text-primary);
box-shadow: var(--tdm-shadow-modal);
max-height: 85vh;
overflow: hidden;
display: none;
flex-direction: column;
opacity: 0;
pointer-events: none;
transition: opacity var(--tdm-transition-normal), transform var(--tdm-transition-normal);
}
.tdm-modal.visible {
display: flex;
opacity: 1;
pointer-events: auto;
transform: translate(-50%, -50%) scale(1);
}
/* Size Variants */
.tdm-modal--sm {
width: 400px;
max-width: 90vw;
}
.tdm-modal--md {
width: 600px;
max-width: 90vw;
}
.tdm-modal--lg {
width: 800px;
max-width: 95vw;
}
.tdm-modal--xl {
width: 1000px;
max-width: 95vw;
}
.tdm-modal--full {
width: 95vw;
height: 90vh;
}
/* Modal Header */
.tdm-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--tdm-space-md) var(--tdm-space-lg);
background: var(--tdm-modal-header);
border-bottom: 1px solid var(--tdm-modal-border);
flex-shrink: 0;
}
.tdm-modal-title {
font-size: var(--tdm-font-size-lg);
font-weight: 600;
margin: 0;
color: var(--tdm-text-primary);
}
.tdm-modal-close {
background: transparent;
border: none;
color: var(--tdm-text-secondary);
font-size: var(--tdm-font-size-xl);
cursor: pointer;
padding: var(--tdm-space-xs);
line-height: 1;
transition: color var(--tdm-transition-fast);
}
.tdm-modal-close:hover {
color: var(--tdm-color-error);
}
/* Modal Body */
.tdm-modal-body {
padding: var(--tdm-space-lg);
overflow-y: auto;
flex: 1;
}
/* Modal Footer */
.tdm-modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--tdm-space-md);
padding: var(--tdm-space-md) var(--tdm-space-lg);
background: var(--tdm-bg-tertiary);
border-top: 1px solid var(--tdm-modal-border);
flex-shrink: 0;
}
/* ============================================
TIMER COMPONENT - Countdown displays
============================================ */
.tdm-timer {
display: inline-flex;
align-items: center;
gap: var(--tdm-space-xs);
font-family: 'Monaco', 'Consolas', monospace;
font-size: var(--tdm-font-size-sm);
color: var(--tdm-text-primary);
padding: var(--tdm-space-xs) var(--tdm-space-sm);
background: var(--tdm-bg-secondary);
border-radius: var(--tdm-radius-sm);
white-space: nowrap;
}
.tdm-timer--urgent {
color: var(--tdm-color-error);
animation: tdm-pulse 1s ease-in-out infinite;
}
.tdm-timer--warning {
color: var(--tdm-color-warning);
}
.tdm-timer--success {
color: var(--tdm-color-success);
}
.tdm-timer-icon {
font-size: var(--tdm-font-size-xs);
opacity: 0.8;
}
/* ============================================
BADGE COMPONENT - Status indicators
============================================ */
.tdm-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--tdm-space-xs) var(--tdm-space-sm);
font-size: var(--tdm-font-size-xs);
font-weight: 600;
line-height: 1;
color: var(--tdm-text-primary);
background: var(--tdm-bg-secondary);
border-radius: var(--tdm-radius-full);
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Badge Color Variants */
.tdm-badge--success {
background: var(--tdm-color-success);
color: #fff;
}
.tdm-badge--error, .tdm-badge--danger {
background: var(--tdm-color-error);
color: #fff;
}
.tdm-badge--warning {
background: var(--tdm-color-warning);
color: #000;
}
.tdm-badge--info {
background: var(--tdm-color-info);
color: #fff;
}
.tdm-badge--neutral {
background: var(--tdm-text-muted);
color: #fff;
}
/* Badge Size Variants */
.tdm-badge--sm {
padding: 2px var(--tdm-space-xs);
font-size: 9px;
}
.tdm-badge--lg {
padding: var(--tdm-space-sm) var(--tdm-space-md);
font-size: var(--tdm-font-size-sm);
}
/* Badge with dot indicator */
.tdm-badge--dot {
width: 8px;
height: 8px;
padding: 0;
border-radius: 50%;
}
.tdm-badge--dot.tdm-badge--lg {
width: 12px;
height: 12px;
}
/* Online/Status Badge Dock */
.tdm-badge-dock {
display: flex;
align-items: center;
gap: var(--tdm-space-sm);
flex-wrap: wrap;
}
/* ============================================
ANIMATIONS & TRANSITIONS
============================================ */
/* Keyframe Animations */
@keyframes tdm-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
@keyframes tdm-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes tdm-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes tdm-slide-up {
from { transform: translateY(10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes tdm-slide-down {
from { transform: translateY(-10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes tdm-scale-in {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes tdm-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* Animation Utility Classes */
.tdm-fade-in {
animation: tdm-fade-in var(--tdm-transition-normal) forwards;
}
.tdm-fade-out {
animation: tdm-fade-out var(--tdm-transition-normal) forwards;
}
.tdm-slide-up {
animation: tdm-slide-up var(--tdm-transition-normal) forwards;
}
.tdm-slide-down {
animation: tdm-slide-down var(--tdm-transition-normal) forwards;
}
.tdm-scale-in {
animation: tdm-scale-in var(--tdm-transition-normal) forwards;
}
/* Loading Skeleton */
.tdm-skeleton {
background: linear-gradient(
90deg,
var(--tdm-bg-secondary) 25%,
var(--tdm-bg-main) 50%,
var(--tdm-bg-secondary) 75%
);
background-size: 200% 100%;
animation: tdm-shimmer 1.5s ease-in-out infinite;
border-radius: var(--tdm-radius-sm);
}
.tdm-skeleton--text {
height: 14px;
width: 100%;
margin-bottom: var(--tdm-space-xs);
}
.tdm-skeleton--circle {
border-radius: 50%;
}
.tdm-skeleton--button {
height: var(--tdm-btn-height-md);
width: 80px;
}
/* Loading Spinner */
.tdm-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--tdm-bg-main);
border-top-color: var(--tdm-color-info);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.tdm-spinner--sm {
width: 12px;
height: 12px;
border-width: 1.5px;
}
.tdm-spinner--lg {
width: 24px;
height: 24px;
border-width: 3px;
}
/* Transition Utilities */
.tdm-transition-all {
transition: all var(--tdm-transition-normal);
}
.tdm-transition-colors {
transition: color var(--tdm-transition-fast),
background-color var(--tdm-transition-fast),
border-color var(--tdm-transition-fast);
}
.tdm-transition-transform {
transition: transform var(--tdm-transition-fast);
}
.tdm-transition-opacity {
transition: opacity var(--tdm-transition-fast);
}
/* ============================================
RESPONSIVE DESIGN - Mobile/PDA support
============================================ */
/* Responsive Visibility Utilities */
.tdm-hide-mobile { display: block; }
.tdm-show-mobile { display: none; }
.tdm-hide-tablet { display: block; }
.tdm-show-tablet { display: none; }
/* Tablet Breakpoint (768px) */
@media (max-width: 768px) {
.tdm-hide-tablet { display: none !important; }
.tdm-show-tablet { display: block !important; }
/* Larger touch targets */
.tdm-btn {
min-height: 36px;
padding: var(--tdm-space-sm) var(--tdm-space-md);
}
/* Settings tabs stack */
.tdm-settings-tabs {
flex-wrap: wrap;
}
.tdm-settings-tab {
flex: 1 1 auto;
min-width: 80px;
}
/* Modal adjustments */
.tdm-modal--lg,
.tdm-modal--xl {
width: 95vw;
}
/* Toggle switch larger for touch */
.tdm-toggle-switch {
width: 50px;
height: 26px;
}
.tdm-toggle-switch::after {
width: 20px;
height: 20px;
}
.tdm-toggle-switch.active::after {
transform: translateX(24px);
}
}
/* Mobile Breakpoint (480px) */
@media (max-width: 480px) {
.tdm-hide-mobile { display: none !important; }
.tdm-show-mobile { display: block !important; }
/* Minimum touch target 44px (Apple HIG) */
.tdm-btn {
min-height: 44px;
font-size: var(--tdm-font-size-base);
}
/* Full width buttons on mobile */
.tdm-btn--mobile-full {
width: 100%;
}
/* Stack settings content */
.tdm-settings-panel {
padding: 4px;
}
.settings-section {
padding: 6px !important;
}
.settings-header {
font-size: 12px !important;
padding: 4px 6px !important;
}
/* Modal full screen on mobile */
.tdm-modal {
width: 100vw !important;
max-width: 100vw !important;
height: 100vh !important;
max-height: 100vh !important;
border-radius: 0;
}
.tdm-modal-body {
padding: var(--tdm-space-md);
}
/* Column grid single column */
.tdm-column-grid {
grid-template-columns: 1fr;
gap: var(--tdm-space-md);
}
/* Column visibility - 2 columns on mobile, compact */
#column-visibility-rw,
#column-visibility-ml {
grid-template-columns: repeat(2, 1fr) !important;
gap: 6px 8px !important;
}
/* Compact column control on mobile - aligned inputs */
.column-control.tdm-toggle-container {
display: flex !important;
flex-wrap: wrap !important;
align-items: center !important;
gap: 4px !important;
width: 100% !important;
height: auto !important;
}
.column-control .tdm-toggle-label {
flex: 1 !important;
min-width: unset !important;
font-size: 11px !important;
line-height: 24px !important;
}
.column-control .column-width-input {
width: 36px !important;
min-width: 36px !important;
height: 20px !important;
padding: 0 2px !important;
font-size: 11px !important;
line-height: 20px !important;
text-align: center !important;
}
.column-control > div:last-child {
font-size: 11px !important;
line-height: 24px !important;
}
/* Badge adjustments */
.tdm-badge {
padding: var(--tdm-space-sm) var(--tdm-space-md);
font-size: var(--tdm-font-size-sm);
}
}
/* TornPDA specific adjustments */
@media (max-width: 400px) {
.tdm-settings-tabs {
font-size: var(--tdm-font-size-xs);
}
.tdm-settings-tab {
padding: var(--tdm-space-sm) var(--tdm-space-md);
}
/* Single column for very narrow screens */
#column-visibility-rw,
#column-visibility-ml {
grid-template-columns: 1fr !important;
}
}
/* ============================================
CONSOLIDATED STYLES - From scattered blocks
============================================ */
/* Mini Settings Grid (from tdm-mini-style) */
.tdm-grid-war {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--tdm-space-md);
align-items: end;
}
.tdm-mini-lbl {
display: block;
font-size: var(--tdm-font-size-sm);
color: var(--tdm-text-secondary);
margin-bottom: var(--tdm-space-xs);
font-weight: 500;
}
.tdm-mini-checkbox {
font-size: var(--tdm-font-size-base);
color: var(--tdm-text-secondary);
display: flex;
align-items: center;
gap: var(--tdm-space-sm);
}
.tdm-check-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--tdm-space-sm);
align-items: center;
}
.tdm-term-required {
border-color: var(--tdm-color-info) !important;
outline: 1px solid var(--tdm-color-info);
}
.tdm-term-calculated {
border-color: var(--tdm-color-success) !important;
}
.tdm-term-error {
border-color: var(--tdm-color-error) !important;
outline: 1px solid var(--tdm-color-error);
}
.tdm-initial-readonly {
background: var(--tdm-bg-secondary) !important;
color: var(--tdm-text-muted) !important;
}
/* API Key Card States (from tdm-api-key-style) */
#tdm-api-key-card {
transition: border-color var(--tdm-transition-fast), box-shadow var(--tdm-transition-fast);
}
#tdm-api-key-card[data-tone="success"] {
border-color: var(--tdm-color-success);
}
#tdm-api-key-card[data-tone="warning"] {
border-color: var(--tdm-color-warning);
}
#tdm-api-key-card[data-tone="info"] {
border-color: var(--tdm-color-info);
}
#tdm-api-key-card[data-tone="error"] {
border-color: var(--tdm-color-error);
}
.tdm-api-key-highlight {
box-shadow: 0 0 12px 2px rgba(59, 130, 246, 0.5);
}
/* War Attack Faction Colors (from tdm-war-attack-color-css) */
.tdm-war-our {
color: var(--tdm-color-success) !important;
font-weight: 600;
}
.tdm-war-opp {
color: var(--tdm-color-error) !important;
font-weight: 600;
}
.tdm-war-our.t-blue,
.tdm-war-opp.t-blue {
color: inherit;
}
/* userInfoWrap honorWrap*/
.honorWrap___BHau4, .members-cont a a > div { margin-left: 1px !important; margin-right: 1px !important; }
/* status cells */
.table-body .table-cell .status, .members-list .status { line-height: 1.2 !important; padding: 2px 4px !important; margin: 1px !important; }
/* Level Cell Indicators */
.tdm-level-cell { position: relative; }
.tdm-level-indicator { position: absolute; top: 1px; right: 1px; font-size: 7px; opacity: 0.6; line-height: 1; pointer-events: none; font-weight: normal; color: inherit; }
/* Hide FF Scouter V2 columns to avoid redundancy */
.ff-scouter-ff-visible, .ff-scouter-est-visible, .ff-scouter-ff-hidden, .ff-scouter-est-hidden { display: none !important; }
/* Compact last-action tag in status column */
/* Hide legacy FF Scouter last-action row to prevent layout shifts; inline renderer handles display */
li[data-last-action] .last-action-row{display:none !important;}
/* Force 386px for ranked war tables */
.d .f-war-list.war-new .faction-war .tab-menu-cont{min-width:385px;}
/* Inline last-action placed within our subrow */
.tdm-last-action-inline{font-size:10px;line-height:1.1;opacity:.8;margin-top:0;margin-left:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.tdm-fav-heart{color:#94a3b8;font-size:18px;line-height:1;margin-right:6px;cursor:pointer;background:transparent;border:none;padding:0;transition:color .12s ease,transform .12s ease;}
.tdm-fav-heart:hover{color:#bc7100fa;transform:scale(1.05);}
.tdm-fav-heart--active{color:#f00404fa;}
.tdm-favorite-row{background:rgba(250, 204, 21, 0.05)!important;}
.tdm-travel-eta{font-size:10px;line-height:1.1;opacity:.85;margin-left:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.tdm-travel-eta.tdm-travel-conf{font-weight:600;opacity:.95;}
.tdm-travel-eta.tdm-travel-lowconf{opacity:.6;}
/* Travel icon next to last-action */
.tdm-la-online{color:#82C91E;}
.tdm-la-idle{color:#f59e0b;}
.tdm-la-offline{color:#9ca3af;}
/* Layout and icon styles for our dibs/notes subrow */
.dibs-notes-subrow{display:flex;align-items:center;gap:6px;margin-top:2px;max-width:100%;flex-wrap:wrap;overflow:visible;}
/* Note button visuals for ranked-war subrow: match dibs/med-deal sizing and colors via CSS vars.
Note button width intentionally doubled relative to other subrow buttons. */
.dibs-notes-subrow .note-button.btn{
background: transparent;
border: 1px solid var(--tdm-note-inactive) !important;
padding: 1px 1px !important;
min-width: 40px !important;
max-width: 40px !important;
max-height: 30px !important;
font-size: 0.75em !important;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 3px;
box-sizing: border-box;
color: var(--tdm-text-primary) !important;
}
.dibs-notes-subrow .note-button.btn.inactive-note-button:hover{ border-color: var(--tdm-note-inactive-hover) !important; background-color: var(--tdm-note-inactive-hover); color: var(--tdm-note-inactive-text) !important; }
.dibs-notes-subrow .note-button.btn.active-note-button{ border-color: var(--tdm-note-active) !important; background-color: var(--tdm-note-active); color: var(--tdm-note-active-text) !important; font-weight:600; }
.dibs-notes-subrow .note-button.btn.active-note-button:hover{ border-color: var(--tdm-note-active-hover) !important; background-color: var(--tdm-note-active-hover); color: var(--tdm-note-active-text) !important; }
/* Icon sizing uses currentColor so it follows border/text coloring */
.dibs-notes-subrow .note-button.btn svg{ width: 18px; height: 18px; stroke: currentColor; background: none; padding: 0; border-radius: 0; }
/* Keep compact class for logic but let CSS control sizing */
.dibs-notes-subrow .note-button.btn.tdm-note-compact{ min-width: 40px !important; max-width: 40px !important; height: auto !important; }
/* Preview state applied by JS when compact and note exists; CSS handles truncation */
.dibs-notes-subrow .note-button.btn.tdm-note-preview{ white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; max-width: 140px !important; display: inline-block !important; text-align: left !important; padding: 2px 6px !important; }
/* Expanded text-label state (non-compact) - keep single-line visual in subrow
JS may add this class for a populated note; keep the button a single-line
pill and truncate with ellipsis so it doesn't grow taller. */
.dibs-notes-subrow .note-button.btn.tdm-note-expanded{
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
text-align: left !important;
display: inline-block !important;
padding: 2px 6px !important;
max-height: 30px !important;
}
/* Empty-compact placeholder */
.dibs-notes-subrow .note-button.btn.tdm-note-empty{ opacity: 0.95; }
/* --- Main Controls Container --- */
.tdm-dibs-deals-container, .tdm-notes-container {
min-width: 48px;
/* allow the emitted percent-based widths to control layout; avoid fixed max width */
max-width: none;
padding: 2px !important;
display: flex;
gap: 2px;
flex-direction: row;
}
/* Make the notes column fill the space on our own faction page (when dibsDeals container is hidden) */
.f-war-list .table-body > li:has(.tdm-dibs-deals-container[style*="display: none"]) .tdm-notes-container,
.f-war-list .table-body > li:has(.tdm-dibs-deals-container[style*="display: none"]) .notes-cell {
width: 100%;
}
/* Header for the controls column (split into dibs, med-deal, and notes) */
#col-header-member-index { min-width: 3%; max-width: 3%; }
#col-header-dibs-deals, #col-header-notes {
min-width: 48px;
max-width: none;
}
/* --- Individual Cell Styling (Dibs/Notes) --- */
.dibs-cell, .notes-cell {
flex: 1; /* Make cells share space equally */
display: flex;
flex-direction: column;
gap: 1px; /* MODIFIED: Reduced gap for tighter fit */
min-width: 0; /* Important for flex-shrinking */
}
/* Early Leave Highlight */
.tdm-early-leave {
background-color: rgba(255, 255, 0, 0.2) !important;
border: 1px solid rgba(255, 255, 0, 0.5) !important;
}
/* --- Button Styling --- */
.tdm-dibs-deals-container .btn, .tdm-notes-container .btn {
flex: 1; /* Make buttons share vertical space */
min-height: 0; /* Allows buttons to shrink */
padding: 0 4px !important; /* MODIFIED: Removed vertical padding */
font-size: 0.8em !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
justify-content: center;
line-height: 1.1; /* MODIFIED: Reduced line height */
border-radius: 3px; /* Added for consistency */
}
.tdm-dibs-deals-container .med-deal-button {
white-space: normal; /* Allow med deal text to wrap */
}
.tdm-dibs-deals-container .dibs-cell:has(.med-deal-button[style*="display: none"]) .dibs-button {
flex: 2; /* Make dibs button fill the whole cell if no med deal */
height: 100%;
}
/* --- Other Styles --- */
.dibs-spinner { border: 2px solid rgba(255, 255, 255, 0.3); border-top: 2px solid #fff; border-radius: 50%; width: 12px; height: 12px; animation: spin 1s linear infinite; display: inline-block; vertical-align: middle; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.message-box-on-top { position: fixed; top: 10px; left: 50%; transform: translateX(-50%); background-color: rgba(0, 0, 0, 0.8); color: white; border-radius: 4px; z-index: 10000; padding: 5px 10px; font-family: 'Inter', sans-serif; font-size: 14px; display: flex; align-items: center; cursor: pointer; }
.dibs-cell, .notes-cell, #col-header-dibs-deals, #col-header-notes, .notes-container { display: flex; flex-direction: column; justify-content: center; align-items: stretch; gap: 0px; padding: 0px !important; box-sizing: border-box; height: 100% !important; }
.dibs-cell button, .dibs-cell .dibs-button, .dibs-cell .btn-med-deal, #col-header-dibs-deals button, #col-header-notes button, .notes-cell button, .notes-container button, .notes-container .btn {
display: flex !important;
flex: 1 1 auto !important;
width: 100% !important;
height: auto !important;
margin: 0 !important;
box-sizing: border-box !important;
font-size: 0.85em !important;
align-items: center !important;
justify-content: center !important;
}
.dibs-cell .dibs-button:only-child, .dibs-cell-full .btn-med-deal:only-child, .inactive-note-button:only-child, .active-note-button:only-child, .btn-med-deal-inactive:only-child, .notes-cell .btn:only-child, .notes-cell .button:only-child, .notes-container .btn:only-child, .notes-container .button:only-child { flex: 1 1 100% !important; margin: 0 !important; width: 100% !important; height: 100% !important; }
/* Button Colors */
.btn-dibs-inactive { background-color: var(--tdm-dibs-inactive) !important; color: #fff !important; }
.btn-dibs-inactive:hover { background-color: var(--tdm-dibs-inactive-hover) !important; }
/* Dibs Disabled (policy) */
.btn-dibs-disabled { background-color: #5a5a5a !important; color: #cfcfcf !important; cursor: not-allowed !important; border: 1px solid #777 !important; }
.btn-dibs-disabled:hover { background-color: #505050 !important; color: #e0e0e0 !important; }
.btn-dibs-success-you { background-color: var(--tdm-dibs-success) !important; color: #fff !important; }
.btn-dibs-success-you:hover { background-color: var(--tdm-dibs-success-hover) !important; }
.btn-dibs-success-other { background-color: var(--tdm-dibs-other) !important; color: #fff !important; }
.btn-dibs-success-other:hover { background-color: var(--tdm-dibs-other-hover) !important; }
.inactive-note-button, .note-button {
background-color: var(--tdm-note-inactive) !important;
border: 1px solid var(--tdm-note-inactive) !important;
color: var(--tdm-note-inactive-text) !important;
}
.inactive-note-button:hover, .note-button:hover {
background-color: var(--tdm-note-inactive-hover) !important;
border-color: var(--tdm-note-inactive-hover) !important;
color: var(--tdm-note-inactive-text) !important;
}
.active-note-button {
background-color: var(--tdm-note-active) !important;
border: 1px solid var(--tdm-note-active) !important;
color: var(--tdm-note-active-text) !important;
font-weight: 600 !important;
}
.active-note-button:hover {
background-color: var(--tdm-note-active-hover) !important;
border-color: var(--tdm-note-active-hover) !important;
color: var(--tdm-note-active-text) !important;
}
.btn-med-deal-inactive, .btn-med-deal-default { background-color: var(--tdm-med-inactive) !important; color: #fff !important; }
.btn-med-deal-inactive:hover, .btn-med-deal-default:hover { background-color: var(--tdm-med-inactive-hover) !important; }
.btn-med-deal-set { background-color: var(--tdm-med-set) !important; color: #fff !important; }
.btn-med-deal-set:hover { background-color: var(--tdm-med-set-hover) !important; }
.btn-med-deal-mine { background-color: var(--tdm-med-mine) !important; color: #fff !important; }
.btn-med-deal-mine:hover { background-color: var(--tdm-med-mine-hover) !important; }
.req-assist-button { background-color: var(--tdm-assist) !important; color: #fff !important; }
.req-assist-button:hover { background-color: var(--tdm-assist-hover) !important; }
.btn-retal-inactive { background-color: #555555 !important; color: #ccc !important; }
.btn-retal-inactive:hover { background-color: #444444 !important; color: #ddd !important; }
/* Standardized alert button base */
.tdm-alert-btn { min-width: 36px; max-width: 90px; max-height: 30px; font-size: 0.75em; padding: 1px 1px; border-radius: 3px; margin-left: 0; margin-right: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display:flex; align-items:center; justify-content:center; flex: 0 1 auto; }
/* Variants */
.tdm-alert-retal { background-color: #ff5722 !important; color: #fff !important; }
.tdm-alert-retal:hover { background-color: #e64a19 !important; color: #fff !important; }
.tdm-alert-green { background-color: #3cba54 !important; color: #fff !important; } /* Idle/Offline -> Online */
.tdm-alert-yellow { background-color: #f4c20d !important; color: #000 !important; } /* Online -> Idle or misc */
.tdm-alert-grey { background-color: #6b7280b1 !important; color: #fff !important; } /* Offline-related or Okay->Hosp */
.tdm-alert-red { background-color: #ef4444d6 !important; color: #fff !important; } /* Travel->Abroad/Okay, Hosp->Okay */
.tdm-alert-inactive { background-color: #55555587 !important; color: #ccc !important; }
.btn-retal-expired { background-color: #795548 !important; color: #fff !important; }
.btn-retal-expired:hover { background-color: #5d4037 !important; color: #fff !important; }
.dibs-notes-subrow { width: 100% !important; flex-basis:100%; order:100; display:flex; gap:4px; margin-top:1px; margin-bottom:1px; justify-content:flex-start; background:transparent; border:none; box-sizing:border-box; position:relative; max-width:100%; flex-wrap:wrap; overflow:visible; }
.members-list > li, .members-cont > li { flex-wrap: wrap !important; width: inherit !important; }
.dibs-notes-subrow .btn { min-width: 70px; max-width: 70px; max-height: 30px; font-size: 0.75em; padding: 1px 1px; border-radius: 3px; margin-left: 0; margin-right: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Keep retal alert visible on narrow viewports */
.tdm-retal-container{flex:0 0 auto;display:flex;justify-content:flex-end;align-items:center;gap:4px;max-width:100%;box-sizing:border-box;min-width:0;}
.dibs-notes-subrow .retal-btn{min-width:28px !important;max-width:90px !important;padding:0 6px !important;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:0 0 auto;}
/* Settings Panel Styles (Compacted) */
#tdm-settings-popup,
#tdm-settings-popup *,
#tdm-settings-content,
#tdm-settings-content * {
max-width: 100%;
box-sizing: border-box;
}
#tdm-settings-popup {
overflow-x: hidden !important;
}
.settings-section {
margin-bottom: var(--tdm-space-md);
width: 100%;
max-width: 100%;
background-color: var(--tdm-bg-card);
border: 2px solid var(--tdm-bg-secondary);
border-radius: var(--tdm-radius-md);
padding: var(--tdm-space-md);
box-sizing: border-box;
overflow-x: hidden;
}
.settings-section-divided {
padding-top: var(--tdm-space-md);
border-top: 1px solid var(--tdm-bg-secondary);
display: flex;
gap: var(--tdm-space-md);
justify-content: center;
align-items: center;
flex-wrap: wrap;
width: 100%;
}
.settings-header {
font-size: var(--tdm-font-size-base);
font-weight: 700;
margin: 0 0 var(--tdm-space-md) 0;
text-align: center;
color: var(--tdm-text-accent);
background-color: var(--tdm-bg-secondary);
padding: var(--tdm-space-sm) var(--tdm-space-md);
border-radius: var(--tdm-radius-sm);
width: 100%;
user-select: none;
box-sizing: border-box;
}
/* Make subsection headers same style but slightly different color */
.settings-subsection > .settings-header {
font-size: var(--tdm-font-size-base);
font-weight: 700;
color: #7ab3ec !important;
background-color: var(--tdm-bg-tertiary);
}
.settings-subheader {
font-size: var(--tdm-font-size-base);
font-weight: 700;
color: var(--tdm-text-secondary);
}
/* Mini labels as subheadings */
.mini-label, .tdm-mini-lbl {
font-size: var(--tdm-font-size-sm);
font-weight: 700;
}
.settings-button-group { display: flex; flex-wrap: wrap; gap: var(--tdm-space-sm); justify-content: center; width: 100%; }
.settings-input, .settings-input-display {
width: 100%;
padding: var(--tdm-space-sm);
background-color: var(--tdm-bg-secondary);
color: var(--tdm-text-primary);
border: 1px solid #888;
border-radius: var(--tdm-radius-sm);
box-sizing: border-box;
font-size: var(--tdm-font-size-sm);
transition: border-color var(--tdm-transition-fast);
}
.settings-input:focus, .settings-input-display:focus {
outline: none;
border-color: var(--tdm-color-info);
}
/* Apply light grey border to all text inputs globally */
input[type="text"], input[type="number"], input[type="password"], textarea, select {
border: 1px solid #888 !important;
}
input[type="text"]:focus, input[type="number"]:focus, input[type="password"]:focus, textarea:focus, select:focus {
border-color: var(--tdm-color-info) !important;
}
.settings-input-display { padding: var(--tdm-space-md) var(--tdm-space-sm); }
.settings-btn {
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--tdm-color-info);
color: var(--tdm-text-primary);
border: 1px solid var(--tdm-color-info);
padding: var(--tdm-space-sm) var(--tdm-space-md);
font-size: var(--tdm-font-size-sm);
font-weight: 500;
border-radius: var(--tdm-radius-sm);
cursor: pointer;
transition: background-color var(--tdm-transition-fast),
border-color var(--tdm-transition-fast),
transform var(--tdm-transition-fast);
white-space: nowrap;
}
.settings-btn:hover { background-color: #1976D2; border-color: #1976D2; }
.settings-btn:active { transform: scale(0.98); }
.settings-btn-green { background-color: var(--tdm-color-success); border-color: var(--tdm-color-success); }
.settings-btn-green:hover { background-color: #45a049; border-color: #45a049; }
.settings-btn-red { background-color: var(--tdm-color-error); border-color: var(--tdm-color-error); }
.settings-btn-red:hover { background-color: #e53935; border-color: #e53935; }
.settings-btn-blue { background-color: var(--tdm-color-info); border-color: var(--tdm-color-info); }
.settings-btn-blue:hover { background-color: #1976D2; border-color: #1976D2; }
.settings-btn-yellow { background-color: var(--tdm-color-warning); border-color: var(--tdm-color-warning); color: #000; }
.settings-btn-yellow:hover { background-color: #f57c00; border-color: #f57c00; }
.settings-btn-grey, .settings-btn-gray { background-color: var(--tdm-bg-secondary); border-color: var(--tdm-bg-main); }
.settings-btn-grey:hover, .settings-btn-gray:hover { background-color: var(--tdm-bg-main); border-color: #888; }
.war-type-controls { display: flex; gap: var(--tdm-space-md); justify-content: center; margin-bottom: var(--tdm-space-lg); }
.war-type-controls .settings-btn { flex: 1; }
.column-control {
display: grid;
grid-template-columns: 28px 80px 50px 20px;
gap: var(--tdm-space-sm);
align-items: center;
padding: var(--tdm-space-xs) 0;
}
/* Column settings grid container */
#column-visibility-rw,
#column-visibility-ml {
display: grid !important;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)) !important;
gap: var(--tdm-space-sm) var(--tdm-space-md) !important;
justify-items: start;
}
/* Force ALL settings panel toggles to stay small */
.tdm-settings-panel .tdm-toggle-switch,
.settings-section .tdm-toggle-switch {
width: 24px !important;
height: 12px !important;
}
.tdm-settings-panel .tdm-toggle-switch::after,
.settings-section .tdm-toggle-switch::after {
width: 8px !important;
height: 8px !important;
top: 1px !important;
left: 1px !important;
}
.tdm-settings-panel .tdm-toggle-switch.active::after,
.settings-section .tdm-toggle-switch.active::after {
transform: translateX(12px) !important;
}
.column-width-input {
padding: var(--tdm-space-sm);
border-radius: var(--tdm-radius-sm);
border: 1px solid #888;
background: var(--tdm-bg-secondary);
color: var(--tdm-text-primary);
font-size: var(--tdm-font-size-sm);
text-align: center;
box-sizing: border-box;
transition: border-color var(--tdm-transition-fast);
}
.column-width-input:focus {
outline: none;
border-color: var(--tdm-color-info);
}
.column-toggle-btn {
padding: var(--tdm-space-sm) var(--tdm-space-lg);
border: 2px solid var(--tdm-bg-secondary);
border-radius: var(--tdm-radius-md);
background: var(--tdm-bg-card);
color: var(--tdm-text-secondary);
cursor: pointer;
transition: all var(--tdm-transition-normal);
font-size: var(--tdm-font-size-sm);
font-weight: 500;
min-width: 92px;
text-align: center;
line-height: 1.2;
}
.column-toggle-btn.active {
background: var(--tdm-color-success);
border-color: var(--tdm-color-success);
color: var(--tdm-text-primary);
}
.column-toggle-btn.active:hover {
background: #45a049;
border-color: #45a049;
}
.column-toggle-btn.inactive {
background: var(--tdm-color-error);
border-color: var(--tdm-color-error);
color: var(--tdm-text-primary);
}
/* ============================================
SETTINGS TABS - Display / Dibs / Advanced
============================================ */
.tdm-settings-tabs {
display: flex;
border-bottom: 2px solid var(--tdm-bg-secondary);
margin-bottom: var(--tdm-space-md);
gap: var(--tdm-space-xs);
width: 100%;
}
.tdm-settings-tab {
flex: 1;
padding: var(--tdm-space-md) var(--tdm-space-lg);
background: var(--tdm-bg-tertiary);
border: none;
border-bottom: 3px solid transparent;
color: var(--tdm-text-secondary);
font-size: var(--tdm-font-size-sm);
font-weight: 600;
cursor: pointer;
text-align: center;
transition: all var(--tdm-transition-fast);
border-radius: var(--tdm-radius-sm) var(--tdm-radius-sm) 0 0;
}
.tdm-settings-tab:hover {
background: var(--tdm-bg-secondary);
color: var(--tdm-text-primary);
}
.tdm-settings-tab--active {
background: var(--tdm-bg-secondary);
color: var(--tdm-text-accent);
border-bottom-color: var(--tdm-color-info);
}
.tdm-settings-panel {
display: none;
padding: var(--tdm-space-md);
width: 100%;
max-width: 100%;
box-sizing: border-box;
overflow-x: hidden;
}
.tdm-settings-panel--active {
display: block;
max-height: 70vh;
overflow-y: auto;
}
/* ============================================
TOGGLE SWITCH - Modern on/off switches
============================================ */
.tdm-toggle-switch {
position: relative;
width: 28px;
height: 14px;
background: var(--tdm-bg-secondary);
border: 1px solid #555;
border-radius: var(--tdm-radius-full);
cursor: pointer;
transition: background var(--tdm-transition-fast), border-color var(--tdm-transition-fast);
flex-shrink: 0;
}
.tdm-toggle-switch::after {
content: '';
position: absolute;
width: 10px;
height: 10px;
background: var(--tdm-text-secondary);
border-radius: 50%;
top: 1px;
left: 1px;
transition: transform var(--tdm-transition-fast), background var(--tdm-transition-fast);
}
.tdm-toggle-switch:hover::after {
background: var(--tdm-text-primary);
}
.tdm-toggle-switch.active {
background: var(--tdm-color-success);
border-color: var(--tdm-color-success);
}
.tdm-toggle-switch.active::after {
transform: translateX(14px);
background: var(--tdm-text-primary);
}
.tdm-toggle-switch.inactive {
background: var(--tdm-color-error);
border-color: var(--tdm-color-error);
}
.tdm-toggle-switch.inactive::after {
background: var(--tdm-text-primary);
}
/* Toggle with label container */
.tdm-toggle-container {
display: flex;
align-items: center;
gap: var(--tdm-space-md);
padding: var(--tdm-space-sm) 0;
}
.tdm-toggle-label {
font-size: var(--tdm-font-size-sm);
color: var(--tdm-text-primary);
flex: 1;
min-width: 80px;
}
/* Column control grid layout for alignment */
.tdm-column-grid {
display: grid;
grid-template-columns: 1fr auto auto;
gap: var(--tdm-space-sm) var(--tdm-space-md);
align-items: center;
width: 100%;
}
.tdm-column-grid .tdm-toggle-label {
text-align: left;
}
.tdm-column-grid .column-width-input {
width: 60px;
text-align: center;
}
/* Number input fix - remove up/down arrows from ALL number inputs */
input[type="number"] {
text-align: center;
-moz-appearance: textfield;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Collapsible sections - now always expanded with 2px borders */
.collapsible {
background-color: var(--tdm-bg-card);
border: 2px solid var(--tdm-bg-secondary);
border-radius: var(--tdm-radius-md);
padding: var(--tdm-space-md);
margin-bottom: var(--tdm-space-md);
box-sizing: border-box;
}
.collapsible .collapsible-header { cursor: default; position: relative; }
.collapsible .chevron { display: none; } /* Hide chevrons since we're not collapsing */
.collapsible .collapsible-content { display: block !important; } /* Always show content */
.collapsible.collapsed .collapsible-content { display: block !important; } /* Override collapsed state */
.collapsible.settings-section-divided { flex-direction: column; align-items: stretch; }
.collapsible.settings-section-divided > .collapsible-header,
.collapsible.settings-section-divided > .collapsible-content { width: 100%; }
/* Text halo for timers */
.tdm-text-halo, .tdm-text-halo a {
text-shadow:
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000,
1px 1px 0 #000,
0 0 3px #000;
}
.tdm-halo-link { color: inherit; text-decoration: underline; cursor: pointer; }
/* Thin red border highlight for score increases */
.tdm-score-bump { outline: 2px solid rgba(239,68,68,0.9) !important; outline-offset: -2px; transition: outline-color 0.6s ease; }
.tdm-score-bump.fade { outline-color: rgba(239,68,68,0.0) !important; }
/* When the red burst expires it transitions to an orange persistent highlight.
The orange stay lasts longer (controlled in JS) and will be cleared on status change/hospital. */
.tdm-score-bump-orange { outline: 2px solid rgba(255,165,0,0.95) !important; outline-offset: -2px; transition: outline-color 0.6s ease; }
/* Very short click feedback for dibs buttons */
.tdm-dibs-clicked { box-shadow: 0 0 10px 4px rgba(255,180,80,0.9) !important; transition: box-shadow 160ms ease; }
`
});
document.head.appendChild(styleTag);
// Ensure delegated click visual feedback for dibs buttons (short glow)
ensureDibsClickListener();
},
updateColumnVisibilityStyles: () => {
let styleTag = document.getElementById('dibs-column-visibility-dynamic-style');
if (!styleTag) {
styleTag = utils.createElement('style', { id: 'dibs-column-visibility-dynamic-style' });
document.head.appendChild(styleTag);
}
let css = '';
// Ensure our dibs/notes headers & cells show torn vertical dividers where appropriate
try {
const hdrSelectors = ['.tab-menu-cont .members-list #col-header-dibs-deals', '.tab-menu-cont .members-list #col-header-notes', '.f-war-list.members-list #col-header-dibs-deals', '.f-war-list.members-list #col-header-notes', '#faction-war #col-header-dibs', '#faction-war #col-header-notes', '.f-war-list .table-header #col-header-dibs', '.f-war-list .table-header #col-header-notes'];
document.querySelectorAll(hdrSelectors.join(', ')).forEach(el => { try { el.classList.add('torn-divider','divider-vertical'); } catch(_){} });
const cellSelectors = ['.tab-menu-cont .members-list .tdm-dibs-deals-container', '.tab-menu-cont .members-list .tdm-notes-container', '.f-war-list.members-list .tdm-dibs-deals-container', '.f-war-list.members-list .tdm-notes-container', '.f-war-list .tdm-dibs-deals-container', '.f-war-list .tdm-notes-container'];
document.querySelectorAll(cellSelectors.join(', ')).forEach(el => { try { el.classList.add('torn-divider','divider-vertical'); } catch(_){} });
} catch(_) { /* noop */ }
// Update column visibility for both tables using table-specific keys
const vis = storage.get('columnVisibility', config.DEFAULT_COLUMN_VISIBILITY);
// Members List selectors
const membersSelectors = {
// Members list: prefer f-war-list.members-list (explicit members-list variant)
lvl: [
'.f-war-list.members-list .table-header .lvl.torn-divider.divider-vertical', '.f-war-list.members-list .table-body .lvl',
'.f-war-list.members-list .lvl', '.f-war-list.members-list .table-body .lvl'
],
memberIcons: [
'.f-war-list.members-list .table-header .member-icons.torn-divider.divider-vertical', '.f-war-list.members-list .table-body .member-icons'
],
position: ['.f-war-list.members-list .table-header .position', '.f-war-list.members-list .table-body .position', '.f-war-list.members-list .table-header .position', '.f-war-list.members-list .table-body .position'],
days: ['.f-war-list.members-list .table-header .days', '.f-war-list.members-list .table-body .days', '.f-war-list.members-list .table-header .days', '.f-war-list.members-list .table-body .days'],
factionIcon: ['.f-war-list.members-list .factionWrap___GhZMa'],
// Target header li and direct per-row table-cell elements to ensure header/row widths match
dibsDeals: [
'.f-war-list.members-list .table-header #col-header-dibs-deals',
'.f-war-list.members-list .table-header > li#col-header-dibs-deals',
'.f-war-list.members-list .table-body > li > .tdm-dibs-deals-container',
'.f-war-list.members-list .table-body > li .tdm-dibs-deals-container',
'.f-war-list.members-list .table-body .table-cell .tdm-dibs-deals-container',
'.f-war-list.members-list .table-body .dibs-cell',
'.f-war-list.members-list .table-body .med-deal-button'
],
memberIndex: ['.f-war-list.members-list .table-header #col-header-member-index', '.f-war-list.members-list .table-body .tt-member-index', '.f-war-list.members-list .table-header #col-header-member-index', '.f-war-list.members-list .table-body .tt-member-index'],
member: ['.f-war-list.members-list .table-header .member', '.f-war-list.members-list .table-body .member', '.f-war-list.members-list .table-header .member', '.f-war-list.members-list .table-body .member'],
statusHeader: ['.f-war-list.members-list .table-header .status'],
statusBody: ['.f-war-list.members-list .table-body .status'],
notes: [
'.f-war-list.members-list .table-header #col-header-notes',
'.f-war-list.members-list .table-header > li#col-header-notes',
'.f-war-list.members-list .table-body > li > .tdm-notes-container',
'.f-war-list.members-list .table-body > li .tdm-notes-container',
'.f-war-list.members-list .table-body .notes-cell'
]
};
// Ranked War selectors
const rankedWarSelectors = {
lvl: ['.tab-menu-cont .level', '.white-grad.c-pointer .level'],
members: ['.tab-menu-cont .member','.white-grad.c-pointer .member'],
points: ['.tab-menu-cont .points','.white-grad.c-pointer .points'],
status: ['.tab-menu-cont .status','.white-grad.c-pointer .status'],
attack: ['.tab-menu-cont .attack', '.white-grad.c-pointer .attack'],
factionIcon: ['.tab-menu-cont .factionWrap___GhZMa']
};
// Hide columns for Members List
for (const colName in membersSelectors) {
// treat header/body variants as the same logical column for visibility settings
const baseCol = String(colName).replace(/(?:Header|Body)$/, '');
if (vis.membersList?.[baseCol] === false) {
const selectors = membersSelectors[colName];
if (selectors) css += `${selectors.join(', ')} { display: none !important; }\n`;
}
}
// Special: hide dibsDeals entirely on our own faction page (members list)
try {
if (state.page?.isMyFactionPage) {
const ds = membersSelectors.dibsDeals || [];
if (ds.length) css += `${ds.join(', ')} { display: none !important; }\n`;
}
} catch(_) {}
// Special: if both dibsDeals and notes are hidden, remove combined header cell if present
try {
const dibsHidden = vis.membersList?.dibsDeals === false;
const notesHidden = vis.membersList?.notes === false;
if (dibsHidden && notesHidden) {
// hide combined dibs/notes header + controls for members-list variants only
css += `.tab-menu-cont .members-list .table-header #col-header-dibs-notes, .f-war-list.members-list .table-header #col-header-dibs-notes, .tab-menu-cont .members-list .table-body .tdm-controls-container, .f-war-list.members-list .table-body .tdm-controls-container, .tab-menu-cont .members-list .table-body .dibs-cell, .f-war-list.members-list .table-body .dibs-cell, .tab-menu-cont .members-list .table-body .notes-cell, .f-war-list.members-list .table-body .notes-cell { display: none !important; }\n`;
} else if (notesHidden) {
// If notes hidden but our members rows have only notes (no dibs/med deal), collapse empty space by shrinking container
css += `.tab-menu-cont .members-list .table-body .tdm-controls-container:empty, .f-war-list.members-list .table-body .tdm-controls-container:empty, .tab-menu-cont .members-list .table-body .tdm-controls-container:not(:has(.dibs-cell,.med-deal-button)):not(:has(.note-button)), .f-war-list.members-list .table-body .tdm-controls-container:not(:has(.dibs-cell,.med-deal-button)):not(:has(.note-button)) { display:none !important; }\n`;
}
} catch(_) { /* noop */ }
// Special: if dibs, medDeals and notes are ALL hidden, remove the whole controls container
try {
const dibsDealsHidden = vis.membersList?.dibsDeals === false;
const notesHidden = vis.membersList?.notes === false;
if (dibsDealsHidden && notesHidden) {
css += `.tab-menu-cont .members-list .table-header #col-header-dibs-deals, .f-war-list.members-list .table-header #col-header-dibs-deals, .tab-menu-cont .members-list .table-header > li#col-header-dibs-deals, .f-war-list.members-list .table-header > li#col-header-dibs-deals, .tab-menu-cont .members-list .table-header #col-header-notes, .f-war-list.members-list .table-header #col-header-notes, .tab-menu-cont .members-list .table-header > li#col-header-notes, .f-war-list.members-list .table-header > li#col-header-notes, .tab-menu-cont .members-list .table-body > li > .tdm-dibs-deals-container, .f-war-list.members-list .table-body > li > .tdm-dibs-deals-container, .tab-menu-cont .members-list .table-body > li .tdm-dibs-deals-container, .f-war-list.members-list .table-body > li .tdm-dibs-deals-container, .tab-menu-cont .members-list .table-body .tdm-dibs-deals-container, .f-war-list.members-list .table-body .tdm-dibs-deals-container, .tab-menu-cont .members-list .table-body > li > .tdm-notes-container, .f-war-list.members-list .table-body > li > .tdm-notes-container, .tab-menu-cont .members-list .table-body > li .tdm-notes-container, .f-war-list.members-list .table-body > li .tdm-notes-container, .tab-menu-cont .members-list .table-body .tdm-notes-container, .f-war-list.members-list .table-body .tdm-notes-container, .tab-menu-cont .members-list .table-body .dibs-cell, .f-war-list.members-list .table-body .dibs-cell, .tab-menu-cont .members-list .table-body .notes-cell, .f-war-list.members-list .table-body .notes-cell, .tab-menu-cont .members-list .table-body .med-deal-button, .f-war-list.members-list .table-body .med-deal-button { display: none !important; }`;
} else if (notesHidden) {
// If notes hidden but our faction rows only have notes (no dibs/med deal), collapse empty space by shrinking containers
css += `.tab-menu-cont .members-list .table-body > li > .tdm-notes-container:empty, .f-war-list.members-list .table-body > li > .tdm-notes-container:empty, .tab-menu-cont .members-list .table-body > li .tdm-notes-container:empty, .f-war-list.members-list .table-body > li .tdm-notes-container:empty, .tab-menu-cont .members-list .table-body .tdm-notes-container:empty, .f-war-list.members-list .table-body .tdm-notes-container:empty, .tab-menu-cont .members-list .table-body > li > .tdm-notes-container:not(:has(.note-button)), .f-war-list.members-list .table-body > li > .tdm-notes-container:not(:has(.note-button)) { display:none !important; }`;
}
} catch(_) { /* noop */ }
try {
const widths = storage.get('columnWidths', config.DEFAULT_COLUMN_WIDTHS) || {};
const mw = widths.membersList || {};
for (const colName in membersSelectors) {
// treat header/body variants as the same logical column when reading configured widths
const baseCol = String(colName).replace(/(?:Header|Body)$/, '');
// Special handling: if the memberIndex rows are not present/visible on small devices,
// don't apply explicit widths for that column — avoids header-only width when rows collapse.
if (baseCol === 'memberIndex') {
try {
const selArr = (membersSelectors[colName] || []).filter(s => s.includes('.table-body') || s.includes('.tt-member-index'));
let foundVisible = false;
for (const s of selArr) {
const nodes = Array.from(document.querySelectorAll(s));
if (nodes.some(n => n && n.offsetParent !== null && window.getComputedStyle(n).display !== 'none')) { foundVisible = true; break; }
}
if (!foundVisible) continue; // skip width application when rows are not visible (small screens)
} catch(_) { /* ignore and continue with width */ }
}
// Don't apply explicit widths to icon-only columns like factionIcon
if (baseCol === 'factionIcon') continue;
const w = mw[baseCol];
if (typeof w === 'number' && w > 0) {
const selectors = membersSelectors[colName] || [];
// For the members list we want percent widths applied to the outer container/header
// but inner cells (.dibs-cell / .notes-cell) should be 100% so inner contents fill the outer container.
const outerSelectors = selectors.filter(s => !s.includes('.dibs-cell') && !s.includes('.notes-cell') && !s.includes('.med-deal-button'));
const innerSelectors = selectors.filter(s => s.includes('.dibs-cell') || s.includes('.notes-cell') || s.includes('.med-deal-button'));
if (outerSelectors.length) css += `${outerSelectors.join(', ')} { flex: 0 0 ${w}% !important; width: ${w}% !important; max-width: ${w}% !important; }`;
if (innerSelectors.length) css += `${innerSelectors.join(', ')} { flex: 1 1 auto !important; width: 100% !important; max-width: none !important; }`;
}
}
} catch(_) {}
// Hide columns for Ranked War
for (const colName in rankedWarSelectors) {
if (vis.rankedWar?.[colName] === false) {
const selectors = rankedWarSelectors[colName];
if (selectors) css += `${selectors.join(', ')} { display: none !important; }\n`;
}
}
// Apply explicit widths (percent) for Ranked War cols if configured
try {
const widths = storage.get('columnWidths', config.DEFAULT_COLUMN_WIDTHS) || {};
const rw = widths.rankedWar || {};
for (const colName in rankedWarSelectors) {
// Don't apply explicit widths to icon-only columns like factionIcon
if (colName === 'factionIcon') continue;
const w = rw[colName];
if (typeof w === 'number' && w > 0) {
const selectors = rankedWarSelectors[colName] || [];
if (selectors.length) {
// Some ranked-war cells contain inner containers (icons, dibs/notes controls).
// Apply percent widths to the outer/header containers, and force inner cells to fill 100%.
const outerSelectors = selectors.filter(s => !s.includes('.dibs-cell') && !s.includes('.notes-cell') && !s.includes('.med-deal-button') && !s.includes('.member-icons') && !s.includes('membersCol'));
const innerSelectors = selectors.filter(s => s.includes('.dibs-cell') || s.includes('.notes-cell') || s.includes('.med-deal-button') || s.includes('.member-icons') || s.includes('membersCol'));
if (outerSelectors.length) css += `${outerSelectors.join(', ')} { flex: 0 0 ${w}% !important; width: ${w}% !important; max-width: ${w}% !important; }
`;
if (innerSelectors.length) css += `${innerSelectors.join(', ')} { flex: 1 1 auto !important; width: 100% !important; max-width: none !important; }
`;
}
}
}
} catch(_) {}
// Ensure Status column header is vertically centered (fix alignment introduced by DOM mutations)
try {
css += `
/* TDM: center the Status header TEXT only, keep sort icon placement intact */
.f-war-list.members-list .table-header .status,
.tab-menu-cont .members-list .table-header .status,
#react-root-faction-info .f-war-list.members-list .table-header .status {
display: block !important;
}
.f-war-list.members-list .table-header .status > *:not([class*="sortIcon"]),
.tab-menu-cont .members-list .table-header .status > *:not([class*="sortIcon"]),
#react-root-faction-info .f-war-list.members-list .table-header .status > *:not([class*="sortIcon"]) {
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
/* TDM: Notes button — single-line with ellipsis, top-left aligned visual start, avoid vertical clipping */
.f-war-list.members-list .tdm-notes-container .note-button,
.tab-menu-cont .members-list .tdm-notes-container .note-button,
#react-root-faction-info .f-war-list.members-list .tdm-notes-container .note-button {
display: block !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
text-align: left !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
line-height: 1.1 !important;
height: auto !important;
min-height: 0 !important;
max-height: none !important;
}
`;
} catch(_) {}
styleTag.textContent = css;
},
showCurrentWarAttacksModal: async function(warId) {
const { modal, controls, tableWrap, footer, setLoading, clearLoading, setError } = ui.createReportModal({ id: 'current-war-attacks-modal', title: `War Attacks (ID: ${warId})` });
setLoading('Loading war attacks...');
try {
let allAttacks = await api.getRankedWarAttacksSmart(warId, state.user.factionId, { onDemand: true }) || [];
if ((!allAttacks || allAttacks.length === 0) && state.rankedWarAttacksCache?.[warId]?.attacks?.length) {
tdmlogger('warn', `[WarAttacks] Smart fetch empty, using cached fallback: ${state.rankedWarAttacksCache[warId].attacks.length}`);
allAttacks = state.rankedWarAttacksCache[warId].attacks;
}
const normalizeAttack = (a) => {
if (!a || typeof a !== 'object') return null;
const attackerId = a.attacker?.id ?? a.attackerId ?? a.attacker_id ?? a.attacker;
const defenderId = a.defender?.id ?? a.defenderId ?? a.defender_id ?? a.defender;
const attackerName = a.attacker?.name ?? a.attackerName ?? a.attacker_name ?? '';
const defenderName = a.defender?.name ?? a.defenderName ?? a.defender_name ?? '';
const attackerFactionId = a.attacker?.faction?.id ?? a.attackerFactionId ?? a.attacker_faction ?? a.attackerFaction ?? null;
const defenderFactionId = a.defender?.faction?.id ?? a.defenderFactionId ?? a.defender_faction ?? a.defenderFaction ?? null;
const ended = Number(a.ended || a.end || a.finish || a.timestamp || 0) || 0;
const started = Number(a.started || a.start || a.begin || ended || 0) || ended;
// Expose common numeric/boolean fields used by backend: respect_gain, respect_gain_no_bonus, respect_loss, chain, time_since_last_attack, chain_saver, modifiers
const respect_gain = Number(a.respect_gain || a.respectGain || a.respect_gain_no_bonus || 0) || 0;
const respect_gain_no_bonus = Number(a.respect_gain_no_bonus || a.respectGainNoBonus || 0) || 0;
const respect_loss = Number(a.respect_loss || a.respectLoss || 0) || 0;
const chain = Number(a.modifiers?.chain || a.chain || 1) || 1;
const chain_gap = Number(a.time_since_last_attack || a.chain_gap || a.chainGap || 0) || 0;
const chain_saver = !!(a.chain_saver || a.chainSaver || a.chain_saver_flag);
const outside = Number(a?.modifiers?.war ?? a.outside ?? 0) !== 2;
const overseas = (a.modifiers?.overseas || a.overseas || 1) > 1;
const retaliation = (a.modifiers?.retaliation || a.retaliation || 1) > 1;
const attackCode = a.code || a.attackCode || a.attack_id || a.attackId || '';
return {
// keep original payload for any other fields
__raw: a,
attackId: a.attackId || a.id || a.attack_id || a.attackSeq || a.seq || attackCode || null,
attacker: { id: attackerId, name: attackerName, faction: { id: attackerFactionId } },
defender: { id: defenderId, name: defenderName, faction: { id: defenderFactionId } },
ended, started,
respect_gain, respect_gain_no_bonus, respect_loss,
chain, chain_gap, chain_saver,
outside, overseas, retaliation,
code: attackCode,
result: a.result || a.outcome || a.type || '',
modifiers: a.modifiers || {},
// preserve arbitrary backend-provided fields in top-level for dynamic columns
...a
};
};
allAttacks = allAttacks.map(normalizeAttack).filter(a => a && a.attacker?.id && a.defender?.id);
if (!allAttacks.length) {
clearLoading();
controls.appendChild(utils.createElement('div', { style: { color: '#fff' }, textContent: 'No attacks found (final file maybe not published or normalization empty).' }));
tdmlogger('warn', `[WarAttacks] No normalized attacks. Raw cache entry: ${state.rankedWarAttacksCache?.[warId]}`);
return;
}
// Determine opponent faction id for color-coding
const ourFactionId = String(state.user?.factionId || '');
let opponentFactionId = '';
try {
const warObj = utils.getWarById(warId) || state.lastRankWar || {};
const candidates = [];
if (warObj.faction1) candidates.push(warObj.faction1);
if (warObj.faction2) candidates.push(warObj.faction2);
if (warObj.opponent) candidates.push(warObj.opponent);
if (warObj.enemy) candidates.push(warObj.enemy);
// Each candidate may be object or id
for (const c of candidates) {
const fid = String(c?.faction_id || c?.id || c?.factionId || (typeof c === 'number' ? c : ''));
if (fid && fid !== ourFactionId) { opponentFactionId = fid; break; }
}
// Fallback: scan attacks for a faction id not ours
if (!opponentFactionId) {
for (const a of allAttacks) {
const fidA = String(a.attacker?.faction?.id || '');
const fidD = String(a.defender?.faction?.id || '');
if (fidA && fidA !== ourFactionId) { opponentFactionId = fidA; break; }
if (fidD && fidD !== ourFactionId) { opponentFactionId = fidD; break; }
}
}
// Dev button for forcing backend war summary rebuild (when debug enabled)
if (state.debug?.apiLogs && !document.getElementById('force-full-war-summary-rebuild-btn')) {
const rebuildBtn = utils.createElement('button', {
id: 'force-full-war-summary-rebuild-btn',
className: 'settings-btn settings-btn-yellow',
textContent: 'Force Full Summary Rebuild',
title: 'Trigger backend function triggerRankedWarSummaryRebuild to rebuild summary.'
});
rebuildBtn.addEventListener('click', async () => {
const selWarId = rankedWarSelect.value || state.lastRankWar?.id;
if (!selWarId) { ui.showMessageBox('Select a war first.', 'error'); return; }
const oldTxt = rebuildBtn.textContent; rebuildBtn.disabled = true; rebuildBtn.textContent = 'Rebuilding...';
try {
const ok = await api.forceFullWarSummaryRebuild(selWarId, state.user.factionId);
if (ok) {
ui.showTransientMessage('Summary rebuild triggered.', { type: 'success' });
setTimeout(async ()=>{ try { await api.fetchWarManifestV2(selWarId, state.user.factionId, { force: true }); await api.assembleAttacksFromV2(selWarId, state.user.factionId, { forceWindowBootstrap: false }); } catch(_) {} }, 2500);
} else {
ui.showTransientMessage('Rebuild callable unavailable.', { type: 'error' });
}
} catch(e){ ui.showMessageBox('Rebuild failed: '+(e.message||e), 'error'); }
finally { rebuildBtn.disabled=false; rebuildBtn.textContent=oldTxt; }
});
viewWarAttacksBtn.parentElement?.insertBefore(rebuildBtn, viewWarAttacksBtn.nextSibling);
}
} catch(_) { /* noop */ }
// Inject styles once for faction highlighting
try {
if (!document.getElementById('tdm-war-attack-color-css')) {
const st = document.createElement('style');
st.id = 'tdm-war-attack-color-css';
st.textContent = `.tdm-war-our{color:#4caf50 !important;font-weight:600;} .tdm-war-opp{color:#ff5252 !important;font-weight:600;} .tdm-war-our.t-blue,.tdm-war-opp.t-blue{color:inherit;}`; // base t-blue overridden by explicit color due to !important
document.head.appendChild(st);
}
} catch(_) { /* noop */ }
// Diagnostics removed (was used for snapshot/window debugging)
const factionClass = (fid) => {
const s = String(fid||'');
if (s && s === ourFactionId) return 'tdm-war-our';
if (s && s === opponentFactionId) return 'tdm-war-opp';
return '';
};
// Pagination & sorting state
let currentPage = 1, attacksPerPage = 50, sortKey = 'attackTime', sortAsc = false;
// Dynamic filter state: array of { fieldKey, op, value }
let dynamicFilters = [];
const uniqueAttackers = [...new Set(allAttacks.map(a => a.attacker?.name).filter(Boolean))].sort();
const uniqueDefenders = [...new Set(allAttacks.map(a => a.defender?.name).filter(Boolean))].sort();
const controlsBar = utils.createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '10px', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' } });
const left = utils.createElement('div'); const mid = utils.createElement('div');
const perPageInput = utils.createElement('input', { id: 'attacks-per-page', type: 'number', value: String(attacksPerPage), min: '1', max: '5000', className: 'settings-input', style: { width: '60px' } });
// Filter definitions: key, label, accessor, optional choices for dropdown
const filterDefs = [
{ key: 'attacker.name', label: 'Attacker', accessor: (a) => String(a.attacker?.name || ''), choices: uniqueAttackers },
{ key: 'attacker.id', label: 'AttackerId', accessor: (a) => a.attacker?.id || '' },
{ key: 'defender.name', label: 'Defender', accessor: (a) => String(a.defender?.name || ''), choices: uniqueDefenders },
{ key: 'defender.id', label: 'DefenderId', accessor: (a) => a.defender?.id || '' },
{ key: 'result', label: 'Result', accessor: (a) => String(a.result || '') },
{ key: 'direction', label: 'Direction', accessor: (a) => String(a.direction || '') },
// Treat an attack as ranked if explicit flag is set OR modifiers.war==2 (Torn war modifier)
{ key: 'is_ranked_war', label: 'Ranked', accessor: (a) => !!(a.is_ranked_war || a.isRankedWar || Number(a?.modifiers?.war || 0) === 2) },
{ key: 'is_stealthed', label: 'Stealthed', accessor: (a) => !!(a.is_stealthed || a.isStealthed) },
{ key: 'chain', label: 'Chain', accessor: (a) => (typeof a.chain !== 'undefined' ? a.chain : (a.modifiers?.chain || '')) },
{ key: 'respect_gain', label: 'RespectGain', accessor: (a) => Number(a.respect_gain || 0) },
{ key: 'code', label: 'LogCode', accessor: (a) => String(a.code || '') },
{ key: 'attackTime', label: 'AttackTime', accessor: (a) => Number(a.ended || a.started || 0) }
];
const makeFieldSelect = (selectedKey) => {
const sel = utils.createElement('select', { className: 'settings-input', style: { width: '160px' } });
sel.appendChild(utils.createElement('option', { value: '', textContent: '-- Field --' }));
filterDefs.forEach(def => sel.appendChild(utils.createElement('option', { value: def.key, textContent: def.label, selected: def.key === selectedKey })));
// Ensure the select's displayed value matches the requested selectedKey (some environments need explicit assignment)
try { sel.value = selectedKey || ''; } catch(_) {}
return sel;
};
const filterRowsContainer = utils.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px', minWidth: '320px' } });
const addFilterBtn = utils.createElement('button', { className: 'settings-btn', textContent: 'Add filter', title: 'Add a filter condition' });
controlsBar.appendChild(left); controlsBar.appendChild(mid); controls.appendChild(controlsBar);
left.appendChild(utils.createElement('label', { htmlFor: 'attacks-per-page', textContent: 'Records per page: ' })); left.appendChild(perPageInput);
mid.appendChild(filterRowsContainer); mid.appendChild(addFilterBtn);
const operators = ['=','!=','>','<','>=','<=','contains'];
const booleanFields = ['is_ranked_war', 'is_stealthed'];
const evaluateFilter = (attack, f) => {
try {
const def = filterDefs.find(d => d.key === f.fieldKey);
if (!def) return true;
let lv = def.accessor(attack);
const rv = f.value;
const op = f.op;
if (op === 'contains') {
return String(lv || '').toLowerCase().includes(String(rv || '').toLowerCase());
}
// Numeric comparisons
if (['>','<','>=','<='].includes(op)) {
const Ln = Number(lv || 0);
const Rn = Number(rv || 0);
if (op === '>') return Ln > Rn;
if (op === '<') return Ln < Rn;
if (op === '>=') return Ln >= Rn;
if (op === '<=') return Ln <= Rn;
}
// Equality (handle booleans specially)
if (typeof lv === 'boolean') {
const rvb = (String(rv).toLowerCase() === 'true' || String(rv).toLowerCase() === 'yes');
return op === '=' ? lv === rvb : lv !== rvb;
}
// Default equality string compare (case-insensitive)
if (op === '=' || op === '!=') {
const res = String(lv == null ? '' : String(lv)).toLowerCase() === String(rv == null ? '' : String(rv)).toLowerCase();
return op === '=' ? res : !res;
}
} catch(_) { return true; }
return true;
};
const renderFilters = () => {
filterRowsContainer.innerHTML = '';
dynamicFilters.forEach((f) => {
const idx = dynamicFilters.indexOf(f);
const row = utils.createElement('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } });
const fieldSel = makeFieldSelect(f.fieldKey || '');
const opSel = utils.createElement('select', { className: 'settings-input', style: { width: '90px' } });
operators.forEach(op => opSel.appendChild(utils.createElement('option', { value: op, textContent: op, selected: op === f.op })));
try { opSel.value = f.op || '='; } catch(_) {}
let valInput;
const def = filterDefs.find(d => d.key === f.fieldKey);
if (def && Array.isArray(def.choices) && def.choices.length) {
valInput = utils.createElement('select', { className: 'settings-input', style: { width: '160px' } });
valInput.appendChild(utils.createElement('option', { value: '', textContent: '-- any --' }));
def.choices.forEach(c => valInput.appendChild(utils.createElement('option', { value: c, textContent: c, selected: String(c) === String(f.value) })));
} else if (def && booleanFields.includes(def.key)) {
// Boolean fields: provide a Yes/No dropdown
valInput = utils.createElement('select', { className: 'settings-input', style: { width: '160px' } });
valInput.appendChild(utils.createElement('option', { value: '', textContent: '-- any --' }));
valInput.appendChild(utils.createElement('option', { value: 'Yes', textContent: 'Yes', selected: String(f.value) === 'Yes' }));
valInput.appendChild(utils.createElement('option', { value: 'No', textContent: 'No', selected: String(f.value) === 'No' }));
} else {
valInput = utils.createElement('input', { className: 'settings-input', type: 'text', value: f.value || '', style: { width: '160px' } });
}
const removeBtn = utils.createElement('button', { className: 'settings-btn settings-btn-red', textContent: 'Remove' });
// Wire events using current index lookup
fieldSel.addEventListener('change', (e) => {
const i = dynamicFilters.indexOf(f);
if (i === -1) return;
const newKey = e.target.value;
const oldVal = dynamicFilters[i].value;
dynamicFilters[i].fieldKey = newKey;
// Coerce/clear stored value when switching to a field with a different choice set or boolean type
const newDef = filterDefs.find(d => d.key === newKey);
if (newDef) {
if (Array.isArray(newDef.choices) && newDef.choices.length) {
if (!newDef.choices.includes(oldVal)) dynamicFilters[i].value = '';
} else if (booleanFields.includes(newDef.key)) {
// Normalize oldVal to Yes/No if possible
const nv = String(oldVal || '').toLowerCase();
if (nv === 'true' || nv === 'yes' || nv === '1') dynamicFilters[i].value = 'Yes';
else if (nv === 'false' || nv === 'no' || nv === '0') dynamicFilters[i].value = 'No';
else dynamicFilters[i].value = '';
}
}
// Re-render to refresh value control type
renderFilters();
currentPage = 1; renderAll();
});
opSel.addEventListener('change', (e) => { const i = dynamicFilters.indexOf(f); if (i === -1) return; dynamicFilters[i].op = e.target.value; currentPage = 1; renderAll(); });
valInput.addEventListener('change', (e) => { const i = dynamicFilters.indexOf(f); if (i === -1) return; dynamicFilters[i].value = e.target.value; currentPage = 1; renderAll(); });
removeBtn.addEventListener('click', () => { const i = dynamicFilters.indexOf(f); if (i === -1) return; dynamicFilters.splice(i, 1); renderFilters(); currentPage = 1; renderAll(); });
row.appendChild(fieldSel); row.appendChild(opSel); row.appendChild(valInput); row.appendChild(removeBtn);
filterRowsContainer.appendChild(row);
});
};
addFilterBtn.addEventListener('click', () => { dynamicFilters.push({ fieldKey: '', op: '=', value: '' }); renderFilters(); });
// Initialize empty filters UI
renderFilters();
// computeRows: filter -> sort -> paginate -> map to row objects for rendering
const computeRows = () => {
const filteredAttacks = allAttacks.filter(a => {
// apply all dynamic filters (AND semantics)
if (Array.isArray(dynamicFilters) && dynamicFilters.length) {
for (const f of dynamicFilters) {
if (!evaluateFilter(a, f)) return false;
}
}
return true;
});
// Sort accessor map for common sort keys. Defaults to numeric attack time.
const accessor = (k) => {
if (!k || k === 'attackTime') return (a) => Number(a.ended || a.started || 0) || 0;
if (k === 'attacker') return (a) => String(a.attacker?.name || '').toLowerCase();
if (k === 'defender') return (a) => String(a.defender?.name || '').toLowerCase();
if (k === 'result') return (a) => String(a.result || '').toLowerCase();
if (k === 'resDelta' || k === 'respect_gain') return (a) => Number(a.respect_gain || a.respect_gain_no_bonus || 0) || 0;
if (k === 'respect_gain_no_bonus') return (a) => Number(a.respect_gain_no_bonus || 0) || 0;
if (k === 'respect_loss') return (a) => Number(a.respect_loss || 0) || 0;
if (k === 'chain' || k === 'chain_val') return (a) => Number(a.modifiers?.chain || a.chain || 1) || 1;
if (k === 'chain_gap' || k === 'time_since_last_attack') return (a) => Number(a.time_since_last_attack || a.chain_gap || 0) || 0;
// Fallback: try to read raw field
return (a) => {
const v = a[k];
if (v == null) return '';
if (typeof v === 'number') return v;
return String(v).toLowerCase();
};
};
const acc = accessor(sortKey);
const sorted = filteredAttacks.slice().sort((a, b) => {
try {
const A = acc(a); const B = acc(b);
if (A === B) return 0;
// numeric compare when both numbers
if (typeof A === 'number' && typeof B === 'number') return sortAsc ? (A - B) : (B - A);
return sortAsc ? (A > B ? 1 : -1) : (A < B ? 1 : -1);
} catch(_) { return 0; }
});
const totalPages = Math.max(1, Math.ceil(sorted.length / attacksPerPage));
currentPage = Math.max(1, Math.min(currentPage, totalPages));
const pageAttacks = sorted.slice((currentPage - 1) * attacksPerPage, (currentPage - 1) * attacksPerPage + attacksPerPage);
const rows = pageAttacks.map(a => {
const attackerFaction = a.attacker?.faction?.id?.toString();
const defenderFaction = a.defender?.faction?.id?.toString();
// Time: local time from ended (seconds -> ms)
const timeLocal = (a.ended || a.started) ? new Date((a.ended || a.started) * 1000).toLocaleString() : '';
const attackerLink = utils.createElement('a', { href: `/profiles.php?XID=${a.attacker?.id}`, textContent: a.attacker?.name || a.attacker?.id || '', className: `t-blue ${factionClass(attackerFaction)}` });
const defenderLink = utils.createElement('a', { href: `/profiles.php?XID=${a.defender?.id}`, textContent: a.defender?.name || a.defender?.id || '', className: `t-blue ${factionClass(defenderFaction)}` });
const modifiersStr = a.modifiers ? JSON.stringify(a.modifiers) : '';
// New fields
const attackerFactionName = a.attacker?.faction?.name || a.attackerFactionName || a.attacker_faction_name || '';
const defenderFactionName = a.defender?.faction?.name || a.defenderFactionName || a.defender_faction_name || '';
const attackerStatus = a.attacker_status || '';
const attackerActivity = a.attacker_activity || '';
const attackerLastAction = a.attacker_last_action_ts ? new Date(a.attacker_last_action_ts * 1000).toLocaleString() : '';
const attackerLADiff = a.attacker_la_diff != null ? a.attacker_la_diff : '';
const defenderStatus = a.defender_status || '';
const defenderActivity = a.defender_activity || '';
const defenderLastAction = a.defender_last_action_ts ? new Date(a.defender_last_action_ts * 1000).toLocaleString() : '';
const defenderLADiff = a.defender_la_diff != null ? a.defender_la_diff : '';
return {
time: timeLocal,
log: a.code ? utils.createElement('a', { href: `https://www.torn.com/loader.php?sid=attackLog&ID=${a.code}`, target: '_blank', textContent: 'view' }) : '',
attacker: attackerLink,
attacker_faction: attackerFactionName,
attacker_status: attackerStatus,
attacker_activity: attackerActivity,
attacker_last_action: attackerLastAction,
attacker_la_diff: attackerLADiff,
defender: defenderLink,
defender_faction: defenderFactionName,
defender_status: defenderStatus,
defender_activity: defenderActivity,
defender_last_action: defenderLastAction,
defender_la_diff: defenderLADiff,
result: a.result || '',
direction: a.direction || '',
// Treat as ranked war when Torn API flag present or war modifier indicates a war context
is_ranked_war: !!(a.is_ranked_war || a.isRankedWar || a.isRankedWar === true || a.is_ranked_war === true || Number(a?.modifiers?.war || 0) === 2),
is_stealthed: !!(a.is_stealthed || a.isStealthed || a.is_stealthed === true),
chain: (typeof a.chain !== 'undefined' ? a.chain : (a.modifiers?.chain || '')),
chain_saver: a.chain_saver ? 'Yes' : '',
time_since_last_attack: a.time_since_last_attack || a.timeSinceLastAttack || '',
respect_gain: Number(a.respect_gain || 0).toFixed(2),
respect_gain_no_bonus: Number(a.respect_gain_no_bonus || a.respect_gain_no_bonus || 0).toFixed(2),
respect_gain_no_chain: Number(a.respect_gain_no_chain || 0).toFixed(2),
modifiers: modifiersStr,
__attack: a
};
});
return { rows, total: filteredAttacks.length, totalPages };
};
// Fixed columns in the exact order requested by user
// Order: time (local ended), log (code), attacker.name (link colored), defender (link colored), result, direction,
// is_ranked_war, is_stealthed, chain, chain_saver, time_since_last_attack, respect_gain, respect_gain_no_bonus, respect_gain_no_chain, modifiers
const allColumns = [
{ key: 'time', label: 'Time' },
{ key: 'log', label: 'Log', align: 'center' },
{ key: 'attacker', label: 'Attacker' },
{ key: 'attacker_faction', label: 'AtkFaction' },
{ key: 'attacker_status', label: 'AtkStatus' },
{ key: 'defender', label: 'Defender' },
{ key: 'defender_faction', label: 'DefFaction' },
{ key: 'defender_status', label: 'DefStatus' },
{ key: 'result', label: 'Result' },
{ key: 'direction', label: 'Direction', align: 'center' },
{ key: 'is_ranked_war', label: 'RankedWar', align: 'center' },
{ key: 'is_stealthed', label: 'Stealthed', align: 'center' },
{ key: 'chain', label: 'Chain', align: 'center' },
{ key: 'chain_saver', label: 'ChainSaver', align: 'center' },
{ key: 'time_since_last_attack', label: 'ChainCountdown(sec)', align: 'center' },
{ key: 'respect_gain', label: 'Respect', align: 'center' },
{ key: 'respect_gain_no_bonus', label: 'RespectNoBonus', align: 'center' },
{ key: 'respect_gain_no_chain', label: 'RespectNoChain', align: 'center' },
{ key: 'attacker_activity', label: 'AtkActivity' },
{ key: 'attacker_last_action', label: 'AtkLastAction' },
{ key: 'attacker_la_diff', label: 'AtkLADiff' },
{ key: 'defender_activity', label: 'DefActivity' },
{ key: 'defender_last_action', label: 'DefLastAction' },
{ key: 'defender_la_diff', label: 'DefLADiff' },
{ key: 'modifiers', label: 'Modifiers' }
];
// Column visibility state
const visibleColumnKeys = new Set(allColumns.map(c => c.key));
// Create Column Toggler
const columnToggleWrap = utils.createElement('div', { style: { position: 'relative', display: 'inline-block', marginLeft: '10px' } });
const columnToggleBtn = utils.createElement('button', { className: 'settings-btn', textContent: 'Columns \u25BC', style: { padding: '2px 8px', fontSize: '12px' } });
const columnDropdown = utils.createElement('div', {
style: {
display: 'none', position: 'absolute', top: '100%', right: '0',
backgroundColor: '#222', border: '1px solid #444', padding: '10px',
zIndex: '1000', maxHeight: '300px', overflowY: 'auto', minWidth: '200px',
boxShadow: '0 4px 8px rgba(0,0,0,0.5)', borderRadius: '4px'
}
});
allColumns.forEach(col => {
const label = utils.createElement('label', { style: { display: 'block', marginBottom: '4px', cursor: 'pointer', color: '#ddd', fontSize: '12px', userSelect: 'none' } });
const cb = utils.createElement('input', { type: 'checkbox', style: { marginRight: '6px' } });
cb.checked = visibleColumnKeys.has(col.key);
cb.onchange = () => {
if (cb.checked) visibleColumnKeys.add(col.key); else visibleColumnKeys.delete(col.key);
renderAll();
};
label.appendChild(cb);
label.appendChild(document.createTextNode(col.label));
columnDropdown.appendChild(label);
});
columnToggleBtn.onclick = (e) => {
e.stopPropagation();
columnDropdown.style.display = columnDropdown.style.display === 'none' ? 'block' : 'none';
};
const closeDropdownHandler = (e) => {
if (!columnToggleWrap.contains(e.target)) columnDropdown.style.display = 'none';
};
document.addEventListener('click', closeDropdownHandler);
columnToggleWrap.appendChild(columnDropdown);
columnToggleWrap.appendChild(columnToggleBtn);
clearLoading();
let paginationBar = utils.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '10px' } });
const renderAll = () => {
const { rows, total, totalPages } = computeRows();
try { console.debug('[WarAttacks] renderAll', { currentPage, attacksPerPage, total, totalPages, sortKey, sortAsc, dynamicFilters }); } catch(_) {}
const visibleColumns = allColumns.filter(c => visibleColumnKeys.has(c.key));
tableWrap.innerHTML = '';
ui.renderReportTable(tableWrap, { columns: visibleColumns, rows, tableId: 'war-attacks-table', manualSort: true, defaultSort: { key: sortKey, asc: sortAsc }, onSortChange: (k, asc) => { sortKey = k; sortAsc = asc; currentPage = 1; renderAll(); } });
paginationBar.innerHTML = '';
// Previous button (no stale closure on totalPages)
const prevBtn = utils.createElement('button', { className: 'settings-btn', textContent: 'Previous' });
// Ensure button semantics
try { prevBtn.type = 'button'; } catch(_) {}
const prevHandler = () => { try { console.debug('[WarAttacks] Prev clicked', { currentPage, totalPages }); currentPage = Math.max(1, Number(currentPage) - 1); renderAll(); } catch(e){ console.error('[WarAttacks][Prev] handler error', e); } };
prevBtn.addEventListener('click', prevHandler);
// Fallback for environments that replace event listeners
prevBtn.onclick = prevHandler;
prevBtn.disabled = (currentPage === 1);
prevBtn.style.cursor = prevBtn.disabled ? 'not-allowed' : 'pointer';
prevBtn.style.pointerEvents = 'auto';
prevBtn.tabIndex = 0;
paginationBar.appendChild(prevBtn);
// Middle status + export
const statusWrap = utils.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } });
statusWrap.appendChild(utils.createElement('span', { textContent: `Page ${currentPage} of ${totalPages} (${total} total)` }));
const exportBtn = utils.createElement('button', { className: 'settings-btn', textContent: 'Export CSV', onclick: () => {
try {
// Build full (filtered, sorted) list using same accessor used by computeRows
const filtered = allAttacks.filter(a => {
if (Array.isArray(dynamicFilters) && dynamicFilters.length) {
for (const f of dynamicFilters) { if (!evaluateFilter(a, f)) return false; }
}
return true;
});
const accessor = (k) => {
if (!k || k === 'attackTime' || k === 'time') return (a) => Number(a.ended || a.started || 0) || 0;
if (k === 'attacker') return (a) => String(a.attacker?.name || '').toLowerCase();
if (k === 'defender') return (a) => String(a.defender?.name || '').toLowerCase();
if (k === 'result') return (a) => String(a.result || '').toLowerCase();
if (k === 'resDelta' || k === 'respect_gain') return (a) => Number(a.respect_gain || a.respect_gain_no_bonus || 0) || 0;
if (k === 'respect_gain_no_bonus') return (a) => Number(a.respect_gain_no_bonus || 0) || 0;
if (k === 'respect_loss') return (a) => Number(a.respect_loss || 0) || 0;
if (k === 'chain' || k === 'chain_val') return (a) => Number(a.modifiers?.chain || a.chain || 1) || 1;
if (k === 'chain_gap' || k === 'time_since_last_attack') return (a) => Number(a.time_since_last_attack || a.chain_gap || 0) || 0;
return (a) => {
const v = a[k];
if (v == null) return '';
if (typeof v === 'number') return v;
return String(v).toLowerCase();
};
};
const acc = accessor(sortKey);
const sorted = filtered.slice().sort((a, b) => {
try {
const A = acc(a); const B = acc(b);
if (A === B) return 0;
if (typeof A === 'number' && typeof B === 'number') return sortAsc ? (A - B) : (B - A);
return sortAsc ? (A > B ? 1 : -1) : (A < B ? 1 : -1);
} catch(_) { return 0; }
});
// CSV header in exact display order (expand attacker/defender to id+name for export)
const header = ['Time','Log','AttackerId','Attacker','AtkFaction','AtkStatus','DefenderId','Defender','DefFaction','DefStatus','Result','Direction','is_ranked_war','is_stealthed','Chain','ChainSaver','TimeSinceLastAttack','RespectGain','RespectGainNoBonus','RespectGainNoChain','AtkActivity','AtkLastAction','AtkLADiff','DefActivity','DefLastAction','DefLADiff','Modifiers'];
const csvLines = [header.join(',')];
const csvEscape = (v) => {
if (v === null || typeof v === 'undefined') return '';
const s = String(v);
return /[",\n]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s;
};
for (const a of sorted) {
const endedIso = (a.ended || a.started) ? new Date((a.ended||a.started)*1000).toISOString() : '';
const modifiersStr = a.modifiers ? JSON.stringify(a.modifiers) : '';
const row = [
endedIso,
a.code || '',
a.attacker?.id || '',
a.attacker?.name || '',
a.attacker?.faction?.name || a.attackerFactionName || a.attacker_faction_name || '',
a.attacker_status || '',
a.attacker_activity || '',
a.attacker_last_action_ts ? new Date(a.attacker_last_action_ts * 1000).toISOString() : '',
a.attacker_la_diff != null ? a.attacker_la_diff : '',
a.defender?.id || '',
a.defender?.name || '',
a.defender?.faction?.name || a.defenderFactionName || a.defender_faction_name || '',
a.defender_status || '',
a.defender_activity || '',
a.defender_last_action_ts ? new Date(a.defender_last_action_ts * 1000).toISOString() : '',
a.defender_la_diff != null ? a.defender_la_diff : '',
a.result || '',
a.direction || '',
a.is_ranked_war ? 'Yes' : (a.isRankedWar ? 'Yes' : ''),
a.is_stealthed ? 'Yes' : (a.isStealthed ? 'Yes' : ''),
a.chain || '',
a.chain_saver ? 'Yes' : '',
a.time_since_last_attack || '',
Number(a.respect_gain || 0).toFixed(2),
Number(a.respect_gain_no_bonus || 0).toFixed(2),
Number(a.respect_gain_no_chain || 0).toFixed(2),
modifiersStr
].map(csvEscape);
csvLines.push(row.join(','));
}
const blob = new Blob([csvLines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const aEl = document.createElement('a');
aEl.href = url;
aEl.download = `war_${warId || 'unknown'}_attacks_${Date.now()}.csv`;
document.body.appendChild(aEl);
aEl.click();
setTimeout(()=>{ URL.revokeObjectURL(url); aEl.remove(); }, 2500);
} catch(err) {
tdmlogger('warn', '[WarAttacks][ExportCSV] Failed', err);
ui.showTransientMessage('CSV export failed', { type: 'error' });
}
} });
statusWrap.appendChild(exportBtn);
statusWrap.appendChild(columnToggleWrap);
paginationBar.appendChild(statusWrap);
// Next button (simplified logic)
const nextBtn = utils.createElement('button', { className: 'settings-btn', textContent: 'Next' });
try { nextBtn.type = 'button'; } catch(_) {}
const nextHandler = () => { try { console.debug('[WarAttacks] Next clicked', { currentPage, totalPages }); currentPage = Math.min(Number(totalPages), Number(currentPage) + 1); renderAll(); } catch(e){ console.error('[WarAttacks][Next] handler error', e); } };
nextBtn.addEventListener('click', nextHandler);
nextBtn.onclick = nextHandler;
nextBtn.disabled = (currentPage >= totalPages);
nextBtn.style.cursor = nextBtn.disabled ? 'not-allowed' : 'pointer';
nextBtn.style.pointerEvents = 'auto';
nextBtn.tabIndex = 0;
paginationBar.appendChild(nextBtn);
};
controls.appendChild(paginationBar);
perPageInput.addEventListener('change', (e)=>{ const v=parseInt(e.target.value)||1; attacksPerPage=Math.max(1,Math.min(5000,v)); currentPage=1; renderAll(); });
renderAll();
if (!footer.querySelector('#close-attacks-btn')) footer.appendChild(utils.createElement('button', { id: 'close-attacks-btn', className: 'settings-btn settings-btn-red', textContent: 'Close', style: { width: '100%' }, onclick: () => { modal.style.display = 'none'; } }));
} catch(e) {
setError(`Error loading attacks: ${e.message}`);
}
},
showUnauthorizedAttacksModal: async function() {
if (!state.unauthorizedAttacks || !state.unauthorizedAttacks.length) {
await handlers.fetchUnauthorizedAttacks();
}
const { modal, controls, tableWrap, footer } = ui.createReportModal({ id: 'unauthorized-attacks-modal', title: 'Unauthorized Attacks', maxWidth: '820px' });
controls.innerHTML = '';
tableWrap.innerHTML = '';
const attacks = Array.isArray(state.unauthorizedAttacks) ? state.unauthorizedAttacks.slice() : [];
if (!attacks.length) {
controls.appendChild(utils.createElement('div', { style: { textAlign: 'center', color: '#fff', margin: '12px 0' }, textContent: 'No unauthorized attacks recorded.' }));
} else {
const rows = attacks.map((attack) => {
const attackTime = Number(attack.attackTime || attack.timestamp || attack.ended || 0);
const attackerId = String(attack.attackerUserId || attack.attackerId || attack.attacker || attack.attacker_id || '').trim();
const defenderId = String(attack.defenderUserId || attack.defenderId || attack.defender || attack.defender_id || '').trim();
const attackerNameRaw = (attack.attackerUsername || attack.attackerName || '').trim();
const defenderNameRaw = (attack.defenderUsername || attack.defenderName || '').trim();
return {
attackTime,
attackerId,
attackerName: attackerNameRaw || (attackerId ? `ID ${attackerId}` : 'Unknown'),
defenderId,
defenderName: defenderNameRaw || (defenderId ? `ID ${defenderId}` : 'Unknown'),
violation: attack.violationType || attack.reason || 'Policy violation',
result: attack.result || attack.outcome || ''
};
});
controls.appendChild(utils.createElement('div', {
style: { color: '#9ca3af', fontSize: '12px', marginBottom: '8px' },
textContent: `${rows.length} unauthorized attack${rows.length === 1 ? '' : 's'} recorded.`
}));
const columns = [
{
key: 'attackTime',
label: 'Time',
render: (row) => row.attackTime ? new Date(row.attackTime * 1000).toLocaleString() : '—',
sortValue: (row) => row.attackTime || 0
},
{
key: 'attackerName',
label: 'Attacker',
render: (row) => {
if (!row.attackerId) return row.attackerName;
return utils.createElement('a', {
href: `/profiles.php?XID=${row.attackerId}`,
textContent: row.attackerName,
style: { color: 'var(--tdm-color-error)', textDecoration: 'underline', fontWeight: 'bold' }
});
},
sortValue: (row) => (row.attackerName || '').toLowerCase()
},
{
key: 'defenderName',
label: 'Defender',
render: (row) => {
if (!row.defenderId) return row.defenderName;
return utils.createElement('a', {
href: `/profiles.php?XID=${row.defenderId}`,
textContent: row.defenderName,
style: { color: '#ffffff', textDecoration: 'underline', fontWeight: 'bold' }
});
},
sortValue: (row) => (row.defenderName || '').toLowerCase()
},
{
key: 'violation',
label: 'Violation',
sortValue: (row) => (row.violation || '').toLowerCase()
},
{
key: 'result',
label: 'Result',
render: (row) => row.result || '—',
sortValue: (row) => (row.result || '').toLowerCase()
}
];
ui.renderReportTable(tableWrap, { columns, rows, defaultSort: { key: 'attackTime', asc: false }, tableId: 'unauthorized-attacks-table' });
}
if (!footer.querySelector('#unauthorized-attacks-close-btn')) {
footer.appendChild(utils.createElement('button', {
id: 'unauthorized-attacks-close-btn',
className: 'settings-btn settings-btn-red',
textContent: 'Close',
style: { width: '100%' },
onclick: () => { modal.style.display = 'none'; }
}));
}
},
showRankedWarSummaryModal: function(initialSummaryData, rankedWarId) {
const { modal, header, controls, tableWrap, footer } = ui.createReportModal({ id: 'ranked-war-summary-modal', title: `Ranked War Summary (ID: ${rankedWarId})` });
let summaryData = [];
try {
if (Array.isArray(initialSummaryData)) summaryData = initialSummaryData.slice();
else if (initialSummaryData && Array.isArray(initialSummaryData.items)) {
summaryData = initialSummaryData.items.slice();
try { state.rankedWarLastSummaryMeta = state.rankedWarLastSummaryMeta || {}; state.rankedWarLastSummaryMeta.scoreBleed = initialSummaryData.scoreBleed || null; } catch(_) {}
}
} catch (_) { summaryData = Array.isArray(initialSummaryData) ? initialSummaryData.slice() : []; }
const factionId = state.user.factionId;
const formatAge = (ts) => {
if (!ts) return '—';
const ageMs = Date.now() - ts;
if (ageMs < 0) return 'just now';
const mins = Math.floor(ageMs / 60000);
if (mins < 1) return '<1m ago';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ${mins % 60}m ago`;
const days = Math.floor(hrs / 24);
return `${days}d ${hrs % 24}h ago`;
};
const getSummaryFreshnessTs = () => {
try {
const meta = state.rankedWarLastSummaryMeta || {};
const lm = meta.lastModified ? Date.parse(meta.lastModified) : 0;
// Fallback to max lastTs in rows
const maxLocal = summaryData.reduce((m,r)=>Math.max(m, Number(r.lastTs||0)*1000),0);
return Math.max(lm||0, maxLocal||0);
} catch(_) { return 0; }
};
const renderEmpty = (reason = 'No summary data available for this war.') => {
controls.innerHTML = '';
tableWrap.innerHTML = '';
controls.appendChild(utils.createElement('div', { style: { color: 'var(--tdm-color-error)', marginBottom: '10px', textAlign:'center' }, textContent: reason }));
const closeBtn = utils.createElement('button', { className: 'settings-btn settings-btn-red', textContent: 'Close', onclick: () => { modal.style.display = 'none'; }, style: { width: '100%' } });
if (!footer.querySelector('#war-summary-close-btn')) footer.appendChild(closeBtn);
};
let activeFactionId = null;
const buildFactionGroups = () => {
const factionGroups = {};
summaryData.forEach(attacker => {
const factionId = attacker.attackerFaction || attacker.attackerFactionId || 'Unknown';
const factionName = attacker.attackerFactionName || 'Unknown Faction';
if (!factionGroups[factionId]) {
factionGroups[factionId] = {
name: factionName,
attackers: [],
totalAttacks: 0,
totalAttacksScoring: 0,
totalFailedAttacks: 0,
totalRespect: 0,
totalRespectNoChain: 0,
totalRespectNoBonus: 0,
totalRespectLost: 0,
totalChainSavers: 0,
totalAssists: 0,
totalOutside: 0,
totalOverseas: 0,
totalRetaliations: 0
};
}
factionGroups[factionId].attackers.push(attacker);
factionGroups[factionId].totalAttacks += attacker.totalAttacks || 0;
factionGroups[factionId].totalAttacksScoring += attacker.totalAttacksScoring || 0;
factionGroups[factionId].totalFailedAttacks += attacker.failedAttackCount || 0;
factionGroups[factionId].totalRespect += attacker.totalRespectGain || 0;
factionGroups[factionId].totalRespectNoChain += attacker.totalRespectGainNoChain || 0;
factionGroups[factionId].totalRespectNoBonus += attacker.totalRespectGainNoBonus || 0;
factionGroups[factionId].totalRespectLost += attacker.totalRespectLoss || 0;
factionGroups[factionId].totalChainSavers += attacker.chainSaverCount || 0;
factionGroups[factionId].totalAssists += attacker.assistCount || 0;
factionGroups[factionId].totalOutside += attacker.outsideCount || 0;
factionGroups[factionId].totalOverseas += attacker.overseasCount || 0;
factionGroups[factionId].totalRetaliations += attacker.retaliationCount || 0;
});
return factionGroups;
};
// Show score-bleed summary when available
try {
const sb = state.rankedWarLastSummaryMeta?.scoreBleed || null;
if (sb) {
const el = utils.createElement('div', { style: { marginBottom: '6px', color: 'var(--tdm-color-warning)', textAlign: 'center', fontSize: '12px' }, textContent: `Score Bleed: ${sb.count || 0} offline-hits, ${sb.respect || 0} total respect` });
controls.appendChild(el);
}
} catch(_) {}
const allColumns = [
{ key: 'attackerName', label: 'Name', render: (r) => { const a=document.createElement('a'); a.href=`/profiles.php?XID=${r.attackerId}`; a.textContent=r.attackerName||`ID ${r.attackerId}`; a.style.color='var(--tdm-color-success)'; a.style.textDecoration='underline'; return a; }, sortValue: (r) => (r.attackerName||'').toLowerCase() },
{ key: 'totalAttacks', label: 'Attacks', align: 'center', sortValue: (r) => Number(r.totalAttacks)||0 },
{ key: 'totalAttacksScoring', label: 'Scoring', align: 'center', sortValue: (r) => Number(r.totalAttacksScoring)||0 },
{ key: 'failedAttackCount', label: 'Failed', align: 'center', sortValue: (r) => Number(r.failedAttackCount)||0 },
{ key: 'totalRespectGain', label: 'Respect', align: 'center', render: (r)=> (Number(r.totalRespectGain||0)).toFixed(2), sortValue: (r)=>Number(r.totalRespectGain)||0 },
{ key: 'totalRespectGainNoChain', label: 'Respect (No Chain)', align: 'center', render: (r)=> (Number(r.totalRespectGainNoChain||0)).toFixed(2), sortValue: (r)=>Number(r.totalRespectGainNoChain)||0 },
{ key: 'totalRespectGainNoBonus', label: 'Respect (No Bonus)', align: 'center', render: (r)=> (Number(r.totalRespectGainNoBonus||0)).toFixed(2), sortValue: (r)=>Number(r.totalRespectGainNoBonus)||0 },
{ key: 'totalRespectLoss', label: 'Respect Lost', align: 'center', render: (r)=> (Number(r.totalRespectLoss||0)).toFixed(2), sortValue: (r)=>Number(r.totalRespectLoss)||0 },
{ key: 'averageRespectGain', label: 'Avg Respect', align: 'center', render: (r)=> (Number(r.averageRespectGain||0)).toFixed(2), sortValue: (r)=>Number(r.averageRespectGain)||0 },
{ key: 'averageRespectGainNoChain', label: 'Avg Respect (No Chain)', align: 'center', render: (r)=> (Number(r.averageRespectGainNoChain||0)).toFixed(2), sortValue: (r)=>Number(r.averageRespectGainNoChain)||0 },
{ key: 'averageRespectGainNoBonus', label: 'Avg Respect (No Bonus)', align: 'center', render: (r)=> (Number(r.averageRespectGainNoBonus||0)).toFixed(2), sortValue: (r)=>Number(r.averageRespectGainNoBonus)||0 },
{ key: 'chainSaverCount', label: 'Chain Savers', align: 'center', sortValue: (r)=>Number(r.chainSaverCount)||0 },
{ key: 'averageTimeSinceLastAttack', label: 'Avg Chain Gap (s)', align: 'center', render: (r)=> r.averageTimeSinceLastAttack > 0 ? (Number(r.averageTimeSinceLastAttack||0)).toFixed(1) : '', sortValue: (r)=>Number(r.averageTimeSinceLastAttack)||0 },
{
key: 'resultCounts',
label: 'Results',
align: 'center',
render: (r) => {
if (!r.resultCounts) return '';
const counts = r.resultCounts;
const mainResults = ['Mugged', 'Attacked', 'Hospitalized', 'Arrested', 'Bounty'].filter(result => counts[result] > 0);
const otherResults = Object.keys(counts).filter(result => !['Mugged', 'Attacked', 'Hospitalized', 'Arrested', 'Bounty'].includes(result) && counts[result] > 0);
const parts = [...mainResults.map(r => `${r}:${counts[r]}`), ...otherResults.map(r => `${r}:${counts[r]}`)];
const text = parts.join(', ');
const span = document.createElement('span');
span.textContent = text.length > 20 ? text.substring(0, 20) + '...' : text;
span.title = parts.join(' | ');
span.style.cursor = 'help';
return span;
},
sortValue: (r) => Object.values(r.resultCounts || {}).reduce((sum, count) => sum + count, 0)
},
{ key: 'assistCount', label: 'Assists', align: 'center', sortValue: (r)=>Number(r.assistCount)||0 },
{ key: 'outsideCount', label: 'Outside', align: 'center', sortValue: (r)=>Number(r.outsideCount)||0 },
{ key: 'overseasCount', label: 'Overseas', align: 'center', sortValue: (r)=>Number(r.overseasCount)||0 },
{ key: 'retaliationCount', label: 'Retals', align: 'center', sortValue: (r)=>Number(r.retaliationCount)||0 },
{ key: 'averageModifiers.fair_fight', label: 'FF', align: 'center', render: (r)=> (Number(r.averageModifiers?.fair_fight||0)).toFixed(2), sortValue: (r)=>Number(r.averageModifiers?.fair_fight)||0 }
];
// Column visibility state
const visibleColumnKeys = new Set(allColumns.map(c => c.key));
// Create Column Toggler
const columnToggleWrap = utils.createElement('div', { style: { position: 'relative', display: 'inline-block', marginLeft: '10px', float: 'right' } });
const columnToggleBtn = utils.createElement('button', { className: 'settings-btn', textContent: 'Columns \u25BC', style: { padding: '2px 8px', fontSize: '12px' } });
const columnDropdown = utils.createElement('div', {
style: {
display: 'none', position: 'absolute', top: '100%', right: '0',
backgroundColor: '#222', border: '1px solid #444', padding: '10px',
zIndex: '1000', maxHeight: '300px', overflowY: 'auto', minWidth: '200px',
boxShadow: '0 4px 8px rgba(0,0,0,0.5)', borderRadius: '4px'
}
});
allColumns.forEach(col => {
const label = utils.createElement('label', { style: { display: 'block', marginBottom: '4px', cursor: 'pointer', color: '#ddd', fontSize: '12px', userSelect: 'none' } });
const cb = utils.createElement('input', { type: 'checkbox', style: { marginRight: '6px' } });
cb.checked = visibleColumnKeys.has(col.key);
cb.onchange = () => {
if (cb.checked) visibleColumnKeys.add(col.key); else visibleColumnKeys.delete(col.key);
if (currentFactionGroups) renderFactionTable(currentFactionGroups);
};
label.appendChild(cb);
label.appendChild(document.createTextNode(col.label));
columnDropdown.appendChild(label);
});
columnToggleBtn.onclick = (e) => {
e.stopPropagation();
columnDropdown.style.display = columnDropdown.style.display === 'none' ? 'block' : 'none';
};
const closeDropdownHandler = (e) => {
if (!columnToggleWrap.contains(e.target)) columnDropdown.style.display = 'none';
};
document.addEventListener('click', closeDropdownHandler);
columnToggleWrap.appendChild(columnDropdown);
columnToggleWrap.appendChild(columnToggleBtn);
let tabsContainer = null;
let totalsWrap = null;
let currentTable = null;
let currentFactionGroups = null;
const renderFactionTable = (factionGroups) => {
currentFactionGroups = factionGroups;
const f = factionGroups[activeFactionId];
if (!f) return;
const visibleColumns = allColumns.filter(c => visibleColumnKeys.has(c.key));
totalsWrap.innerHTML = '';
totalsWrap.appendChild(utils.createElement('span', { style: { marginRight:'5px' }, innerHTML: `<strong>Total Attacks:</strong> ${f.totalAttacks}` }));
totalsWrap.appendChild(utils.createElement('span', { style: { marginRight:'5px' }, innerHTML: `<strong>Total Respect:</strong> ${f.totalRespect.toFixed(2)}` }));
totalsWrap.appendChild(utils.createElement('span', { style: { marginRight:'5px' }, innerHTML: `<strong>Total Respect Lost:</strong> ${(f.totalRespectLost||0).toFixed(2)}` }));
totalsWrap.appendChild(utils.createElement('span', { innerHTML: `<strong>Total Assists:</strong> ${f.totalAssists}` }));
currentTable = ui.renderReportTable(tableWrap, { columns: visibleColumns, rows: f.attackers, defaultSort: { key:'totalAttacks', asc:false } });
};
const buildTabs = (factionGroups) => {
tabsContainer = utils.createElement('div', { id:'faction-tabs-container', style:{ display:'flex', marginBottom:'10px', borderBottom:'1px solid #444', gap:'6px', flexWrap:'wrap' } });
const factionIds = Object.keys(factionGroups);
if (!activeFactionId) activeFactionId = factionIds[0];
factionIds.forEach(fid => {
const isActive = fid === activeFactionId;
const btn = utils.createElement('button', {
'data-faction-id': fid,
className: `faction-tab ${isActive ? 'active-tab':''}`,
textContent: `${factionGroups[fid].name} (${factionGroups[fid].attackers.length})`,
style: { padding:'6px 8px', backgroundColor: isActive?'var(--tdm-color-success)':'#555', color:'white', border:'none', borderTopLeftRadius:'4px', borderTopRightRadius:'4px', cursor:'pointer' },
onclick: (e)=>{ activeFactionId = fid; tabsContainer.querySelectorAll('.faction-tab').forEach(b=>{ b.style.backgroundColor='#555'; b.classList.remove('active-tab'); }); e.currentTarget.style.backgroundColor = 'var(--tdm-color-success)'; e.currentTarget.classList.add('active-tab'); renderFactionTable(factionGroups); }
});
tabsContainer.appendChild(btn);
});
controls.appendChild(tabsContainer);
};
const renderMetaBar = () => {
const wrap = utils.createElement('div', { style:{ display:'flex', flexWrap:'wrap', gap:'8px', alignItems:'center', justifyContent:'space-between', marginBottom:'8px', fontSize:'12px', background:'#181818', padding:'6px 8px', borderRadius:'6px' } });
const src = state.rankedWarLastSummarySource || 'unknown';
const meta = state.rankedWarLastSummaryMeta || {};
const attacksSrc = state.rankedWarLastAttacksSource || 'unknown';
const freshnessTs = getSummaryFreshnessTs();
const ageStr = formatAge(freshnessTs);
const warActive = !!utils.isWarActive(rankedWarId);
const stale = warActive && freshnessTs && (Date.now() - freshnessTs > 5*60*1000);
const sourceLine = utils.createElement('div', { style:{ display:'flex', flexDirection:'column', gap:'2px', flex:'1 1 260px' } });
sourceLine.appendChild(utils.createElement('div', { innerHTML: `<strong>Summary Source:</strong> ${src}${meta.count?` (${meta.count})`:''}` }));
sourceLine.appendChild(utils.createElement('div', { innerHTML: `<strong>Attacks Source:</strong> ${attacksSrc}` }));
sourceLine.appendChild(utils.createElement('div', { innerHTML: `<strong>Freshness:</strong> ${ageStr}${stale?` <span style='color:#f59e0b;'>(stale)</span>`:''}` }));
wrap.appendChild(sourceLine);
const btnWrap = utils.createElement('div', { style:{ display:'flex', gap:'6px', alignItems:'center' } });
const refreshBtn = utils.createElement('button', { id:'war-summary-refresh-btn', className:'settings-btn settings-btn-blue', textContent:'Refresh', title:'Re-fetch summary choosing freshest (local vs storage).', onclick: ()=>doRefresh(false, refreshBtn) });
const hardBtn = utils.createElement('button', { id:'war-summary-hard-refresh-btn', className:'settings-btn', textContent:'Hard Refresh', title:'Force manifest/attacks pull then rebuild summary locally.', style:{ background:'#666' }, onclick: ()=>doRefresh(true, hardBtn) });
const exportBtn = utils.createElement('button', { id:'war-summary-export-btn', className:'settings-btn settings-btn-green', textContent:'Export CSV', title:'Download CSV for current faction tab.', onclick: exportCurrentFaction });
btnWrap.appendChild(refreshBtn); btnWrap.appendChild(hardBtn);
btnWrap.appendChild(exportBtn);
wrap.appendChild(btnWrap);
controls.appendChild(wrap);
};
let refreshing = false;
const doRefresh = async (force, btn) => {
if (refreshing) return; refreshing = true;
const original = btn.textContent; btn.disabled = true; btn.innerHTML = '<span class="dibs-spinner"></span>';
try {
// Pull attacks (onDemand). Force manifest optionally
try { await api.getRankedWarAttacksSmart(rankedWarId, factionId, { onDemand: true, forceManifest: !!force }); } catch(_) {}
let fresh = await api.getRankedWarSummaryFreshest(rankedWarId, factionId).catch(()=>[]);
if ((!fresh || fresh.length === 0) && state.rankedWarAttacksCache?.[String(rankedWarId)]?.attacks?.length) {
// Fallback to local aggregation if server/remote summary empty
try { fresh = await api.getRankedWarSummaryLocal(rankedWarId, factionId); } catch(_) {}
}
if (Array.isArray(fresh)) summaryData = fresh.slice();
renderAll();
} catch(e) {
controls.appendChild(utils.createElement('div', { style:{ color:'var(--tdm-color-error)', fontSize:'11px' }, textContent:`Refresh failed: ${e.message}` }));
} finally {
btn.disabled = false; btn.textContent = original; refreshing = false;
}
};
// CSV Export (current active faction)
const exportCurrentFaction = () => {
try {
const factionGroups = buildFactionGroups();
const grp = factionGroups[activeFactionId];
if (!grp || !Array.isArray(grp.attackers) || grp.attackers.length === 0) {
ui.showMessageBox('No data to export for current faction tab.', 'error');
return;
}
const rows = grp.attackers;
const headers = [
'attackerId','attackerName','attackerFactionId','attackerFactionName','totalAttacks','wins','losses','stalemates','totalRespectGain','totalRespectGainNoChain','totalRespectLoss','averageRespectGain','averageRespectGainNoChain','assistCount','outsideCount','overseasCount','retaliationCount','avgFairFight','lastTs'
];
const csvEscape = (v) => {
if (v === null || typeof v === 'undefined') return '';
const s = String(v);
return /[",\n]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s;
};
const lines = [];
lines.push(headers.join(','));
for (const r of rows) {
const line = [
r.attackerId,
r.attackerName,
r.attackerFactionId || '',
r.attackerFactionName || '',
r.totalAttacks || 0,
r.wins || 0,
r.losses || 0,
r.stalemates || 0,
(Number(r.totalRespectGain || 0)).toFixed(2),
(Number(r.totalRespectGainNoChain || 0)).toFixed(2),
(Number(r.totalRespectLoss || 0)).toFixed(2),
(Number(r.averageRespectGain || 0)).toFixed(2),
(Number(r.averageRespectGainNoChain || 0)).toFixed(2),
r.assistCount || 0,
r.outsideCount || 0,
r.overseasCount || 0,
r.retaliationCount || 0,
(Number(r.averageModifiers?.fair_fight || 0)).toFixed(2),
r.lastTs || 0
].map(csvEscape).join(',');
lines.push(line);
}
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const safeFaction = (grp.name || 'faction').replace(/[^a-z0-9_-]+/gi,'_');
a.href = url;
a.download = `war_${rankedWarId}_${safeFaction}_${Date.now()}.csv`;
document.body.appendChild(a);
a.click();
setTimeout(()=>{ URL.revokeObjectURL(url); a.remove(); }, 2000);
} catch(e) {
tdmlogger('error', `[CSV export failed] ${e}`);
ui.showMessageBox('CSV export failed: ' + (e.message || 'Unknown error'), 'error');
}
};
const renderAll = () => {
controls.innerHTML = '';
tableWrap.innerHTML = '';
if (!summaryData || summaryData.length === 0) { renderEmpty('No summary data (try Refresh).'); return; }
renderMetaBar();
controls.appendChild(columnToggleWrap);
// name-resolution removed: no background enqueue
const factionGroups = buildFactionGroups();
if (!Object.keys(factionGroups).length) { renderEmpty('No grouped summary data.'); return; }
totalsWrap = utils.createElement('div', { style:{ marginBottom:'6px', textAlign:'right', fontSize:'0.9em' } });
controls.appendChild(totalsWrap);
buildTabs(factionGroups);
renderFactionTable(factionGroups);
// Footer button (only once)
if (!footer.querySelector('#war-summary-close-btn')) {
footer.appendChild(utils.createElement('button', { id:'war-summary-close-btn', className:'settings-btn settings-btn-red', textContent:'Close', style:{ width:'100%' }, onclick: ()=>{ modal.style.display='none'; } }));
}
};
renderAll();
// name-resolution event handler removed (no background resolution exists)
},
showAllRetaliationsNotification: function() {
const opportunities = state.retaliationOpportunities;
const activeOpportunities = Object.values(opportunities || {}).filter(opp => opp.timeRemaining > -60);
if (activeOpportunities.length === 0) {
ui.showMessageBox('No active retaliation opportunities available', 'info');
return;
}
// Clean up any previous popups and timers
let existingPopup = document.getElementById('tdm-retals-popup');
if (existingPopup) existingPopup.remove();
state.ui.retalTimerIntervals.forEach(id => { try { utils.unregisterInterval(id); } catch(_) {} });
state.ui.retalTimerIntervals = [];
const popupContent = utils.createElement('div', {
style: {
position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', backgroundColor: '#2c2c2c', border: '1px solid #333', borderRadius: '8px',
boxShadow: '0 4px 10px rgba(0,0,0,0.5)', padding: '15px', color: 'white', zIndex: 10001, maxWidth: '350px'
}
});
const header = utils.createElement('h3', { textContent: 'Active Retaliation Opportunities', style: { marginTop: '0', marginBottom: '5px', textAlign: 'center' } });
const list = utils.createElement('ul', { style: { listStyle: 'none', padding: '0', margin: '0', maxHeight: '300px', overflowY: 'auto' } });
activeOpportunities.forEach(opp => {
const timeLeftSpan = utils.createElement('span', { style: { color: '#ffcc00' } });
const alertButton = utils.createElement('button', {
textContent: 'Send Alert',
style: { backgroundColor: '#ff5722', color: 'white', border: 'none', borderRadius: '4px', padding: '4px 8px', cursor: 'pointer', fontSize: '12px' },
onclick: () => ui.sendRetaliationAlert(opp.attackerId, opp.attackerName)
});
const listItem = utils.createElement('li', {
style: { marginBottom: '15px', padding: '10px', backgroundColor: '#1a1a1a', textAlign: 'center', borderRadius: '5px' }
}, [
utils.createElement('div', {
innerHTML: `<a href="/profiles.php?XID=${opp.attackerId}" style="color:#ff6b6b;font-weight:bold;">${opp.attackerName}</a>
<span> attacked </span><a href="/profiles.php?XID=${opp.defenderId}" style="color:#ffffff;font-weight:bold;">${opp.defenderName}</a><span> - </span>`
}, [ timeLeftSpan ]), // Append the span element here
utils.createElement('div', { style: { marginTop: '8px' } }, [alertButton])
]);
// --- COUNTDOWN TIMER LOGIC ---
const updateCountdown = () => {
const timeRemaining = opp.retaliationEndTime - (Date.now() / 1000);
if (timeRemaining > 0) {
const minutes = Math.floor(timeRemaining / 60);
const seconds = Math.floor(timeRemaining % 60);
timeLeftSpan.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
} else {
timeLeftSpan.textContent = `Expired`;
timeLeftSpan.style.color = '#aaa';
alertButton.disabled = true;
alertButton.style.backgroundColor = '#777';
// Stop the timer after it expires
utils.unregisterInterval(intervalId);
// Remove the item 60 seconds after expiring
setTimeout(() => {
if (listItem.parentNode) {
listItem.parentNode.removeChild(listItem);
// If list is empty, close the popup
if (list.children.length === 0 && document.getElementById('tdm-retals-popup')) {
document.getElementById('tdm-retals-popup').remove();
}
}
}, 60000);
}
};
updateCountdown(); // Initial call to set the time immediately
const intervalId = utils.registerInterval(setInterval(updateCountdown, 1000));
state.ui.retalTimerIntervals.push(intervalId);
// --- END TIMER LOGIC ---
list.appendChild(listItem);
});
const dismissButton = utils.createElement('button', {
textContent: 'Dismiss',
style: { backgroundColor: '#f44336', color: 'white', border: 'none', borderRadius: '4px', padding: '8px 20px', cursor: 'pointer', display: 'block', margin: '15px auto 0', fontSize: '14px' },
onclick: (e) => {
e.currentTarget.closest('#tdm-retals-popup').remove();
// Clear all timers when the user dismisses the popup
state.ui.retalTimerIntervals.forEach(id => { try { utils.unregisterInterval(id); } catch(_) {} });
state.ui.retalTimerIntervals = [];
}
});
// Only add the list if there are opportunities to show
if (list.children.length > 0) {
popupContent.appendChild(header);
popupContent.appendChild(list);
} else {
popupContent.appendChild(utils.createElement('p', {textContent: 'No active retaliation opportunities.', style: {textAlign: 'center'}}));
}
popupContent.appendChild(dismissButton);
const notification = utils.createElement('div', { id: 'tdm-retals-popup' }, [popupContent]);
document.body.appendChild(notification);
},
showTDMAdoptionModal: async function() {
const { modal, controls, tableWrap, footer, setLoading, clearLoading, setError } = ui.createReportModal({ id: 'tdm-adoption-modal', title: 'Faction TDM Adoption' });
setLoading('Loading adoption stats...');
// Helper: safe Firestore Timestamp/string/number -> Date or null
const toSafeDate = (val) => {
try {
if (!val) return null;
if (val instanceof Date) return isNaN(val.getTime()) ? null : val;
if (typeof val?.toMillis === 'function') return new Date(val.toMillis());
if (typeof val?._seconds === 'number') return new Date(val._seconds * 1000);
if (typeof val?.seconds === 'number') return new Date(val.seconds * 1000);
if (typeof val === 'number') return new Date(val);
if (typeof val === 'string') {
const d = new Date(val);
return isNaN(d.getTime()) ? null : d;
}
} catch (_) { /* ignore */ }
return null;
};
let tdmUsers = [];
try {
const apiUsers = await api.get('getTDMUsersByFaction', { factionId: state.user.factionId });
tdmUsers = Array.isArray(apiUsers) ? apiUsers : [];
tdmUsers = tdmUsers.map(u => ({ ...u, lastVerified: toSafeDate(u.lastVerified) }));
tdmUsers = tdmUsers.filter(u => u.position !== 'Resting in Elysian Fields' && u.name !== 'Wunda');
} catch (e) {
tdmlogger('error', `[Error fetching TDM users] ${e}`);
setError('Error fetching TDM user data.');
return;
}
const members = Array.isArray(state.factionMembers) ? state.factionMembers : [];
const merged = members.map(m => {
// pick most recent record for this member (if any)
const recs = tdmUsers.filter(u => String(u.tornId) === String(m.id));
let mostRecent = null;
if (recs.length > 0) {
mostRecent = recs.reduce((latest, current) => {
const a = toSafeDate(latest?.lastVerified);
const b = toSafeDate(current?.lastVerified);
return (b && (!a || b > a)) ? current : latest;
});
}
const lastVerified = toSafeDate(mostRecent?.lastVerified);
return {
id: m.id,
name: m.name,
level: m.level,
days: m.days_in_faction,
position: m.position,
isTDM: !!mostRecent,
tdmVersion: mostRecent?.version || '',
last_action: new Date(Number(m.last_action?.timestamp || 0) * 1000) || '',
lastVerified: lastVerified && lastVerified.getTime() > 0 ? lastVerified : ''
};
});
const adoptedCount = merged.filter(m => m.isTDM).length;
const totalCount = merged.length;
const percent = totalCount ? Math.round((adoptedCount / totalCount) * 100) : 0;
clearLoading();
// Progress summary
const progressWrap = utils.createElement('div', { style: { marginBottom: '16px' } });
progressWrap.appendChild(utils.createElement('div', { style: { fontSize: '1.1em' }, textContent: `${adoptedCount} of ${totalCount} members have installed TDM (${percent}%)` }));
const bar = utils.createElement('div', { style: { background: '#333', borderRadius: '6px', height: '22px', width: '100%', marginTop: '8px', position: 'relative' } });
bar.appendChild(utils.createElement('div', { style: { background: 'var(--tdm-color-success)', height: '100%', borderRadius: '6px', width: `${percent}%`, transition: 'width 0.5s' } }));
bar.appendChild(utils.createElement('div', { style: { position: 'absolute', left: '50%', top: '0', transform: 'translateX(-50%)', color: 'white', fontWeight: 'bold', lineHeight: '22px' }, textContent: `${percent}%` }));
progressWrap.appendChild(bar);
controls.appendChild(progressWrap);
// Table
const columns = [
{ key: 'name', label: 'Name', render: (m) => {
const a = utils.buildProfileLink(m.id, m.name || `ID ${m.id}`);
a.style.color = 'var(--tdm-color-success)'; a.style.textDecoration = 'underline';
return a;
}, sortValue: (m) => (m.name || '').toLowerCase() },
{ key: 'level', label: 'Lvl', align: 'center', sortValue: (m) => Number(m.level) || 0 },
{ key: 'position', label: 'Position' },
{ key: 'days', label: 'Days', align: 'center', sortValue: (m) => Number(m.days) || 0 },
{ key: 'isTDM', label: 'TDM?', align: 'center', render: (m) => (m.isTDM ? '✅' : ''), sortValue: (m) => (m.isTDM ? 1 : 0) },
{ key: 'tdmVersion', label: 'Version' },
{ key: 'last_action', label: 'Last Action', render: (m) => (m.last_action ? m.last_action.toLocaleDateString(undefined, { year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''), sortValue: (m) => (m.last_action instanceof Date ? m.last_action.getTime() : 0) },
{ key: 'lastVerified', label: 'Last Verified', render: (m) => (m.lastVerified ? m.lastVerified.toLocaleDateString(undefined, { year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''), sortValue: (m) => (m.lastVerified instanceof Date ? m.lastVerified.getTime() : 0) }
];
ui.renderReportTable(tableWrap, { columns, rows: merged, defaultSort: { key: 'lastVerified', asc: false }, tableId: 'tdm-adoption-table' });
// Footer note and dismiss
footer.textContent = 'TDM = TreeDibsMapper userscript installed and verified with backend.';
if (!modal.querySelector('#tdm-adoption-dismiss')) {
const dismissBtn = utils.createElement('button', {
id: 'tdm-adoption-dismiss',
className: 'settings-btn settings-btn-red',
style: { marginTop: '8px', display: 'block', width: '100%' },
textContent: 'Dismiss',
onclick: () => { modal.style.display = 'none'; }
});
modal.appendChild(dismissBtn);
}
},
openSettingsToApiKeySection: async ({ highlight = false, focusInput = false } = {}) => {
try {
const existed = document.getElementById('tdm-settings-popup');
if (!existed) {
await ui.toggleSettingsPopup();
} else {
ui.updateSettingsContent?.();
}
const content = document.getElementById('tdm-settings-content');
if (!content) return;
const section = content.querySelector('[data-section="api-keys"]');
if (!section) return;
if (section.classList.contains('collapsed')) {
section.classList.remove('collapsed');
}
const apiCard = content.querySelector('#tdm-api-key-card');
if (highlight && apiCard) {
apiCard.dataset.highlighted = 'true';
apiCard.classList.add('tdm-api-key-highlight');
setTimeout(() => {
apiCard?.classList?.remove('tdm-api-key-highlight');
if (apiCard?.dataset) delete apiCard.dataset.highlighted;
}, 2500);
}
if (focusInput) {
const input = document.getElementById('tdm-api-key-input');
if (input) {
input.focus();
input.select?.();
}
}
apiCard?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} catch(_) {}
}
};
state.events.on('script:admin-permissions-updated', () => {
try {
if (document.getElementById('tdm-settings-popup')) ui.updateSettingsContent?.();
} catch (_) {}
try {
if (state.page.isFactionPage && state.dom.factionListContainer) ui.updateFactionPageUI(state.dom.factionListContainer);
} catch (_) {}
try {
if (state.page.isAttackPage && typeof ui.injectAttackPageUI === 'function') {
const maybePromise = ui.injectAttackPageUI();
if (maybePromise && typeof maybePromise.catch === 'function') maybePromise.catch(() => {});
}
} catch (_) {}
});
//======================================================================
// 6. EVENT HANDLERS & CORE LOGIC
//======================================================================
const handlers = {
// Hard reset: purge legacy keys, indexedDB (async), in-memory tracking, and mark a guard so tracking doesn't auto-start mid-reset.
performHardReset: async (opts={}) => {
try {
if (state._hardResetInProgress) return;
state._hardResetInProgress = true;
const factory = !!opts.factory;
tdmlogger('info', '[Reset] Initiating hard reset');
// Best-effort clear of long-lived timers/observers/listeners to avoid cross-reset leaks
try { utils.cleanupAllResources(); } catch(_) {}
// 1. Stop activity tracking loop if running
try { handlers._teardownActivityTracking(); } catch(_) {}
// 2. Clear unified status & phase history caches
state.unifiedStatus = {};
if (state._activityTracking) {
delete state._activityTracking._phaseHistory;
delete state._activityTracking._transitionLog;
}
// 4. Clear new structured keys (notes cache etc.) except user settings & api key (unless factory)
// Normal reset: keep tdm.user.customApiKey, clear other tdm.* keys.
// Factory reset: clear everything.
const preserve = factory ? new Set() : new Set(['tdm.user.customApiKey','tdm.columnVisibility', 'tdm.columnWidths']);
try {
const toRemove=[];
for (let i=0;i<localStorage.length;i++) {
const k = localStorage.key(i); if (!k) continue; if (preserve.has(k)) continue; if (k.startsWith('tdm')) toRemove.push(k);
}
toRemove.forEach(k=>{ try { localStorage.removeItem(k); } catch(_) {} });
} catch(_) {}
// 5. IndexedDB purge (best-effort) - known DB name(s) and tdm-store (kv)
// Only performed on factory reset.
let idbDeleted = false;
if (factory) {
try {
// Delete tdm-store (kv) via ui._kv if available
if (typeof ui !== 'undefined' && ui._kv && typeof ui._kv.deleteDb === 'function') {
const ok = await ui._kv.deleteDb();
if (ok) idbDeleted = true;
}
const dbNames = ['TDM_DB','TDM_CACHE','tdm-store'];
for (const dbName of dbNames) {
await new Promise(res=>{ const req = indexedDB.deleteDatabase(dbName); req.onsuccess=()=>{ idbDeleted=true; res(); }; req.onerror=()=>res(); req.onblocked=()=>res(); });
}
} catch(_) { /* ignore */ }
}
// 6. Session markers for post-reset verification path
sessionStorage.setItem('post_reset_check','1');
if (idbDeleted) sessionStorage.setItem('post_reset_idb_deleted','1');
// 7. Clear UI artifacts
try { document.querySelectorAll('.tdm-travel-eta').forEach(el=>el.remove()); } catch(_) {}
try {
const ov=document.getElementById('tdm-live-track-overlay');
if (ov) ov.remove();
ui.ensureDebugOverlayContainer?.({ passive: true, skipShow: true });
} catch(_) {}
// 8. Provide feedback (overlay style flash)
try { alert(factory ? 'TreeDibsMapper: Factory reset complete. Reload to start fresh.' : 'TreeDibsMapper: Hard reset completed. Reload to reinitialize.'); } catch(_) {}
} finally {
state._hardResetInProgress = false;
}
},
toggleLiveTrackDebugOverlay: (force) => {
try {
const current = !!storage.get('liveTrackDebugOverlayEnabled', false);
const next = typeof force === 'boolean' ? force : !current;
storage.set('liveTrackDebugOverlayEnabled', next);
ui.ensureDebugOverlayContainer?.({ passive: true, skipShow: true });
if (next) {
handlers._renderLiveTrackDebugOverlay?.();
} else {
const overlay = document.getElementById('tdm-live-track-overlay');
if (overlay) overlay.remove();
state.ui.debugOverlayMinimizeEl = null;
}
const checkbox = document.getElementById('tdm-debug-overlay-toggle');
if (checkbox) checkbox.checked = next;
} catch (_) { /* non-fatal */ }
},
// Activity tracking helper methods (migrated from legacy live tracking)
_didStateChange: (prev, curr) => {
if (!prev) return true; if (!curr) return true;
if (prev.canonical !== curr.canonical) return true;
if (prev.dest !== curr.dest) return true;
if (prev.arrivalMs !== curr.arrivalMs) return true;
if (prev.startMs !== curr.startMs) return true;
return false;
},
_maybeInjectLanded: (prev, curr) => {
if (!prev || !curr) return null;
const p = prev.canonical; const c = curr.canonical;
if ((p === 'Travel' || p === 'Abroad' || p === 'Returning') && (c === 'Okay' || c === 'Idle')) {
return { ...curr, canonical: 'Landed', transient: true, expiresAt: Date.now()+config.LANDED_TTL_MS };
}
return null;
},
// Per-player debounced phaseHistory persistence utilities
_phaseHistoryWriteTimers: {},
_writePhaseHistoryToKV: function(playerId){
try {
if (!storage.get('tdmActivityTrackingEnabled', false)) return Promise.resolve();
if (!ui || !ui._kv) return Promise.resolve();
if (!state._activityTracking || !state._activityTracking._phaseHistory) return Promise.resolve();
const arr = state._activityTracking._phaseHistory[playerId] || [];
const toSave = Array.isArray(arr) ? arr.slice(-100) : [];
return ui._kv.setItem('tdm.phaseHistory.id_' + playerId, toSave).then(()=>{
if (storage.get('tdmDebugPersist', false)) tdmlogger('debug','[KV Persist] wrote phaseHistory for ' + playerId + ' len=' + toSave.length);
}).catch(()=>{});
} catch(e){ return Promise.resolve(); }
},
_schedulePhaseHistoryWrite: function(playerId){
try {
if (!playerId) return;
if (this._phaseHistoryWriteTimers[playerId]) try { utils.unregisterTimeout(this._phaseHistoryWriteTimers[playerId]); } catch(_) {}
this._phaseHistoryWriteTimers[playerId] = utils.registerTimeout(setTimeout(()=>{
delete this._phaseHistoryWriteTimers[playerId];
try { this._writePhaseHistoryToKV(playerId); } catch(_) {}
}, 1000));
} catch(e){}
},
_applyActivityTransitions: (transitions = []) => {
if (!transitions || !transitions.length) return;
const now = Date.now();
state.unifiedStatus = state.unifiedStatus || {};
// process incoming transitions (accept items of shape {id, state})
const prevMap = {};
for (const t of transitions) {
const id = t.id || (t.playerId || t.pid);
const incoming = t.state || t;
if (!id || !incoming) continue;
// Skip already-expired transients
if (incoming.transient && incoming.expiresAt && incoming.expiresAt < now) continue;
const existing = state.unifiedStatus[id] || {};
prevMap[id] = existing;
// Merge (preserve existing higher-confidence)
const merged = { ...existing, ...incoming };
const map = { LOW: 1, MED: 2, HIGH: 3 };
if (existing.confidence && merged.confidence) {
if ((map[merged.confidence] || 0) < (map[existing.confidence] || 0)) merged.confidence = existing.confidence;
}
merged.updated = now;
state.unifiedStatus[id] = merged;
}
// persist snapshot (debounced elsewhere)
try { scheduleUnifiedStatusSnapshotSave(); } catch (_) {}
// Minimal UI refresh across known row containers (ranked war + members lists)
try {
const selector = '.f-war-list .table-body .table-row, .members-cont .members-list li, #faction-war .table-body .table-row';
const rows = document.querySelectorAll(selector);
rows.forEach(row => {
try {
const link = row.querySelector('a[href*="profiles.php?XID="]');
if (!link) return; const pid = (link.href.match(/XID=(\d+)/) || [])[1]; if (!pid) return;
const rec = state.unifiedStatus[pid]; if (!rec) return;
const statusCell = row.querySelector('.tdm-status-cell') || row.querySelector('.status') || row.lastElementChild;
if (!statusCell) return;
const prevDataPhase = statusCell.dataset.tdmPhase;
const prevDataConf = statusCell.dataset.tdmConf;
// Early-leave detection: compare previous canonical status (from prevMap) to new
const prevRec = (typeof prevMap !== 'undefined' && prevMap[pid]) ? prevMap[pid] : null;
if (prevRec) {
const wasHospital = /hospital/i.test(String(prevRec.canonical || prevRec.rawState || prevRec.status || ''));
const nowOk = /okay/i.test(String(rec.canonical || rec.rawState || rec.status || ''));
// compute previous until ms if available
let prevUntilMs = 0;
try {
if (prevRec.rawUntil) prevUntilMs = (prevRec.rawUntil < 1000000000000 ? prevRec.rawUntil*1000 : prevRec.rawUntil);
else if (prevRec.arrivalMs) prevUntilMs = prevRec.arrivalMs;
else if (prevRec.until) prevUntilMs = (prevRec.until < 1000000000000 ? prevRec.until*1000 : prevRec.until);
} catch(_) { prevUntilMs = 0; }
if (wasHospital && nowOk && prevUntilMs && prevUntilMs > Date.now() + 30000) {
statusCell.classList.add('tdm-early-leave');
setTimeout(() => statusCell.classList.remove('tdm-early-leave'), 60000);
}
if (/hospital/i.test(String(rec.canonical || rec.rawState || rec.status || ''))) {
statusCell.classList.remove('tdm-early-leave');
}
}
// Format status via shared helper if available, fallback to canonical label
const meta = state.rankedWarChangeMeta ? state.rankedWarChangeMeta[pid] : null;
let label = rec.canonical || rec.phase || rec.status || '';
let remainingText = '';
let sortVal = '';
let subRank = 0;
if (ui && ui._formatStatus) {
try {
const out = ui._formatStatus(rec, meta) || {};
label = out.label || label;
remainingText = out.remainingText || '';
sortVal = out.sortVal || '';
subRank = out.subRank || 0;
} catch (_) {}
}
let disp = label + (remainingText || '');
const currRendered = (statusCell.firstElementChild && statusCell.firstElementChild.textContent) || (statusCell.textContent || '');
if (currRendered !== disp || statusCell.dataset.tdmPhase !== (rec.canonical || rec.phase) || statusCell.dataset.tdmConf !== (rec.confidence || '')) {
// Update visible text when rendered display differs, or phase/confidence changed.
if (statusCell.firstElementChild) statusCell.firstElementChild.textContent = disp; else statusCell.textContent = disp;
statusCell.dataset.tdmPhase = rec.canonical || rec.phase || '';
statusCell.dataset.tdmConf = rec.confidence || '';
}
if (sortVal) statusCell.dataset.sortValue = sortVal; else delete statusCell.dataset.sortValue;
if (subRank > 0) statusCell.dataset.subRank = subRank; else delete statusCell.dataset.subRank;
// Special visual for Landed transients
const landed = ((rec.canonical||'').toLowerCase() === 'landed');
if (landed) {
statusCell.style.opacity = '0.85'; statusCell.style.transition = 'opacity 0.6s';
setTimeout(()=>{ try { if ((statusCell.textContent||'').includes('Landed')) statusCell.style.opacity = '1'; } catch(_){} }, 4800);
} else { statusCell.style.opacity = '1'; }
} catch(_) {}
});
} catch(_) {}
// Notify listeners
try { window.dispatchEvent(new CustomEvent('tdm:unifiedStatusUpdated', { detail: { ts: Date.now(), count: transitions.length } })); } catch(_) {}
},
_escalateConfidence: (prev, curr) => {
// Basic heuristic: first observation -> LOW, consecutive identical with supporting fields -> MED, stable across 2+ ticks or with travel timing alignment -> HIGH.
if (!curr) return curr;
const ladder = ['LOW','MED','HIGH'];
const map = { LOW:1, MED:2, HIGH:3 };
const prevLevel = prev?.confidence || 'LOW';
let next = prevLevel;
// Determine base criteria
const samePhase = prev && prev.canonical === curr.canonical;
const isTravel = curr.canonical === 'Travel';
const hasTiming = isTravel && curr.arrivalMs && curr.startMs;
if (!prev) {
next = 'LOW';
} else if (samePhase) {
if (prevLevel==='LOW') next = 'MED';
else if (prevLevel==='MED') {
if (hasTiming) next = 'HIGH';
}
} else {
const pCanon = prev.canonical; const cCanon = curr.canonical;
const travelPair = (a,b) => (a==='Travel' && b==='Returning') || (a==='Returning' && b==='Travel');
if (travelPair(pCanon, cCanon)) {
// Preserve momentum when bouncing between Travel and Returning (landing grace) states
next = prevLevel;
if (map[next] < map.MED && curr.dest) next = 'MED';
if (map[next] < map.HIGH && curr.arrivalMs && prev.arrivalMs && Math.abs((curr.arrivalMs||0)-(prev.arrivalMs||0))<=2000) next = 'HIGH';
} else if (cCanon==='Landed' && (pCanon==='Travel'||pCanon==='Returning'||pCanon==='Abroad') && prevLevel==='HIGH') {
next='HIGH';
} else {
next='LOW';
}
}
// Witness escalation: if curr.witnessCount (future) or supporting evidence flagged
if (curr.witness && map[next] < map.HIGH) next='HIGH';
return { ...curr, confidence: next };
},
_expireTransients: () => {
const unified = state.unifiedStatus||{}; const now = Date.now();
let changed=false;
for (const [id, rec] of Object.entries(unified)) {
if (rec.canonical==='Landed' && rec.expiresAt && rec.expiresAt < now) {
// Replace with stable fallback (Okay) if still showing Landed
unified[id] = { ...rec, canonical:'Okay', confidence: rec.confidence||'MED' };
changed=true;
}
}
if (changed) {
state.unifiedStatus = unified;
try { window.dispatchEvent(new CustomEvent('tdm:unifiedStatusUpdated', { detail: { ts: now, pruned:true } })); } catch(_) {}
}
},
_renderLiveTrackDebugOverlay: () => {
try {
if (!storage.get('liveTrackDebugOverlayEnabled', false)) { ui.ensureDebugOverlayStyles?.(false); return; }
const at = state._activityTracking; if (!at) { ui.ensureDebugOverlayStyles?.(false); return; }
const el = ui.ensureDebugOverlayContainer({ passive: true });
ui.ensureDebugOverlayContainer?.({ passive: true });
const body = el._innerBodyRef || el; // backward safety
const unified = state.unifiedStatus||{};
const total = Object.keys(unified).length;
const phCounts = { Trav:0, Abroad:0, Ret:0, Hosp:0, Jail:0, Landed:0 };
const confCounts = { LOW:0, MED:0, HIGH:0 };
const travelConf = { LOW:0, MED:0, HIGH:0 };
let landedTransients=0;
const destCounts = {};
for (const rec of Object.values(unified)) {
const canon = rec?.canonical;
switch(canon){
case 'Travel': phCounts.Trav++; break;
case 'Abroad': phCounts.Abroad++; break;
case 'Returning': phCounts.Ret++; break;
case 'Hospital': phCounts.Hosp++; break;
case 'Jail': phCounts.Jail++; break;
case 'Landed': phCounts.Landed++; break;
}
if (rec?.confidence && confCounts[rec.confidence]!==undefined) confCounts[rec.confidence]++;
const canonPhase = (rec && (rec.canonical || rec.phase)) || '';
if (canonPhase === 'Travel' || canonPhase === 'Returning' || canonPhase === 'Abroad') {
travelConf[rec.confidence||'LOW'] = (travelConf[rec.confidence||'LOW']||0)+1;
if (rec.dest) destCounts[rec.dest] = (destCounts[rec.dest]||0)+1;
}
if (rec && rec.canonical === 'Landed' && rec.expiresAt && rec.expiresAt > Date.now()) { landedTransients++; }
}
// derive percentages
const travelTotal = Object.values(travelConf).reduce((a,b)=>a+b,0);
const pct = (n)=> travelTotal? ((n/travelTotal*100).toFixed(0)+'%'):'0%';
// transitions per minute & avg diff
const metrics = state._activityTracking.metrics || {};
const now = Date.now();
const windowMs = 5*60*1000; // 5m rolling window of transition timestamps if tracked
state._activityTracking._recentTransitions = state._activityTracking._recentTransitions || [];
// Prune old
state._activityTracking._recentTransitions = state._activityTracking._recentTransitions.filter(t=> now - t < windowMs);
const tpm = state._activityTracking._recentTransitions.length / (windowMs/60000);
// Maintain rolling avg of diff times
state._activityTracking._diffSamples = state._activityTracking._diffSamples || [];
state._activityTracking._diffSamples.push(metrics.lastDiffMs||0);
if (state._activityTracking._diffSamples.length>60) state._activityTracking._diffSamples.shift();
const avgDiff = state._activityTracking._diffSamples.reduce((a,b)=>a+b,0)/(state._activityTracking._diffSamples.length||1);
// Top destinations
const topDests = Object.entries(destCounts).sort((a,b)=>b[1]-a[1]).slice(0,4).map(([d,c])=>`${d}:${c}`).join(' ');
const totalTicks = metrics.totalTicks || 0;
const signatureSkips = (metrics.signatureSkips != null ? metrics.signatureSkips : (metrics.skippedTicks || 0)) || 0;
const skipRate = totalTicks ? ((signatureSkips / totalTicks) * 100) : 0;
const throttleInf = metrics.bundleSkipsInFlight || 0;
const throttleRecent = metrics.bundleSkipsRecent || 0;
const fetchHits = metrics.fetchHits || 0;
const fetchErrors = metrics.fetchErrors || 0;
const lastFetch = metrics.lastFetchReason || 'n/a';
const lastOutcome = metrics.lastTickOutcome || 'n/a';
const skipped = signatureSkips;
const lines = [];
lines.push('ActivityTrack');
lines.push(`members: ${total}`);
lines.push(`counts: T:${phCounts.Trav} Ab:${phCounts.Abroad} R:${phCounts.Ret} Ld:${phCounts.Landed} H:${phCounts.Hosp} J:${phCounts.Jail}`);
lines.push(`conf all: L:${confCounts.LOW} M:${confCounts.MED} H:${confCounts.HIGH}`);
lines.push(`travel conf: H:${travelConf.HIGH||0}(${pct(travelConf.HIGH||0)}) M:${travelConf.MED||0}(${pct(travelConf.MED||0)}) L:${travelConf.LOW||0}(${pct(travelConf.LOW||0)})`);
lines.push(`landed transients: ${landedTransients}`);
// Validation stats (if available)
if (metrics.validationLast) {
const v = metrics.validationLast;
lines.push(`validate: norm=${v.normalized} prunedEta=${v.etaPruned} malformed=${v.malformed}`);
}
// Drift and timing averages
const avg = (arr)=> arr && arr.length ? (arr.reduce((a,b)=>a+b,0)/arr.length) : 0;
const avgDrift = avg(metrics.driftSamples||[]);
const avgTick = avg(state._activityTracking.metrics.tickSamples||[]);
// Compute p95 & max for tick durations (rolling window)
let p95='0.0', maxTick='0.0';
try {
const samples = (state._activityTracking.metrics.tickSamples||[]).slice().sort((a,b)=>a-b);
if (samples.length) {
const idx = Math.min(samples.length-1, Math.floor(samples.length*0.95));
p95 = samples[idx].toFixed(1);
maxTick = samples[samples.length-1].toFixed(1);
}
} catch(_) {}
lines.push(`poll: last=${new Date(metrics.lastPoll).toLocaleTimeString()} tick=${(metrics.lastDiffMs||0).toFixed(1)}ms (api:${(metrics.lastApiMs||0).toFixed(1)} build:${(metrics.lastBuildMs||0).toFixed(1)} apply:${(metrics.lastApplyMs||0).toFixed(1)}) avg=${avgTick.toFixed(1)}ms p95=${p95}ms max=${maxTick}ms`);
const driftWarnThreshold = (at.cadenceMs||10000) + 500;
const driftLine = `rate: tpm=${tpm.toFixed(2)} skipped=${skipped}(${skipRate.toFixed(1)}%) drift=${(metrics.lastDriftMs||0).toFixed(1)}ms avgDrift=${avgDrift.toFixed(1)}ms`;
if ((metrics.lastDriftMs||0) > driftWarnThreshold) {
lines.push(`<span style="color:#f87171" title="Drift exceeded cadence+500ms; consider reducing work per tick or increasing cadence.">${driftLine}</span>`);
} else {
lines.push(driftLine);
}
const tickLine = `ticks: total=${totalTicks} sigSkip=${signatureSkips} throttle(if/rec)=${throttleInf}/${throttleRecent} fetches=${fetchHits}${fetchErrors ? ' err='+fetchErrors : ''}`;
lines.push(tickLine);
if (totalTicks && (signatureSkips/totalTicks) > 0.8) {
lines.push('<span style="color:#f59e0b">note: sigSkip grows when roster phases stay unchanged; fetch cadence shown above.</span>');
}
lines.push(`last tick: outcome=${lastOutcome} fetch=${lastFetch}`);
if (topDests) lines.push(`top dest: ${topDests}`);
// Confidence promotion counts (since page load)
if (metrics.confPromos) {
const cp = metrics.confPromos;
lines.push(`promos: L->M:${cp.L2M||0} M->H:${cp.M2H||0}`);
}
// Diagnostics counters from singleton (script reinjection, ensures, badge updates)
try {
if (window.__TDM_SINGLETON__) {
const diag = window.__TDM_SINGLETON__;
lines.push(`diag: reloads=${diag.reloads} overlayEns=${diag.overlayEnsures} dockEns=${diag.badgeDockEnsures} dibsDealsUpd=${diag.dibsDealsUpdates}`);
}
} catch(_) {}
// name-resolution queue processing removed
if (metrics.warNameRes) {
const wn = metrics.warNameRes;
lines.push(`war names: pend:${(wn.queue&&wn.queue.length)||0} infl:${wn.inFlight||0} res:${wn.resolved||0} fail:${wn.failed||0}`);
}
// Cadence & store sizing
const cadenceMs = at.cadenceMs || 10000;
// Approximate unified status serialized size (in KB) - lightweight, skip if too frequent
if (!metrics._lastSizeSample || (now - metrics._lastSizeSample) > 15000) {
try {
metrics._lastSizeSample = now;
metrics._lastUnifiedJsonLen = JSON.stringify(unified).length;
} catch(_) { metrics._lastUnifiedJsonLen = 0; }
}
const jsonLen = metrics._lastUnifiedJsonLen || 0;
const approxKB = jsonLen ? (jsonLen/1024).toFixed(jsonLen>10240?1:2) : '0';
lines.push(`cadence: ${(cadenceMs/1000).toFixed(1)}s store≈${approxKB}KB`);
// Memory stats (browser support dependent)
try {
state._activityTracking.metrics.memorySamples = state._activityTracking.metrics.memorySamples || [];
let memLine='';
if (performance && performance.memory) {
const { usedJSHeapSize, totalJSHeapSize } = performance.memory; // bytes
const usedMB = (usedJSHeapSize/1048576).toFixed(1);
const totalMB = (totalJSHeapSize/1048576).toFixed(1);
state._activityTracking.metrics.memorySamples.push(usedJSHeapSize);
if (state._activityTracking.metrics.memorySamples.length>60) state._activityTracking.metrics.memorySamples.shift();
const avgUsed = state._activityTracking.metrics.memorySamples.reduce((a,b)=>a+b,0)/state._activityTracking.metrics.memorySamples.length;
memLine = `mem: ${usedMB}/${totalMB}MB avg ${(avgUsed/1048576).toFixed(1)}MB`;
} else if (!metrics._memUnsupportedLogged) {
metrics._memUnsupportedLogged = true;
memLine = 'mem: n/a';
}
if (memLine) lines.push(memLine);
} catch(_) { /* ignore memory */ }
// Recent transition mini-log (last 5)
if (state._activityTracking._transitionLog && state._activityTracking._transitionLog.length) {
const recent = state._activityTracking._transitionLog.slice(-5).reverse();
const nowTs = Date.now();
lines.push('recent:');
for (const tr of recent) {
const age = ((nowTs - tr.ts)/1000).toFixed(0);
lines.push(` ${tr.id}:${tr.from}->${tr.to} (${age}s)`);
}
}
const uiMetrics = state.metrics?.uiRankedWarUi;
if (uiMetrics) {
const totalCalls = uiMetrics.total || 0;
const perMin = uiMetrics.perMinute || 0;
lines.push(`rw ui: total=${totalCalls} rpm=${perMin}`);
if (Array.isArray(uiMetrics.history) && uiMetrics.history.length) {
const hist = uiMetrics.history.slice(-45).map(ts => Math.max(0, Math.round((now - ts) / 1000)));
lines.push(`rw last45s: ${hist.join(',')}`);
}
}
const applyStats = state.metrics?.fetchApply;
if (applyStats && Object.keys(applyStats).length) {
const top = Object.entries(applyStats)
.sort((a, b) => ((b[1]?.lastMs || 0) - (a[1]?.lastMs || 0)))
.slice(0, 3);
if (top.length) {
lines.push('fgd apply (last/avg/max ms):');
for (const [key, stat] of top) {
if (!stat) continue;
const avg = stat.totalMs && stat.runs ? (stat.totalMs / stat.runs) : 0;
const last = typeof stat.lastMs === 'number' ? stat.lastMs.toFixed(1) : '0.0';
const avgVal = Number.isFinite(avg) ? avg.toFixed(1) : '0.0';
const max = typeof stat.maxMs === 'number' ? stat.maxMs.toFixed(1) : '0.0';
lines.push(` ${key}: ${last}/${avgVal}/${max}`);
}
}
}
const storageStats = state.metrics?.storageWrites;
if (storageStats && Object.keys(storageStats).length) {
const recent = Object.entries(storageStats)
.sort((a, b) => ((b[1]?.lastMs || 0) - (a[1]?.lastMs || 0)))
.slice(0, 2);
if (recent.length) {
lines.push('storage set (last ms / size):');
for (const [key, stat] of recent) {
if (!stat) continue;
const sizeKb = stat.lastBytes ? (stat.lastBytes / 1024).toFixed(stat.lastBytes > 2048 ? 1 : 2) : '0.00';
const stringify = typeof stat.lastStringifyMs === 'number' && stat.lastStringifyMs > 0 ? ` s=${stat.lastStringifyMs.toFixed(1)}` : '';
const last = typeof stat.lastMs === 'number' ? stat.lastMs.toFixed(1) : '0.0';
lines.push(` ${key}: ${last}ms / ${sizeKb}KB${stringify}`);
}
}
}
body.innerHTML = lines.join('<br>');
} catch(_) { /* ignore */ }
},
// Scan ranked war summary rows for missing attacker names and enqueue for resolution
// NOTE: name resolution (profile page scraping) has been disabled due to TOS/privacy concerns.
// These stubs preserve the metrics shape but perform no network requests.
_scanWarSummaryForMissingNames: (rows) => {
try {
if (!Array.isArray(rows) || !rows.length) return;
const metrics = state._activityTracking.metrics || (state._activityTracking.metrics = {});
// Ensure structure exists but do not enqueue or fetch profile pages
metrics.warNameRes = metrics.warNameRes || { queue: [], attempts: {}, inFlight: 0, lastAttempt: 0, resolved: 0, failed: 0 };
return;
} catch(_) { /* noop */ }
},
// Name-fetching removed: return a resolved failure immediately
_fetchProfileName: (playerId) => {
return Promise.resolve({ ok: false });
},
// Process name resolution queue removed — keep metrics but avoid any network activity
_processWarNameResolutionQueue: async () => {
try {
const metrics = state._activityTracking.metrics || (state._activityTracking.metrics = {});
metrics.warNameRes = metrics.warNameRes || { queue: [], attempts: {}, inFlight: 0, lastAttempt: 0, resolved: 0, failed: 0 };
return;
} catch(_) { /* noop */ }
},
fetchGlobalData: async (opts = {}) => {
const { force = false, focus = null } = opts || {};
if (document.hidden && !force) {
if (state.debug.cadence || state.debug.apiLogs) {
try { tdmlogger('debug', '[Fetch] skip: document hidden and force flag not set'); } catch(_) {}
}
return;
}
if (api._shouldBailDueToIpRateLimit('fetchGlobalData')) {
if (state.debug.cadence || state.debug.apiLogs) {
try { tdmlogger('debug', '[Fetch] skip: backend IP rate limit active'); } catch(_) {}
}
return;
}
if (state.debug.cadence || state.debug.apiLogs) { try { tdmlogger('debug', `[Fetch] fetchGlobalData begin opts= ${opts}`); } catch(_) {} }
utils.perf.start('fetchGlobalData');
const measureApply = async (name, fn) => {
const hasPerf = typeof performance !== 'undefined' && typeof performance.now === 'function';
const start = hasPerf ? performance.now() : Date.now();
try {
return await fn();
} finally {
const end = hasPerf ? performance.now() : Date.now();
const duration = Math.max(0, end - start);
try {
const metricsRoot = state.metrics || (state.metrics = {});
const applyStats = metricsRoot.fetchApply || (metricsRoot.fetchApply = {});
const entry = applyStats[name] || (applyStats[name] = { runs: 0, totalMs: 0, maxMs: 0, lastMs: 0 });
entry.runs += 1;
entry.lastMs = duration;
entry.totalMs += duration;
if (duration > entry.maxMs) entry.maxMs = duration;
entry.lastAt = Date.now();
} catch(_) { /* metrics collection is best-effort */ }
}
};
// Re-entrancy/throttle guard (PDA can trigger rapid duplicate calls)
const nowMsFG = Date.now();
if (!force && state.script._lastGlobalFetch && (nowMsFG - state.script._lastGlobalFetch) < config.MIN_GLOBAL_FETCH_INTERVAL_MS) {
if (state.debug.cadence || state.debug.apiLogs) { try { tdmlogger('debug', '[Fetch] throttled: last fetch too recent'); } catch(_) {} }
utils.perf.stop('fetchGlobalData');
return;
}
state.script._lastGlobalFetch = nowMsFG;
if (!state.user.tornId) {
tdmlogger('warn', '[TDM] fetchGlobalData: User context missing.');
if (state.debug.cadence || state.debug.apiLogs) { try { tdmlogger('info', '[Fetch] abort: missing user context'); } catch(_) {} }
utils.perf.stop('fetchGlobalData');
return;
}
try {
// Call new backend endpoint
const originalClientTimestamps = (state.dataTimestamps && typeof state.dataTimestamps === 'object') ? state.dataTimestamps : {};
const clientTimestamps = {};
try {
for (const key of Object.keys(originalClientTimestamps)) {
clientTimestamps[key] = originalClientTimestamps[key];
}
// Inject warData timestamp for freshness check
if (state.warData && state.warData.lastUpdated) {
clientTimestamps.warData = state.warData.lastUpdated;
}
// Sanity check: if we claim to have data (via timestamp) but local state is empty, drop the timestamp to force refetch
// This fixes the "chicken-and-egg" issue after a hard reset where timestamps might persist but data is gone.
if (clientTimestamps.warData && (!state.warData || Object.keys(state.warData).length === 0)) {
delete clientTimestamps.warData;
}
if (clientTimestamps.dibsData && (!state.dibsData || !Array.isArray(state.dibsData) || state.dibsData.length === 0)) {
delete clientTimestamps.dibsData;
}
if (clientTimestamps.medDeals && (!state.medDeals || Object.keys(state.medDeals).length === 0)) {
delete clientTimestamps.medDeals;
}
} catch(_) {}
const pendingTracker = typeof handlers._getPendingDibsTracker === 'function' ? handlers._getPendingDibsTracker(false) : null;
const hasPendingDibs = !!(pendingTracker && pendingTracker.ids && pendingTracker.ids.size);
const localDibsMissingButFingerprint = (!Array.isArray(state.dibsData) || state.dibsData.length === 0) && !!state._fingerprints?.dibs;
const shouldForceDibsPayload = (focus === 'dibs') || hasPendingDibs || localDibsMissingButFingerprint;
// Precompute dynamic inputs and time them separately
// TODO figure out if visibleOpponentIds and clientNoteTimestamps is cheapest method for db queries
utils.perf.start('fetchGlobalData.compute.visibleOpponentIds');
const _visibleOpponentIds = utils.getVisibleOpponentIds();
utils.perf.stop('fetchGlobalData.compute.visibleOpponentIds');
utils.perf.start('fetchGlobalData.compute.clientNoteTimestamps');
const _clientNoteTimestamps = utils.getClientNoteTimestamps();
utils.perf.stop('fetchGlobalData.compute.clientNoteTimestamps');
utils.perf.start('fetchGlobalData.api.getGlobalDataForUser');
// Heartbeat logic: if user has an active dib and passive heartbeat enabled, override lastActivityTime to "now"
let effectiveLastActivity = state.script.lastActivityTime;
const clientFingerprints = {
dibs: state._fingerprints?.dibs || null,
medDeals: state._fingerprints?.medDeals || null
};
if (shouldForceDibsPayload) {
if (typeof clientTimestamps.dibs !== 'undefined') delete clientTimestamps.dibs;
clientFingerprints.dibs = null;
if (state.debug?.apiLogs) {
tdmlogger('debug', '[dibs] forcing payload refresh', {
focus,
hasPendingDibs,
localDibsMissingButFingerprint,
originalClientTimestamp: originalClientTimestamps?.dibs
});
}
}
const globalData = await api.post('getGlobalDataForUser', {
tornId: state.user.tornId,
factionId: state.user.factionId,
clientTimestamps,
clientFingerprints,
lastActivityTime: effectiveLastActivity,
visibleOpponentIds: _visibleOpponentIds,
clientNoteTimestamps: _clientNoteTimestamps,
// Pass latest warId if we have one cached from local rankedwars fetch
warId: state?.lastRankWar?.id || null,
warType: state?.warData?.warType || null
});
utils.perf.stop('fetchGlobalData.api.getGlobalDataForUser');
const clientApiMs = utils.perf.getLast('fetchGlobalData.api.getGlobalDataForUser');
if (clientApiMs >= 3000 && globalData && globalData.timings) {
try {
// Avoid [object Object] in some consoles (e.g., PDA)
const pretty = JSON.stringify(globalData.timings);
tdmlogger('info', `[Perf][backend timings] ${pretty}`);
} catch (_) {
tdmlogger('info', `[Perf][backend timings] ${globalData.timings}`);
}
// Slow report: highlight only expensive subtasks to keep logs concise
try {
const t = globalData.timings || {};
const importantKeys = new Set([
'verifyUserMs',
'getFactionSettingsMs',
'getMasterTimestampsMs',
'parallelFetchMs',
'totalHandlerMs'
]);
const entries = Object.entries(t).filter(([k, v]) =>
typeof v === 'number' && (k.startsWith('pf_') || importantKeys.has(k))
);
const SLOW_THRESHOLD = 300; // ms per-subtask
const slow = entries
.filter(([_, v]) => v >= SLOW_THRESHOLD)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `${k}:${v.toFixed(0)}ms`);
// Compute rough overhead (client - backend), when backend timings are available
let overhead = null;
if (typeof t.totalHandlerMs === 'number' && t.totalHandlerMs > 0) {
const delta = clientApiMs - t.totalHandlerMs;
if (isFinite(delta)) overhead = Math.max(0, Math.round(delta));
}
const overheadStr = overhead !== null ? `, overhead≈${overhead}ms` : '';
const report = `SlowReport client=${clientApiMs.toFixed(0)}ms${overheadStr}${slow.length ? ' | ' + slow.join(', ') : ' | no hot subtasks ≥300ms'}`;
tdmlogger('warn', `[Perf] ${report}`);
} catch (_) { /* ignore formatting issues */ }
}
// Persist warStatus meta (phase, nextPollHintSec, updatedAt) if provided so cadence can piggyback on global fetches
try {
// Merge backend-provided chainWatcher list into state for UI convenience
try {
if (globalData && globalData.firebase && globalData.firebase.chainWatcher) {
// Server returns { watchers, meta }
const srv = globalData.firebase.chainWatcher;
const watchers = Array.isArray(srv.watchers) ? srv.watchers.map(c => ({ id: String(c.id || c), name: c.name || c.username || c.displayName || '' })).filter(Boolean) : [];
// Always overwrite local storage with authoritative server list
storage.set('chainWatchers', watchers || []);
// Persist meta for UI display
const meta = srv.meta || null;
try { storage.set('chainWatchers_meta', meta); } catch(_) {}
state.chainWatcher = srv.watchers || [];
// Update timestamp so future polls can skip if unchanged
if (globalData.masterTimestamps && globalData.masterTimestamps.chainWatcher) {
state.dataTimestamps.chainWatcher = globalData.masterTimestamps.chainWatcher;
storage.set('dataTimestamps', state.dataTimestamps);
}
// Refresh select options if present and apply selections
try {
const sel = document.getElementById('tdm-chainwatcher-select');
if (sel && typeof sel === 'object') {
// Unselect all, then select those present in watchers
const vals = new Set((watchers||[]).map(w=>String(w.id)));
for (const opt of sel.options) opt.selected = vals.has(opt.value);
}
} catch(_) {}
ui.updateChainWatcherMeta && ui.updateChainWatcherMeta(meta || null);
}
} catch(_) {}
const wsMeta = globalData?.meta?.warStatus;
if (wsMeta && (wsMeta.phase || wsMeta.nextPollHintSec)) {
const updatedAtMs = (() => {
const u = wsMeta.updatedAt;
if (!u) return Date.now();
if (typeof u === 'number') return u;
if (u && typeof u.toMillis === 'function') return u.toMillis();
if (u && u._seconds) return (u._seconds * 1000) + Math.floor((u._nanoseconds||0)/1e6);
return Date.now();
})();
// Mirror structure of getWarStatus cache: { data: {...status fields...}, fetchedAt }
state._warStatusCache = state._warStatusCache || {};
const existingPhase = state._warStatusCache?.data?.phase;
const phaseChanged = existingPhase && wsMeta.phase && existingPhase !== wsMeta.phase;
state._warStatusCache.data = {
...(state._warStatusCache.data || {}),
phase: wsMeta.phase,
nextPollHintSec: wsMeta.nextPollHintSec,
lastAttackAgeSec: (typeof wsMeta.lastAttackAgeSec === 'number' ? wsMeta.lastAttackAgeSec : state._warStatusCache.data?.lastAttackAgeSec),
// Provide a synthetic lastAttackStarted estimate if absent so interval heuristics (age buckets) can still function.
lastAttackStarted: (() => {
if (state._warStatusCache?.data?.lastAttackStarted) return state._warStatusCache.data.lastAttackStarted;
if (typeof wsMeta.lastAttackAgeSec === 'number') return Math.floor(Date.now()/1000) - wsMeta.lastAttackAgeSec;
return state._warStatusCache?.data?.lastAttackStarted || null;
})()
};
state._warStatusCache.fetchedAt = updatedAtMs;
state.script.lastWarStatusFetchMs = updatedAtMs;
if (phaseChanged && state.debug.cadence) {
tdmlogger('debug', `[Cadence] warStatus phase changed via global fetch -> ${existingPhase} -> ${wsMeta.phase}`);
}
}
// Handle userScore from meta if present (Enhancement #1)
if (globalData?.meta?.userScore) {
state.userScore = globalData.meta.userScore;
try {
tdmlogger('debug', `[GlobalData] Received userScore: ${JSON.stringify(state.userScore)}`);
// Persist lightweight userScore together with the warId to avoid cross-war bleed
try { storage.set('userScore', { warId: state.lastRankWar?.id || null, v: state.userScore }); } catch(_) { storage.set('userScore', state.userScore); }
} catch(_) {}
// Update the badge if the UI function exists
if (typeof ui.updateUserScoreBadge === 'function') {
ui.updateUserScoreBadge();
}
} else {
try { tdmlogger('debug', `[GlobalData] No userScore in meta.`); } catch(_) {}
}
} catch(_) { /* non-fatal */ }
const TRACKED_COLLECTIONS = [
'dibs',
'userNotes',
'medDeals',
'rankedWars',
'rankedWars_attacks',
'rankedWars_summary',
'unauthorizedAttacks',
'attackerActivity',
'warData'
];
// Check if collections have changed
// Removed TornAPICalls_rankedwars handling; ranked wars list is handled client-side
if (utils.isCollectionChanged(clientTimestamps, globalData.masterTimestamps, 'rankedWars')) {
await measureApply('rankedWars', async () => {
utils.perf.start('fetchGlobalData.apply.rankedWars');
// Backend provides warData when changed; rankedWars list is managed client-side now
tdmlogger('debug', `[rankedwars][warData][Master] ${globalData.tornApi.warData}`);
tdmlogger('debug', `[rankedwars][warData][Client] ${state.warData}`);
const incomingWarData = globalData?.tornApi?.warData;
let normalizedWarData;
if (incomingWarData && typeof incomingWarData === 'object' && !Array.isArray(incomingWarData)) {
normalizedWarData = { ...incomingWarData };
} else if (incomingWarData === null) {
normalizedWarData = { warType: 'War Type Not Set' };
} else if (state.warData && typeof state.warData === 'object') {
normalizedWarData = { ...state.warData };
} else {
normalizedWarData = { warType: 'War Type Not Set' };
}
// If the lastRankWar differs from the warId stored in incoming warData or we have a new lastRankWar,
// ensure we reset per-war saved state for the new war so old settings don't carry forward.
const appliedWarData = { ...normalizedWarData };
try {
const currentLastWarId = state.lastRankWar?.id || null;
const incomingWarId = (typeof appliedWarData.warId !== 'undefined') ? appliedWarData.warId : (appliedWarData?.war?.id || appliedWarData?.id || null);
if (currentLastWarId && incomingWarId && String(currentLastWarId) !== String(incomingWarId)) {
// Backend warData doesn't match the currently known lastRankWar — reset to defaults for new war
const defaultInitial = (state.lastRankWar?.war && Number(state.lastRankWar.war.target)) || Number(state.lastRankWar?.target) || 0;
const newWarData = Object.assign({}, { warType: 'War Type Not Set', warId: currentLastWarId, initialTargetScore: defaultInitial });
// Keep opponent info if incoming provided or derive from lastRankWar factions
try {
if (state.lastRankWar && state.lastRankWar.factions) {
const opp = Object.values(state.lastRankWar.factions).find(f => String(f.id) !== String(state.user.factionId));
if (opp) {
newWarData.opponentFactionId = opp.id;
newWarData.opponentFactionName = opp.name;
}
}
} catch(_) {}
storage.updateStateAndStorage('warData', newWarData);
// New war detected: invalidate lightweight userScore cache so we don't show stale scores
try {
state.userScore = null;
if (storage && typeof storage.remove === 'function') storage.remove('userScore');
} catch(_) {}
try { ui.updateUserScoreBadge?.(); } catch(_) {}
try { ui.ensureAttackModeBadge?.(); } catch(_) {}
} else {
// If warId matches (or not provable), accept normalized warData
storage.updateStateAndStorage('warData', appliedWarData);
try { ui.ensureAttackModeBadge?.(); } catch(_) {}
}
} catch (e) {
// If anything goes wrong, fallback to applying the incoming warData to avoid blocking
storage.updateStateAndStorage('warData', appliedWarData);
try { ui.ensureAttackModeBadge?.(); } catch(_) {}
}
state.dataTimestamps.rankedWars = globalData.masterTimestamps.rankedWars;
storage.set('dataTimestamps', state.dataTimestamps);
utils.perf.stop('fetchGlobalData.apply.rankedWars');
});
}
const dibsCollectionChanged = utils.isCollectionChanged(clientTimestamps, globalData.masterTimestamps, 'dibs');
const isForcedDibsRefresh = focus === 'dibs';
const shouldProcessDibs = dibsCollectionChanged || isForcedDibsRefresh || localDibsMissingButFingerprint;
if (shouldProcessDibs && !dibsCollectionChanged && !isForcedDibsRefresh) {
try { tdmlogger('debug', `[dibs] Processing triggered by localDibsMissingButFingerprint (fingerprint: ${state._fingerprints?.dibs})`); } catch(_) {}
}
if (!shouldProcessDibs && (state.debug?.cadence || state.debug?.apiLogs)) {
tdmlogger('debug', '[dibs] collection skip', { changed: dibsCollectionChanged, focus, force, clientTs: originalClientTimestamps?.dibs, masterTs: globalData.masterTimestamps?.dibs });
}
if (shouldProcessDibs) {
await measureApply('dibs', async () => {
utils.perf.start('fetchGlobalData.apply.dibs');
// tdmlogger('debug', `[dibs][Master] ${globalData.firebase.dibsData}`);
// tdmlogger('debug', `[dibs][Client] ${state.dibsData}`);
const incomingFp = globalData?.meta?.warStatus?.dibsFingerprint || globalData?.statusDoc?.dibsFingerprint || globalData?.meta?.dibsFingerprint || null;
try { state._fingerprints = state._fingerprints || {}; } catch(_) {}
const prevFp = state._fingerprints?.dibs || null;
const rawDibs = Array.isArray(globalData.firebase?.dibsData) ? globalData.firebase.dibsData : null;
const computedFp = rawDibs ? utils.computeDibsFingerprint(rawDibs) : null;
const cacheFriendlyDibs = rawDibs ? rawDibs.slice() : (Array.isArray(state.dibsData) ? state.dibsData.slice() : []);
const fingerprintToStore = incomingFp || computedFp || null;
if (state.debug?.apiLogs) {
tdmlogger('debug', '[dibs] fetched payload', {
rawLength: rawDibs ? rawDibs.length : null,
incomingFp,
prevFp,
computedFp,
masterTimestamp: globalData.masterTimestamps?.dibs
});
}
const isFocusedDibsRefresh = focus === 'dibs';
let shouldApply = true;
let dibsApplyReason = 'always apply (simplified)';
const masterTimestamp = globalData.masterTimestamps?.dibs;
const clientTimestamp = originalClientTimestamps?.dibs;
const currentClientLength = Array.isArray(state.dibsData) ? state.dibsData.length : null;
// [Refactor] Removed "fingerprint unchanged" skip logic.
// We always apply server data to ensure consistency and fix stale/shifting UI issues.
if (shouldApply) {
// [Refactor] Removed "Merge pending optimistic dibs" logic.
// Server data is now authoritative. Optimistic updates are for immediate UI feedback only
// and will be overwritten by the next fetch to ensure a single source of truth.
try {
if (state._mutate?.setDibsData) {
state._mutate.setDibsData(cacheFriendlyDibs, { fingerprint: fingerprintToStore, source: 'fetchGlobalData' });
} else {
storage.updateStateAndStorage('dibsData', cacheFriendlyDibs);
if (fingerprintToStore) {
state._fingerprints = state._fingerprints || {};
state._fingerprints.dibs = fingerprintToStore;
try { storage.set('fingerprints', state._fingerprints); } catch(_) {}
}
}
} catch (_) {
storage.updateStateAndStorage('dibsData', cacheFriendlyDibs);
if (fingerprintToStore) {
state._fingerprints = state._fingerprints || {};
state._fingerprints.dibs = fingerprintToStore;
try { storage.set('fingerprints', state._fingerprints); } catch(_) {}
}
}
ui.updateAllPages();
// Attack page inline dib/med message refresh only on change
try { ui.showAttackPageDibMedSummary?.(); } catch(_) {}
}
if (!shouldApply || state.debug?.apiLogs) {
tdmlogger('info', '[dibs] apply decision', {
shouldApply,
reason: dibsApplyReason,
masterTimestamp,
clientTimestamp,
incomingFp,
prevFp,
computedFp,
fingerprintToStore,
rawLength: rawDibs ? rawDibs.length : null,
clientLength: currentClientLength
});
}
// Always advance timestamp to acknowledge server change (even if fingerprint same)
state.dataTimestamps.dibs = globalData.masterTimestamps.dibs;
storage.set('dataTimestamps', state.dataTimestamps);
// [Refactor] Removed pending resolution call.
// handlers._resolvePendingDibsAfterApply(cacheFriendlyDibs);
utils.perf.stop('fetchGlobalData.apply.dibs');
});
}
// Check if we should process med deals (collection changed OR client empty but backend has data)
const collectionChanged = utils.isCollectionChanged(clientTimestamps, globalData.masterTimestamps, 'medDeals');
const clientEmpty = Object.keys(state.medDeals || {}).length === 0;
const backendHasData = Array.isArray(globalData.firebase?.medDeals) && globalData.firebase.medDeals.length > 0;
const shouldProcessMedDeals = collectionChanged || (clientEmpty && backendHasData);
// Debug logging for med deals decision (when debug enabled)
if (state.debug?.apiLogs) {
tdmlogger('debug', '[medDeals] Processing decision after cache clear', {
collectionChanged,
clientEmpty,
backendHasData,
shouldProcessMedDeals
});
}
if (shouldProcessMedDeals) {
await measureApply('medDeals', async () => {
utils.perf.start('fetchGlobalData.apply.medDeals');
tdmlogger('debug', `[medDeals][Master] ${globalData.firebase.medDeals}`);
tdmlogger('debug', `[medDeals][Client] ${state.medDeals}`);
// Enhanced debugging for med deals issue
if (state.debug?.apiLogs) {
tdmlogger('debug', '[medDeals] Raw backend response', {
firebaseSection: globalData.firebase,
medDealsRaw: globalData.firebase?.medDeals,
medDealsType: typeof globalData.firebase?.medDeals,
medDealsIsArray: Array.isArray(globalData.firebase?.medDeals),
globalDataKeys: Object.keys(globalData || {}),
warType: state.warData?.warType,
masterTimestamps: globalData.masterTimestamps?.medDeals
});
}
const incomingFp = globalData?.meta?.warStatus?.medDealsFingerprint || globalData?.statusDoc?.medDealsFingerprint || globalData?.meta?.medDealsFingerprint || null;
try { state._fingerprints = state._fingerprints || {}; } catch(_) {}
const prevFp = state._fingerprints?.medDeals || null;
// Debug logging for med deals processing
if (state.debug?.apiLogs) {
tdmlogger('debug', '[medDeals] Processing details', {
hasData: Array.isArray(globalData.firebase.medDeals),
dataLength: Array.isArray(globalData.firebase.medDeals) ? globalData.firebase.medDeals.length : 'N/A',
incomingFp,
prevFp,
warStatusFp: globalData?.meta?.warStatus?.medDealsFingerprint,
statusDocFp: globalData?.statusDoc?.medDealsFingerprint,
metaFp: globalData?.meta?.medDealsFingerprint
});
}
let applyDeals = true;
let applyReason = 'always apply (simplified)';
// [Refactor] Removed complex fingerprint skipping logic.
// Always apply server data to ensure consistency.
if (applyDeals && Array.isArray(globalData.firebase.medDeals)) {
const incomingArr = globalData.firebase.medDeals;
const nextMap = {};
for (const status of incomingArr) {
if (status && status.id != null) nextMap[status.id] = status;
}
// Use backend fingerprint if available, otherwise compute frontend fingerprint
let fingerprintToUse = incomingFp;
if (!fingerprintToUse) {
try {
fingerprintToUse = utils.computeMedDealsFingerprint(nextMap);
if (state.debug?.apiLogs) tdmlogger('debug', '[medDeals] computed frontend fingerprint:', fingerprintToUse);
} catch(_) {}
}
setMedDeals(nextMap, { fingerprint: fingerprintToUse });
try { ui.showAttackPageDibMedSummary?.(); } catch(_) {}
} else if (!Array.isArray(globalData.firebase.medDeals)) {
if (state.debug?.apiLogs) {
tdmlogger('debug', '[medDeals] Backend omitted medDeals array; preserving existing state', {
medDealsValue: globalData.firebase?.medDeals,
medDealsType: typeof globalData.firebase?.medDeals,
hasFirebaseSection: !!globalData.firebase,
firebaseKeys: globalData.firebase ? Object.keys(globalData.firebase) : 'no firebase section'
});
}
} else if (collectionChanged && clientEmpty && !backendHasData) {
// Special case: Collection changed + client empty + backend empty = likely race condition
// Schedule a retry after a short delay to let backend initialize
if (state.debug?.apiLogs) tdmlogger('debug', '[medDeals] Detected race condition - scheduling retry');
setTimeout(() => {
if (Object.keys(state.medDeals || {}).length === 0) {
if (state.debug?.apiLogs) tdmlogger('debug', '[medDeals] Retrying fetch after race condition');
handlers.fetchGlobalData({ force: true, focus: 'dibs' });
}
}, 2000);
}
// Debug: Check final med deals state (when debug enabled)
if (state.debug?.apiLogs) {
const finalMedDealsCount = Object.keys(state.medDeals || {}).length;
tdmlogger('debug', '[medDeals] Final state after processing', {
finalMedDealsCount,
wasProcessed: shouldProcessMedDeals,
hadBackendData: backendHasData
});
}
// Always advance timestamp regardless of whether we applied new map
state.dataTimestamps.medDeals = globalData.masterTimestamps.medDeals;
storage.set('dataTimestamps', state.dataTimestamps);
utils.perf.stop('fetchGlobalData.apply.medDeals');
});
}
// Always apply retaliation opportunities from backend (10s cached server-side)
await measureApply('retaliationOpportunities', async () => {
try {
const retals = Array.isArray(globalData?.tornApi?.retaliationOpportunities)
? globalData.tornApi.retaliationOpportunities : [];
if (retals) {
const map = {};
const nowSec = Math.floor(Date.now() / 1000);
for (const r of retals) {
if (!r || r.attackerId == null) continue;
// Keep slight grace if server marks expired within a few seconds
const tr = Number(r.timeRemaining || 0);
const expired = !!r.expired && tr <= 0;
if (expired) continue;
map[String(r.attackerId)] = {
attackerId: Number(r.attackerId),
attackerName: r.attackerName,
defenderId: r.defenderId,
defenderName: r.defenderName,
retaliationEndTime: Number(r.retaliationEndTime || 0),
timeRemaining: tr > 0 ? tr : Math.max(0, Number(r.retaliationEndTime || 0) - nowSec),
expired: false
};
// Stamp a short "Retal Done" meta when backend indicates fulfillment
if (r.fulfilled) {
const id = String(r.attackerId);
const nowMs = Date.now();
state.rankedWarChangeMeta = state.rankedWarChangeMeta || {};
const prevMeta = state.rankedWarChangeMeta[id];
const withinTtl = prevMeta && prevMeta.activeType === 'retalDone' && (nowMs - (prevMeta.ts || 0) < 60000);
const moreImportant = prevMeta && (prevMeta.activeType === 'retal' || prevMeta.activeType === 'status' || prevMeta.activeType === 'activity');
if (!withinTtl && !moreImportant) {
state.rankedWarChangeMeta[id] = { activeType: 'retalDone', pendingText: 'Retal Done', ts: nowMs };
}
}
}
storage.updateStateAndStorage('retaliationOpportunities', map);
try { ui.updateRetalsButtonCount?.(); } catch(_) {}
}
} catch(_) { /* non-fatal */ }
});
if (utils.isCollectionChanged(clientTimestamps, globalData.masterTimestamps, 'userNotes')) {
await measureApply('userNotes', async () => {
utils.perf.start('fetchGlobalData.apply.userNotes');
// Merge delta results
const delta = Array.isArray(globalData.firebase.userNotesDelta) ? globalData.firebase.userNotesDelta : [];
const missing = Array.isArray(globalData.firebase.userNotesMissing) ? globalData.firebase.userNotesMissing : [];
if (delta.length > 0 || missing.length > 0) {
const merged = { ...(state.userNotes || {}) };
for (const note of delta) {
if (!note || !note.id) continue;
merged[note.id] = note;
}
for (const id of missing) {
if (id in merged) delete merged[id];
}
storage.updateStateAndStorage('userNotes', merged);
state.dataTimestamps.userNotes = globalData.masterTimestamps.userNotes;
storage.set('dataTimestamps', state.dataTimestamps);
}
utils.perf.stop('fetchGlobalData.apply.userNotes');
});
}
if (utils.isCollectionChanged(clientTimestamps, globalData.masterTimestamps, 'dibsNotifications')) {
await measureApply('dibsNotifications', async () => {
utils.perf.start('fetchGlobalData.apply.dibsNotifications');
tdmlogger('debug', `[dibsNotifications][Master] ${globalData.firebase.dibsNotifications}`);
tdmlogger('debug', `[dibsNotifications][ClientTs] ${state.dataTimestamps.dibsNotifications}`);
// Initialize / load seen map (id -> firstSeenSec)
if (!state._seenDibsNotificationIds) {
const persisted = storage.get('seenDibsNotificationIds', {});
state._seenDibsNotificationIds = (persisted && typeof persisted === 'object') ? persisted : {};
}
const nowSec = Math.floor(Date.now() / 1000);
const SEEN_TTL_SEC = 6 * 60 * 60; // 6h retention window
try {
for (const id of Object.keys(state._seenDibsNotificationIds)) {
if ((nowSec - (state._seenDibsNotificationIds[id] || 0)) > SEEN_TTL_SEC) delete state._seenDibsNotificationIds[id];
}
} catch(_) {}
if (Array.isArray(globalData.firebase.dibsNotifications) && globalData.firebase.dibsNotifications.length > 0) {
const raw = globalData.firebase.dibsNotifications.slice();
raw.sort((a, b) => (b.createdAt?._seconds || 0) - (a.createdAt?._seconds || 0));
const displayedOpponentEventWindow = {}; // opponentId -> lastShownSec
const DEDUPE_WINDOW_SEC = 15; // suppress rapid duplicates
const myTornId = String(state.user?.tornId || '');
raw.forEach(notification => {
if (!notification || !notification.id) return;
const createdAtSec = notification.createdAt?._seconds || 0;
// Skip if already seen
if (state._seenDibsNotificationIds[notification.id]) return;
// Skip if action initiated by current user
if (String(notification.removedByUserId || '') === myTornId) return;
// Skip notification for "Replaced by new dibs" (auto-acknowledge)
if (notification.removalReason === 'Replaced by new dibs') {
state._seenDibsNotificationIds[notification.id] = nowSec;
storage.set('seenDibsNotificationIds', state._seenDibsNotificationIds);
api.post('markDibsNotificationAsRead', { notificationId: notification.id }).catch(() => {});
return;
}
// Dedupe per opponent in short window
const oppId = String(notification.opponentId || '');
if (oppId) {
const lastShown = displayedOpponentEventWindow[oppId] || 0;
if (createdAtSec - lastShown < DEDUPE_WINDOW_SEC) return;
displayedOpponentEventWindow[oppId] = createdAtSec;
}
const message = `Your dibs on ${notification.opponentName} was removed. Reason: ${notification.removalReason}. Click to dismiss.`;
let isMarked = false;
const markAsRead = async () => {
// Local guard for this closure
if (isMarked) return;
// Shared seen map guard - avoid duplicate server calls across tabs/closures
try {
if (state._seenDibsNotificationIds && state._seenDibsNotificationIds[notification.id]) {
isMarked = true;
return;
}
} catch(_) { /* ignore */ }
isMarked = true;
try { await api.post('markDibsNotificationAsRead', { notificationId: notification.id }); } catch(_) {}
state._seenDibsNotificationIds[notification.id] = nowSec;
storage.set('seenDibsNotificationIds', state._seenDibsNotificationIds);
};
// Keep toast visible 60s but attempt to mark read earlier (20s) if still unseen
ui.showMessageBox(message, 'warning', 60000, markAsRead);
setTimeout(() => { try { if (!state._seenDibsNotificationIds || !state._seenDibsNotificationIds[notification.id]) markAsRead(); } catch(_) {} }, 10000);
// Best-effort: force a focused dibs refresh so UI reflects removal immediately
try { setTimeout(() => { try { handlers.fetchGlobalDataForced && handlers.fetchGlobalDataForced('dibs'); } catch(_) {} }, 300); } catch(_) {}
});
storage.set('seenDibsNotificationIds', state._seenDibsNotificationIds);
}
state.dataTimestamps.dibsNotifications = globalData.masterTimestamps.dibsNotifications;
storage.set('dataTimestamps', state.dataTimestamps);
utils.perf.stop('fetchGlobalData.apply.dibsNotifications');
});
}
// Removed Firestore rankedWars_summary fallback; storage artifacts & local aggregation are authoritative now.
// Score cap check
utils.perf.start('fetchGlobalData.scoreCap');
await handlers.checkTermedWarScoreCap();
utils.perf.stop('fetchGlobalData.scoreCap');
if (!focus || focus === 'all') {
utils.perf.start('fetchGlobalData.ui.updateAllPages');
ui.updateAllPages();
utils.perf.stop('fetchGlobalData.ui.updateAllPages');
try { ui.showAttackPageDibMedSummary?.(); } catch(_) {}
} else if (focus === 'dibs') {
// Minimal UI patch: only re-render dibs dependent areas
try { ui.updateFactionPageUI?.(document); } catch(_) {}
try { ui.processRankedWarTables?.().catch(e => {
try { tdmlogger('error', `[processRankedWarTables] failed: ${e}`); } catch(_) {}
}); } catch(_) {}
try { ui.showAttackPageDibMedSummary?.(); } catch(_) {}
}
// Run enforcement pass (auto-removals) after UI updates
utils.perf.start('fetchGlobalData.enforcePolicies');
handlers.enforceDibsPolicies?.();
utils.perf.stop('fetchGlobalData.enforcePolicies');
} catch (error) {
tdmlogger('error', `Error fetching global data: ${error}`);
if (state.debug.cadence || state.debug.apiLogs) { try { tdmlogger('debug', `[Fetch] error: ${error?.message || String(error)}`); } catch(_) {} }
}
utils.perf.stop('fetchGlobalData');
if (state.debug.cadence || state.debug.apiLogs) { try { tdmlogger('debug', `[TDM][Fetch] fetchGlobalData end totalMs=${utils.perf.getLast('fetchGlobalData')}`); } catch(_) {} }
},
fetchGlobalDataForced: async (focus = null) => handlers.fetchGlobalData({ force: true, focus }),
fetchUnauthorizedAttacks: async () => {
try {
const response = await api.get('getUnauthorizedAttacks', { factionId: state.user.factionId });
// Persist to state/storage so UI modals render results immediately
const list = Array.isArray(response) ? response : [];
storage.updateStateAndStorage('unauthorizedAttacks', list);
return list;
} catch (error) {
if (error?.message?.includes('FAILED_PRECONDITION')) {
storage.updateStateAndStorage('unauthorizedAttacks', []);
return [];
}
tdmlogger('warn', `Non-critical error fetching unauthorized attacks: ${error.message || 'Unknown error'}`);
storage.updateStateAndStorage('unauthorizedAttacks', []);
return [];
}
},
checkAndDisplayDibsNotifications: async () => {
if (!state.user.tornId) return;
try {
let notifications = await api.get('getDibsNotifications', { factionId: state.user.factionId });
if (Array.isArray(notifications) && notifications.length > 0) {
notifications.sort((a, b) => (b.createdAt?._seconds || 0) - (a.createdAt?._seconds || 0));
notifications.forEach(notification => {
const message = `Your dibs on ${notification.opponentName} was removed. Reason: ${notification.removalReason}. Click to dismiss.`;
let isMarked = false;
const markAsRead = async () => {
if (isMarked) return;
try {
if (state._seenDibsNotificationIds && state._seenDibsNotificationIds[notification.id]) {
isMarked = true;
return;
}
} catch(_) { /* ignore */ }
isMarked = true;
await api.post('markDibsNotificationAsRead', { notificationId: notification.id, factionId: state.user.factionId });
// Persist seen so other closures/tabs will short-circuit
try { state._seenDibsNotificationIds = state._seenDibsNotificationIds || {}; state._seenDibsNotificationIds[notification.id] = Math.floor(Date.now()/1000); storage.set('seenDibsNotificationIds', state._seenDibsNotificationIds); } catch(_) {}
};
ui.showMessageBox(message, 'warning', 60000, markAsRead);
setTimeout(() => { try { if (!state._seenDibsNotificationIds || !state._seenDibsNotificationIds[notification.id]) markAsRead(); } catch(_) {} }, 10000);
});
}
} catch (error) {
tdmlogger('error', `Error fetching dibs notifications: ${error}`);
}
},
dibsTarget: async (opponentId, opponentName, buttonElement) => {
const originalText = buttonElement.textContent;
try {
const opts = utils.getDibsStyleOptions();
const [oppStat, meStat] = await Promise.all([
utils.getUserStatus(opponentId),
utils.getUserStatus(null)
]);
const myCanon = meStat.canonical;
if (opts.allowedUserStatuses && opts.allowedUserStatuses[myCanon] === false) {
throw new Error(`Your status (${myCanon}) is not allowed to place dibs by faction policy.`);
}
const canonOpp = oppStat.canonical;
if (opts.allowStatuses && opts.allowStatuses[canonOpp] === false) {
throw new Error(`Target status (${canonOpp}) is not allowed by faction policy.`);
}
// New: last_action.status gate (Online/Idle/Offline)
const act = String(oppStat.activity || '').trim();
if (opts.allowLastActionStatuses && act && opts.allowLastActionStatuses[act] === false) {
throw new Error(`Target activity (${act}) is not allowed by faction policy.`);
}
// If configured: limit dibbing a Hospital opponent to those with release time under N minutes
if (canonOpp === 'Hospital') {
const limitMin = Number(opts.maxHospitalReleaseMinutes || 0);
if (limitMin > 0) {
const remaining = Math.max(0, (oppStat.until || 0) - Math.floor(Date.now() / 1000));
if (remaining > limitMin * 60) {
throw new Error(`Target is hospitalized too long (${Math.ceil(remaining/60)}m). Policy allows < ${limitMin}m.`);
}
}
}
// Derive opponent faction id directly from status cache (preferred) then snapshot/warData fallbacks
let opponentFactionId = null;
try {
const entry = state.session?.userStatusCache?.[opponentId];
if (entry && entry.factionId) opponentFactionId = String(entry.factionId);
} catch(_) {}
if (!opponentFactionId) {
try {
const snap = state.rankedWarTableSnapshot && state.rankedWarTableSnapshot[opponentId];
if (snap?.factionId) opponentFactionId = String(snap.factionId);
} catch(_) {}
}
if (!opponentFactionId) {
try { if (oppStat?.factionId) opponentFactionId = String(oppStat.factionId); } catch(_) {}
}
if (!opponentFactionId) {
try { if (state.warData?.opponentFactionId) opponentFactionId = String(state.warData.opponentFactionId); } catch(_) {}
}
// Immediate feedback: Set button to "Saving..." state
if (buttonElement) {
try {
buttonElement.textContent = 'Saving...';
buttonElement.disabled = true;
buttonElement.className = 'btn dibs-btn btn-dibs-inactive'; // Keep neutral style while saving
} catch(_) {}
}
const resp = await api.post('dibsTarget', { userid: state.user.tornId, username: state.user.tornUsername, opponentId, opponentname: opponentName, warType: state.warData.warType, factionId: state.user.factionId, userStatusAtDib: myCanon, opponentStatusAtDib: canonOpp, opponentFactionId });
// Backend returns an object. When a new dib is created it includes an `id`.
// If no `id` is present the backend may have returned a non-error informational
// message (for example: "You already have dibs on X."). Handle that gracefully
// instead of always showing a success toast.
const createdId = resp?.id;
const serverMsg = (resp && resp.message) ? String(resp.message) : '';
let needsDibsRefresh = false;
if (createdId) {
ui.showMessageBox(`Successfully dibbed ${opponentName}!`, 'success');
if (resp.dibsData && Array.isArray(resp.dibsData)) {
// Use reactive setter to ensure UI updates and fingerprint consistency
if (state._mutate?.setDibsData) {
const fp = utils.computeDibsFingerprint ? utils.computeDibsFingerprint(resp.dibsData) : null;
state._mutate.setDibsData(resp.dibsData, { fingerprint: fp, source: 'dibsTarget' });
} else {
storage.updateStateAndStorage('dibsData', resp.dibsData);
}
// Mark recent-active window so activityTick treats this user as active
try {
const pingMs = Number(config.ATTACKER_ACTIVITY_PING_MS) || 60000;
state._recentlyHadActiveDibsUntil = Date.now() + (pingMs * 2);
} catch(_) {}
} else {
// Optimistic local state update so immediate removal works without waiting for global refresh
try {
const previouslyActive = [];
// Mark any existing active dibs by me as inactive locally (they were server-updated)
for (const d of state.dibsData) {
if (d.userId === String(state.user.tornId) && d.dibsActive) {
if (String(d.opponentId) !== String(opponentId)) {
previouslyActive.push({ id: String(d.opponentId), name: d.opponentname || d.opponentName || '' });
}
d.dibsActive = false; // local optimistic
}
}
// Push new dib record
const optimistic = {
id: createdId,
factionId: String(state.user.factionId),
userId: String(state.user.tornId),
username: state.user.tornUsername,
opponentId: String(opponentId),
opponentname: opponentName,
dibbedAt: { _seconds: Math.floor(Date.now()/1000) },
lastActionTimestamp: { _seconds: Math.floor(Date.now()/1000) },
dibsActive: true,
warType: state.warData.warType || 'unknown',
userStatusAtDib: myCanon,
opponentStatusAtDib: canonOpp,
opponentFactionId: opponentFactionId || null
};
state.dibsData.push(optimistic);
// [Refactor] Removed pending tracker call. Server data is authoritative.
// handlers._trackPendingDibsId(createdId);
// Use reactive setter to persist & emit
try { state._mutate.setDibsData(state.dibsData.slice(), { source: 'optimistic-dib' }); } catch(_) { storage.set('dibsData', state.dibsData); }
// Mark recent-active window so activityTick treats this user as active
try {
const pingMs = Number(config.ATTACKER_ACTIVITY_PING_MS) || 60000;
state._recentlyHadActiveDibsUntil = Date.now() + (pingMs * 2);
} catch(_) {}
if (previouslyActive.length && utils.updateDibsButton) {
previouslyActive.forEach(({ id, name }) => {
try {
const btns = document.querySelectorAll(`.dibs-btn[data-opponent-id='${id}']`);
btns.forEach(btn => utils.updateDibsButton(btn, id, name || btn?.dataset?.opponentName || opponentName));
} catch(_) { /* ignore */ }
});
}
} catch (e) {
tdmlogger('warn', `[dibsTarget][optimistic] failed: ${e}`);
}
}
} else if (serverMsg.toLowerCase().includes('already have dibs')) {
// Informational: user already owns dibs. Reactivate locally if needed.
ui.showMessageBox(`You already have dibs on ${opponentName}.`, 'info');
try {
const existing = state.dibsData.find(d => String(d.opponentId) === String(opponentId) && String(d.userId) === String(state.user.tornId));
if (existing) {
existing.dibsActive = true;
try { const pingMs = Number(config.ATTACKER_ACTIVITY_PING_MS) || 60000; state._recentlyHadActiveDibsUntil = Date.now() + (pingMs * 2); } catch(_) {}
// Invalidate fingerprint to force re-sync
if (state._fingerprints) state._fingerprints.dibs = null;
try { state._mutate.setDibsData(state.dibsData.slice(), { source: 'optimistic-reactivate' }); } catch(_) { storage.set('dibsData', state.dibsData); }
}
} catch(_) {}
} else {
// Unknown non-error response: show server message if present, otherwise show generic success
ui.showMessageBox(serverMsg || `Successfully dibbed ${opponentName}!`, 'success');
// If success but no ID, assume it worked and try to reactivate local state if possible
try {
const existing = state.dibsData.find(d => String(d.opponentId) === String(opponentId) && String(d.userId) === String(state.user.tornId));
if (existing) {
existing.dibsActive = true;
try { const pingMs = Number(config.ATTACKER_ACTIVITY_PING_MS) || 60000; state._recentlyHadActiveDibsUntil = Date.now() + (pingMs * 2); } catch(_) {}
if (state._fingerprints) state._fingerprints.dibs = null;
try { state._mutate.setDibsData(state.dibsData.slice(), { source: 'optimistic-reactivate' }); } catch(_) { storage.set('dibsData', state.dibsData); }
}
} catch(_) {}
}
// Refresh dibs data to ensure authoritative state from backend
// [Refactor] Removed delay. We want to fetch ASAP. Optimistic UI handles the gap.
// If the server is slightly behind, the next poll will catch it.
handlers.fetchGlobalDataForced('dibs');
// Update UI via centralized helper for all matching dibs buttons (optimistic local state)
try {
const allBtns = document.querySelectorAll(`.dibs-btn[data-opponent-id='${opponentId}']`);
if (allBtns.length) {
allBtns.forEach(btn => {
if (utils.updateDibsButton) utils.updateDibsButton(btn, opponentId, opponentName);
});
} else if (buttonElement && utils.updateDibsButton) {
utils.updateDibsButton(buttonElement, opponentId, opponentName);
}
// Ensure the clicked button shows the 'YOU Dibbed' state immediately
try {
if (buttonElement && buttonElement instanceof Element) {
const active = state.dibsData.find(d => d && d.opponentId === String(opponentId) && d.dibsActive && String(d.userId) === String(state.user.tornId));
if (active) {
try {
buttonElement.textContent = 'YOU Dibbed';
buttonElement.className = 'btn dibs-btn btn-dibs-success-you';
// clickable only if removable by owner or admin
const dis = !(String(active.userId) === String(state.user.tornId) || (state.script?.canAdministerMedDeals && storage.get('adminFunctionality', true) === true));
buttonElement.disabled = dis;
const removeHandler = (typeof handlers.debouncedRemoveDibsForTarget === 'function') ? handlers.debouncedRemoveDibsForTarget : (typeof handlers.removeDibsForTarget === 'function' ? handlers.removeDibsForTarget : null);
if (removeHandler) buttonElement.onclick = (e) => removeHandler(opponentId, e.currentTarget);
} catch(_) { /* silent */ }
}
}
} catch(_) { /* noop */ }
} catch(_) { /* noop */ }
// Optional: auto-compose dibs message to chat
// Defer to allow UI to repaint first (chat operations can be heavy)
setTimeout(() => ui.sendDibsMessage(opponentId, opponentName, null), 50);
// Background normal refresh (debounced) for rest of data
try { if (state.debug.cadence) tdmlogger('debug', `[Cadence] scheduling debounced fetchGlobalData`); } catch(_) {}
handlers.debouncedFetchGlobalData();
} catch (error) {
// Revert button state on error
if (buttonElement) {
try {
buttonElement.textContent = originalText || 'Dibs';
buttonElement.disabled = false;
buttonElement.className = 'btn dibs-btn btn-dibs-inactive';
} catch(_) {}
}
const msg = String(error?.message || 'Unknown error');
const already = msg.toLowerCase().includes('already') || error?.code === 'already-exists' || error?.alreadyDibbed === true;
if (!already) {
ui.showMessageBox(`Failed to dib: ${msg}`, 'error');
buttonElement.className = 'btn dibs-btn btn-dibs-inactive';
buttonElement.textContent = originalText;
return;
}
// Resolve current owner name
let dibberName = error?.dibber?.name || error?.dibberName || null;
if (!dibberName) {
try {
const existing = state.dibsData.find(d => d.opponentId === String(opponentId) && d.dibsActive);
if (existing) dibberName = existing.username || existing.user || existing.userId;
} catch(_) {}
}
if (!dibberName) dibberName = 'Someone';
ui.showMessageBox(`${opponentName} is already dibbed by ${dibberName}.`, 'info');
const canRemove = !!(state.script?.canAdministerMedDeals && (storage.get('adminFunctionality', true) === true));
if (dibberName !== 'Someone') {
// Use helper for consistency (will compute correct class/disable)
if (utils.updateDibsButton) utils.updateDibsButton(buttonElement, opponentId, opponentName);
} else {
// Unknown owner -> neutral inactive until refresh clarifies
if (utils.updateDibsButton) utils.updateDibsButton(buttonElement, opponentId, opponentName);
}
// Force a dibs-only refresh soon; also a debounced global refresh
handlers.fetchGlobalDataForced('dibs');
handlers.debouncedFetchGlobalData();
}
},
removeDibsForTarget: async (opponentId, buttonElement) => {
const originalText = buttonElement.textContent;
try {
const dib = state.dibsData.find(d => d.opponentId === String(opponentId) && d.dibsActive);
if (dib) {
// Provide third button: Send to Chat (posts status then removes)
const result = await ui.showConfirmationBox(`Remove ${dib.username}'s dibs for ${dib.opponentname}?`, true, {
thirdLabel: 'Send to Chat',
onThird: () => {
// Re-announce the existing dib with current status (not an undib)
ui.sendDibsMessage(opponentId, dib.opponentname, dib.username);
}
});
if (result === 'third') {
// "Send to Chat" was clicked - don't remove the dib, just return
buttonElement.textContent = originalText;
return;
} else if (result === true) {
// "Yes" was clicked - remove the dib
let resp = null;
try {
resp = await api.post('removeDibs', { dibsDocId: dib.id, removedByUsername: state.user.tornUsername, factionId: state.user.factionId });
} catch (e) {
const em = String(e?.message||'').toLowerCase();
if (!(em.includes('not found') || em.includes('inactive') || e?.code === 'not-found')) throw e;
}
if (resp && resp.dibsData && Array.isArray(resp.dibsData)) {
if (state._mutate?.setDibsData) {
const fp = utils.computeDibsFingerprint ? utils.computeDibsFingerprint(resp.dibsData) : null;
state._mutate.setDibsData(resp.dibsData, { fingerprint: fp, source: 'removeDibs' });
} else {
storage.updateStateAndStorage('dibsData', resp.dibsData);
}
try { state._recentlyHadActiveDibsUntil = 0; } catch(_) {}
} else {
dib.dibsActive = false;
try { state._recentlyHadActiveDibsUntil = 0; } catch(_) {}
// Invalidate fingerprint so next fetch forces a re-apply (crucial if we re-add the same dib quickly)
if (state._fingerprints) state._fingerprints.dibs = null;
}
ui.showMessageBox('Dibs removed!', 'success');
if (utils.updateDibsButton) utils.updateDibsButton(buttonElement, opponentId, dib.opponentname || opponentName);
// handlers.fetchGlobalDataForced('dibs'); // No longer needed if we have data
} else {
// "No" was clicked
buttonElement.textContent = originalText;
}
} else {
// Possible race: local state not yet updated; treat as success-idempotent
ui.showMessageBox('No active dibs to remove (already cleared).', 'info');
if (utils.updateDibsButton) utils.updateDibsButton(buttonElement, opponentId, opponentName);
}
} catch (error) {
ui.showMessageBox(`Failed to remove dibs: ${error.message}`, 'error');
buttonElement.disabled = false;
buttonElement.textContent = originalText;
}
},
handleMedDealToggle: async (opponentId, opponentName, setMedDeal, medDealForUserId, medDealForUsername, buttonElement) => {
const originalText = buttonElement ? (buttonElement.dataset.originalText || buttonElement.innerHTML) : '';
let previousMedDeals = {};
try {
if (!setMedDeal) {
const confirmed = await ui.showConfirmationBox(`Remove Med Deal with ${opponentName}?`);
if (!confirmed) {
buttonElement.disabled = false;
buttonElement.innerHTML = originalText;
return;
}
}
// [Refactor] Optimistic UI update
previousMedDeals = (state.medDeals && typeof state.medDeals === 'object') ? { ...state.medDeals } : {};
try {
if (typeof patchMedDeals === 'function') {
patchMedDeals(current => {
if (setMedDeal) {
current[opponentId] = {
id: opponentId,
isMedDeal: true,
medDealForUserId: medDealForUserId,
medDealForUsername: medDealForUsername,
updatedAt: Date.now()
};
} else {
if (current[opponentId]) delete current[opponentId];
}
return current;
}, { source: 'optimistic-toggle' });
}
if (buttonElement && utils.updateMedDealButton) {
utils.updateMedDealButton(buttonElement, opponentId, opponentName);
}
} catch (e) { tdmlogger('warn', `[medDeals] optimistic update failed: ${e}`); }
let opponentFactionId = null;
try {
const entry = state.session?.userStatusCache?.[opponentId];
if (entry?.factionId) opponentFactionId = String(entry.factionId);
} catch(_) {}
if (!opponentFactionId) {
try {
const snap = state.rankedWarTableSnapshot && state.rankedWarTableSnapshot[opponentId];
if (snap?.factionId) opponentFactionId = String(snap.factionId);
} catch(_) {}
}
if (!opponentFactionId) {
try { const st = await utils.getUserStatus(opponentId); if (st?.factionId) opponentFactionId = String(st.factionId); } catch(_) {}
}
if (!opponentFactionId) {
try { if (state.warData?.opponentFactionId) opponentFactionId = String(state.warData.opponentFactionId); } catch(_) {}
}
const resp = await api.post('updateMedDeal', {
actionInitiatorUserId: state.user.tornId,
actionInitiatorUsername: state.user.tornUsername,
targetOpponentId: opponentId,
opponentName,
setMedDeal,
medDealForUserId,
medDealForUsername,
factionId: state.user.factionId,
opponentFactionId
});
if (resp && resp.medDeals && Array.isArray(resp.medDeals)) {
if (state._mutate?.setMedDeals) {
// Convert array to map if needed, or assume setMedDeals handles it?
// Wait, medDeals is usually a map in state, but backend returns array?
// Let's check backend return format.
// Backend: medDeals = medDealsSnap.docs.map(d => ({ id: d.id, ...d.data() })); -> Array
// Frontend state.medDeals is usually an object/map.
// Let's check setMedDeals implementation.
// If setMedDeals expects a map, we need to convert.
// If setMedDeals expects an array, we are good.
// Let's assume we need to convert array to map for consistency with typical state structure.
const map = {};
resp.medDeals.forEach(d => { map[d.id] = d; });
const fp = utils.computeMedDealsFingerprint ? utils.computeMedDealsFingerprint(map) : null;
state._mutate.setMedDeals(map, { fingerprint: fp, source: 'updateMedDeal' });
} else {
// Fallback: storage.updateStateAndStorage might expect map too?
// getGlobalData converts? No, getGlobalData receives object from backend usually?
// Let's check getGlobalData backend.
// Backend getGlobalData returns object for medDeals?
// No, Firestore returns collections as arrays usually, but getGlobalData might format it.
// Let's check setMedDeals signature first.
const map = {};
resp.medDeals.forEach(d => { map[d.id] = d; });
storage.updateStateAndStorage('medDeals', map);
}
}
ui.showMessageBox(`Med Deal with ${opponentName} ${setMedDeal ? 'set' : 'removed'}.`, 'success');
// await handlers.fetchGlobalData();
} catch (error) {
// Revert optimistic update
try {
if (typeof setMedDeals === 'function') {
setMedDeals(previousMedDeals, { source: 'revert-error' });
}
if (buttonElement && utils.updateMedDealButton) {
utils.updateMedDealButton(buttonElement, opponentId, opponentName);
}
} catch(_) {}
ui.showMessageBox(`Failed to update Med Deal: ${error.message}`, 'error');
buttonElement.innerHTML = originalText;
}
},
checkTermedWarScoreCap: async () => {
// Only run this check if it's a termed war with caps set
if (state.warData.warType !== 'Termed War') {
return;
}
utils.perf.start('checkTermedWarScoreCap');
const warId = state.lastRankWar?.id;
if (!warId) return; // Exit if we don't have a valid war ID
// Only check while the war is active or within grace window
if (!utils.isWarInActiveOrGrace(warId, 6)) return;
const storageKey = `scoreCapAcknowledged_${warId}`; // individual
const storageKeyFaction = `scoreCapFactionAcknowledged_${warId}`;
const factionBannerKey = `factionCapNotified_${warId}`;
// If a prior war left the session flag set, clear it so we can re-evaluate for this war
if (state.user.hasReachedScoreCap && state.user._scoreCapWarId && state.user._scoreCapWarId !== warId) {
state.user.hasReachedScoreCap = false;
}
// Check if the user has already acknowledged the cap for this specific war
if (storage.get(storageKey, false)) {
state.user.hasReachedScoreCap = true; // Set session state for attack page warnings
state.user._scoreCapWarId = warId;
return; // Exit to prevent showing the popup again
}
// Stop if the user has already been notified in this session (fallback check)
if (state.user.hasReachedScoreCap && state.user._scoreCapWarId === warId) {
return;
}
try {
utils.perf.start('computeUserScoreCapCheck');
const scoreType = state.warData.individualScoreType || state.warData.scoreType || 'Respect';
let userScore = 0;
let usedLightweight = false;
// Enhancement #1: Use lightweight userScore if available and sufficient
if (state.userScore) {
if (scoreType === 'Respect') { userScore = Number(state.userScore.r || 0); usedLightweight = true; }
else if (scoreType === 'Attacks') { userScore = Number(state.userScore.s || 0); usedLightweight = true; }
else if (scoreType === 'Respect (no chain)') { userScore = Number(state.userScore.rnc || 0); usedLightweight = true; }
else if (scoreType === 'Respect (no bonus)') { userScore = Number(state.userScore.rnb || 0); usedLightweight = true; }
}
// TODO Prune now that user score comes from fetchGlobalData
if (!usedLightweight) {
const rows = await utils.getSummaryRowsCached(warId, state.user.factionId);
if (Array.isArray(rows) && rows.length > 0) {
const me = rows.find(r => String(r.attackerId) === String(state.user.tornId));
if (me) {
userScore = utils.computeScoreFromRow(me, scoreType);
}
}
}
const indivCap = Number((state.warData.individualScoreCap ?? state.warData.scoreCap) || 0) || 0;
if (indivCap > 0 && userScore >= indivCap) {
state.user.hasReachedScoreCap = true;
state.user._scoreCapWarId = warId;
const confirmed = await ui.showConfirmationBox('You have reached your target score. Do not make any more attacks. Your dibs and med deals will be deactivated.', false);
if (confirmed) {
storage.set(storageKey, true);
try { await api.post('deactivateDibsAndDealsForUser', { userId: state.user.tornId, factionId: state.user.factionId }); } catch(_) {}
await handlers.fetchGlobalData();
}
} else {
state.user.hasReachedScoreCap = false;
state.user._scoreCapWarId = warId;
}
utils.perf.stop('computeUserScoreCapCheck');
utils.perf.start('checkFactionScoreCap');
// FACTION CAP (authoritative lastRankWar.factions only) - banner removed
const facCap = Number((state.warData.factionScoreCap ?? state.warData.scoreCap) || 0) || 0;
if (facCap > 0) {
let factionTotal = 0;
try {
const lw = state.lastRankWar;
if (lw && Array.isArray(lw.factions)) {
const ourFac = lw.factions.find(f => String(f.id) === String(state.user.factionId));
if (ourFac && typeof ourFac.score === 'number') factionTotal = Number(ourFac.score) || 0;
}
} catch(_) { /* noop */ }
const reached = factionTotal >= facCap;
if (reached && !storage.get(storageKeyFaction, false)) {
await ui.showConfirmationBox('Faction score cap reached. Stop attacks unless leadership directs otherwise.', false);
storage.set(storageKeyFaction, true);
}
}
utils.perf.stop('checkFactionScoreCap');
} catch(error) {
tdmlogger('error', `Error checking score cap: ${error}`);
utils.perf.stop('checkTermedWarScoreCap');
utils.perf.stop('computeUserScoreCapCheck');
utils.perf.stop('checkFactionScoreCap');
}
// utils.perf.stop('checkTermedWarScoreCap');
utils.perf.stop('checkTermedWarScoreCap');
},
setFactionWarData: async (warData, buttonElement) => {
const originalText = buttonElement.textContent;
buttonElement.disabled = true;
buttonElement.innerHTML = '<span class="dibs-spinner"></span> Saving...';
try {
await api.post('setWarData', { warId: state.lastRankWar.id.toString(), warData, factionId: state.user.factionId });
ui.showMessageBox('War data saved!', 'success');
await handlers.fetchGlobalData();
} catch (error) {
ui.showMessageBox(`Failed to save war data: ${error.message}`, 'error');
} finally {
buttonElement.disabled = false;
buttonElement.textContent = originalText;
}
},
handleSaveUserNote: async (noteTargetId, noteContent, buttonElement, { silent = false } = {}) => {
if (!noteTargetId) return;
const existing = state.userNotes[noteTargetId]?.noteContent || '';
const trimmedNew = (noteContent || '').trim();
// Short-circuit if unchanged
if (existing.trim() === trimmedNew) {
if (buttonElement) utils.updateNoteButtonState(buttonElement, trimmedNew);
return;
}
const originalText = buttonElement && buttonElement.textContent;
try {
let noteTargetFactionId = null; let noteTargetUsername = null;
try { const entry = state.session?.userStatusCache?.[noteTargetId]; if (entry?.factionId) noteTargetFactionId = String(entry.factionId); } catch(_) {}
if (!noteTargetFactionId) {
try { const snap = state.rankedWarTableSnapshot && state.rankedWarTableSnapshot[noteTargetId]; if (snap?.factionId) noteTargetFactionId = String(snap.factionId); if (!noteTargetUsername && snap) noteTargetUsername = snap.name || snap.username || null; } catch(_) {}
}
if (!noteTargetFactionId) {
try { const st = await utils.getUserStatus(noteTargetId); if (st?.factionId) noteTargetFactionId = String(st.factionId); } catch(_) {}
}
if (!noteTargetFactionId) {
try { if (state.warData?.opponentFactionId) noteTargetFactionId = String(state.warData.opponentFactionId); } catch(_) {}
}
if (!noteTargetUsername) {
try { const st = state.rankedWarTableSnapshot && state.rankedWarTableSnapshot[noteTargetId]; if (st) noteTargetUsername = st.name || st.username || null; } catch(_) {}
}
const resp = await api.post('updateUserNote', { noteTargetId, noteContent: trimmedNew, factionId: state.user.factionId, noteTargetFactionId, noteTargetUsername });
// Defensive handling: backend may return userNotes in different shapes
// (array or object). Normalize to a map and merge into local state to
// avoid accidentally replacing the entire store with an empty array.
try {
const current = (state.userNotes && typeof state.userNotes === 'object') ? { ...state.userNotes } : {};
if (resp && resp.userNotes) {
// If backend returned an array, convert to map by id
if (Array.isArray(resp.userNotes)) {
const map = {};
resp.userNotes.forEach(n => {
if (!n) return;
const id = n.id || n.userId || n.noteTargetId || n.targetId || null;
if (!id) return;
map[String(id)] = {
noteContent: (n.noteContent || n.content || '').toString(),
updated: n.updated || Date.now(),
updatedBy: n.updatedBy || null
};
});
const merged = { ...current, ...map };
storage.updateStateAndStorage('userNotes', merged);
} else if (typeof resp.userNotes === 'object') {
// Assume it's already a map; merge with existing
const merged = { ...current, ...resp.userNotes };
storage.updateStateAndStorage('userNotes', merged);
} else {
// Unexpected shape - fallback to per-key update
if (trimmedNew === '') {
if (current[noteTargetId]) delete current[noteTargetId];
storage.updateStateAndStorage('userNotes', current);
} else {
current[noteTargetId] = { noteContent: trimmedNew, updated: Date.now(), updatedBy: state.user.tornId };
storage.updateStateAndStorage('userNotes', current);
}
}
} else {
// No bulk data returned; perform local per-note update (or delete)
if (trimmedNew === '') {
if (current[noteTargetId]) delete current[noteTargetId];
} else {
current[noteTargetId] = { noteContent: trimmedNew, updated: Date.now(), updatedBy: state.user.tornId };
}
storage.updateStateAndStorage('userNotes', current);
}
} catch (e) {
// If normalization fails for any reason, fall back to safe per-key update
try {
if (trimmedNew === '') {
if (state.userNotes && state.userNotes[noteTargetId]) delete state.userNotes[noteTargetId];
} else {
state.userNotes[noteTargetId] = { noteContent: trimmedNew, updated: Date.now(), updatedBy: state.user.tornId };
}
storage.updateStateAndStorage('userNotes', state.userNotes);
} catch(_) { /* swallow */ }
}
if (!silent) ui.showMessageBox('[TDM] Note saved!', 'success');
if (buttonElement) {
utils.updateNoteButtonState(buttonElement, trimmedNew);
if (originalText && buttonElement.textContent !== originalText) buttonElement.textContent = originalText;
}
// Local-only refresh of ranked war UI; avoid full global fetch unless needed
try { ui._renderEpoch.schedule(); } catch(_) {}
} catch (error) {
if (!silent) ui.showMessageBox(`[TDM] Failed to save note: ${error.message}`, 'error');
if (buttonElement) {
if (originalText) buttonElement.textContent = originalText;
}
}
},
assignDibs: async (opponentId, opponentName, dibsForUserId, dibsForUsername, buttonElement) => {
const originalText = buttonElement ? (buttonElement.dataset.originalText || buttonElement.textContent) : '';
// Immediate feedback
if (buttonElement) {
try {
buttonElement.textContent = 'Saving...';
buttonElement.disabled = true;
buttonElement.className = 'btn dibs-btn btn-dibs-inactive';
} catch(_) {}
}
try {
const opts = utils.getDibsStyleOptions();
const [oppStat, assigneeStat] = await Promise.all([
utils.getUserStatus(opponentId),
utils.getUserStatus(dibsForUserId)
]);
const assCanon = assigneeStat.canonical;
if (opts.allowedUserStatuses && opts.allowedUserStatuses[assCanon] === false) {
throw new Error(`User status (${assCanon}) is not allowed to place dibs by faction policy.`);
}
const canonOpp = oppStat.canonical;
if (opts.allowStatuses && opts.allowStatuses[canonOpp] === false) {
throw new Error(`Target status (${canonOpp}) is not allowed by faction policy.`);
}
if (canonOpp === 'Hospital') {
const limitMin = Number(opts.maxHospitalReleaseMinutes || 0);
if (limitMin > 0) {
const remaining = Math.max(0, (oppStat.until || 0) - Math.floor(Date.now() / 1000));
if (remaining > limitMin * 60) {
throw new Error(`Target is hospitalized too long (${Math.ceil(remaining/60)}m). Policy allows < ${limitMin}m.`);
}
}
}
let opponentFactionId = null;
try { const entry = state.session?.userStatusCache?.[opponentId]; if (entry?.factionId) opponentFactionId = String(entry.factionId); } catch(_) {}
if (!opponentFactionId) { try { if (oppStat?.factionId) opponentFactionId = String(oppStat.factionId); } catch(_) {} }
if (!opponentFactionId) { try { const snap = state.rankedWarTableSnapshot && state.rankedWarTableSnapshot[opponentId]; if (snap?.factionId) opponentFactionId = String(snap.factionId); } catch(_) {} }
if (!opponentFactionId) { try { if (state.warData?.opponentFactionId) opponentFactionId = String(state.warData.opponentFactionId); } catch(_) {} }
// If there's an existing active dib locally for this opponent by a different user,
// require admin to confirm removal before proceeding to assign.
try {
const existingOther = Array.isArray(state.dibsData) ? state.dibsData.find(d => d && d.dibsActive && String(d.opponentId) === String(opponentId) && String(d.userId) !== String(dibsForUserId)) : null;
const isAdmin = !!(state.script?.canAdministerMedDeals || state.script?.canAdministerDibs || state.script?.isAdmin);
if (existingOther && isAdmin) {
const existingName = existingOther.username || existingOther.user || 'Someone';
const prompt = `Assigning dibs will remove existing dibs held by ${existingName} on ${opponentName}. Do you want to continue?`;
const confirm = await ui.showConfirmationBox(prompt, true);
if (confirm !== true) {
// User cancelled - revert button state and abort
if (buttonElement) {
try {
buttonElement.textContent = originalText;
buttonElement.disabled = false;
buttonElement.className = 'btn dibs-btn btn-dibs-inactive';
} catch(_) {}
}
return;
}
}
} catch(_) {}
const assignResp = await api.post('dibsTarget', {
userid: dibsForUserId,
username: dibsForUsername,
opponentId,
opponentname: opponentName,
warType: state.warData.warType,
factionId: state.user.factionId,
userStatusAtDib: assCanon,
opponentStatusAtDib: canonOpp,
opponentFactionId
});
const createdId = assignResp?.id;
const serverMsg = (assignResp && assignResp.message) ? String(assignResp.message) : '';
if (createdId) {
ui.showMessageBox(`[TDM] Assigned dibs on ${opponentName} to ${dibsForUsername}!`, 'success');
// Optimistic update
try {
const previouslyActive = [];
for (const d of state.dibsData) {
if (d.dibsActive && String(d.opponentId) === String(opponentId)) {
d.dibsActive = false;
}
if (d.dibsActive && String(d.userId) === String(dibsForUserId)) {
if (String(d.opponentId) !== String(opponentId)) {
previouslyActive.push({ id: String(d.opponentId), name: d.opponentname || d.opponentName || '' });
}
d.dibsActive = false;
}
}
const optimistic = {
id: createdId,
factionId: String(state.user.factionId),
userId: String(dibsForUserId),
username: dibsForUsername,
opponentId: String(opponentId),
opponentname: opponentName,
dibbedAt: { _seconds: Math.floor(Date.now()/1000) },
lastActionTimestamp: { _seconds: Math.floor(Date.now()/1000) },
dibsActive: true,
warType: state.warData.warType || 'unknown',
userStatusAtDib: assCanon,
opponentStatusAtDib: canonOpp,
opponentFactionId: opponentFactionId || null
};
state.dibsData.push(optimistic);
// handlers._trackPendingDibsId(createdId); // Removed in refactor
try { state._mutate.setDibsData(state.dibsData.slice(), { source: 'optimistic-assign' }); } catch(_) { storage.set('dibsData', state.dibsData); }
if (previouslyActive.length && utils.updateDibsButton) {
previouslyActive.forEach(({ id, name }) => {
try {
const btns = document.querySelectorAll(`.dibs-btn[data-opponent-id='${id}']`);
btns.forEach(btn => utils.updateDibsButton(btn, id, name || btn?.dataset?.opponentName || opponentName));
} catch(_) { /* ignore */ }
});
}
} catch(e) { tdmlogger('warn', `[assignDibs][optimistic] failed: ${e}`); }
} else if (serverMsg.toLowerCase().includes('already')) {
ui.showMessageBox(serverMsg, 'info');
try {
const existing = state.dibsData.find(d => String(d.opponentId) === String(opponentId) && String(d.userId) === String(dibsForUserId));
if (existing) {
existing.dibsActive = true;
if (state._fingerprints) state._fingerprints.dibs = null;
try { state._mutate.setDibsData(state.dibsData.slice(), { source: 'optimistic-reactivate' }); } catch(_) { storage.set('dibsData', state.dibsData); }
}
} catch(_) {}
} else {
ui.showMessageBox(serverMsg || `[TDM] Assigned dibs on ${opponentName} to ${dibsForUsername}!`, 'success');
try {
const existing = state.dibsData.find(d => String(d.opponentId) === String(opponentId) && String(d.userId) === String(dibsForUserId));
if (existing) {
existing.dibsActive = true;
if (state._fingerprints) state._fingerprints.dibs = null;
try { state._mutate.setDibsData(state.dibsData.slice(), { source: 'optimistic-reactivate' }); } catch(_) { storage.set('dibsData', state.dibsData); }
}
} catch(_) {}
}
setTimeout(() => handlers.fetchGlobalDataForced('dibs'), 250);
try {
const allBtns = document.querySelectorAll(`.dibs-btn[data-opponent-id='${opponentId}']`);
if (allBtns.length) {
allBtns.forEach(btn => {
if (utils.updateDibsButton) utils.updateDibsButton(btn, opponentId, opponentName);
});
} else if (buttonElement && utils.updateDibsButton) {
utils.updateDibsButton(buttonElement, opponentId, opponentName);
}
} catch(_) {}
setTimeout(() => ui.sendDibsMessage(opponentId, opponentName, dibsForUsername), 50);
handlers.debouncedFetchGlobalData();
} catch (error) {
const msg = String(error?.message || 'Unknown error');
const already = msg.toLowerCase().includes('already') || error?.code === 'already-exists' || error?.alreadyDibbed === true;
if (already) {
const dibberName = error?.dibber?.name || error?.dibberName || 'Someone';
ui.showMessageBox(`${opponentName} is already dibbed by ${dibberName}.`, 'info');
handlers.debouncedFetchGlobalData();
} else {
ui.showMessageBox(`[TDM] Failed to assign dibs: ${msg}`, 'error');
}
if (buttonElement) {
buttonElement.textContent = originalText;
buttonElement.disabled = false;
buttonElement.className = 'btn dibs-btn btn-dibs-inactive';
}
}
},
// Auto-enforcement sweep: remove dibs when opponent/user travels if policy enabled
enforceDibsPolicies: async () => {
try {
const now = Date.now();
if (now - (state.session.lastEnforcementMs || 0) < 8000) return; // every ~8s max
state.session.lastEnforcementMs = now;
const opts = utils.getDibsStyleOptions();
const myActive = state.dibsData.find(d => d.dibsActive && d.userId === state.user.tornId);
if (!myActive) return;
// Determine allowances: if travel is allowed for user/opponent, skip corresponding removals even if legacy flags set
const userAllowedTravel = opts.allowedUserStatuses.Travel !== false && opts.allowedUserStatuses.Abroad !== false;
const oppAllowedTravel = opts.allowStatuses.Travel !== false && opts.allowStatuses.Abroad !== false;
// Check opponent travel removal
if (opts.removeOnFly && !oppAllowedTravel) {
try {
utils.perf.start('enforceDibsPolicies.getOpponentStatus');
const oppStat = await utils.getUserStatus(myActive.opponentId);
try { if (storage.get('debugDibsEnforce', false)) tdmlogger('info', '[enforceDibsPolicies] opponentStatus', { opponentId: myActive.opponentId, oppStat }); } catch(_) {}
utils.perf.stop('enforceDibsPolicies.getOpponentStatus');
if (oppStat.canonical === 'Travel' || oppStat.canonical === 'Abroad') {
utils.perf.start('enforceDibsPolicies.api.removeDibs.opponentTravel');
await api.post('removeDibs', { dibsDocId: myActive.id, removedByUsername: state.user.tornUsername, factionId: state.user.factionId, reason: 'policy-opponent-travel' });
utils.perf.stop('enforceDibsPolicies.api.removeDibs.opponentTravel');
ui.showMessageBox(`Your dib on ${myActive.opponentname || myActive.opponentId} was removed (opponent traveling policy).`, 'warning');
handlers.debouncedFetchGlobalData();
return;
}
} catch (_) { /* ignore */ }
}
// Check user travel removal
if (opts.removeWhenUserTravels && !userAllowedTravel) {
try {
utils.perf.start('enforceDibsPolicies.getMyStatus');
const me = await utils.getUserStatus(null);
try { if (storage.get('debugDibsEnforce', false)) tdmlogger('info', '[enforceDibsPolicies] myStatus', { me }); } catch(_) {}
utils.perf.stop('enforceDibsPolicies.getMyStatus');
if (me.canonical === 'Travel' || me.canonical === 'Abroad') {
utils.perf.start('enforceDibsPolicies.api.removeDibs.userTravel');
await api.post('removeDibs', { dibsDocId: myActive.id, removedByUsername: state.user.tornUsername, factionId: state.user.factionId, reason: 'policy-user-travel' });
utils.perf.stop('enforceDibsPolicies.api.removeDibs.userTravel');
ui.showMessageBox(`Your dib on ${myActive.opponentname || myActive.opponentId} was removed (your travel policy).`, 'warning');
handlers.debouncedFetchGlobalData();
return;
}
} catch (_) { /* ignore */ }
}
} catch (e) { /* non-fatal */ }
},
// Check OC status once per userscript load
checkOCReminder: async () => {
const ocReminderEnabled = storage.get('ocReminderEnabled', true);
const ocReminderShown = storage.get('ocReminderShown', false);
if (ocReminderEnabled && !ocReminderShown) {
const currentUser = state.factionMembers.find(member => member.id == state.user.tornId);
if (currentUser && !currentUser.is_in_oc) {
ui.showConfirmationBox('[TDM] JOIN AN OC!');
storage.set('ocReminderShown', true); // Ensure it only shows once per load
}
}
},
/* ================= Live Tracking Snapshot/Diff (Phase 2: Retal + Canonical Status) =================
(Legacy baseline model documentation removed – superseded by unified activity tracking.)
Baseline entry shape (expanded): {
rt:1, // retaliation flag (optional)
c:'Hospital', // canonical status string (optional)
u: epochSec, // hospital until (if Hospital)
ab:1, // abroad hospital flag (if Hospital abroad)
te: epochSec, // travel end (arrival) time if traveling
ls: epochSec, // last seen timestamp for any tracked dimension
ver:1 // schema version
}
Snapshot entry (transient) mirrors the subset: { retal:1, c:'Hospital', u:1234567890, ab:1, te:1234567, lastSeenSec }
Events now include: retalOpen/retalClose, statusChange, earlyHospOut, abroadHospEnter, abroadHospExit
*/
// Legacy live-tracking initializer: intentionally a no-op.
// The legacy live tracker has been retired in favor of the activity tracking engine
// which uses state._activityTracking and state.unifiedStatus as the canonical sources.
// Keep a no-op initializer to avoid surprising side-effects from older call sites.
_initLiveTracking: () => {
// Ensure the legacy container is not recreated. If an older runtime expects an object,
// they should guard access; new code should use state._activityTracking instead.
state._liveTrack = null;
return;
},
_buildLiveSnapshot: () => {
handlers._initLiveTracking();
const t0 = performance.now();
const snapshot = {};
// Retal opportunities already normalized into state.retaliationOpportunities (map of opponentId-> data)
try {
const retals = state.retaliationOpportunities || {};
const nowSec = Math.floor(Date.now()/1000);
for (const oid of Object.keys(retals)) {
snapshot[oid] = snapshot[oid] || {};
snapshot[oid].retal = 1; // boolean flag for now
snapshot[oid].lastSeenSec = nowSec;
}
} catch(_) { /* noop */ }
// Canonical status & hospital info from rankedWarTableSnapshot + meta
try {
const tableSnap = state.rankedWarTableSnapshot || {};
const meta = state.rankedWarChangeMeta || {};
const nowSec = Math.floor(Date.now()/1000);
for (const oid of Object.keys(tableSnap)) {
const row = tableSnap[oid];
const m = meta[oid] || {};
const canon = row?.statusCanon || row?.canon || null;
if (!canon) continue;
const s = (snapshot[oid] = snapshot[oid] || {});
s.c = canon; // canonical status
if (canon === 'Hospital') {
// Hospital until: prefer meta.hospitalUntil; else attempt parse from row.status if numeric pattern present
let until = null;
if (typeof m.hospitalUntil === 'number' && m.hospitalUntil > 0) until = m.hospitalUntil;
if (!until) {
try {
// row.status might contain remaining mm:ss; we can't reverse exact until precisely without server time; skip.
} catch(_) {}
}
if (until) s.u = until;
if (m.hospitalAbroad) s.ab = 1;
} else if (canon === 'Travel') {
// Attempt parse of remaining time mm:ss or m:ss in row.status or meta.travelUntil
try {
let travelUntil = null;
if (typeof m.travelUntil === 'number' && m.travelUntil > 0) travelUntil = m.travelUntil;
if (!travelUntil) {
const rawStatus = row.status || row.statusText || '';
const match = rawStatus.match(/(\d+):(\d{2})/); // mm:ss
if (match) {
const mm = Number(match[1]); const ss = Number(match[2]);
if (Number.isFinite(mm) && Number.isFinite(ss)) {
const remain = mm * 60 + ss;
if (remain > 0 && remain < 6*3600) { // sanity cap 6h
travelUntil = Math.floor(Date.now()/1000) + remain;
}
}
}
}
if (travelUntil) { s.te = travelUntil; }
} catch(_) { /* ignore parse */ }
}
s.lastSeenSec = nowSec;
}
} catch(_) { /* non-fatal */ }
const t1 = performance.now();
if (state._liveTrack && state._liveTrack.metrics) {
state._liveTrack.metrics.lastBuildMs = +(t1 - t0).toFixed(2);
}
return snapshot;
},
_diffLiveSnapshots: (prev, curr) => {
// Simplified diffing: derive canonical transitions based on prev vs curr snapshots.
// No longer relies on persisted baseline or multiple per-flag toggles; use the single activityTrackingEnabled toggle.
handlers._initLiveTracking();
const t0 = performance.now();
const events = [];
// If activity tracking globally disabled, short-circuit
if (!storage.get('tdmActivityTrackingEnabled', false)) {
if (state._liveTrack && state._liveTrack.metrics) {
state._liveTrack.metrics.lastDiffMs = 0;
state._liveTrack.metrics.lastEvents = 0;
}
return [];
}
// Universe of opponentIds from union
const ids = new Set([
...(prev ? Object.keys(prev) : []),
...Object.keys(curr || {})
]);
const nowSec = Math.floor(Date.now()/1000);
for (const id of ids) {
const p = (prev && prev[id]) || {};
const c = curr[id] || {};
// Retal change
if (!!p.retal !== !!c.retal) events.push({ type: c.retal ? 'retalOpen' : 'retalClose', opponentId: id, ts: nowSec });
// Status change
const prevCanon = p.c || p.canonical || null;
const currCanon = c.c || c.canonical || null;
if (prevCanon && currCanon && prevCanon !== currCanon) {
events.push({ type: 'statusChange', opponentId: id, ts: nowSec, prevStatus: prevCanon, newStatus: currCanon });
}
// Early hospital exit
if (prevCanon === 'Hospital' && currCanon !== 'Hospital') {
const prevUntil = p.u || null;
if (prevUntil && prevUntil - nowSec > 30) events.push({ type: 'earlyHospOut', opponentId: id, ts: nowSec, remaining: prevUntil - nowSec, expectedUntil: prevUntil });
}
// Abroad hospital transitions with ab flag
const prevAb = !!p.ab; const currAb = !!c.ab;
if (currCanon === 'Hospital' && currAb && !(prevCanon === 'Hospital' && prevAb)) events.push({ type: 'abroadHospEnter', opponentId: id, ts: nowSec });
if (prevCanon === 'Hospital' && prevAb && !(currCanon === 'Hospital' && currAb)) events.push({ type: 'abroadHospExit', opponentId: id, ts: nowSec });
// Travel events
const prevIsTravel = prevCanon === 'Travel' || prevCanon === 'Returning' || prevCanon === 'Abroad';
const currIsTravel = currCanon === 'Travel' || currCanon === 'Returning' || currCanon === 'Abroad';
if (!prevIsTravel && currIsTravel) events.push({ type: 'travelDepart', opponentId: id, ts: nowSec });
if (prevIsTravel && currCanon === 'Abroad' && prevCanon !== 'Abroad') events.push({ type: 'travelArriveAbroad', opponentId: id, ts: nowSec });
if (prevIsTravel && currCanon === 'Okay') events.push({ type: 'travelReturn', opponentId: id, ts: nowSec });
}
const t1 = performance.now();
if (state._liveTrack && state._liveTrack.metrics) {
state._liveTrack.metrics.lastDiffMs = +(t1 - t0).toFixed(2);
state._liveTrack.metrics.lastEvents = events.length;
}
return events;
},
_mapLiveEventsToAlerts: (events) => {
if (!events || !events.length) return;
let needsScan = false;
const meta = state.rankedWarChangeMeta || (state.rankedWarChangeMeta = {});
const now = Date.now();
events.forEach(ev => {
const id = ev.opponentId;
if (ev.type === 'retalOpen') {
meta[id] = { ...(meta[id]||{}), activeType: 'retal', pendingText: 'Retal', ts: now };
needsScan = true;
} else if (ev.type === 'retalClose') {
// Show brief retalDone state
meta[id] = { ...(meta[id]||{}), activeType: 'retalDone', pendingText: 'Retal', ts: now };
needsScan = true;
} else if (ev.type === 'earlyHospOut') {
meta[id] = { activeType: 'earlyHospOut', pendingText: 'Hosp Early', ts: now, prevStatus: 'Hospital', newStatus: 'Okay' };
needsScan = true;
} else if (ev.type === 'abroadHospEnter') {
meta[id] = { ...(meta[id]||{}), activeType: 'abroadHosp', pendingText: 'Hosp Abroad', ts: now, hospitalAbroad: true };
needsScan = true;
} else if (ev.type === 'abroadHospExit') {
if (meta[id]?.activeType === 'abroadHosp') delete meta[id];
needsScan = true;
} else if (ev.type === 'travelDepart') {
// For now we do not display travel alert (button types restricted); could future-add.
} else if (ev.type === 'travelArriveAbroad') {
// Could mark abroad travel state for other UI; leaving no-op.
} else if (ev.type === 'travelReturn') {
// Clear transient travel meta if present
if (meta[id]?.activeType === 'travel') delete meta[id];
} else if (ev.type === 'statusChange') {
// We only create an alert if transition leads to abroad hospital (handled above) or hospital exit early (handled separately)
}
});
if (needsScan) {
try { state._rankedWarScheduleScan && state._rankedWarScheduleScan(); } catch(_) {}
}
},
_persistLiveBaselineMaybe: () => {
// No-op: legacy baseline persistence removed in favor of transient unifiedStatus updates.
return;
},
_pruneBaselineOccasionally: () => {
// No-op: baseline pruning retained as historical artifact; unifiedStatus pruning handled elsewhere if needed.
return;
},
// Legacy live tracking removed. New Activity Tracking engine (diff-on-poll) hooks below.
/*
* Activity Tracking Engine (Overview)
* ----------------------------------
* Purpose: Lightweight periodic snapshot + diff system replacing the legacy live tracker & timeline UI.
* Cycle:
* 1. Poll (api.refreshFactionBundles lightweight path) at configurable cadence (default 10s).
* 2. Normalize current opponent states via utils.buildCurrentUnifiedActivityState().
* 3. Compute a compact rolling signature (id:phase) to cheaply skip unchanged ticks (increments skippedTicks metric).
* 4. When changes detected, derive transitions, inject transient Landed, escalate confidence (LOW→MED→HIGH, no downgrades).
* 5. Apply transitions to UI, expire transients, render travel ETAs, update debug overlay if enabled.
* Confidence Model (Unified):
* - LOW: Newly observed / insufficient timing data (or post-phase change reset).
* - MED: Travel with destination & minutes known but awaiting stability (first poll or < promotion threshold).
* - HIGH: Travel stable after promotion (2nd observation or elapsed >= TRAVEL_PROMOTE_MS); precise ETA computed.
* Transients:
* - Landed: Short-lived (config.LANDED_TTL_MS) display replacing the immediate post-travel state before settling to Okay.
* Performance Notes:
* - Signature hashing is O(n) without sort to minimize CPU churn.
* - Expiration / rendering paths are guarded by feature toggles & visibility checks elsewhere.
* Metrics (at.metrics): lastPoll, lastDiffMs, transitions, skippedTicks.
* Extensibility: Additional state facets (e.g. witness escalation) can hook into _escalateConfidence without altering tick loop.
*/
_initActivityTracking: () => {
if (state._hardResetInProgress) { tdmlogger('warn', '[ActivityTrack] init skipped during hard reset'); return; }
if (state._activityTracking?.initialized) return;
state._activityTracking = {
initialized: true,
prevById: {}, // id -> previous normalized state
cadenceMs: Number(storage.get('tdmActivityCadenceMs', 10000)) || 10000,
timerId: null,
metrics: {
lastPoll: 0,
lastDiffMs: 0,
transitions: 0,
skippedTicks: 0,
signatureSkips: 0,
totalTicks: 0,
lastSignature: '',
plannedNextTick: 0,
lastTickStart: 0,
lastTickEnd: 0,
lastDriftMs: 0,
lastApiMs: 0,
lastBuildMs: 0,
lastApplyMs: 0,
driftSamples: [],
tickSamples: [],
bundleSkipsInFlight: 0,
bundleSkipsRecent: 0,
bundleErrors: 0,
fetchHits: 0,
fetchErrors: 0,
lastFetchReason: 'init',
lastTickOutcome: 'init'
}
};
handlers._scheduleActivityTick();
},
_teardownActivityTracking: () => {
try { if (state._activityTracking?.timerId) try { utils.unregisterTimeout(state._activityTracking.timerId); } catch(_) {} } catch(_) {}
// Purge phase history / logs prior to nulling reference for GC friendliness
try {
if (state._activityTracking) {
delete state._activityTracking._phaseHistory;
delete state._activityTracking._recentTransitions;
delete state._activityTracking._transitionLog;
}
} catch(_) { /* ignore */ }
state._activityTracking = null;
// Remove transient UI (travel etas, overlay) but leave core status cells intact
document.querySelectorAll('.tdm-travel-eta').forEach(el=>el.remove());
const ov = document.getElementById('tdm-live-track-overlay'); if (ov) ov.remove();
ui.ensureDebugOverlayContainer?.({ passive: true, skipShow: true });
},
_flushActivityCache: async () => {
if (state._activityTracking) {
state._activityTracking.prevById = {};
const metrics = state._activityTracking.metrics;
if (metrics) {
metrics.transitions = 0;
metrics.signatureSkips = 0;
metrics.skippedTicks = 0;
metrics.totalTicks = 0;
metrics.fetchHits = 0;
metrics.fetchErrors = 0;
metrics.bundleSkipsInFlight = 0;
metrics.bundleSkipsRecent = 0;
}
}
// Remove any persisted per-id historical keys (future extension)
await ui?._kv?.removeByPrefix('tdm.act.prev.id_');
},
_updateActivityCadence: (ms) => {
if (state._activityTracking) {
state._activityTracking.cadenceMs = ms;
handlers._scheduleActivityTick(true);
}
},
_scheduleActivityTick: (restart=false) => {
const at = state._activityTracking; if (!at) return;
if (restart && at.timerId) { try { utils.unregisterTimeout(at.timerId); } catch(_) {} }
// Attempt drift-corrected scheduling: base on lastTickStart when available
let delay = at.cadenceMs;
const m = at.metrics||{};
if (m.lastTickStart) {
const elapsedSinceStart = Date.now() - m.lastTickStart;
delay = Math.max(0, at.cadenceMs - elapsedSinceStart);
}
m.plannedNextTick = Date.now() + delay;
try {tdmlogger('debug', '[ActivityTick][schedule]', { delay, plannedNextTick: m.plannedNextTick, cadenceMs: at.cadenceMs }); } catch(_) {}
at.timerId = utils.registerTimeout(setTimeout(handlers._activityTick, delay));
},
// Lightweight attacker heartbeat: tiny, dedicated ping when the user has active dibs
_activityHeartbeatState: { timerId: null, cadenceMs: null },
_scheduleActivityHeartbeat: (restart = false) => {
const hb = handlers._activityHeartbeatState;
// cadence falls back to the configured attacker ping ms or 60s
hb.cadenceMs = hb.cadenceMs || (Number(config.ATTACKER_ACTIVITY_PING_MS) || 60000);
if (restart && hb.timerId) {
try { clearTimeout(hb.timerId); } catch(_) {}
hb.timerId = null;
}
try {
hb.timerId = utils.registerTimeout(setTimeout(handlers._activityHeartbeatTick, hb.cadenceMs));
} catch (_) { hb.timerId = null; }
},
_activityHeartbeatTick: async () => {
// Compatibility wrapper: delegate to the immediate sender
try { await handlers._sendAttackerHeartbeatNow?.(); } catch(_) {}
},
// Immediate sender: performs minimal heartbeat check and sends a best-effort ping.
// This function does NOT reschedule itself; callers (runTick/visibility handlers) control cadence.
_sendAttackerHeartbeatNow: async () => {
try {
const myTornId = String(state.user?.tornId || '');
if (!myTornId) return;
const dibs = Array.isArray(state.dibsData) ? state.dibsData : (state.dibsData || []);
let hasActiveDibs = false;
try {
for (const d of dibs) {
if (!d) continue;
if (String(d.userId) === myTornId && d.dibsActive) { hasActiveDibs = true; break; }
}
} catch (_) { hasActiveDibs = false; }
if (!hasActiveDibs) {
try { tdmlogger('debug', '[ActivityHeartbeat] not sending (no active dibs)'); } catch(_) {}
return;
}
if (api.isIpRateLimited?.()) {
try { tdmlogger('debug', '[ActivityHeartbeat] not sending (rate-limited)'); } catch(_) {}
return;
}
const HEARTBEAT_ACTIVE_MS = 30000; // target ~30s cadence while window active
const nowMsPing = Date.now();
state._lastAttackerPingMs = state._lastAttackerPingMs || 0;
if ((nowMsPing - state._lastAttackerPingMs) < HEARTBEAT_ACTIVE_MS) {
try { tdmlogger('debug', '[ActivityHeartbeat] skipped due to ping throttle', { nowMsPing, last: state._lastAttackerPingMs, HEARTBEAT_ACTIVE_MS }); } catch(_) {}
return;
}
state._lastAttackerPingMs = nowMsPing; // best-effort immediate update to avoid bursts
try { tdmlogger('debug', '[ActivityHeartbeat] sending attacker ping', { nowMsPing, HEARTBEAT_ACTIVE_MS }); } catch(_) {}
try {
api.post('updateAttackerLastAction', { lastActionTimestamp: nowMsPing, factionId: state.user.factionId }).catch(e => {
try { tdmlogger('warn', '[ActivityHeartbeat] updateAttackerLastAction failed', { e: e?.message }); } catch(_) {}
});
} catch(_) { /* swallow */ }
} catch (e) {
try { tdmlogger('warn', '[ActivityHeartbeat] error', { e: e?.message }); } catch(_) {}
}
},
// Step 2: Normalize an activity record to the canonical contract
_normalizeActivityRecord: (rec, prev) => {
if (!rec) return rec;
const out = { ...rec };
const raw = (out.canonical || out.phase || '').toString().toLowerCase();
const mapPhase = (p) => {
switch (p) {
case 'travel':
case 'traveling': return 'Travel';
case 'returning': return 'Returning';
case 'abroad': return 'Abroad';
case 'hospital':
case 'hosp': return 'Hospital';
case 'hospitalabroad':
case 'abroad_hosp':
case 'hospital_abroad': return 'HospitalAbroad';
case 'jail': return 'Jail';
case 'landed': return 'Landed';
case 'okay':
case 'ok':
case '': return 'Okay';
default: return 'Okay';
}
};
const canon = mapPhase(raw);
out.canonical = canon; out.phase = canon;
if (!['LOW','MED','HIGH'].includes(out.confidence)) out.confidence = 'LOW';
const prevCanon = prev ? (prev.canonical||prev.phase) : null;
const phaseChanged = prevCanon && prevCanon !== canon;
const now = Date.now();
out.startedMs = out.startedMs || out.startMs || (phaseChanged ? now : (prev && prev.startedMs) || now);
const isTravel = canon === 'Travel' || canon === 'Returning';
if (!isTravel && canon !== 'Landed') {
// Allow Abroad and HospitalAbroad to retain destination reference so history and UI
// can show the foreign destination for hospital abroad cases.
if (out.dest && canon !== 'Abroad' && canon !== 'HospitalAbroad') delete out.dest;
if (out.arrivalMs) delete out.arrivalMs;
}
// Witness only true on the first snapshot after a phase change
if (!prev) {
out.witness = false; // initial load has no witnessed transition yet
} else if (phaseChanged) {
out.witness = true;
out.previousPhase = prevCanon || null;
} else {
out.witness = false;
if (prev.previousPhase && !out.previousPhase) out.previousPhase = prev.previousPhase; // carry forward if desired
}
// Plane normalization (future use in confidence heuristics)
try { out.plane = out.plane || utils.getKnownPlaneTypeForId?.(out.id) || null; } catch(_) { /* ignore */ }
out.updatedMs = out.updatedMs || out.updated || now;
return out;
},
// Step 3 helper: prune absurd / stale ETAs
_pruneInvalidEtas: (rec) => {
const MAX_TRAVEL_MS = 6 * 60 * 60 * 1000; // 6h safety window
if (!rec) return { rec, prunedEta:false };
if (rec.arrivalMs) {
const now = Date.now();
if (rec.arrivalMs < now - 2000 || rec.arrivalMs - now > MAX_TRAVEL_MS) {
const cloned = { ...rec }; delete cloned.arrivalMs; return { rec: cloned, prunedEta:true };
}
}
return { rec, prunedEta:false };
},
// Step 3: validate & normalize entire snapshot map prior to hashing
_validateAndCleanSnapshotMap: (currMap) => {
if (!currMap || typeof currMap !== 'object') return currMap;
const at = state._activityTracking;
let normalized=0, etaPruned=0, malformed=0;
const out = {};
for (const [id, rec] of Object.entries(currMap)) {
if (!rec || !id) { malformed++; continue; }
const prev = at?.prevById ? at.prevById[id] : null;
let norm = handlers._normalizeActivityRecord(rec, prev);
const res = handlers._pruneInvalidEtas(norm); norm = res.rec; if (res.prunedEta) etaPruned++;
out[id] = norm; normalized++;
}
if (at && at.metrics) {
at.metrics.validationLast = { ts: Date.now(), normalized, etaPruned, malformed };
at.metrics.validationSamples = at.metrics.validationSamples || [];
at.metrics.validationSamples.push({ t: Date.now(), normalized, etaPruned, malformed });
if (at.metrics.validationSamples.length > 30) at.metrics.validationSamples.shift();
}
return out;
},
_activityTick: async () => {
const at = state._activityTracking; if (!at) return;
try { tdmlogger('debug', '[ActivityTick][enter]', { now: Date.now(), plannedNextTick: at.metrics?.plannedNextTick || 0, cadenceMs: at.cadenceMs, lastTickStart: at.metrics?.lastTickStart || 0 }); } catch(_) {}
const perfNow = () => performance.now?.()||Date.now();
const metrics = at.metrics || (at.metrics = {});
metrics.totalTicks = (metrics.totalTicks || 0) + 1;
metrics.lastTickOutcome = 'running';
metrics.lastFetchReason = 'pending';
const t0 = perfNow();
metrics.lastTickStart = Date.now();
// drift: actual start minus planned
if (metrics.plannedNextTick) {
const rawDrift = metrics.lastTickStart - metrics.plannedNextTick;
metrics.lastDriftMs = Math.max(0, rawDrift);
} else {
metrics.lastDriftMs = 0;
}
metrics.driftSamples.push(metrics.lastDriftMs);
if (metrics.driftSamples.length > 60) metrics.driftSamples.shift();
let tAfterApi= t0, tAfterBuild = t0, tAfterApply = t0;
try {
// Pull faction bundle (respect shared throttle + per-source pacing)
const nowMs = Date.now();
const throttleState = state._factionBundleThrottle || null;
const cadenceMs = at.cadenceMs || (config.DEFAULT_FACTION_BUNDLE_REFRESH_MS || 15000);
const minGapMs = Math.max(config.MIN_GLOBAL_FETCH_INTERVAL_MS || 2000, Math.floor(cadenceMs * 0.6));
const lastActivityCall = throttleState?.lastSourceCall?.activityTick || 0;
let fetchedBundles = false;
if (throttleState?.inFlight) {
metrics.bundleSkipsInFlight = (metrics.bundleSkipsInFlight || 0) + 1;
metrics.lastFetchReason = 'in-flight';
try { tdmlogger('debug', '[ActivityTick] throttle skip in-flight', { factionId: state.user.factionId, lastActivityCall, throttleState }); } catch(_) {}
} else if (lastActivityCall && (nowMs - lastActivityCall) < minGapMs) {
metrics.bundleSkipsRecent = (metrics.bundleSkipsRecent || 0) + 1;
metrics.lastFetchReason = 'cooldown';
try { tdmlogger('debug', '[ActivityTick] throttle skip cooldown', { factionId: state.user.factionId, lastActivityCall, minGapMs, elapsed: nowMs - lastActivityCall }); } catch(_) {}
} else {
try {
try { tdmlogger('debug', '[ActivityTick] fetching bundles', { factionId: state.user.factionId, source: 'activityTick' }); } catch(_) {}
await api.refreshFactionBundles?.({ source: 'activityTick' });
fetchedBundles = true;
metrics.fetchHits = (metrics.fetchHits || 0) + 1;
metrics.lastFetchReason = 'fetched';
try { tdmlogger('debug', '[ActivityTick] fetched bundles', { factionId: state.user.factionId }); } catch(_) {}
} catch (err) {
metrics.bundleErrors = (metrics.bundleErrors || 0) + 1;
metrics.fetchErrors = (metrics.fetchErrors || 0) + 1;
metrics.lastFetchReason = 'error';
tAfterApi = perfNow();
throw err;
} finally {
if (fetchedBundles) {
const throttle = state._factionBundleThrottle || (state._factionBundleThrottle = { lastCall: 0, lastIds: [], skipped: 0, lastSkipLog: 0 });
throttle.lastSourceCall = throttle.lastSourceCall || {};
throttle.lastSourceCall.activityTick = Date.now();
}
}
}
tAfterApi = perfNow();
if (fetchedBundles) at.metrics.lastPoll = Date.now();
// Throttled: send attacker activity ping when user has active dibs to ensure backend activity docs stay fresh
try {
// attacker heartbeat removed from _activityTick; handled by dedicated heartbeat scheduler
} catch(_) { /* best-effort ping, ignore errors */ }
// Build normalized current states (placeholder – integrate existing unified status builder)
let curr = utils.buildCurrentUnifiedActivityState?.();
if (!curr) {
// Fallback builder: derive minimal phase objects from unifiedStatus or factionMembers.
curr = {};
const unified = state.unifiedStatus || {};
for (const [id, rec] of Object.entries(unified)) {
curr[id] = {
id,
phase: rec.canonical || null,
canonical: ec.canonical || null,
confidence: rec.confidence || 'LOW',
dest: rec.dest || null,
arrivalMs: rec.arrivalMs || rec.etams || 0,
startedMs: rec.startedMs || rec.ct0 || 0,
updatedMs: rec.updated || Date.now()
};
}
}
// Steps 2 & 3: normalize and validate before signature hashing
curr = handlers._validateAndCleanSnapshotMap(curr);
tAfterBuild = perfNow();
// Optional skip if nothing changed (simple hash of keys + key-phase pairs)
try {
// Optimized signature: deterministic iteration without array sort.
// Assumption: object key insertion order for stable id set is sufficient; if membership changes order changes anyway.
let hash = 0, count = 0;
for (const [id, r] of Object.entries(curr)) {
const phase = r.canonical||'';
// simple rolling hash: hash = ((hash << 5) - hash) + charCode
const frag = id+':'+phase+'|';
for (let i=0;i<frag.length;i++) hash = ((hash << 5) - hash) + frag.charCodeAt(i), hash |= 0;
count++;
}
const sig = count+':'+hash; // include count to distinguish permutations of equal hash length sets
if (at.lastSig && at.lastSig === sig) {
at.metrics.signatureSkips = (at.metrics.signatureSkips||0) + 1;
at.metrics.skippedTicks = at.metrics.signatureSkips;
metrics.lastTickOutcome = 'sig-skip';
handlers._expireTransients();
handlers._renderTravelEtas?.();
if (storage.get('liveTrackDebugOverlayEnabled', false)) handlers._renderLiveTrackDebugOverlay?.();
at.metrics.lastDiffMs = (performance.now?.()||Date.now()) - t0;
return;
}
at.lastSig = sig;
} catch(_) { /* signature calc failure -> fall through */ }
const prev = at.prevById || {};
const transitions = [];
for (const [id, curState] of Object.entries(curr)) {
const p = prev[id];
if (!p) { prev[id] = curState; continue; }
if (handlers._didStateChange?.(p, curState)) {
// Insert Landed transient if travel finished
const land = handlers._maybeInjectLanded?.(p, curState);
if (land) transitions.push({ id, state: land });
// Apply confidence escalation
const escalated = handlers._escalateConfidence(p, curState);
// Add previousPhase + witness (phase change only)
const prevPhase = (p.canonical||p.phase||'');
const newPhase = (curState.canonical||curState.phase||'');
if (prevPhase !== newPhase) {
escalated.previousPhase = prevPhase || null;
escalated.witness = true;
} else {
escalated.witness = false;
}
transitions.push({ id, state: escalated });
// Phase history (now includes Landed)
try {
if (prevPhase && newPhase && prevPhase !== newPhase) {
state._activityTracking._phaseHistory = state._activityTracking._phaseHistory || {};
const arr = state._activityTracking._phaseHistory[id] = state._activityTracking._phaseHistory[id] || [];
arr.push({ from: prevPhase, to: newPhase, ts: Date.now(), confFrom: p.confidence||'', confTo: escalated.confidence||'', dest: escalated.dest||null, arrivalMs: escalated.arrivalMs||null });
if (arr.length > 100) arr.splice(0, arr.length - 100);
try { handlers._schedulePhaseHistoryWrite(String(id)); } catch(_) {}
}
} catch(_) { /* ignore history issues */ }
prev[id] = curState;
at.metrics.transitions++;
try {
state._activityTracking._recentTransitions = state._activityTracking._recentTransitions || [];
state._activityTracking._recentTransitions.push(Date.now());
// trim excessive growth safeguard (retain last 500 timestamps)
if (state._activityTracking._recentTransitions.length > 500) {
state._activityTracking._recentTransitions.splice(0, state._activityTracking._recentTransitions.length - 500);
}
// Transition event log (phase changes only) for overlay recent list
try {
const prevPhase = (p.canonical||p.phase||'');
const newPhase = (curState.canonical||curState.phase||'');
if (prevPhase && newPhase && prevPhase !== newPhase) {
state._activityTracking._transitionLog = state._activityTracking._transitionLog || [];
state._activityTracking._transitionLog.push({ id, from: prevPhase, to: newPhase, ts: Date.now() });
if (state._activityTracking._transitionLog.length > 50) {
state._activityTracking._transitionLog.splice(0, state._activityTracking._transitionLog.length - 50);
}
}
} catch(_) { /* ignore log issues */ }
} catch(_) { /* ignore metric hook issues */ }
}
}
// Apply transitions to UI
if (transitions.length) handlers._applyActivityTransitions?.(transitions);
metrics.lastTickOutcome = transitions.length ? `transitions:${transitions.length}` : 'steady';
tAfterApply = perfNow();
handlers._expireTransients();
// Update ETAs for any active travel
handlers._renderTravelEtas?.();
if (storage.get('liveTrackDebugOverlayEnabled', false)) handlers._renderLiveTrackDebugOverlay?.();
} catch(e) {
if (state.debug?.rowLogs) tdmlogger('warn', `[ActivityTick] error: ${e}`);
metrics.lastTickOutcome = 'error';
}
finally {
const tEnd = perfNow();
metrics.lastApiMs = Math.max(0, tAfterApi - t0);
metrics.lastBuildMs = Math.max(0, tAfterBuild - tAfterApi);
metrics.lastApplyMs = Math.max(0, tAfterApply - tAfterBuild);
metrics.lastDiffMs = Math.max(0, tEnd - t0); // total tick runtime
metrics.lastTickEnd = Date.now();
metrics.tickSamples.push(metrics.lastDiffMs);
if (metrics.tickSamples.length > 60) metrics.tickSamples.shift();
try { tdmlogger('debug', '[ActivityTick][exit]', { outcome: metrics.lastTickOutcome, lastDiffMs: metrics.lastDiffMs, plannedNextTick: at.metrics?.plannedNextTick || 0 }); } catch(_) {}
handlers._scheduleActivityTick();
}
},
// Determine if state meaningfully changed (phase change or dest/arrival update)
_didStateChange: (prev, curr) => {
if (!prev || !curr) return true;
if ((prev.canonical||prev.phase) !== (curr.canonical||curr.phase)) return true;
if ((prev.dest||'') !== (curr.dest||'')) return true;
// arrival change > 2s counts
if (Math.abs((prev.arrivalMs||0) - (curr.arrivalMs||0)) > 2000) return true;
return false;
},
// Inject transient Landed state when leaving Travel/Returning/Abroad into Okay (or domestic phase) with recent arrival
_maybeInjectLanded: (prev, curr) => {
const p = (prev.canonical||prev.phase||'').toLowerCase();
const c = (curr.canonical||curr.phase||'').toLowerCase();
const travelSet = new Set(['travel','traveling','returning','abroad']);
if (travelSet.has(p) && (c === 'okay' || c === 'hospital' || c === 'jail' || c === 'abroad_hosp')) {
// Only if arrivalMs was within last 30s
const now = Date.now();
const arr = prev.arrivalMs || prev.etams || 0;
if (arr && now - arr < 30000) {
return {
id: prev.id,
canonical: 'Landed',
phase: 'Landed',
confidence: 'HIGH', // arrival verified
transient: true,
expiresAt: now + (config?.LANDED_TTL_MS || 30000),
dest: prev.dest || null,
startedMs: prev.startedMs || prev.ct0 || now,
arrivalMs: arr,
updatedMs: now
};
}
}
return null;
},
// Confidence escalation ladder (LOW -> MED -> HIGH). No downgrades unless explicit reset (not handled here yet).
_escalateConfidence: (prev, curr) => {
// Advanced confidence heuristic with stability tracking and promotion thresholds.
const out = { ...curr };
const at = state._activityTracking;
const metrics = at?.metrics;
const phase = (curr.canonical||curr.phase||'').toLowerCase();
const prevPhase = (prev.canonical||prev.phase||'').toLowerCase();
const now = Date.now();
// Per-id stability record
at._confState = at._confState || {};
let st = at._confState[curr.id];
if (!st || st.phase !== phase) {
st = at._confState[curr.id] = {
phase,
phaseStart: now,
lastArrivalMs: curr.arrivalMs || 0,
stableArrivalCount: 0,
dest: curr.dest || null
};
} else {
// Update arrival stability
if (curr.arrivalMs && st.lastArrivalMs) {
const delta = Math.abs(curr.arrivalMs - st.lastArrivalMs);
if (delta <= 1500) st.stableArrivalCount++;
else st.stableArrivalCount = 0;
st.lastArrivalMs = curr.arrivalMs;
} else if (curr.arrivalMs) {
st.lastArrivalMs = curr.arrivalMs;
}
if (curr.dest && !st.dest) st.dest = curr.dest;
}
const isTravel = phase === 'travel';
const isReturning = phase === 'returning';
const phaseChanged = phase !== prevPhase;
const prevConf = prev.confidence || 'LOW';
let next = curr.confidence || prevConf || 'LOW';
const rank = { LOW:1, MED:2, HIGH:3 };
if (phaseChanged) {
const fromPhase = (curr.previousPhase || prevPhase || '').toLowerCase();
if (isTravel) {
const sawOutbound = ['okay','idle','active','landed'].includes(fromPhase);
const sawInboundDeparture = fromPhase === 'abroad';
if (curr.dest && curr.arrivalMs && (sawOutbound || sawInboundDeparture)) {
next = 'MED';
} else {
next = 'LOW';
}
} else if (isReturning) {
if (fromPhase === 'travel') {
next = prevConf || 'LOW';
if (rank[next] < rank.MED && curr.dest) next = 'MED';
} else if (fromPhase === 'abroad') {
next = curr.dest ? 'MED' : 'LOW';
} else {
next = 'LOW';
}
} else {
next = 'LOW';
}
st.phaseStart = now;
st.stableArrivalCount = 0;
st.lastArrivalMs = curr.arrivalMs || 0;
st.dest = curr.dest || null;
} else {
const stableMs = now - st.phaseStart;
if (isTravel) {
const MED_MIN_MS = config.TRAVEL_PROMOTE_MS || 8000; // reuse existing if defined
const HIGH_MIN_MS = config.TRAVEL_HIGH_PROMOTE_MS || 25000;
if (rank[next] <= rank.LOW && curr.dest && curr.arrivalMs && stableMs >= MED_MIN_MS) {
next = 'MED';
if (metrics) {
metrics.confPromos = metrics.confPromos || {};
metrics.confPromos.L2M = (metrics.confPromos.L2M || 0) + 1;
}
}
if (rank[next] <= rank.MED && stableMs >= HIGH_MIN_MS) {
if (st.stableArrivalCount >= 2 || (curr.arrivalMs && (curr.arrivalMs - now) < 90000)) {
if (next !== 'HIGH' && metrics) {
metrics.confPromos = metrics.confPromos || {};
metrics.confPromos.M2H = (metrics.confPromos.M2H || 0) + 1;
}
next = 'HIGH';
}
}
} else if (isReturning) {
const RETURN_MED_MS = 8000;
const RETURN_HIGH_MS = 30000;
if (rank[next] <= rank.LOW && stableMs >= RETURN_MED_MS) {
next = 'MED';
if (metrics) {
metrics.confPromos = metrics.confPromos || {};
metrics.confPromos.L2M = (metrics.confPromos.L2M || 0) + 1;
}
}
if (rank[next] <= rank.MED && stableMs >= RETURN_HIGH_MS) {
if (next !== 'HIGH' && metrics) {
metrics.confPromos = metrics.confPromos || {};
metrics.confPromos.M2H = (metrics.confPromos.M2H || 0) + 1;
}
next = 'HIGH';
}
} else {
const NON_TRAVEL_MED_MS = 15000;
const NON_TRAVEL_HIGH_MS = 60000;
if (rank[next] <= rank.LOW && stableMs >= NON_TRAVEL_MED_MS) {
next = 'MED';
if (metrics) {
metrics.confPromos = metrics.confPromos || {};
metrics.confPromos.L2M = (metrics.confPromos.L2M || 0) + 1;
}
}
if (rank[next] <= rank.MED && stableMs >= NON_TRAVEL_HIGH_MS) {
if (next !== 'HIGH' && metrics) {
metrics.confPromos = metrics.confPromos || {};
metrics.confPromos.M2H = (metrics.confPromos.M2H || 0) + 1;
}
next = 'HIGH';
}
}
}
const prevPhaseForWitness = (curr.previousPhase || prevPhase || '').toLowerCase();
let witnessBoost = false;
if (curr.witness) {
if (isTravel && ['okay','idle','active','landed','abroad'].includes(prevPhaseForWitness)) {
witnessBoost = true;
} else if (isReturning && prevPhaseForWitness === 'abroad') {
witnessBoost = true;
} else if (!isTravel && !isReturning) {
witnessBoost = true;
}
}
if (witnessBoost && next !== 'HIGH') next = 'HIGH';
const nextRank = rank[next] ?? 0;
const prevRank = rank[prevConf] ?? 0;
if (nextRank < prevRank) next = prevConf; // never downgrade
out.confidence = next;
return out;
},
// Expire transients (Landed)
_expireTransients: () => {
const unified = state.unifiedStatus || {}; if (!Object.keys(unified).length) return;
const now = Date.now(); let changed = false;
for (const [id, rec] of Object.entries(unified)) {
if (rec && rec.transient && rec.expiresAt && now >= rec.expiresAt) {
// Replace Landed with stable phase (Okay unless hospital/jail already in current snapshot)
const stable = { ...rec };
stable.transient = false;
if ((rec.canonical||'').toLowerCase() === 'landed') stable.canonical = 'Okay';
unified[id] = stable; changed = true;
}
}
if (changed) {
// Trigger minimal UI refresh (reuse apply transitions)
const transitions = Object.entries(unified).filter(([_,r]) => !r.transient).map(([id,state]) => ({ id, state }));
handlers._applyActivityTransitions(transitions);
}
},
// Travel ETA rendering (API-only): uses unifiedStatus arrivalMs/startMs
_renderTravelEtas: () => {
try {
if (!storage.get('tdmActivityTrackingEnabled', false)) return;
const unified = state.unifiedStatus || {};
const now = Date.now();
const hydrating = !state._travelEtaHydrated;
const rows = document.querySelectorAll('#faction-war .table-body .table-row');
let anyActive = false;
rows.forEach(row => {
try {
const link = row.querySelector('a[href*="profiles.php?XID="]');
if (!link) return; const id = (link.href.match(/XID=(\d+)/)||[])[1];
if (!id) return;
const rec = unified[id];
const etaElOld = row.querySelector('.tdm-travel-eta');
const isTravel = rec && rec.canonical === 'Travel' && rec.arrivalMs && rec.startedMs && rec.arrivalMs > now;
if (!isTravel) {
if (etaElOld) etaElOld.remove();
return;
}
anyActive = true;
const remainMs = rec.arrivalMs - now;
if (remainMs <= 0) { if (etaElOld) etaElOld.remove(); return; }
const remainSec = Math.floor(remainMs/1000);
const mm = Math.floor(remainSec/60);
const ss = String(remainSec%60).padStart(2,'0');
let etaEl = etaElOld;
if (!etaEl) {
etaEl = document.createElement('span');
etaEl.className = 'tdm-travel-eta';
etaEl.style.cssText = 'margin-left:4px;font-size:10px;color:#66c;';
const statusCell = row.querySelector('.status') || row.lastElementChild;
if (statusCell) statusCell.appendChild(etaEl); else row.appendChild(etaEl);
}
let txt = mm > 99 ? `${mm}m` : `${mm}:${ss}`;
if (hydrating) txt += ' ...';
if (etaEl.textContent !== txt) etaEl.textContent = txt;
const dest = rec.dest ? utils.travel?.abbrevDest?.(rec.dest) || rec.dest : '';
// Use unified textual confidence (omit if HIGH)
let etaConfText = '';
try {
const c = rec.confidence;
if (c && c !== 'HIGH') etaConfText = ` (${c[0]})`;
} catch(_) { /* ignore */ }
etaEl.title = `${dest?dest+' ':''}ETA ${new Date(rec.arrivalMs).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}${etaConfText}`;
} catch(_) { /* ignore row */ }
});
if (anyActive) {
state._travelEtaHydrated = true;
utils.unregisterTimeout(state._travelEtaTimer);
state._travelEtaTimer = utils.registerTimeout(setTimeout(handlers._renderTravelEtas, 1000));
}
} catch(_) { /* ignore top-level */ }
},
//======================================================================
// 7. INITIALIZATION & MAIN EXECUTION
//======================================================================
};
// Start the lightweight attacker heartbeat independent of activity-tracking.
// This keeps the tiny ping loop separate from the heavier activity tick and
// avoids coupling it to the activity-tracking lifecycle.
// When the page becomes visible again, attempt an immediate heartbeat tick
try {
document.addEventListener('visibilitychange', () => {
try {
// Only attempt when the page is visible and window is active
if (document.visibilityState === 'visible' && !document.hidden && (state.script.isWindowActive !== false)) {
try { handlers._sendAttackerHeartbeatNow?.().catch(() => {}); } catch(_) {}
}
} catch(_) {}
});
} catch(_) {}
// Ensure debounced handler shims exist in case code calls them before
// `initializeDebouncedHandlers` runs. These will forward to the
// non-debounced implementations (immediate) and will be overwritten
// by `initializeDebouncedHandlers` when it runs.
try {
const _ensureShim = (debName, baseName) => {
try {
if (typeof handlers[debName] !== 'function') {
handlers[debName] = function(...args) {
const base = handlers[baseName];
if (typeof base === 'function') {
try { return base.apply(handlers, args); } catch (e) { return Promise.reject(e); }
}
return Promise.resolve();
};
}
} catch(_) {}
};
_ensureShim('debouncedDibsTarget', 'dibsTarget');
_ensureShim('debouncedRemoveDibsForTarget', 'removeDibsForTarget');
_ensureShim('debouncedHandleMedDealToggle', 'handleMedDealToggle');
_ensureShim('debouncedFetchGlobalData', 'fetchGlobalData');
_ensureShim('debouncedAssignDibs', 'assignDibs');
_ensureShim('debouncedHandleSaveUserNote', 'handleSaveUserNote');
} catch(_) { /* silent */ }
const main = {
init: async () => {
utils.perf.start('main.init');
try { utils.loadUnifiedStatusSnapshot(); } catch(_) {}
try { handlers._renderTravelEtas?.(); } catch(_) {}
// Restore API usage counter for this tab/session to avoid drops after SPA hash changes
// Reset API usage on full page reload (not SPA navigation)
try {
const nav = performance.getEntriesByType && performance.getEntriesByType('navigation');
const navType = Array.isArray(nav) && nav.length ? nav[0].type : (performance.navigation ? (performance.navigation.type === 1 ? 'reload' : 'navigate') : 'navigate');
if (navType === 'navigate' || navType === 'reload') {
// Fresh load: clear prior session counters
sessionStorage.removeItem('tdm.api_calls');
sessionStorage.removeItem('tdm.api_calls_client');
sessionStorage.removeItem('tdm.api_calls_backend');
}
} catch(_) {}
try { const saved = sessionStorage.getItem('tdm.api_calls'); if (saved !== null) state.session.apiCalls = Number(saved) || 0; } catch(_) {}
try { const savedC = sessionStorage.getItem('tdm.api_calls_client'); if (savedC !== null) state.session.apiCallsClient = Number(savedC) || 0; } catch(_) {}
try { const savedB = sessionStorage.getItem('tdm.api_calls_backend'); if (savedB !== null) state.session.apiCallsBackend = Number(savedB) || 0; } catch(_) {}
main.setupGmFunctions();
main.registerTampermonkeyMenuCommands();
// Non-blocking update check (cached)
main.checkForUserscriptUpdate().catch(() => {});
// Render from cache as early as possible to avoid blocking UI on network
try { await main.initializeScriptLogic({ skipFetch: true }); } catch (_) { /* non-fatal */ }
// Resolve API key: prefer PDA placeholder when present, else use localStorage
try {
if (state.script.isPDA && config.PDA_API_KEY_PLACEHOLDER && config.PDA_API_KEY_PLACEHOLDER[0] !== '#') {
state.user.actualTornApiKey = config.PDA_API_KEY_PLACEHOLDER;
} else {
state.user.actualTornApiKey = getStoredCustomApiKey();
}
} catch (_) { state.user.actualTornApiKey = null; }
const initResult = await main.initializeUserAndApiKey();
if (initResult && initResult.ok) {
main.initializeDebouncedHandlers();
await main.initializeScriptLogic();
main.startPolling();
main.setupActivityListeners();
try { utils.ensureUnifiedStatusUiListener(); } catch(_) {}
// Auto-start activity tracking if enabled in storage
try {
if (storage.get('tdmActivityTrackingEnabled', false) || storage.get('liveTrackDebugOverlayEnabled', false)) {
handlers._initActivityTracking?.();
if (storage.get('liveTrackDebugOverlayEnabled', false)) {
ui.ensureDebugOverlayContainer?.({ passive: true });
}
}
} catch(_) {}
// Ensure toggles have defaults
try {
if (storage.get('alertButtonsEnabled', null) === null) storage.set('alertButtonsEnabled', true);
if (storage.get('userScoreBadgeEnabled', null) === null) storage.set('userScoreBadgeEnabled', true);
if (storage.get('factionScoreBadgeEnabled', null) === null) storage.set('factionScoreBadgeEnabled', true);
if (storage.get('liveTrackingEnabled', null) === null) storage.set('liveTrackingEnabled', false); // default off until stable
if (storage.get('tdmActivityTrackWhileIdle', null) === null) storage.set('tdmActivityTrackWhileIdle', false);
} catch(_) {}
// Render badges once at startup
ui.ensureUserScoreBadge();
ui.ensureFactionScoreBadge();
} else if (initResult && initResult.message) {
const keyIssueReasons = ['missing-key', 'missing-pda-key', 'missing-scopes', 'no-response', 'exception'];
if (keyIssueReasons.includes(initResult.reason)) {
main.handleApiKeyNotReady(initResult);
}
}
utils.perf.stop('main.init');
},
// Centralized note-activity helper so various parts of the app can reset inactivity
noteActivity: () => {
try {
state.script.lastActivityTime = Date.now();
utils.unregisterTimeout(state.script.activityTimeoutId);
const keepActive = utils.isActivityKeepActiveEnabled();
const prevMode = state.script.activityMode || 'inactive';
state.script.activityMode = 'active';
if (prevMode !== 'active') {
main.startPolling();
}
if (!keepActive) {
state.script.activityTimeoutId = utils.registerTimeout(setTimeout(() => {
const prior = state.script.activityMode || 'active';
state.script.activityMode = 'inactive';
if (prior !== 'inactive') {
main.startPolling();
}
}, config.ACTIVITY_TIMEOUT_MS));
} else {
if (state.script.activityTimeoutId) { try { utils.unregisterTimeout(state.script.activityTimeoutId); } catch(_) {} state.script.activityTimeoutId = null; }
}
} catch (_) { /* non-fatal */ }
},
initializeDebouncedHandlers: () => {
handlers.debouncedFetchGlobalData = utils.debounce(async () => {
try { await handlers.fetchGlobalData(); } catch(e) { tdmlogger('error', 'debouncedFetchGlobalData error', e); }
}, 500);
handlers.debouncedDibsTarget = utils.debounce(handlers.dibsTarget, 500);
handlers.debouncedRemoveDibsForTarget = utils.debounce(handlers.removeDibsForTarget, 500);
handlers.debouncedHandleMedDealToggle = utils.debounce(handlers.handleMedDealToggle, 500);
handlers.debouncedSetFactionWarData = utils.debounce(handlers.setFactionWarData, 500);
handlers.debouncedHandleSaveUserNote = utils.debounce(handlers.handleSaveUserNote, 500);
handlers.debouncedAssignDibs = utils.debounce(handlers.assignDibs, 500);
// Debounced badge updater: only repaint if counts actually changed since last invocation
handlers.debouncedUpdateDibsDealsBadge = utils.debounce(() => {
try {
const el = state.ui.dibsDealsBadgeEl || document.getElementById('tdm-dibs-deals');
if (!el) { ui.updateDibsDealsBadge(); return; }
const prevText = el.textContent || '';
// Use underlying direct logic to compute fresh text without double DOM writes
// We'll call the direct updater, then if text unchanged revert timestamp (minor optimization not crucial)
ui.updateDibsDealsBadge();
const nextText = el.textContent || '';
if (nextText === prevText) {
// No visual change; could optionally revert any ts touches or skip future work (noop)
}
} catch(_) { /* swallow */ }
}, 400);
// Debounced API usage badge updater to collapse clustered increments
handlers.debouncedUpdateApiUsageBadge = utils.debounce(() => {
try { ui.updateApiUsageBadge?.(); } catch(_) { /* noop */ }
}, 400);
},
// Register Tampermonkey menu commands (non-PDA only)
registerTampermonkeyMenuCommands: () => {
if (typeof state.gm.rD_registerMenuCommand !== 'function') return;
try {
state.gm.rD_registerMenuCommand('TreeDibs: Set/Update Torn API Key', async () => {
try {
await ui.openSettingsToApiKeySection({ highlight: true, focusInput: true });
ui.showMessageBox('Settings opened. Paste your custom Torn API key in the API Key & Access section.', 'info', 4000);
} catch(_) {
ui.showMessageBox('Open the TreeDibs settings button on the faction page to update your Torn API key.', 'warning', 4000);
}
});
state.gm.rD_registerMenuCommand('TreeDibs: Clear Torn API Key', async () => {
await main.clearStoredApiKey({ confirm: true, viaMenu: true });
});
state.gm.rD_registerMenuCommand('TreeDibs: Open Settings Panel', () => ui.toggleSettingsPopup());
state.gm.rD_registerMenuCommand('TreeDibs: Refresh Now', () => handlers.debouncedFetchGlobalData());
state.gm.rD_registerMenuCommand('TreeDibs: Check for Update', () => main.checkForUserscriptUpdate(true));
// (Force Active Polling menu command removed; now managed via settings panel button.)
} catch (e) {
tdmlogger('error', `Failed to register Tampermonkey menu commands: ${e}`);
}
},
checkForUserscriptUpdate: async (force = false) => {
try {
const now = Date.now();
const lastCheck = storage.get('lastUpdateCheck', 0);
const throttled = (!force && (now - lastCheck) < 6 * 60 * 60 * 1000);
try { tdmlogger('debug', `[Update] checkForUserscriptUpdate start force=${!!force} last=${new Date(Number(lastCheck)||0).toISOString()} throttled=${throttled}`); } catch(_) {}
if (throttled) return; // 6h throttle
storage.set('lastUpdateCheck', now);
// Try meta URL first with a cache buster to avoid CDN edge caching quirks
const metaUrl = `${config.GREASYFORK.updateMetaUrl}?_=${now % 1e7}`;
const metaRes = await utils.httpGetDetailed(metaUrl);
let latest = null;
if (metaRes && metaRes.text) {
const m = metaRes.text.match(/@version\s+([^\n\r]+)/);
latest = m ? m[1].trim() : null;
if (!latest) { try { tdmlogger('warn', `[Update] Could not parse @version from meta (status=${metaRes.status})`); } catch(_) {} }
} else {
try { tdmlogger('warn', `[Update] No metaText returned from updateMetaUrl (status=${metaRes?.status})`); } catch(_) {}
}
// Fallback: scrape the GreasyFork page for the Version: field
if (!latest) {
try {
const pageUrl = `${config.GREASYFORK.pageUrl}?_=${now % 1e7}`;
const pageRes = await utils.httpGetDetailed(pageUrl);
if (pageRes && pageRes.text) {
// Common patterns on GreasyFork pages
// Example: <dd class="script-show-version" data-script-version="2.4.0">2.4.0</dd>
let m = pageRes.text.match(/script-show-version[^>]*>([^<]+)/i);
if (!m) {
// Another fallback: "Version" label
m = pageRes.text.match(/>\s*Version\s*<\/dt>\s*<dd[^>]*>\s*([\d\.]+)\s*<\/dd>/i);
}
if (!m) {
// Loose fallback: find @version in code blocks
m = pageRes.text.match(/@version\s+([\d\.]+)/i);
}
if (m) latest = String(m[1]).trim();
if (!latest) { try { tdmlogger('warn', `[Update] Could not extract version from page (status=${pageRes.status})`); } catch(_) {} }
} else {
try { tdmlogger('warn', `[Update] Page fetch returned empty body (status=${pageRes?.status})`); } catch(_) {}
}
} catch (e) {
try { tdmlogger('warn', `[Update] Page fallback failed: ${e?.message || e}`); } catch(_) {}
}
}
if (!latest) return; // Nothing to update
storage.set('lastKnownLatestVersion', latest);
try { tdmlogger('info', `[Update] Current=${config.VERSION} Latest=${latest}`); } catch(_) {}
// Non-intrusive: only store flag and adjust UI label
if (utils.compareVersions(config.VERSION, latest) < 0) {
state.script.updateAvailableLatestVersion = latest;
ui.updateSettingsButtonUpdateState();
try { tdmlogger('info', '[Update] Update available'); } catch(_) {}
} else {
state.script.updateAvailableLatestVersion = null;
ui.updateSettingsButtonUpdateState();
try { tdmlogger('info', '[Update] Up to date'); } catch(_) {}
}
} catch (e) {
try { tdmlogger('error', `[TDM][Update] check failed: ${e?.message || e}`); } catch(_) {}
// Silent fail otherwise; not critical
}
},
setupGmFunctions: () => {
// Robust feature detection for multiple userscript runtimes (GM3/GM4) and PDA
const placeholder = config.PDA_API_KEY_PLACEHOLDER || '###PDA-APIKEY###';
state.script.isPDA = (placeholder && placeholder[0] !== '#');
// Helpers to detect GM variants without throwing ReferenceError
let hasGM4 = false;
try { hasGM4 = (typeof GM !== 'undefined' && GM && typeof GM.getValue === 'function'); } catch(_) {}
let hasGM_xmlhttpRequest = false;
try { hasGM_xmlhttpRequest = (typeof GM_xmlhttpRequest === 'function') || (hasGM4 && typeof GM.xmlHttpRequest === 'function'); } catch(_) {}
// Fix for "Cannot redefine property: GM" error in some environments
// We do not attempt to write to window.GM or global GM here to avoid conflicts.
// We only read from it.
// Legacy GM storage wrappers removed for get/set/delete/getApiKey/addStyle — using page-local fallbacks where needed
// registerMenuCommand: best-effort - no-op when unavailable
state.gm.rD_registerMenuCommand = (caption, cb) => {
try {
if (hasGM4) {
try { if (typeof GM.registerMenuCommand === 'function') return GM.registerMenuCommand(caption, cb); } catch(_) {}
}
if (typeof GM_registerMenuCommand === 'function') return GM_registerMenuCommand(caption, cb);
} catch (_) {}
// fallback: no-op
return null;
};
// xmlhttpRequest adapter: PDA inappwebview, GM xmlhttprequest, or fetch-based shim
let __tdm_xmlPath = 'none';
if (state.script.isPDA && window.flutter_inappwebview && typeof window.flutter_inappwebview.callHandler === 'function') {
state.gm.rD_xmlhttpRequest = (details) => {
const ret = new Promise((resolve, reject) => {
try {
const { method = 'GET', url, headers, data: body } = details;
const pdaPromise = method.toLowerCase() === 'post'
? window.flutter_inappwebview.callHandler('PDA_httpPost', url, headers || {}, body || '')
: window.flutter_inappwebview.callHandler('PDA_httpGet', url, headers || {});
// Catch the promise from PDA bridge to prevent unhandled rejections
pdaPromise.catch(() => {});
pdaPromise.then(response => {
// Defensive: if PDA bridge returned a falsy response, treat as error
if (!response) {
const err = new Error('PDA returned null/undefined response');
try { console.warn('[TDM][PDA] callHandler returned empty response'); } catch(_) {}
try { if (typeof details.onerror === 'function') details.onerror(err); } catch(_) {}
return reject(err);
}
const responseObj = {
status: response?.status || 200,
statusText: response?.statusText || 'OK',
responseText: response?.responseText || '',
finalUrl: url
};
try { if (typeof details.onload === 'function') details.onload(responseObj); } catch(_) {}
resolve(responseObj);
}).catch(error => {
// Normalize undefined/null rejection reasons into Error objects to avoid
// unhelpful unhandledrejection events with `reason === undefined`.
const err = (error instanceof Error) ? error : new Error((error && (error.message || String(error))) || 'PDA callHandler rejected without reason');
try { console.warn('[TDM][PDA] callHandler error', err); } catch(_) {}
try { if (typeof details.onerror === 'function') details.onerror(err); } catch(_) {}
// IMPORTANT: We must NOT re-throw or return a rejected promise here if we want to suppress the global unhandled rejection.
// Since we've called onerror/reject, the caller is notified.
// However, the caller (state.gm.rD_xmlhttpRequest) returns a Promise.
// If we reject that promise, the caller must catch it.
// My previous fix added .catch() to the callers.
// But if pdaPromise itself rejects, we need to make sure we don't leave a dangling rejection.
// We resolve with undefined (or a mock error response) so the outer promise resolves,
// but the error is handled via onerror callback.
// This prevents the "Unhandled Rejection" in the console.
resolve(undefined);
});
} catch (e) { try { console.error('[TDM][PDA] callHandler threw', e); } catch(_) {}
try { if (typeof details.onerror === 'function') details.onerror(e); } catch(_) {} resolve(undefined); }
});
// Ensure the returned promise from rD_xmlhttpRequest is also caught if the caller doesn't await it
if (ret && typeof ret.catch === 'function') ret.catch(() => {});
return ret;
};
__tdm_xmlPath = 'pda';
} else if (hasGM_xmlhttpRequest) {
try {
if (hasGM4) {
try { if (typeof GM.xmlHttpRequest === 'function') state.gm.rD_xmlhttpRequest = GM.xmlHttpRequest; } catch(_) {}
}
if (!state.gm.rD_xmlhttpRequest && typeof GM_xmlhttpRequest === 'function') state.gm.rD_xmlhttpRequest = GM_xmlhttpRequest;
if (state.gm.rD_xmlhttpRequest) {
__tdm_xmlPath = (hasGM4 && typeof GM.xmlHttpRequest === 'function') ? 'GM4.xmlHttpRequest' : 'GM_xmlhttpRequest';
// Wrap the native GM function to ensure it returns a promise (some implementations might not)
// and to catch errors.
const original = state.gm.rD_xmlhttpRequest;
state.gm.rD_xmlhttpRequest = (details) => {
// GM_xmlhttpRequest usually returns an object with .abort(), not a promise.
// But our code expects a promise-like return or at least something awaitable if we wrapped it.
// Actually, our usage throughout the file is `state.gm.rD_xmlhttpRequest({...})` often without await,
// or `await new Promise(...)` wrapping it.
// However, if we want to be safe, we should just call it.
// BUT, if we want to catch synchronous errors:
try {
return original(details);
} catch (e) {
try { if (typeof details.onerror === 'function') details.onerror(e); } catch(_) {}
return undefined;
}
};
}
} catch (_) {
// fall through to fetch shim
}
}
// Fallback fetch-based shim if nothing else assigned
if (!state.gm.rD_xmlhttpRequest) {
state.gm.rD_xmlhttpRequest = (details) => {
// Return a promise to match the interface of other adapters (even though GM_xmlhttpRequest doesn't strictly return one, our wrapper does)
const ret = (async () => {
const method = (details.method || 'GET').toUpperCase();
const headers = details.headers || {};
const body = details.data;
try {
const resp = await fetch(details.url, { method, headers, body });
const text = await resp.text();
const responseObj = { status: resp.status, statusText: resp.statusText, responseText: text, finalUrl: resp.url };
try { if (typeof details.onload === 'function') details.onload(responseObj); } catch(_) {}
return responseObj;
} catch (err) {
try { if (typeof details.onerror === 'function') details.onerror(err); } catch(_) {}
// Swallow error to avoid unhandled rejection if caller doesn't catch, matching GM_xmlhttpRequest behavior
return undefined;
}
})();
// Ensure the returned promise is caught if the caller doesn't await it
if (ret && typeof ret.catch === 'function') ret.catch(() => {});
return ret;
};
__tdm_xmlPath = 'fetch';
}
state.script.apiTransport = __tdm_xmlPath;
state.script.useHttpEndpoints = (__tdm_xmlPath === 'fetch');
// Report chosen GM/fallback paths under debug
try {
let __tdm_registerPath = 'noop';
try {
if (hasGM4 && typeof GM.registerMenuCommand === 'function') __tdm_registerPath = 'GM4.registerMenuCommand';
else if (typeof GM_registerMenuCommand === 'function') __tdm_registerPath = 'GM_registerMenuCommand';
} catch(_) {}
tdmlogger('debug', `[TDM] chosen gm paths -> xmlhttprequest: ${__tdm_xmlPath}, registerMenu: ${__tdm_registerPath}`);
} catch(_) {}
},
verifyApiKey: async ({ key } = {}) => {
if (!key || typeof key !== 'string') {
return { ok: false, reason: 'missing-key', message: 'TreeDibsMapper needs a custom Torn API key.', validation: null };
}
try {
const keyInfo = await api.getKeyInfo(key);
if (!keyInfo) {
return {
ok: false,
reason: 'no-response',
message: '[TDM] Unable to verify your custom API key (Torn API may be unavailable). Try again shortly.',
validation: null,
keyInfo: null
};
}
const validation = validateApiKeyScopes(keyInfo);
if (!validation.ok) {
const missingList = (validation.missing || []).map(scope => scope.replace('.', ' -> ')).join(', ');
return {
ok: false,
reason: 'missing-scopes',
message: `[TDM] Custom API key is missing required selections (${missingList}). Regenerate it via Torn > Preferences > API.`,
validation,
keyInfo
};
}
return {
ok: true,
reason: 'verified',
message: validation.isLimited ? '[TDM] Limited key detected.' : 'Custom API key verified.',
validation,
keyInfo
};
} catch (error) {
return {
ok: false,
reason: 'exception',
message: `[TDM] API key verification failed: ${error?.message || error || 'Unknown error.'}`,
validation: null,
keyInfo: null,
error
};
}
},
storeCustomApiKey: async (key, { reload = true, validation = null, keyInfo = null } = {}) => {
if (!key || typeof key !== 'string' || !key.trim()) {
throw new Error('Custom API key value is required.');
}
const value = setStoredCustomApiKey(key);
state.user.actualTornApiKey = value;
state.user.apiKeySource = 'local';
// If verification metadata was supplied, persist it so the UI
// reflects verified status after the reload. Otherwise, clear
// validation so clients re-verify on next init.
if (validation && typeof validation === 'object') {
state.user.keyValidation = validation;
state.user.keyValidatedAt = Date.now();
} else {
state.user.keyValidation = null;
state.user.keyValidatedAt = null;
}
state.user.keyInfoCache = keyInfo || null;
state.user.actualTornApiKeyAccess = 0;
state.user.apiKeyUiMessage = {
tone: validation ? (validation.isLimited ? 'warning' : 'success') : 'info',
text: validation ? (validation.isLimited ? '[TDM] Limited key detected.' : 'Custom API key verified and saved.') : (reload ? 'Custom API key saved. Reloading...' : 'Custom API key saved. Remember to revalidate.'),
ts: Date.now(),
reason: validation ? (validation.isLimited ? 'verified-limited' : 'verified') : 'stored'
};
storage.updateStateAndStorage('user', state.user);
// Only reload automatically when explicitly requested AND the key is
// a fully verified custom key (not a "limited" key). Limited keys are
// saved but do not trigger a reload so users can inspect diagnostics.
if (reload) {
const isLimited = validation?.isLimited === true;
if (isLimited) {
try { ui.showMessageBox('Custom API key saved (limited access). Not reloading. Check required selections and re-save when corrected.', 'warning'); } catch(_) {}
} else {
try { ui.showMessageBox('Custom API key saved. Reloading...', 'info'); } catch(_) {}
setTimeout(() => { try { location.reload(); } catch(_) {}; }, 300);
}
}
return { ok: true };
},
clearStoredApiKey: async ({ confirm = false, viaMenu = false } = {}) => {
if (confirm) {
const proceed = await ui.showConfirmationBox('Clear the stored Torn custom API key? You will be prompted again next time.');
if (!proceed) return { ok: false, cancelled: true };
}
clearStoredCustomApiKey();
const pdaFallback = (state.script.isPDA && config.PDA_API_KEY_PLACEHOLDER && config.PDA_API_KEY_PLACEHOLDER[0] !== '#') ? config.PDA_API_KEY_PLACEHOLDER : null;
state.user.actualTornApiKey = pdaFallback || null;
state.user.apiKeySource = pdaFallback ? 'pda' : 'none';
state.user.keyValidation = null;
state.user.keyValidatedAt = null;
state.user.keyInfoCache = null;
state.user.actualTornApiKeyAccess = 0;
const message = pdaFallback
? 'Custom override cleared. Falling back to PDA-provided key. Reloading...'
: 'Custom API key cleared. Reloading...';
state.user.apiKeyUiMessage = {
tone: pdaFallback ? 'info' : 'warning',
text: message,
ts: Date.now(),
reason: 'cleared'
};
storage.updateStateAndStorage('user', state.user);
try { ui.showMessageBox(message, 'info'); } catch(_) {}
setTimeout(() => { try { location.reload(); } catch(_) {}; }, viaMenu ? 400 : 250);
return { ok: true, fallback: !!pdaFallback };
},
revalidateStoredApiKey: async ({ showToasts = true } = {}) => {
const localKey = getStoredCustomApiKey();
const pdaKey = (state.script.isPDA && config.PDA_API_KEY_PLACEHOLDER && config.PDA_API_KEY_PLACEHOLDER[0] !== '#') ? config.PDA_API_KEY_PLACEHOLDER : null;
const activeKey = state.user.actualTornApiKey || localKey || pdaKey;
if (!activeKey) {
const msg = 'No Torn API key available to verify.';
if (showToasts) ui.showMessageBox(msg, 'warning');
state.user.apiKeyUiMessage = { tone: 'warning', text: msg, ts: Date.now(), reason: 'missing-key' };
storage.updateStateAndStorage('user', state.user);
return { ok: false, tone: 'warning', reason: 'missing-key', message: msg };
}
const result = await main.verifyApiKey({ key: activeKey });
state.user.keyValidation = result.validation || null;
state.user.keyInfoCache = result.keyInfo || null;
state.user.keyValidatedAt = Date.now();
state.user.actualTornApiKeyAccess = result.validation?.level || 0;
if (!result.ok) {
if (showToasts) ui.showMessageBox(result.message, 'error');
state.user.apiKeyUiMessage = { tone: 'error', text: result.message, ts: Date.now(), reason: result.reason || 'verify-failed' };
storage.updateStateAndStorage('user', state.user);
return { ok: false, tone: 'error', reason: result.reason, message: result.message };
}
if (showToasts) ui.showMessageBox('API key verified.', 'success');
state.user.apiKeyUiMessage = {
tone: 'success',
text: result.message,
ts: Date.now(),
reason: result.reason || 'verified'
};
storage.updateStateAndStorage('user', state.user);
return {
ok: true,
tone: 'success',
reason: result.reason,
message: result.message,
validation: result.validation
};
},
handleApiKeyNotReady: (result) => {
const tone = result?.tone || (result?.reason === 'missing-key' ? 'warning' : 'error');
const message = result?.message || 'TreeDibsMapper needs a valid Torn API key.';
state.user.apiKeyUiMessage = { tone, text: message, ts: Date.now(), reason: result?.reason || 'unknown' };
storage.updateStateAndStorage('user', state.user);
try { ui.updateSettingsContent?.(); } catch(_) {}
setTimeout(() => {
try { ui.openSettingsToApiKeySection({ highlight: true, focusInput: true }); } catch(_) {}
}, 300);
},
initializeUserAndApiKey: async () => {
try {
const storedKey = getStoredCustomApiKey();
const pdaInjectedKey = (state.script.isPDA && config.PDA_API_KEY_PLACEHOLDER && config.PDA_API_KEY_PLACEHOLDER[0] !== '#') ? config.PDA_API_KEY_PLACEHOLDER : null;
let activeKey = storedKey && String(storedKey).trim();
let source = 'local';
if (!activeKey) {
if (pdaInjectedKey) {
activeKey = String(pdaInjectedKey).trim();
source = 'pda';
} else {
source = 'none';
}
}
state.user.actualTornApiKey = activeKey || null;
state.user.apiKeySource = source;
if (!activeKey) {
const msg = state.script.isPDA
? 'PDA API Key placeholder not replaced. Please enter a custom key with the required selections.'
: 'No custom API key provided. TreeDibsMapper will remain idle until a valid key is entered.';
ui.showMessageBox(msg, 'warning');
storage.updateStateAndStorage('user', state.user);
return { ok: false, reason: state.script.isPDA ? 'missing-pda-key' : 'missing-key', message: msg, tone: 'warning' };
}
const verification = await main.verifyApiKey({ key: activeKey });
state.user.keyValidation = verification.validation || null;
state.user.keyInfoCache = verification.keyInfo || null;
state.user.keyValidatedAt = Date.now();
state.user.actualTornApiKeyAccess = verification.validation?.level || 0;
if (!verification.ok) {
ui.showMessageBox(verification.message, 'error');
storage.updateStateAndStorage('user', state.user);
return { ok: false, reason: verification.reason, message: verification.message, tone: 'error' };
}
// Persist a user-facing UI message so the settings panel reflects
// the verified status immediately after reload.
state.user.apiKeyUiMessage = {
tone: 'success',
text: verification.message || (verification.validation?.isLimited ? '[TDM] Limited key detected.' : 'Custom API key verified.'),
ts: Date.now(),
reason: verification.reason || 'verified'
};
// Persist the message immediately so the UI is consistent after the page reload
storage.updateStateAndStorage('user', state.user);
const factionData = await api.getTornFaction(state.user.actualTornApiKey, 'basic,members,rankedwars');
if (factionData && factionData.members && Array.isArray(factionData.members)) {
// Store all member keys from v2 response
state.factionMembers = factionData.members.map(member => ({
id: member.id,
name: member.name,
level: member.level,
days_in_faction: member.days_in_faction,
last_action: member.last_action,
status: member.status,
revive_setting: member.revive_setting,
position: member.position,
is_revivable: member.is_revivable,
is_on_wall: member.is_on_wall,
is_in_oc: member.is_in_oc,
has_early_discharge: member.has_early_discharge
})).filter(m => m.id && m.status !== 'Fallen');
}
// Prefer cached faction bundle for self basic info if available
let tornUser = null;
if (state.user && state.user.tornUserObject && !tornUser && (state.user.tornUserFetchAt && (Date.now() - state.user.tornUserFetchedAt) < 60000)) {
// throttle cache to once a minute
tornUser = state.user.tornUserObject;
} else {
tornUser = await api.getTornUser(state.user.actualTornApiKey);
state.user.tornUserFetchedAt = Date.now();
}
if (!tornUser) {
ui.showMessageBox('Failed to Get User [TreeDibsMapper]', 'error');
return { ok: false, reason: 'user-fetch-failed', message: 'Failed to load player profile.', tone: 'error' }; }
state.user.tornUserObject = tornUser;
state.user.tornId = (tornUser.profile?.id || tornUser.player_id || tornUser.id).toString();
state.user.tornUsername = tornUser.profile?.name || tornUser.name;
state.user.factionId = (tornUser.faction?.id || tornUser.profile?.faction_id || tornUser.faction_id || null);
if (state.user.factionId) state.user.factionId = String(state.user.factionId);
state.user.factionName = tornUser.faction?.name || state.user.factionName || null;
storage.updateStateAndStorage('user', state.user);
try {
await firebaseAuth.signIn({
tornApiKey: state.user.actualTornApiKey,
tornId: state.user.tornId,
factionId: state.user.factionId,
version: config.VERSION
});
} catch (authError) {
try { tdmlogger('error', `[TDM][Auth] sign-in failed: ${authError?.message || authError}`); } catch(_) {}
ui.showMessageBox('Failed to authenticate with TreeDibs servers. Some features may not work until you retry.', 'error');
}
// Determine admin rights via backend settings for this faction
const settings = await api.get('getFactionSettings', { factionId: state.user.factionId });
if (settings && settings.options) {
// Optionally map future per-faction options here
}
if (settings && settings.approved === false) {
ui.showMessageBox('Your faction is not approved to use TreeDibsMapper yet. Contact your leader.', 'error');
// Return true so the script UI still loads, but avoid admin features implicitly handled by flags
}
state.script.factionSettings = settings;
const currentUserPosition = tornUser.faction?.position || tornUser.profile?.position || tornUser.profile?.role || null;
const adminRolesRaw = Array.isArray(settings?.adminRoles) ? settings.adminRoles : [];
const normalizeRole = (role) => (typeof role === 'string') ? role.trim().toLowerCase() : '';
const normalizedRoles = adminRolesRaw.map(normalizeRole).filter(Boolean);
const normalizedPosition = normalizeRole(currentUserPosition);
const hasWildcardRole = normalizedRoles.some(r => r === '*' || r === 'all' || r === 'any');
const defaultAdminRoles = ['leader', 'co-leader', 'sub-leader', 'officer'];
let canAdmin = state.script.canAdministerMedDeals;
if (normalizedPosition) {
if (normalizedRoles.length > 0) {
canAdmin = normalizedRoles.includes(normalizedPosition) || hasWildcardRole;
} else if (settings !== undefined && settings !== null) {
canAdmin = defaultAdminRoles.includes(normalizedPosition);
}
}
applyAdminCapability({ position: currentUserPosition, computedFlag: canAdmin, source: 'backend-settings', refreshTimestamp: settings != null });
try { ui.updateSettingsContent?.(); } catch(_) {}
if (factionData && factionData.rankedwars && Array.isArray(factionData.rankedwars)) {
// Normalize to array of wars (most recent first if available)
const rankedWars = factionData.rankedwars;
storage.updateStateAndStorage('rankWars', rankedWars);
if (Array.isArray(rankedWars) && rankedWars.length > 0) {
const lastRankWar = rankedWars[0];
// If a new ranked war has started, reset per-war saved settings to sensible defaults
try {
const prevId = state.lastRankWar?.id || null;
storage.updateStateAndStorage('lastRankWar', lastRankWar);
const newId = lastRankWar?.id || null;
if (prevId && newId && String(prevId) !== String(newId)) {
// A new war started — reset mutable, per-war fields unless the backend provides warData
const defaultInitial = (lastRankWar?.war && Number(lastRankWar.war.target)) || Number(lastRankWar?.target) || 0;
const newWarData = Object.assign({}, { warType: 'War Type Not Set', opponentFactionId: undefined, opponentFactionName: undefined, initialTargetScore: defaultInitial, warId: newId });
// Populate opponent info below if available (will be reset/overwritten again by opponent assignments)
storage.updateStateAndStorage('warData', newWarData);
}
} catch (_) { storage.updateStateAndStorage('lastRankWar', lastRankWar); }
// Derive opponent faction info for quick UI use
try {
if (lastRankWar && lastRankWar.factions) {
const opp = Object.values(lastRankWar.factions).find(f => f.id !== parseInt(state.user.factionId));
if (opp) {
storage.updateStateAndStorage('lastOpponentFactionId', opp.id);
storage.updateStateAndStorage('lastOpponentFactionName', opp.name);
// Update warData lightweight fields; full warData still comes from backend if changed
// Defensive: avoid merging stale warData for a previous war into the new war's settings.
let baseWarData = state.warData || {};
try {
if (baseWarData?.warId && state.lastRankWar?.id && String(baseWarData.warId) !== String(state.lastRankWar.id)) {
const defaultInitial = (lastRankWar?.war && Number(lastRankWar.war.target)) || Number(lastRankWar?.target) || 0;
baseWarData = Object.assign({}, { warType: 'War Type Not Set', opponentFactionId: undefined, opponentFactionName: undefined, initialTargetScore: defaultInitial, warId: state.lastRankWar?.id });
}
} catch(_) {}
const wd = Object.assign({}, baseWarData, { opponentFactionId: opp.id, opponentFactionName: opp.name });
storage.updateStateAndStorage('warData', wd);
try { ui.ensureAttackModeBadge?.(); } catch(_) {}
}
}
} catch (_) { /* no-op */ }
}
}
if (factionData && factionData.basic) {
storage.updateStateAndStorage('factionPull', factionData.basic);
}
// Fetch ranked wars on the client at most once per hour and update local state
try {
const nowMs = Date.now();
const lastWarsFetch = parseInt(storage.get('rankedwars_fetched_at', '0') || '0', 10);
if (!Number.isFinite(lastWarsFetch) || (nowMs - lastWarsFetch) > 60 * 60 * 1000) {
const warsUrl = `https://api.torn.com/v2/faction/rankedwars?key=${state.user.actualTornApiKey}&comment=TDM_FEgRW`; // removed ×tamp=${Math.floor(Date.now()/1000)} to reduce call counts
const warsResp = await fetch(warsUrl);
const warsData = await warsResp.json();
utils.incrementClientApiCalls(1);
if (!warsData.error) {
const list = Array.isArray(warsData.rankedwars) ? warsData.rankedwars : (Array.isArray(warsData) ? warsData : []);
// Normalize to array of wars (most recent first if available)
const rankedWars = list;
storage.updateStateAndStorage('rankWars', rankedWars);
if (Array.isArray(rankedWars) && rankedWars.length > 0) {
const lastRankWar = rankedWars[0];
// If a new ranked war has started, reset per-war saved settings to sensible defaults
try {
const prevId = state.lastRankWar?.id || null;
storage.updateStateAndStorage('lastRankWar', lastRankWar);
const newId = lastRankWar?.id || null;
if (prevId && newId && String(prevId) !== String(newId)) {
const defaultInitial = (lastRankWar?.war && Number(lastRankWar.war.target)) || Number(lastRankWar?.target) || 0;
const newWarData = Object.assign({}, { warType: 'War Type Not Set', opponentFactionId: undefined, opponentFactionName: undefined, initialTargetScore: defaultInitial, warId: newId });
storage.updateStateAndStorage('warData', newWarData);
try { ui.ensureAttackModeBadge?.(); } catch(_) {}
}
} catch (_) { storage.updateStateAndStorage('lastRankWar', lastRankWar); }
// Derive opponent faction info for quick UI use
try {
if (lastRankWar && lastRankWar.factions) {
const opp = Object.values(lastRankWar.factions).find(f => f.id !== parseInt(state.user.factionId));
if (opp) {
storage.updateStateAndStorage('lastOpponentFactionId', opp.id);
storage.updateStateAndStorage('lastOpponentFactionName', opp.name);
// Update warData lightweight fields; full warData still comes from backend if changed
// Defensive: prevent mixing previous-war warData when lastRankWar changed or mismatched
let baseWarData = state.warData || {};
try {
if (baseWarData?.warId && state.lastRankWar?.id && String(baseWarData.warId) !== String(state.lastRankWar.id)) {
const defaultInitial = (lastRankWar?.war && Number(lastRankWar.war.target)) || Number(lastRankWar?.target) || 0;
baseWarData = Object.assign({}, { warType: 'War Type Not Set', opponentFactionId: undefined, opponentFactionName: undefined, initialTargetScore: defaultInitial, warId: state.lastRankWar?.id });
}
} catch(_) {}
const wd = Object.assign({}, baseWarData, { opponentFactionId: opp.id, opponentFactionName: opp.name });
storage.updateStateAndStorage('warData', wd);
try { ui.ensureAttackModeBadge?.(); } catch(_) {}
}
}
} catch (_) { /* no-op */ }
}
storage.set('rankedwars_fetched_at', String(nowMs));
}
}
} catch (e) {
tdmlogger('warn', `[TDM] Ranked wars (client) fetch failed: ${e?.message || e}`);
}
return { ok: true };
} catch (error) {
ui.showMessageBox(`API Key Or TDM Version Error: ${error.message}.`, "error");
return { ok: false, reason: 'exception', message: `API Key Or TDM Version Error: ${error.message}.`, tone: 'error', error };
}
},
initializeScriptLogic: async (options = {}) => {
const { skipFetch = false } = options;
// Render-first init: paint from cache, then hydrate in background
utils.perf.start('initializeScriptLogic');
state.script.hasProcessedRankedWarTables = false;
state.script.hasProcessedFactionList = false;
// 1) Build page context and lightweight UI from cached state (no network)
utils.perf.start('initializeScriptLogic.updatePageContext');
ui.updatePageContext();
utils.perf.stop('initializeScriptLogic.updatePageContext');
utils.perf.start('initializeScriptLogic.applyGeneralStyles');
ui.applyGeneralStyles();
utils.perf.stop('initializeScriptLogic.applyGeneralStyles');
// Start status time refresh loop if on faction page or ranked-war page
if (state.page.isFactionPage || state.page.isRankedWarPage) {
if (state.script.statusRefreshInterval) clearInterval(state.script.statusRefreshInterval);
state.script.statusRefreshInterval = setInterval(() => {
ui._refreshStatusTimes();
}, 1000);
}
utils.perf.start('initializeScriptLogic.updateColumnVisibilityStyles');
ui.updateColumnVisibilityStyles();
utils.perf.stop('initializeScriptLogic.updateColumnVisibilityStyles');
if (state.page.isFactionPage || state.page.isAttackPage) {
utils.perf.start('initializeScriptLogic.createSettingsButton');
ui.createSettingsButton();
utils.perf.stop('initializeScriptLogic.createSettingsButton');
}
if (state.page.isAttackPage) {
utils.perf.start('initializeScriptLogic.injectAttackPageUI');
await ui.injectAttackPageUI();
utils.perf.stop('initializeScriptLogic.injectAttackPageUI');
}
if (state.dom.factionListContainer) {
utils.perf.start('initializeScriptLogic.processFactionPageMembers');
await ui.processFactionPageMembers(state.dom.factionListContainer);
utils.perf.stop('initializeScriptLogic.processFactionPageMembers');
state.script.hasProcessedFactionList = true;
utils.perf.start('initializeScriptLogic.updateFactionPageUI');
ui._renderEpochMembers.schedule();
utils.perf.stop('initializeScriptLogic.updateFactionPageUI');
}
// On SPA hash changes, ensure timers and counters even if fetch is throttled
utils.perf.start('initializeScriptLogic.updateRetalsButtonCount');
ui.updateRetalsButtonCount();
utils.perf.stop('initializeScriptLogic.updateRetalsButtonCount');
utils.perf.start('initializeScriptLogic.ensureBadgesSuite');
ui.ensureBadgesSuite();
utils.perf.stop('initializeScriptLogic.ensureBadgesSuite');
utils.perf.start('initializeScriptLogic.checkOCReminder');
try { handlers.checkOCReminder(); } catch(_) {}
utils.perf.stop('initializeScriptLogic.checkOCReminder');
utils.perf.start('initializeScriptLogic.setupMutationObserver');
main.setupMutationObserver();
utils.perf.stop('initializeScriptLogic.setupMutationObserver');
// Ensure alert observer is attached even when warType is not 'Ranked War' (will no-op if tables absent)
try { ui.ensureRankedWarAlertObserver(); } catch(_) {}
// 2) Hydrate in the background: fetch global data without blocking first paint
if (!skipFetch) {
// Use rAF to yield to rendering, then fire the network work.
try {
(window.requestAnimationFrame || setTimeout)(() => {
// Debounced fetch is fine here; we just don't await it.
handlers.debouncedFetchGlobalData?.();
// deprecated
// Warm up Torn faction bundles (ours + opponent); respects 10s freshness
try { api.refreshFactionBundles?.(); } catch(_) {}
// A second UI refresh will happen inside fetchGlobalData after data updates
}, 0);
} catch (_) {
// Fallback: fire immediately if rAF unavailable
handlers.debouncedFetchGlobalData?.();
// deprecated
try { api.refreshFactionBundles?.(); } catch(_) {}
}
}
utils.perf.stop('initializeScriptLogic');
try { utils.exposeDebugToWindow(); } catch(_) {}
},
startPolling: () => {
// Tear down legacy interval if present
if (state.script.mainRefreshIntervalId) { try { utils.unregisterInterval(state.script.mainRefreshIntervalId); } catch(_) {} state.script.mainRefreshIntervalId = null; }
if (state.script._mainDynamicTimeoutId) { utils.unregisterTimeout(state.script._mainDynamicTimeoutId); state.script._mainDynamicTimeoutId = null; }
if (!state.script.activityMode) {
state.script.activityMode = 'active';
}
const trackingEnabled = !!storage.get('tdmActivityTrackingEnabled', false);
const idleOverride = utils.isActivityKeepActiveEnabled();
state.script.idleTrackingOverride = idleOverride;
const log = (...a) => { if (state.debug.cadence) tdmlogger('debug', '[Cadence]', ...a); };
log(`startPolling: prevInterval=${state.script.currentRefreshInterval} tracking=${trackingEnabled} idleOverride=${idleOverride}`);
try { ui.updateApiCadenceInfo?.(); } catch(_) {}
// Watchdog (unchanged logic but decoupled from dynamic loop)
try {
if (state.script.fetchWatchdogIntervalId) try { utils.unregisterInterval(state.script.fetchWatchdogIntervalId); } catch(_) {}
state.script.fetchWatchdogIntervalId = utils.registerInterval(setInterval(() => {
try {
const now = Date.now(); const last = state.script._lastGlobalFetch || 0;
const active = (state.script.isWindowActive !== false) || utils.isActivityKeepActiveEnabled();
if (!active || document.hidden) return;
const baseline = state.script.currentRefreshInterval || 10000;
const threshold = (baseline * 2) + 5000;
if (!last || (now - last) > threshold) { log('Watchdog force fetch', { age: now-last, threshold }); handlers.fetchGlobalData({ force: true }); }
} catch(_) {}
}, 5000));
} catch(_) {}
// Interval computation using cached status doc
const computeInterval = () => {
const warId = state.lastRankWar?.id;
const status = state._warStatusCache?.data; // May have been populated by global fetch meta.warStatus
const warActiveFallback = utils.isWarActive?.(warId);
if (!status) return warActiveFallback ? 5000 : 15000;
const phase = status.phase || 'pre';
const hintSec = Number(status.nextPollHintSec || 0) || (warActiveFallback ? 5 : 15);
let ms = hintSec * 1000;
const ageSec = status.lastAttackAgeSec != null ? status.lastAttackAgeSec : null;
if (phase === 'active' && ageSec != null && ageSec <= 30) ms = Math.min(ms, 15000);
if ((phase === 'dormant' || phase === 'ended') && ageSec != null && ageSec > 7200) ms = Math.min(Math.max(ms * 2, 300000), 900000);
// jitter +/-12%
ms = Math.round(ms * (1 + (Math.random()*2 - 1) * 0.12));
return Math.max(3000, ms);
};
// Seed status quickly (non-blocking)
(async () => { try {
const wid = state.lastRankWar?.id;
if (wid && utils.isWarActive?.(wid)) {
const manifestCache = state.rankedWarAttacksCache || {};
const cacheEntry = manifestCache[wid] || null;
const cachedFingerprint = cacheEntry?._lastManifestFingerprint || null;
const bootstrapState = state.script._manifestBootstrapState || (state.script._manifestBootstrapState = {});
if (!bootstrapState[wid] && cachedFingerprint) {
bootstrapState[wid] = { lastTs: Date.now(), fingerprint: cachedFingerprint };
}
const prev = bootstrapState[wid] || { lastTs: 0, fingerprint: null };
const now = Date.now();
const minInterval = config.MANIFEST_BOOTSTRAP_MIN_INTERVAL_MS || (10 * 60 * 1000);
const elapsed = now - (prev.lastTs || 0);
const intervalExpired = !prev.lastTs || elapsed >= minInterval;
const missingPointers = !cacheEntry || !cachedFingerprint;
const fingerprintChanged = cachedFingerprint && prev.fingerprint && cachedFingerprint !== prev.fingerprint;
if (missingPointers || fingerprintChanged || intervalExpired) {
if (state.debug.cadence) {
log('manifest bootstrap trigger', { warId: wid, missingPointers, fingerprintChanged, intervalExpired, elapsed });
}
await api.getWarStatusAndManifest(wid, state.user.factionId, { ensureArtifacts: false, source: 'cadence.manifest-bootstrap' });
const updatedEntry = state.rankedWarAttacksCache?.[wid] || null;
const nextFingerprint = updatedEntry?._lastManifestFingerprint || null;
bootstrapState[wid] = { lastTs: Date.now(), fingerprint: nextFingerprint };
} else if (state.debug.cadence) {
log('manifest bootstrap skipped', { warId: wid, elapsed, lastFingerprint: prev.fingerprint });
}
}
const seeded = computeInterval();
state.script.currentRefreshInterval = seeded;
log('Seeded interval', seeded);
} catch(_) {} })();
const scheduleNextTick = (delayMs) => {
const fallback = state.script.activityMode === 'inactive' ? (config.REFRESH_INTERVAL_INACTIVE_MS || 60000) : (config.REFRESH_INTERVAL_ACTIVE_MS || 10000);
const ms = Math.max(1000, Number(delayMs) || fallback);
state.script.currentRefreshInterval = ms;
state.script._mainDynamicTimeoutId = utils.registerTimeout(setTimeout(runTick, ms));
};
// Dynamic loop via recursive timeout
const runTick = async () => {
const keepActive = utils.isActivityKeepActiveEnabled();
const windowActive = (!document.hidden) && (state.script.isWindowActive !== false);
// Idle detection: > 5 minutes without interaction
const lastActivity = state.script.lastActivityTime || Date.now();
const isIdle = (Date.now() - lastActivity) > 300000; // 5 mins
// Throttle if: Not KeepActive AND (Hidden OR Idle)
if (!keepActive && (!windowActive || isIdle)) {
if (state.debug.cadence) log(`tick paused/throttled - hidden=${!windowActive} idle=${isIdle}`);
scheduleNextTick(config.REFRESH_INTERVAL_INACTIVE_MS || 60000);
return;
}
// Heartbeat integration: only attempt when the window is active.
// Use a lightweight immediate sender that enforces ~30s throttle.
if (windowActive) {
try { handlers._sendAttackerHeartbeatNow?.().catch(() => {}); } catch(_) {}
}
try {
if (!document.hidden) {
handlers.debouncedFetchGlobalData();
} else if (state.debug.cadence) {
log('skip fetchGlobalData - document hidden');
}
} catch(e) { log('tick error', e?.message||e); }
const nextMs = computeInterval();
log('next interval', nextMs, 'phase', state._warStatusCache?.data?.phase);
scheduleNextTick(nextMs);
};
if (!state.script.currentRefreshInterval) state.script.currentRefreshInterval = computeInterval();
scheduleNextTick(state.script.currentRefreshInterval);
// Faction bundle interval (unchanged)
try {
if (state.script.factionBundleRefreshIntervalId) try { utils.unregisterInterval(state.script.factionBundleRefreshIntervalId); } catch(_) {}
const userMs = Number(storage.get('factionBundleRefreshMs', null)) || null;
const baseMs = Number.isFinite(userMs) && userMs > 0 ? userMs : (state.script.factionBundleRefreshMs || config.DEFAULT_FACTION_BUNDLE_REFRESH_MS);
state.script.factionBundleRefreshMs = baseMs;
state.script.factionBundleRefreshIntervalId = utils.registerInterval(setInterval(() => {
try {
const keepActive = utils.isActivityKeepActiveEnabled();
const windowActive = (state.script.isWindowActive !== false) && !document.hidden;
const lastActivity = state.script.lastActivityTime || Date.now();
const isIdle = (Date.now() - lastActivity) > 300000; // 5 mins
if (keepActive || (windowActive && !isIdle)) {
log('factionBundle tick');
api.refreshFactionBundles?.().catch(e => { try { tdmlogger('error', `[FactionBundles] tick error: ${e}`); } catch(_) {} });
} else {
if (state.debug.cadence) log(`factionBundle skip - hidden=${!windowActive} idle=${isIdle}`);
}
} catch(_) {}
}, baseMs));
log('factionBundle interval', baseMs);
try { ui.updateApiCadenceInfo?.(); } catch(_) {}
} catch(_) {}
},
setupMutationObserver: () => {
// Build list of targets: all ranked-war tables + faction list container; fallback to body
const targets = [];
try {
if (Array.isArray(state.dom?.rankwarfactionTables) && state.dom.rankwarfactionTables.length) {
state.dom.rankwarfactionTables.forEach(n => { if (n) targets.push(n); });
} else if (state.dom?.rankwarContainer) {
targets.push(state.dom.rankwarContainer);
}
if (state.dom?.factionListContainer) targets.push(state.dom.factionListContainer);
} catch(_) { /* ignore */ }
// Ensure at least body as a last-resort observer target
if (!targets.length) targets.push(document.body);
// Ensure lightweight instrumentation for tuning
state.metrics = state.metrics || { mutationsSeen: 0, addedNodesSeen: 0, processRankedMsTotal: 0, processRankedRuns: 0, lastBurstAt: 0 };
// Determine debounce: longer if observing the whole body
const baseDebounce = targets.includes(document.body) ? 500 : 200;
// If there's an existing observer, try to re-attach it to all targets
if (state.script.mutationObserver) {
try {
try { state.script.mutationObserver.disconnect(); } catch(_) {}
targets.forEach(t => {
try { state.script.mutationObserver.observe(t, { childList: true, subtree: true }); } catch(_) {}
});
return; // re-attached existing observer
} catch(_) {
// Fall through and recreate if something failed
}
}
// Create a single observer and observe each target
state.script.mutationObserver = utils.registerObserver(new MutationObserver(utils.debounce(async (mutations, obs) => {
try {
// Update simple metrics
if (Array.isArray(mutations)) {
state.metrics.mutationsSeen += mutations.length;
state.metrics.addedNodesSeen += mutations.reduce((s, m) => s + (m.addedNodes ? m.addedNodes.length : 0), 0);
}
// Light-weight common updates
ui.updatePageContext();
// Detect large external-script mutation bursts and avoid heavy processing
const addedCount = Array.isArray(mutations) ? mutations.reduce((s, m) => s + (m.addedNodes ? m.addedNodes.length : 0), 0) : 0;
if (addedCount > 20) {
// If a heavy burst detected, do minimal updates now and schedule a delayed full pass
if (!state.script._heavyMutScheduled) {
state.script._heavyMutScheduled = true;
setTimeout(async () => {
try {
const t0 = Date.now();
await ui.processRankedWarTables?.();
const dur = Date.now() - t0;
state.metrics.processRankedMsTotal += Number(dur) || 0;
state.metrics.processRankedRuns = (state.metrics.processRankedRuns || 0) + 1;
if (state.dom.factionListContainer && !state.script.hasProcessedFactionList) {
await ui.processFactionPageMembers(state.dom.factionListContainer);
state.script.hasProcessedFactionList = true;
ui._renderEpochMembers.schedule();
}
} catch(_) {}
state.script._heavyMutScheduled = false;
}, 600);
}
// keep lightweight badge updates responsive
try { ui.updateUserScoreBadge?.(); ui.updateFactionScoreBadge?.(); } catch(_) {}
return;
}
// Normal path: when not in a burst, perform standard heavy processing
if (state.dom.rankwarContainer && !state.script.hasProcessedRankedWarTables) {
const t0 = Date.now();
await ui.processRankedWarTables();
const dur = Date.now() - t0;
state.metrics.processRankedMsTotal += Number(dur) || 0;
state.metrics.processRankedRuns = (state.metrics.processRankedRuns || 0) + 1;
}
if (state.dom.factionListContainer && !state.script.hasProcessedFactionList) {
await ui.processFactionPageMembers(state.dom.factionListContainer);
state.script.hasProcessedFactionList = true;
ui._renderEpochMembers.schedule();
}
if (state.page.isAttackPage || (state.script.hasProcessedRankedWarTables && state.script.hasProcessedFactionList)) {
obs.disconnect();
}
} catch(_) { /* noop */ }
}, baseDebounce)));
try {
targets.forEach(t => {
try { state.script.mutationObserver.observe(t, { childList: true, subtree: true }); } catch(_) {}
});
} catch(_) {}
},
setupActivityListeners: () => {
if (state.script.activityListenersInitialized) return;
state.script.activityListenersInitialized = true;
const resetActivityTimer = () => main.noteActivity();
// Broaden event coverage so clicks and mouse down reset inactivity too
const evts = ['mousemove', 'mousedown', 'click', 'keydown', 'keyup', 'scroll', 'wheel', 'touchstart', 'touchend'];
evts.forEach(event => document.addEventListener(event, resetActivityTimer, { passive: true }));
document.addEventListener('visibilitychange', () => {
const keepActive = utils.isActivityKeepActiveEnabled();
state.script.idleTrackingOverride = keepActive;
state.script.isWindowActive = !document.hidden;
if (state.debug.cadence) {
tdmlogger('debug', `[Cadence] visibilitychange hidden=${document.hidden} idleOverride=${keepActive} isWindowActive -> ${state.script.isWindowActive}`);
}
try { ui.updateApiCadenceInfo?.(); } catch(_) {}
// MutationObserver Management: Save resources when hidden
if (document.hidden) {
try { state.script._lastVisibilityHiddenAt = Date.now(); } catch(_) {}
if (state.script.mutationObserver) {
try { state.script.mutationObserver.disconnect(); } catch(_) {}
// We don't null it out, just disconnect. It will be re-attached on show.
}
} else {
// Re-enable observer if it was disconnected or missing
main.setupMutationObserver();
}
if (!document.hidden) {
// For a short period after regaining focus, force badges to show using cached labels
ui._badgesForceShowUntil = Date.now() + 3000; // 3s
// Compute how long tab was hidden
let hiddenDur = 0;
try {
const now = Date.now();
const hiddenAt = state.script._lastVisibilityHiddenAt || 0;
hiddenDur = hiddenAt ? (now - hiddenAt) : 0;
} catch(_) {}
// If hidden for more than 25s force an immediate refresh (skip debounce/throttle)
if (hiddenDur > 25000) {
if (state.debug.cadence) {
tdmlogger('debug', `[Cadence] refocus after long hide: ${hiddenDur} ms -> force immediate fetchGlobalData(force=true)`);
}
// Hospital reconciliation first (local only)
try { ui.forceHospitalCountdownRefocus?.(); } catch(_) {}
try {
(async () => {
try {
await handlers.fetchGlobalData({ force: true, focus: true });
} catch(_) {}
// Refresh badges explicitly (ensure creates if missing, updates values)
try {
ui.ensureUserScoreBadge?.();
ui.ensureFactionScoreBadge?.();
ui.ensureDibsDealsBadge?.();
ui.updateUserScoreBadge?.();
ui.updateFactionScoreBadge?.();
ui.updateDibsDealsBadge?.();
// Retry shortly to cover DOM readiness and data coalescing
setTimeout(() => { try { ui.ensureUserScoreBadge?.(); ui.ensureFactionScoreBadge?.(); ui.updateUserScoreBadge?.(); ui.updateFactionScoreBadge?.(); } catch(_) {} }, 200);
setTimeout(() => { try { ui.ensureUserScoreBadge?.(); ui.ensureFactionScoreBadge?.(); ui.updateUserScoreBadge?.(); ui.updateFactionScoreBadge?.(); } catch(_) {} }, 600);
} catch(_) {}
// deprecated
// faction bundles are handled by decoupled interval; we can opportunistically trigger one now
try { api.refreshFactionBundles?.().catch(() => {}); } catch(_) {}
})();
} catch(_) { /* non-fatal */ }
} else {
// Short hide: to reduce reopen cost, skip any global fetch on quick refocus
if (state.debug.cadence) {
tdmlogger('debug', `[Cadence] refocus after short hide: ${hiddenDur} ms -> SKIP fetch to avoid reopen cost`);
}
// Still update badges from cached values to avoid transient zeros
try {
ui.ensureUserScoreBadge?.();
ui.ensureFactionScoreBadge?.();
ui.ensureDibsDealsBadge?.();
ui.updateUserScoreBadge?.();
ui.updateFactionScoreBadge?.();
ui.updateDibsDealsBadge?.();
setTimeout(() => { try { ui.ensureUserScoreBadge?.(); ui.ensureFactionScoreBadge?.(); ui.updateUserScoreBadge?.(); ui.updateFactionScoreBadge?.(); } catch(_) {} }, 200);
} catch(_) { /* noop */ }
}
// Ensure decoupled faction bundle refresh interval is running after regaining focus
try {
if (!state.script.factionBundleRefreshIntervalId) {
const userMs = Number(storage.get('factionBundleRefreshMs', null)) || null;
const baseMs = Number.isFinite(userMs) && userMs > 0 ? userMs : (state.script.factionBundleRefreshMs || config.DEFAULT_FACTION_BUNDLE_REFRESH_MS);
state.script.factionBundleRefreshMs = baseMs;
state.script.factionBundleRefreshIntervalId = utils.registerInterval(setInterval(() => {
try {
if ((state.script.isWindowActive !== false) || utils.isActivityKeepActiveEnabled()) {
api.refreshFactionBundles?.().catch(e => { try { tdmlogger('error', `[FactionBundles] resume tick error: ${e}`); } catch(_) {} });
}
} catch(_) {}
}, baseMs));
if (state.debug.cadence) {
tdmlogger('debug', `[Cadence] factionBundle interval resumed on focus: ${baseMs} ms`);
}
}
} catch(_) { /* noop */ }
try { ui.updateApiCadenceInfo?.(); } catch(_) {}
resetActivityTimer();
// Ensure watchdog is running on focus
try {
if (!state.script.fetchWatchdogIntervalId) {
state.script.fetchWatchdogIntervalId = utils.registerInterval(setInterval(() => {
try {
const now = Date.now();
const last = state.script._lastGlobalFetch || 0;
const active2 = (state.script.isWindowActive !== false) || utils.isActivityKeepActiveEnabled();
if (!active2) return;
const baseline = state.script.currentRefreshInterval || (config.REFRESH_INTERVAL_ACTIVE_MS || 10000);
const threshold = (baseline * 2) + 5000;
if (!last || (now - last) > threshold) {
if (state.debug.cadence) tdmlogger('debug', `[Cadence] Watchdog (resume): forcing fetch`);
handlers.fetchGlobalData({ force: true });
}
} catch(_) {}
}, 5000));
if (state.debug.cadence) tdmlogger('debug', `[Cadence] fetch watchdog resumed`);
}
} catch(_) { /* noop */ }
} else {
state.script._lastVisibilityHiddenAt = Date.now();
if (!keepActive) {
if (state.debug.cadence) {
tdmlogger('debug', `[Cadence] visibility hidden (no activity tracking) -> stopping intervals`);
}
try { utils.unregisterInterval(state.script.mainRefreshIntervalId); } catch(_) {}
try { utils.unregisterTimeout(state.script.activityTimeoutId); } catch(_) {}
if (state.script.factionBundleRefreshIntervalId) { try { utils.unregisterInterval(state.script.factionBundleRefreshIntervalId); } catch(_) {} state.script.factionBundleRefreshIntervalId = null; }
if (state.script.fetchWatchdogIntervalId) { try { utils.unregisterInterval(state.script.fetchWatchdogIntervalId); } catch(_) {} state.script.fetchWatchdogIntervalId = null; }
if (state.script.lightPingIntervalId) { try { utils.unregisterInterval(state.script.lightPingIntervalId); } catch(_) {} state.script.lightPingIntervalId = null; }
try { ui.updateApiCadenceInfo?.(); } catch(_) {}
}
}
});
// Avoid duplicate initializations on SPA navigation: debounce and teardown before re-init
if (state.script._hashHandler) {
try { utils.unregisterWindowListener('hashchange', state.script._hashHandler); } catch(_) {}
}
state.script._hashHandler = utils.debounce(() => {
try {
// Teardown intervals/observers before re-initializing
if (state.script.mainRefreshIntervalId) { try { utils.unregisterInterval(state.script.mainRefreshIntervalId); } catch(_) {} state.script.mainRefreshIntervalId = null; }
if (state.script.activityTimeoutId) { try { utils.unregisterTimeout(state.script.activityTimeoutId); } catch(_) {} state.script.activityTimeoutId = null; }
if (state.script.mutationObserver) { try { utils.unregisterObserver(state.script.mutationObserver); } catch(_) {} state.script.mutationObserver = null; }
// Ranked war observer teardown
if (state._rankedWarObserver) { try { utils.unregisterObserver(state._rankedWarObserver); } catch(_) {} state._rankedWarObserver = null; }
if (state._rankedWarScoreObserver) { try { utils.unregisterObserver(state._rankedWarScoreObserver); } catch(_) {} state._rankedWarScoreObserver = null; }
// Re-init lightweight logic then restart polling
main.initializeScriptLogic();
main.startPolling();
} catch(_) { /* non-fatal */ }
}, 200);
utils.registerWindowListener('hashchange', state.script._hashHandler);
// Ensure we teardown long-lived runtime handles on unload/navigation to avoid leaks across SPA nav
try { utils.registerWindowListener('beforeunload', utils.cleanupAllResources); } catch(_) {}
try { utils.registerWindowListener('pagehide', utils.cleanupAllResources); } catch(_) {}
// Initialize timer state immediately
resetActivityTimer();
}
};
// Logging helper to set levels with [TDM][level][HH:MM:SS] prefix
// usage tdmlogger('info', `message`)
function tdmlogger(level, ...args) {
try {
const levels = logLevels;
// Read persisted log level from storage each call so UI changes apply immediately
const currentLevel = storage.get('logLevel', 'warn');
const currentLevelIdx = Math.max(0, levels.indexOf(currentLevel));
const messageLevelIdx = levels.indexOf(level);
if (messageLevelIdx === -1 || messageLevelIdx < currentLevelIdx) return; // skip low-level logs
if (!levels.includes(level)) level = 'log';
const now = new Date();
const timestamp = now.toTimeString().split(' ')[0]; // HH:MM:SS
const prefix = `[TDM][${level.toUpperCase()}][${timestamp}]`;
switch(level) {
case 'debug': console.debug(prefix, ...args); break;
case 'info': console.info(prefix, ...args); break;
case 'warn': console.warn(prefix, ...args); break;
case 'error': console.error(prefix, ...args); break;
default: console.log(prefix, ...args);
}
} catch (e) {
try { console.log('[TDM][LOGGER][ERROR]', e); } catch(_) {}
}
}
// Global handlers to capture otherwise-unhandled errors/promises and log them
try {
window.addEventListener('unhandledrejection', (evt) => {
try {
const reason = evt && evt.reason;
if (reason === undefined) {
// Some runtimes deliver undefined reasons; log the whole event for diagnosis
tdmlogger('warn', '[UnhandledRejection] reason=undefined', evt);
try { console.warn('[UnhandledRejection] reason=undefined, event:', evt); } catch(_) {}
// Probe the underlying promise to capture rejection value/stack when possible.
try {
const p = evt && evt.promise;
if (p && typeof p.catch === 'function') {
// p.catch(r => {
// try {
// if (r === undefined) {
// tdmlogger('warn', '[UnhandledRejection][probe] reason still undefined', r);
// try { console.warn('[UnhandledRejection][probe] reason still undefined', r); } catch(_) {}
// } else if (r && typeof r === 'object') {
// const msg = r.message || JSON.stringify(r);
// tdmlogger('warn', '[UnhandledRejection][probe]', msg, r.stack ? { stack: r.stack } : null);
// try { console.warn('[UnhandledRejection][probe]', r); } catch(_) {}
// } else {
// tdmlogger('warn', '[UnhandledRejection][probe]', String(r));
// try { console.warn('[UnhandledRejection][probe]', r); } catch(_) {}
// }
// } catch (e) { try { console.warn('[UnhandledRejection][probe] logger failure', e); } catch(_) {} }
// // return a rejection so this .catch doesn't swallow the original rejection semantics
// return Promise.reject(r);
// }).catch(()=>{});
}
} catch (_) {}
} else if (reason && typeof reason === 'object') {
// Prefer message + stack when available
const msg = reason.message || JSON.stringify(reason);
tdmlogger('warn', '[UnhandledRejection]', msg, reason.stack ? { stack: reason.stack } : null);
} else {
tdmlogger('warn', '[UnhandledRejection]', String(reason));
}
} catch (e) {
try { console.warn('[UnhandledRejection] (logger failure)', e, evt); } catch(_) {}
}
});
// Also log when a previously unhandled rejection is later handled
window.addEventListener('rejectionhandled', (evt) => {
try {
const reason = evt && evt.reason;
tdmlogger('info', '[RejectionHandled]', reason === undefined ? 'undefined' : (reason && reason.message) || String(reason));
} catch (e) { try { console.info('[RejectionHandled]', evt); } catch(_) {} }
});
window.addEventListener('error', (errEvent) => {
try {
const ev = errEvent || {};
const msg = ev.message || (ev.error && ev.error.message) || String(ev);
const stack = (ev.error && ev.error.stack) || ev.stack || null;
const loc = (ev.filename ? `${ev.filename}:${ev.lineno || 0}:${ev.colno || 0}` : null);
tdmlogger('error', '[WindowError]', msg, loc, stack ? { stack } : null);
} catch (_) {
try { console.error(errEvent); } catch(_) {}
}
});
} catch (_) { /* best-effort global handlers */ }
setTimeout(() => {
try { console.info('[TDM] startup - url:', window.location.href, 'version:', config.VERSION); } catch(_) {}
ui.updatePageContext();
try { console.info('[TDM] page flags:', { isFactionPage: state.page.isFactionPage, isAttackPage: state.page.isAttackPage, url: state.page.url?.href }); } catch(_) {}
if (!state.page.isFactionPage && !state.page.isAttackPage) {
return;
}
tdmlogger('info', `Script execution started - Version: ${config.VERSION}`);
// Leader election: only one visible tab should perform structural ensures aggressively
try {
const LEADER_KEY = 'tdm_leader_tab';
const token = state.script._tabToken || (state.script._tabToken = Math.random().toString(36).slice(2));
const now = Date.now();
const raw = localStorage.getItem(LEADER_KEY);
let leader = null;
try { leader = raw ? JSON.parse(raw) : null; } catch(_) {}
const staleMs = 15000; // 15s
const visible = !document.hidden;
const shouldClaim = !leader || (now - (leader.ts||0) > staleMs) || leader.token === token || (!visible ? false : (leader.hidden || false));
if (shouldClaim) {
const record = { token, ts: now, hidden: document.hidden };
localStorage.setItem(LEADER_KEY, JSON.stringify(record));
state.script.isLeaderTab = true;
} else {
state.script.isLeaderTab = leader.token === token;
}
// Heartbeat to renew leadership if we are leader
if (!state.script._leaderHeartbeat) {
state.script._leaderHeartbeat = utils.registerInterval(setInterval(() => {
try {
const raw2 = localStorage.getItem(LEADER_KEY);
let leader2 = null; try { leader2 = raw2 ? JSON.parse(raw2) : null; } catch(_) {}
const amLeader = leader2 && leader2.token === token;
if (amLeader) {
localStorage.setItem(LEADER_KEY, JSON.stringify({ token, ts: Date.now(), hidden: document.hidden }));
state.script.isLeaderTab = true;
} else if (!leader2 || (Date.now() - (leader2.ts||0) > staleMs)) {
// Attempt claim
localStorage.setItem(LEADER_KEY, JSON.stringify({ token, ts: Date.now(), hidden: document.hidden }));
state.script.isLeaderTab = true;
} else {
state.script.isLeaderTab = false;
}
} catch(_) {}
}, 4000));
}
window.addEventListener('visibilitychange', () => {
try {
const raw3 = localStorage.getItem(LEADER_KEY);
let leader3 = null; try { leader3 = raw3 ? JSON.parse(raw3) : null; } catch(_) {}
if (!leader3 || leader3.token === token) {
localStorage.setItem(LEADER_KEY, JSON.stringify({ token, ts: Date.now(), hidden: document.hidden }));
state.script.isLeaderTab = true;
} else {
state.script.isLeaderTab = false;
}
} catch(_) {}
});
} catch(_) { /* non-fatal leader election failure */ }
main.init();
// Memory safety valve
try {
utils.registerInterval(setInterval(() => {
try { utils.enforceMemoryLimits(); } catch(_) {}
}, 30000)); // Check every 30s
} catch(_) {}
try { setTimeout(()=>{ ui.updateDebugOverlayFingerprints?.(); }, 300); } catch(_) {}
// One-time TOS popup after init (only on faction / attack pages)
try { ui.ensureTosComplianceLink(); if (!storage.get(TDM_TOS_ACK_KEY, null)) { setTimeout(() => ui.showTosComplianceModal(), 800); } } catch(_) {}
}, 0);
// =====================================================================
// Adaptive Ranked War Polling Integration (wires to backend bundle meta)
// =====================================================================
(function integrateAdaptiveWarPolling(){
if (!window.TDMAdaptiveWar || typeof window.TDMAdaptiveWar.start !== 'function') return; // module injected separately or not loaded
// Auto-start when we have faction + active war context in state once known
const attemptStart = () => {
try {
const factionId = state.user?.factionId || state.user?.tornUserObject?.faction?.faction_id || null;
const rankedWarId = state.lastRankWar?.id || null;
if (!factionId || !rankedWarId) return;
if (window.TDMAdaptiveWar.state.active) return;
window.TDMAdaptiveWar.start({ factionId, rankedWarId });
if (state.debug.cadence) tdmlogger('info', '[AdaptiveWar] auto-started', { factionId, rankedWarId });
} catch(_) { /* ignore */ }
};
// Poll for readiness (user + last war loaded) then start
let guard = 0;
const readyInterval = utils.registerInterval(setInterval(() => {
guard++;
attemptStart();
if (window.TDMAdaptiveWar.state.active || guard > 40) try { utils.unregisterInterval(readyInterval); } catch(_) {}
}, 1500));
// Hook events to existing summary refresh logic if present
document.addEventListener('tdm:warSummaryVersionChanged', (e) => {
try {
if (state.debug.cadence) tdmlogger('debug', '[AdaptiveWar] summaryVersionChanged event', e.detail);
const factionId = state.user?.factionId || state.user?.tornUserObject?.faction?.faction_id;
const warId = e?.detail?.rankedWarId || state.lastRankWar?.id;
if (!warId || !factionId) return;
// Use existing smart summary fetch (etag / 304 aware) to update cache.
(async () => {
try {
await api.getRankedWarSummarySmart(warId, factionId);
// If summary modal currently open, re-render it (best-effort detection)
const modalOpen = document.querySelector('.tdm-ranked-war-summary-modal');
if (modalOpen && typeof ui.showRankedWarSummaryModal === 'function') {
const cacheEntry = state.rankedWarSummaryCache?.[`${warId}:${factionId}`];
if (cacheEntry && Array.isArray(cacheEntry.summary)) {
ui.showRankedWarSummaryModal(cacheEntry.summary, warId);
}
}
} catch(err) {
if (state.debug.cadence) tdmlogger('warn', '[AdaptiveWar] smart summary refresh failed', { err: err?.message });
}
})();
} catch(_) { /* noop */ }
});
document.addEventListener('tdm:warManifestFingerprintChanged', (e) => {
try {
if (state.debug.cadence) tdmlogger('debug', '[AdaptiveWar] manifestFingerprintChanged event', e.detail);
// Invalidate local manifest/attacks cache entry so next UI access triggers fetch
const warId = String(e.detail?.rankedWarId || state.lastRankWar?.id || '');
if (warId && state.rankedWarAttacksCache[warId]) {
delete state.rankedWarAttacksCache[warId].lastManifestFetchMs;
state.rankedWarAttacksCache[warId].__invalidateDueToManifestChange = Date.now();
try { persistRankedWarAttacksCache(state.rankedWarAttacksCache); } catch(_) {}
try { idb.delAttacks(warId).catch(()=>{}); } catch(_) {}
}
} catch(_) { /* noop */ }
});
})();
})();