Dibs, Faction-wide notes, and war management systems for Torn (PC AND TornPDA Support)
目前為
// ==UserScript==
// @name TreeDibsMapper
// @namespace http://tampermonkey.net/
// @version 3.5.0
// @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_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_listValues
// @grant GM_addStyle
// @connect api.torn.com
// @connect us-central1-tornuserstracker.cloudfunctions.net
// @connect apiget-codod64xdq-uc.a.run.app
// @connect apipost-codod64xdq-uc.a.run.app
// ==/UserScript==
/*
Documentation: Torn Faction Dibs and War Management Userscript
*** FOR MY CARTELIANS @ NEON CARTEL ONLY ***
https://www.torn.com/factions.php?step=profile&ID=41419
This userscript provides a comprehensive dibs, war management, and user notes system
for the game Torn. Authentication has been simplified to directly use the Torn API Key
provided by the user for server-side verification.
If you have any issues, send me a console log on discord.
Features:
- Centralized Dibs System
- Stores and manages dibs data in Firestore via Firebase Functions
- Records attacker, opponent, and timestamp for dibs
- Automatically removes dibs if attacker inactive for 5 minutes (server-side check)
- Prevents multiple users from dibbing same target simultaneously (server-side)
- Notifies users when their dibs are removed (client-side)
- Show retaliation opportunities
- War Management
- Displays current war type - can be set by Leader, Co-leader, Capo, Sicario, or Diablo
- Med Deal tracking to indicate special agreements
- One dibs & one med deal per user (if Termed War)
- Adding new dibs/med deal deactivates old one
- User Notes
- Shared across faction
- Persist and can be added to anyone in faction from faction page
- UI Integration
- Adds "Dibs" and "Notes" columns to ranked war pages
- Toggle buttons for 'lvl', 'member-icons', 'position', and 'days' columns
- Dibs removal notifications via toast messages
- Attack page integration with Dibs, Med Deal, Notes and Assist buttons
- Manual Assist message required in faction chat per Torn rules
- Performance
- Inactivity-based API call reduction
- Dynamic API frequency based on user activity and window focus
Settings (stored in localStorage):
- columnVisibility: Toggle visibility of various columns in the UI
- adminFunctionality: Enable/disable admin features for authorized users
- toggle timers for inactivity, chain, and opponent status
API:
- **Simplified Authentication:** Relies on sending the Torn API Key with each function call for server-side verification.
- **A Limited Key is Required:** This script now requires a Limited API Key for calls to the torn attacks API.
- **Torn PDA Integration:** Now correctly integrates with PDA by using `###PDA-APIKEY###` placeholder and relying on PDA's `GM_` function emulation.
Torn API Terms of Service Compliance: This script is designed to comply with Torn's API Terms of Service. Users are encouraged to review these terms to ensure their usage aligns with Torn's policies.
Data Storage: Cached Locally, API Keys stored on Firebase Firestore collection only accessible by Author
Data Sharing: Faction-wide sharing of dibs, war data (attack metrics), user notes, and organized crimes data
Purpose of Use: Competitive Advantage in ranked wars
Key Storage: API Keys are stored securely in Firebase Firestore and are only accessible by the script author.
Key Access Level: Limited (requires factions (attacks, members, basic), users (attacks, basic))
TODO:
- Make sure unauthorized attacks are actually tracked on the db.
- Always need css tweaking. Any experts experts welcome.
Data Flow:
Torn Page UI <-> Userscript (Tampermonkey/PDA) <-> Firebase Cloud Functions (HTTPS API) <-> Firestore Database
PDA:
- This script relies on PDA's emulation of standard `GM_` functions and its `###PDA-APIKEY###` placeholder.
*/
(function() {
'use strict';
//======================================================================
// 1. CONFIGURATION
//======================================================================
const config = {
VERSION: '3.5.0',
PDA_API_KEY_PLACEHOLDER: '###PDA-APIKEY###',
API_GET_URL: 'https://apiget-codod64xdq-uc.a.run.app',
API_POST_URL: 'https://apipost-codod64xdq-uc.a.run.app',
GREASYFORK: {
scriptId: '540873',
pageUrl: 'https://greasyfork.org/en/scripts/540873',
updateMetaUrl: 'https://update.greasyfork.org/scripts/540873/TreeDibsMapper.meta.js',
downloadUrl: 'https://update.greasyfork.org/scripts/540873/TreeDibsMapper.user.js'
},
// Throttles to coalesce rapid events (PDA can be chatty on load)
MIN_GLOBAL_FETCH_INTERVAL_MS: 2000,
MIN_RETALS_FETCH_INTERVAL_MS: 1500,
REFRESH_INTERVAL_ACTIVE_MS: 10000,
REFRESH_INTERVAL_INACTIVE_MS: 60000,
ACTIVITY_TIMEOUT_MS: 30000,
DEFAULT_COLUMN_VISIBILITY: {
rankedWar: {
lvl: true,
factionIcon: true
},
membersList: {
lvl: true,
memberIcons: true,
position: true,
days: true,
factionIcon: true,
dibsDeals: true,
notes: true
}
},
DEFAULT_SETTINGS: { showAllRetaliations: false, chainTimerEnabled: true, inactivityTimerEnabled: true, opponentStatusTimerEnabled: true, apiUsageCounterEnabled: true },
ALLOWED_POSITIONS_FOR_WAR_CONTROLS: ["Leader", "Co-leader", "Capo", "Sicario", "Diablo"], // legacy fallback only; server-driven adminRoles preferred
CSS: {
colors: {
success: '#4CAF50', error: '#f44336', warning: '#ff9800', info: '#2196F3',
dibsSuccess: '#8e261f', dibsSuccessHover: '#7a1e1a', dibsOther: '#105d22',
dibsOtherHover: '#0b4e1f', dibsInactive: '#854e00cb', dibsInactiveHover: '#6f3f00',
noteInactive: '#0032b1', noteInactiveHover: '#00209c', noteActive: '#670295',
noteActiveHover: '#634466', medDealInactive: '#970167cb', medDealInactiveHover: '#7B1FA2',
medDealSet: '#b600ad', medDealSetHover: '#9C27B0', medDealMine: '#105d22',
medDealMineHover: '#0b4e1f', assistButton: '#40004bff', assistButtonHover: '#35003aff',
modalBg: '#1a1a1a', modalBorder: '#333', buttonBg: '#2c2c2c', mainColor: '#344556'
}
}
};
//======================================================================
// 2. STORAGE & UTILITIES
//======================================================================
const storage = {
get: (key, defaultValue) => {
try {
const value = localStorage.getItem(`tdm_${key}`);
return value !== null ? JSON.parse(value) : defaultValue;
} catch (e) {
console.error(`[TDM] Error getting setting ${key}:`, e);
return defaultValue;
}
},
set: (key, value) => {
try {
localStorage.setItem(`tdm_${key}`, JSON.stringify(value));
return true;
} catch (e) {
if (e.name === 'QuotaExceededError') {
ui.showMessageBox('Storage quota exceeded. Some settings may not be saved.', 'error');
} else {
console.error(`[TDM] Error setting ${key}:`, e);
}
return false;
}
},
updateStateAndStorage: (key, value) => {
state[key] = value;
storage.set(key, value);
}
};
const utils = {
debounce: (func, delay) => {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), delay);
};
},
incrementApiCalls: (n = 1) => {
try {
state.session.apiCalls = (state.session.apiCalls || 0) + (Number(n) || 0);
if (ui && typeof ui.updateApiUsageBadge === 'function') ui.updateApiUsageBadge();
} catch (_) { /* noop */ }
},
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') element.addEventListener('click', value);
else if (key.startsWith('on') && typeof value === 'function') element.addEventListener(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;
},
compareVersions: (a, b) => {
// Returns -1 if a<b, 0 if equal, 1 if a>b
const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const ai = pa[i] || 0; const bi = pb[i] || 0;
if (ai < bi) return -1;
if (ai > bi) return 1;
}
return 0;
},
httpGetText: (url) => {
// Prefer GM/pda bridge to avoid CORS; fallback to fetch
return new Promise((resolve) => {
try {
if (state?.gm?.rD_xmlhttpRequest) {
state.gm.rD_xmlhttpRequest({
method: 'GET', url,
onload: r => resolve(typeof r.responseText === 'string' ? r.responseText : ''),
onerror: () => resolve('')
});
return;
}
} catch (_) { /* ignore and try fetch */ }
// Fallback to fetch
fetch(url).then(res => res.text()).then(txt => resolve(txt)).catch(() => resolve(''));
});
},
perf: {
timers: {},
start: function(name) {
this.timers[name] = performance.now();
},
stop: function(name) {
if (this.timers[name]) {
const duration = performance.now() - this.timers[name];
console.log(`[TDM Perf] ${name} took ${duration.toFixed(2)} ms`);
delete this.timers[name];
}
}
},
isCollectionChanged: (clientTimestamps, masterTimestamps, collectionKey) => {
const clientTs = clientTimestamps?.[collectionKey];
const masterTs = masterTimestamps?.[collectionKey];
if (!masterTs) {
console.log(`[TDM] No master timestamp for ${collectionKey} Default True`);
return true; // If no master, always fetch
}
if (!clientTs) {
console.log(`[TDM] No client timestamp for ${collectionKey} Default True`);
return true; // If no client, always fetch
}
// 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) {
console.log(`[TDM][Collection][${collectionKey}]: `, {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="]').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: (stateStr, descriptionStr = '') => {
try {
const s = String(stateStr || '').toLowerCase();
const d = String(descriptionStr || '').toLowerCase();
if (s.includes('hospital')) return 'Hospital';
if (s.includes('jail')) return 'Jail';
if (s.includes('travel')) return 'Travel'; // covers 'Traveling'
if (s.includes('abroad') || d.includes('abroad')) return 'Abroad';
if (s === 'okay' || d.includes('okay')) return 'Okay';
// Fallbacks for common variants
if (d.includes('hospital')) return 'Hospital';
if (d.includes('jail')) return 'Jail';
if (d.includes('travel')) return 'Travel';
return 'Okay';
} catch (_) { return 'Okay'; }
},
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 };
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 || {}) },
// User status allowance
allowedUserStatuses: { ...defaultStatuses, ...(dibs.allowedUserStatuses || {}) },
// Opponent travel removal
removeOnFly: !!dibs.removeOnFly,
// User travel removal
removeWhenUserTravels: !!dibs.removeWhenUserTravels,
};
},
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)) return cached; // 10s TTL
try {
const user = await api.getTornUser(state.user.actualTornApiKey, userId ? id : null);
const canon = utils.canonicalizeStatus(user?.status?.state, user?.status?.description);
const until = Number(user?.status?.until || 0);
const packed = { raw: user?.status || {}, canonical: canon, until, fetchedAtMs: now };
cache[id] = packed;
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.canonicalizeStatus(selfMember.status?.state, selfMember.status?.description);
}
}
const packed = { raw: {}, canonical: canon, until, fetchedAtMs: now };
cache[id] = packed;
return packed;
}
},
getMyCanonicalStatus: () => {
// Best-effort local status (no network)
try {
const selfMember = (state.factionMembers || []).find(m => String(m.id) === String(state.user.tornId));
if (selfMember?.status) return utils.canonicalizeStatus(selfMember.status?.state, selfMember.status?.description);
const obj = state.user?.tornUserObject;
if (obj?.status) return utils.canonicalizeStatus(obj.status?.state, obj.status?.description);
} catch (_) {}
return 'Okay';
}
};
//======================================================================
// 3. STATE MANAGEMENT
//======================================================================
// Initialize state keys from localStorage or use default values
const state = {
dibsData: storage.get('dibsData', []),
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'),
opponentStatuses: storage.get('opponentStatuses', {}),
userNotes: storage.get('userNotes', {}),
factionMembers: storage.get('factionMembers', []),
factionPull: storage.get('factionPull', null),
dibsNotifications: storage.get('dibsNotifications', []),
unauthorizedAttacks: storage.get('unauthorizedAttacks', []),
retaliationOpportunities: storage.get('retaliationOpportunities', {}),
dataTimestamps: storage.get('dataTimestamps', {}), // --- Timestamp-based polling ---
user: storage.get('user', { tornId: null, tornUsername: '', tornUserObject: null, actualTornApiKey: null, actualTornApiKeyAccess: 0, hasReachedScoreCap: false, factionId: 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: null, canAdministerMedDeals: false, lastActivityTime: Date.now(), isWindowActive: true, currentRefreshInterval: config.REFRESH_INTERVAL_ACTIVE_MS, mainRefreshIntervalId: null, activityTimeoutId: null, mutationObserver: null, hasProcessedRankedWarTables: false, hasProcessedFactionList: 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, chainTimerIntervalId: null, chainFallback: { lastFetch: 0, timeoutEpoch: 0 }, inactivityTimerEl: null, inactivityTimerIntervalId: null, opponentStatusEl: null, opponentStatusIntervalId: null, opponentStatusCache: { lastFetch: 0, untilEpoch: 0, text: '', opponentId: null } },
gm: { rD_xmlhttpRequest: null, rD_setValue: null, rD_getValue: null, rD_registerMenuCommand: null, rD_listValues: null, rD_getApiKey: null, rD_addStyle: null },
session: { apiCalls: 0, userStatusCache: {}, lastEnforcementMs: 0 }
};
//======================================================================
// 4. API MODULE
//======================================================================
const api = {
_call: (method, url, action, params = {}) => {
if (!state.user.tornId || !state.user.actualTornApiKey) {
return Promise.reject(new Error(`Authentication details missing for Firebase ${method}.`));
}
// Pass config.VERSION to backend
const defaultFaction = state?.user?.factionId ? { factionId: state.user.factionId } : {};
const payload = { action: action, tornId: state.user.tornId, tornApiKey: state.user.actualTornApiKey, version: config.VERSION, clientTimestamps: state.dataTimestamps, ...defaultFaction, ...params };
const requestBody = { data: payload };
return new Promise((resolve, reject) => {
state.gm.rD_xmlhttpRequest({
method: 'POST',
url: url,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify(requestBody),
onload: function(response) {
const raw = typeof response?.responseText === 'string' ? response.responseText : '';
// If HTTP error, still try to parse error payload then reject
const isHttpError = typeof response?.status === 'number' && response.status >= 400;
if (!raw || raw.trim() === '') {
return reject(new Error(`Empty or invalid response from server (${method}).`));
}
try {
const payload = JSON.parse(raw);
// If backend included meta with userKeyApiCalls, accumulate it
try {
const metaCalls = payload?.result?.meta?.userKeyApiCalls ?? payload?.meta?.userKeyApiCalls;
// console.log('[TDM][API] Backend reported userKeyApiCalls:', metaCalls);
if (typeof metaCalls === 'number') utils.incrementApiCalls(Number(metaCalls) || 0);
} catch (_) { /* ignore */ }
// Normalize common shapes:
// 1) { result: { status: 'success', data } }
if (payload?.result?.status === 'success' && 'data' in payload.result) {
return resolve(payload.result.data);
}
// 2) { status: 'success', data }
if (payload?.status === 'success' && 'data' in payload) {
return resolve(payload.data);
}
// 3) Firebase error shapes: { error: { message, status, details } } or { result: { error: { ... } } }
const fbError = payload?.error || payload?.result?.error || payload?.data?.error;
if (fbError) {
const err = new Error(fbError.message || 'Firebase error');
// Flatten useful fields for handlers
if (fbError.status) err.code = fbError.status;
if (fbError.details && typeof fbError.details === 'object') {
Object.assign(err, fbError.details);
}
return reject(err);
}
// 4) { result: { data } } without status
if (payload?.result && 'data' in payload.result) {
return resolve(payload.result.data);
}
// 5) Fallback to resolve normalized object
if (isHttpError) {
return reject(new Error(`Request failed (${response.status}): ${raw.slice(0, 200)}`));
}
return resolve(payload);
} catch (e) {
// Parsing failed; include a hint for easier debugging
return reject(new Error(`Failed to parse Firebase API response: ${e.message}`));
}
},
onerror: (error) => reject(new Error(`Firebase API request failed: Status ${error.status || 'Unknown'}`))
});
});
},
get: (action, params = {}) => api._call('GET', config.API_GET_URL, action, params),
post: (action, data = {}) => api._call('POST', config.API_POST_URL, action, data),
getGlobalData: (params = {}) => {
// Use the dedicated backend endpoint for getGlobalData
if (!state.user.tornId || !state.user.actualTornApiKey) {
return Promise.reject(new Error('Authentication details missing for getGlobalData.'));
}
// --- Differential polling: send client timestamps ---
const payload = {
tornId: state.user.tornId,
tornApiKey: state.user.actualTornApiKey,
clientTimestamps: state.dataTimestamps, // Send persisted timestamps
...params
};
return new Promise((resolve, reject) => {
state.gm.rD_xmlhttpRequest({
method: 'POST',
url: 'https://getglobaldata-codod64xdq-uc.a.run.app',
headers: { 'Content-Type': 'application/json' },
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)
if (jsonResponse.result.apiUsage && typeof jsonResponse.result.apiUsage.userKeyApiCalls === 'number') {
utils.incrementApiCalls(Number(jsonResponse.result.apiUsage.userKeyApiCalls) || 0);
}
// --- 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',
'opponentStatuses',
'dibsNotifications',
'unauthorizedAttacks',
'rankedWarSummary'
];
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]);
// state[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]);
// state[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]);
// state[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')))
});
});
},
getTornUser: (apiKey, id = null) => {
if (!apiKey) return Promise.reject(new Error("No API key provided"));
const apiUrl = `https://api.torn.com/v2/user/${id ? id + '/' : ''}?key=${apiKey}&comment=TreeDibsMapper`;
return new Promise((resolve, reject) => {
state.gm.rD_xmlhttpRequest({
method: "GET",
url: apiUrl,
onload: res => {
try {
const data = JSON.parse(res.responseText);
if (data.error) reject(new Error(data.error.error));
else { utils.incrementApiCalls(1); resolve(data); }
} catch (e) { reject(new Error("Invalid JSON from Torn API")); }
},
onerror: () => reject(new Error("Torn API request failed"))
});
});
},
getTornFaction: async function(apiKey, selections = '') {
try {
const factionId = state?.user?.factionId;
if (!factionId) return null;
const url = `https://api.torn.com/v2/faction/${factionId}?selections=${selections}&key=${apiKey}&comment=TreeDibsMapper`;
const response = await fetch(url);
const data = await response.json();
if (data.error) throw new Error(data.error.error);
utils.incrementApiCalls(1);
return data;
} catch (error) {
console.error('[TDM] Error fetching faction data:', error);
return null;
}
},
getKeyInfo:async function(apiKey) {
try{
const url = `https://api.torn.com/v2/key/info?key=${apiKey}&comment=TDMKey`;
const response = await fetch(url);
const data = await response.json();
if (data.error) throw new Error(data.error.error);
utils.incrementApiCalls(1);
return data;
} catch (error) {
console.error('[TDM] Error fetching key info:', error);
return null;
}
}
};
//======================================================================
// 5. UI MODULE
//======================================================================
const ui = {
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'); }
state.dom.rankwarfactionTables = 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=');
},
updateAllPages: () => {
// utils.perf.start('updateAllPages');
ui.updatePageContext();
if (state.page.isAttackPage) ui.injectAttackPageUI();
if (state.page.isRankedWarPage) ui.updateRankedWarUI();
if (state.dom.factionListContainer) ui.updateFactionPageUI(state.dom.factionListContainer);
ui.updateRetalsButtonCount();
ui.ensureChainTimer();
ui.ensureInactivityTimer();
ui.ensureOpponentStatus();
ui.ensureApiUsageBadge();
// utils.perf.stop('updateAllPages');
},
ensureChainTimer: () => {
if (!storage.get('chainTimerEnabled', true)) { ui.removeChainTimer(); return; }
// Find chat root
const chatRoot = document.querySelector('.root___lv7vM');
if (!chatRoot) return;
// Prefer existing element by ID and remove duplicates
const allChainEls = document.querySelectorAll('#tdm-chain-timer');
if (allChainEls.length > 1) {
for (let i = 1; i < allChainEls.length; i++) allChainEls[i].remove();
}
const existing = document.getElementById('tdm-chain-timer');
if (existing && existing !== state.ui.chainTimerEl) {
state.ui.chainTimerEl = existing;
}
if (state.ui.chainTimerEl && !document.body.contains(state.ui.chainTimerEl)) {
state.ui.chainTimerEl = null;
}
if (!state.ui.chainTimerEl) {
const el = utils.createElement('div', {
id: 'tdm-chain-timer',
className: 'tdm-text-halo',
style: { display: 'inline-flex', alignItems: 'center', gap: '6px', marginRight: '8px', color: '#fff', fontWeight: '600', fontSize: '12px', padding: '1px 1px' }
});
// Non-intrusive: prepend without shifting chat controls by using flex inline container
chatRoot.insertBefore(el, chatRoot.firstChild);
state.ui.chainTimerEl = el;
ui.ensureApiUsageBadge();
} else if (state.ui.chainTimerEl.parentNode !== chatRoot) {
chatRoot.insertBefore(state.ui.chainTimerEl, chatRoot.firstChild);
}
// Start/refresh updater
ui.startChainTimerUpdater();
},
removeChainTimer: () => {
if (state.ui.chainTimerIntervalId) { clearInterval(state.ui.chainTimerIntervalId); state.ui.chainTimerIntervalId = null; }
if (state.ui.chainTimerEl) { state.ui.chainTimerEl.remove(); state.ui.chainTimerEl = null; }
},
startChainTimerUpdater: () => {
if (!state.ui.chainTimerEl) return;
if (state.ui.chainTimerIntervalId) return; // already running
const readDomTimer = () => {
const a = document.querySelector('.chain-box-timeleft');
const b = document.querySelector('.bar-timeleft___B9RGV');
const txt = (a?.textContent || b?.textContent || '').trim();
// Expect MM:SS; ignore if 00:00 or empty
if (txt && /^\d{1,2}:\d{2}$/.test(txt) && txt !== '00:00') return txt;
return '';
};
const setDisplay = (text) => {
if (!state.ui.chainTimerEl) return;
if (text) {
state.ui.chainTimerEl.style.display = 'inline-flex';
state.ui.chainTimerEl.textContent = `Chain: ${text}`;
} else {
state.ui.chainTimerEl.style.display = 'none';
}
};
const tick = async () => {
// First preference: mirror existing DOM timer
const domVal = readDomTimer();
if (domVal) { setDisplay(domVal); return; }
// Fallback: use chain bar value to decide whether we care
const barVal = document.querySelector('.bar-value___uxnah')?.textContent?.trim() || '';
const validBar = barVal && !barVal.includes('0 / 10');
if (validBar) {
const nowMs = Date.now();
// Fetch only every 10s
if (nowMs - state.ui.chainFallback.lastFetch >= 10000) {
state.ui.chainFallback.lastFetch = nowMs;
try {
const url = `https://api.torn.com/v2/faction/${state.user.factionId}/chain?key=${state.user.actualTornApiKey}`;
const resp = await fetch(url);
const json = await resp.json();
const chain = json?.chain;
if (chain && Number(chain.current) > 0) {
// timeout is epoch seconds
state.ui.chainFallback.timeoutEpoch = Number(chain.timeout) || 0;
utils.incrementApiCalls(1);
} else {
state.ui.chainFallback.timeoutEpoch = 0;
}
} catch (_) { /* noop */ }
}
// With cached timeout, compute remaining
if (state.ui.chainFallback.timeoutEpoch > 0) {
const now = Math.floor(Date.now() / 1000);
const remaining = state.ui.chainFallback.timeoutEpoch - now;
if (remaining > 0) {
const mm = Math.floor(remaining / 60);
const ss = (remaining % 60).toString().padStart(2, '0');
setDisplay(`${mm}:${ss}`);
return;
}
}
}
// Nothing to show
setDisplay('');
};
// Initial tick and interval
tick();
state.ui.chainTimerIntervalId = setInterval(tick, 1000);
},
ensureInactivityTimer: () => {
if (!storage.get('inactivityTimerEnabled', true)) { ui.removeInactivityTimer(); return; }
const chatRoot = document.querySelector('.root___lv7vM');
if (!chatRoot) return;
const allEls = document.querySelectorAll('#tdm-inactivity-timer');
if (allEls.length > 1) {
for (let i = 1; i < allEls.length; i++) allEls[i].remove();
}
const existing = document.getElementById('tdm-inactivity-timer');
if (existing && existing !== state.ui.inactivityTimerEl) {
state.ui.inactivityTimerEl = existing;
}
if (state.ui.inactivityTimerEl && !document.body.contains(state.ui.inactivityTimerEl)) {
state.ui.inactivityTimerEl = null;
}
if (!state.ui.inactivityTimerEl) {
const el = utils.createElement('div', {
id: 'tdm-inactivity-timer',
className: 'tdm-text-halo',
style: { display: 'inline-flex', alignItems: 'center', gap: '2px', marginRight: '8px', marginLeft: '2px', color: '#ffeb3b', fontWeight: '600', fontSize: '12px', padding: '1px 1px' }
});
chatRoot.insertBefore(el, chatRoot.firstChild?.nextSibling || chatRoot.firstChild);
state.ui.inactivityTimerEl = el;
ui.ensureApiUsageBadge();
} else if (state.ui.inactivityTimerEl.parentNode !== chatRoot) {
chatRoot.insertBefore(state.ui.inactivityTimerEl, chatRoot.firstChild?.nextSibling || chatRoot.firstChild);
}
ui.startInactivityUpdater();
},
removeInactivityTimer: () => {
if (state.ui.inactivityTimerIntervalId) { clearInterval(state.ui.inactivityTimerIntervalId); state.ui.inactivityTimerIntervalId = null; }
if (state.ui.inactivityTimerEl) { state.ui.inactivityTimerEl.remove(); state.ui.inactivityTimerEl = null; }
},
startInactivityUpdater: () => {
if (!state.ui.inactivityTimerEl) return;
if (state.ui.inactivityTimerIntervalId) return;
const tick = () => {
const ms = Date.now() - (state.script.lastActivityTime || Date.now());
const totalSec = Math.floor(ms / 1000);
const hh = Math.floor(totalSec / 3600);
const mm = Math.floor((totalSec % 3600) / 60);
const ss = totalSec % 60;
const pad = (n) => String(n).padStart(2, '0');
const txt = hh > 0 ? `${pad(hh)}:${pad(mm)}:${pad(ss)}` : `${pad(mm)}:${pad(ss)}`;
// set color orange after 4 minutes, red after 5 minutes
if (totalSec >= 300) {
state.ui.inactivityTimerEl.style.color = 'red';
} else if (totalSec >= 240) {
state.ui.inactivityTimerEl.style.color = 'orange';
} else {
state.ui.inactivityTimerEl.style.color = '#ffeb3b';
}
state.ui.inactivityTimerEl.textContent = `Inactivity: ${txt}`;
};
tick();
state.ui.inactivityTimerIntervalId = setInterval(tick, 1000);
},
ensureOpponentStatus: () => {
if (!storage.get('opponentStatusTimerEnabled', true)) { ui.removeOpponentStatus(); return; }
const chatRoot = document.querySelector('.root___lv7vM');
if (!chatRoot) return;
const allEls = document.querySelectorAll('#tdm-opponent-status');
if (allEls.length > 1) {
for (let i = 1; i < allEls.length; i++) allEls[i].remove();
}
const existing = document.getElementById('tdm-opponent-status');
if (existing && existing !== state.ui.opponentStatusEl) {
state.ui.opponentStatusEl = existing;
}
if (state.ui.opponentStatusEl && !document.body.contains(state.ui.opponentStatusEl)) {
state.ui.opponentStatusEl = null;
}
if (!state.ui.opponentStatusEl) {
const el = utils.createElement('div', {
id: 'tdm-opponent-status',
className: 'tdm-text-halo',
style: { display: 'inline-flex', alignItems: 'center', gap: '4px', marginRight: '8px', color: '#9dd6ff', fontWeight: '600', fontSize: '12px', padding: '1px 1px' }
});
chatRoot.insertBefore(el, chatRoot.firstChild?.nextSibling?.nextSibling || chatRoot.firstChild);
state.ui.opponentStatusEl = el;
ui.ensureApiUsageBadge();
} else if (state.ui.opponentStatusEl.parentNode !== chatRoot) {
chatRoot.insertBefore(state.ui.opponentStatusEl, chatRoot.firstChild?.nextSibling?.nextSibling || chatRoot.firstChild);
}
ui.startOpponentStatusUpdater();
},
ensureApiUsageBadge: () => {
if (!storage.get('apiUsageCounterEnabled', true)) return;
const chatRoot = document.querySelector('.root___lv7vM');
if (!chatRoot) return;
let el = document.getElementById('tdm-api-usage');
if (!el) {
el = utils.createElement('div', {
id: 'tdm-api-usage',
className: 'tdm-text-halo',
title: 'Torn API calls (session, user key only)',
style: { display: 'inline-flex', alignItems: 'center', gap: '4px', marginRight: '8px', color: '#cddc39', fontWeight: '700', fontSize: '12px', padding: '1px 2px' }
}, [document.createTextNode('API: 0')]);
chatRoot.insertBefore(el, chatRoot.firstChild);
} else if (el.parentNode !== chatRoot) {
chatRoot.insertBefore(el, chatRoot.firstChild);
}
ui.updateApiUsageBadge();
},
updateApiUsageBadge: () => {
const el = document.getElementById('tdm-api-usage');
if (!el) return;
el.textContent = `API: ${state.session.apiCalls || 0}`;
el.style.display = storage.get('apiUsageCounterEnabled', true) ? 'inline-flex' : 'none';
},
removeOpponentStatus: () => {
if (state.ui.opponentStatusIntervalId) { clearInterval(state.ui.opponentStatusIntervalId); state.ui.opponentStatusIntervalId = null; }
if (state.ui.opponentStatusEl) { state.ui.opponentStatusEl.remove(); state.ui.opponentStatusEl = null; }
},
startOpponentStatusUpdater: () => {
if (!state.ui.opponentStatusEl) return;
if (state.ui.opponentStatusIntervalId) return;
const getMyTargets = () => {
// Find my active dibs or med deals from current page context
const dib = state.dibsData.find(d => d.dibsActive && d.userId === state.user.tornId);
let medOppId = null;
for (const [oid, s] of Object.entries(state.opponentStatuses || {})) {
if (s?.isMedDeal && s.medDealForUserId === state.user.tornId) { medOppId = oid; break; }
}
return { dibOppId: dib?.opponentId || null, medOppId };
};
const tick = async () => {
const { dibOppId, medOppId } = getMyTargets();
const oppId = dibOppId || medOppId || null;
if (!oppId) { state.ui.opponentStatusEl.style.display = 'none'; return; }
// Try to reuse cached hospital release time for the same opponent
const nowMs = Date.now();
if (state.ui.opponentStatusCache.opponentId !== oppId) {
state.ui.opponentStatusCache = { lastFetch: 0, untilEpoch: 0, text: '', opponentId: oppId };
}
// Only refresh every 10s
if (nowMs - state.ui.opponentStatusCache.lastFetch >= 10000) {
state.ui.opponentStatusCache.lastFetch = nowMs;
try {
const user = await api.getTornUser(state.user.actualTornApiKey, oppId);
let text = '';
let until = 0;
if (user?.status?.state === 'Hospital') {
// Torn v2 user has status until epoch seconds
until = Number(user.status.until) || 0;
text = 'Hosp';
} else {
text = user?.status?.description || user?.status?.state || 'Okay';
}
state.ui.opponentStatusCache.untilEpoch = until;
state.ui.opponentStatusCache.text = text;
} catch (_) { /* noop */ }
}
const until = state.ui.opponentStatusCache.untilEpoch;
const baseText = state.ui.opponentStatusCache.text || '';
let display = baseText;
if (baseText === 'Hosp' && until > 0) {
const now = Math.floor(Date.now() / 1000);
const rem = until - now;
if (rem > 0) {
const mm = Math.floor(rem / 60);
const ss = (rem % 60).toString().padStart(2, '0');
display = `Hosp ${mm}:${ss}`;
} else {
display = 'Okay';
}
}
state.ui.opponentStatusEl.style.display = 'inline-flex';
state.ui.opponentStatusEl.style.padding = '1px 1px';
// Make clickable to the attack page for current opponent
const href = `https://www.torn.com/loader.php?sid=attack&user2ID=${oppId}`;
// No target attribute: we control navigation in the handler to avoid double-open
state.ui.opponentStatusEl.innerHTML = `<a class="tdm-halo-link" href="${href}" rel="noopener noreferrer">Opponent: ${display}</a>`;
// Some Torn containers intercept anchor clicks; ensure opening via JS
const anchor = state.ui.opponentStatusEl.querySelector('a');
if (anchor) {
// Make sure the element can receive pointer events and is above overlays
try {
state.ui.opponentStatusEl.style.pointerEvents = 'auto';
state.ui.opponentStatusEl.style.position = 'relative';
state.ui.opponentStatusEl.style.zIndex = '2147483647';
anchor.style.pointerEvents = 'auto';
anchor.style.cursor = 'pointer';
anchor.style.textDecoration = 'underline';
anchor.style.color = 'inherit';
anchor.setAttribute('title', 'Open attack page');
anchor.setAttribute('tabindex', '0');
} catch (_) { /* noop */ }
let opening = false;
const openAttack = (e) => {
if (opening) return false;
opening = true;
try {
if (e && typeof e.preventDefault === 'function') e.preventDefault();
if (e && typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
} catch (_) { /* noop */ }
let opened = false;
try { const w = window.open(href, '_blank', 'noopener'); opened = !!w; } catch (_) { /* noop */ }
if (!opened) {
try { window.location.href = href; } catch (_) { /* noop */ }
}
// Reset guard on next tick in case element persists
setTimeout(() => { opening = false; }, 0);
return false;
};
// Use capture to get ahead of site-level handlers; single handler to avoid duplicate firing
anchor.addEventListener('click', openAttack, { capture: true });
anchor.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') openAttack(e);
}, { capture: true });
}
};
tick();
state.ui.opponentStatusIntervalId = setInterval(tick, 1000);
},
processRankedWarTables: async () => {
// utils.perf.start('processRankedWarTables');
const factionTables = state.dom.rankwarfactionTables;
if (!factionTables || !factionTables.length) {
// utils.perf.stop('processRankedWarTables');
return;
}
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';
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;
}
tableContainer.querySelectorAll('.members-list > li').forEach(row => {
if (row.querySelector('.dibs-notes-subrow')) return; // Already processed
const userLink = row.querySelector('a[href*="profiles.php?XID="]');
if (!userLink) return;
let subrow = utils.createElement('div', { className: 'dibs-notes-subrow' });
if (!isCurrentTableOurFaction) {
// Create placeholders for all buttons to prevent layout shifts
subrow.appendChild(utils.createElement('button', { className: 'btn dibs-btn btn-dibs-inactive', innerHTML: '<span class="dibs-spinner"></span>', disabled: true }));
subrow.appendChild(utils.createElement('button', { className: 'btn btn-med-deal-default', innerHTML: '<span class="dibs-spinner"></span>', disabled: true, style: { display: 'none' } }));
subrow.appendChild(utils.createElement('button', { className: 'btn note-button inactive-note-button', innerHTML: '<span class="dibs-spinner"></span>', disabled: true }));
// Retals placeholder
const retalContainer = utils.createElement('div', { style: { flexGrow: '1', display: 'flex', justifyContent: 'flex-end' } });
retalContainer.appendChild(utils.createElement('button', { className: 'btn retal-btn', innerHTML: '<span class="dibs-spinner"></span>', disabled: true, style: { display: 'none' } }));
subrow.appendChild(retalContainer);
} else {
// For our own faction, just add a notes button placeholder
subrow.appendChild(utils.createElement('button', { className: 'btn note-button inactive-note-button', innerHTML: '<span class="dibs-spinner"></span>', disabled: true }));
}
const lastActionRow = row.querySelector('.last-action-row');
if (lastActionRow) subrow.appendChild(lastActionRow);
row.appendChild(subrow);
});
});
ui.updateRankedWarUI(); // Immediately update with any available data
// utils.perf.stop('processRankedWarTables');
},
processFactionPageMembers: async (container) => {
// utils.perf.start('processFactionPageMembers');
const members = container.querySelectorAll('.table-body > li.table-row');
if (!members.length) {
// utils.perf.stop('processFactionPageMembers');
return;
}
const headerUl = container.querySelector('.table-header');
if (headerUl && !headerUl.querySelector('#col-header-dibs-notes')) {
// Add a single, combined header
headerUl.appendChild(utils.createElement('li', { id: 'col-header-dibs-notes', className: 'table-cell table-header-column', innerHTML: `<span>Dibs/Notes</span>` }));
}
members.forEach(row => {
if (row.querySelector('.tdm-controls-container')) return; // Already processed
// Create a single container for all our controls
const controlsContainer = utils.createElement('div', { className: 'table-cell tdm-controls-container' });
// Create Dibs and Med Deal button placeholders
const dibsCell = utils.createElement('div', { className: 'dibs-cell' });
dibsCell.appendChild(utils.createElement('button', { className: 'btn dibs-button', innerHTML: '<span class="dibs-spinner"></span>' }));
dibsCell.appendChild(utils.createElement('button', { className: 'btn med-deal-button', style: { display: 'none' }, innerHTML: '<span class="dibs-spinner"></span>' }));
// Create Note button placeholder
const notesCell = utils.createElement('div', { className: 'notes-cell' });
notesCell.appendChild(utils.createElement('button', { className: 'btn note-button', innerHTML: '<span class="dibs-spinner"></span>' }));
controlsContainer.appendChild(dibsCell);
controlsContainer.appendChild(notesCell);
row.appendChild(controlsContainer);
});
ui.updateFactionPageUI(container);
// utils.perf.stop('processFactionPageMembers');
},
updateRankedWarUI: () => {
// utils.perf.start('updateRankedWarUI');
const factionTables = state.dom.rankwarfactionTables;
if (!factionTables) {
// utils.perf.stop('updateRankedWarUI');
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() : 'N/A';
const currentFactionName = currentFactionLink ? currentFactionLink.textContent.trim() : 'N/A';
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;
}
tableContainer.querySelectorAll('.members-list > li').forEach(row => {
const userLink = row.querySelector('a[href*="profiles.php?XID="]');
if (!userLink) return;
const opponentId = userLink.href.match(/XID=(\d+)/)[1];
const opponentName = userLink.textContent.trim();
let subrow = row.querySelector('.dibs-notes-subrow');
if (!subrow) return;
// --- Conditional Notes Button Update START ---
let notesBtn = subrow.querySelector('.note-button'); // Generic class
if (notesBtn) {
const userNote = state.userNotes[opponentId];
const newNoteText = userNote?.noteContent || 'Note';
const newNoteClass = 'btn note-button ' + (userNote?.noteContent ? 'active-note-button' : 'inactive-note-button');
if (notesBtn.textContent !== newNoteText) {
notesBtn.textContent = newNoteText;
notesBtn.title = userNote?.noteContent || '';
}
if (notesBtn.className !== newNoteClass) notesBtn.className = newNoteClass;
notesBtn.onclick = (e) => ui.openNoteModal(opponentId, opponentName, userNote?.noteContent || '', e.currentTarget);
notesBtn.disabled = false;
}
// --- Conditional Notes Button Update END ---
if (!isCurrentTableOurFaction) {
// --- Conditional Dibs Button Update START ---
let dibsBtn = subrow.querySelector('.dibs-btn');
if (dibsBtn) {
const activeDibs = state.dibsData.find(d => d.opponentId === opponentId && d.dibsActive);
let newDibsText, newDibsClass, newDibsDisabled;
if (activeDibs) {
newDibsText = activeDibs.userId === state.user.tornId ? 'YOU Dibbed' : activeDibs.username;
newDibsClass = 'btn dibs-btn ' + (activeDibs.userId === state.user.tornId ? 'btn-dibs-success-you' : 'btn-dibs-success-other');
newDibsDisabled = !(activeDibs.userId === state.user.tornId || (state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true));
dibsBtn.onclick = (e) => handlers.debouncedRemoveDibsForTarget(opponentId, e.currentTarget);
} else {
newDibsText = 'Dibs';
newDibsClass = 'btn dibs-btn btn-dibs-inactive';
// Policy-based local disablement (user status only to avoid per-row API calls)
const opts = utils.getDibsStyleOptions();
const myCanon = utils.getMyCanonicalStatus();
newDibsDisabled = opts?.allowedUserStatuses && opts.allowedUserStatuses[myCanon] === false;
dibsBtn.onclick = (state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true)
? (e) => ui.openDibsSetterModal(opponentId, opponentName, e.currentTarget)
: (e) => handlers.debouncedDibsTarget(opponentId, opponentName, e.currentTarget);
if (newDibsDisabled) dibsBtn.title = `Disabled by policy: Your status (${myCanon})`;
}
if (dibsBtn.textContent !== newDibsText) dibsBtn.textContent = newDibsText;
if (dibsBtn.className !== newDibsClass) dibsBtn.className = newDibsClass;
if (dibsBtn.disabled !== newDibsDisabled) dibsBtn.disabled = newDibsDisabled;
}
// --- Conditional Dibs Button Update END ---
// --- Conditional Med Deal Button Update START ---
let medDealBtn = subrow.querySelector('.btn-med-deal-default');
if (medDealBtn) {
if (state.warData.warType === 'Termed War') {
medDealBtn.style.display = 'inline-block';
const medDealStatus = state.opponentStatuses[opponentId];
const isMedDealActive = medDealStatus?.isMedDeal;
const isMyMedDeal = isMedDealActive && medDealStatus.medDealForUserId === state.user.tornId;
let newMedDealHTML, newMedDealClass, newMedDealDisabled;
if (isMyMedDeal) {
newMedDealHTML = 'Remove Deal';
newMedDealClass = 'btn btn-med-deal-default btn-med-deal-mine';
newMedDealDisabled = false;
medDealBtn.onclick = (e) => handlers.debouncedHandleMedDealToggle(opponentId, opponentName, false, state.user.tornId, state.user.tornUsername, e.currentTarget);
} else if (isMedDealActive) {
newMedDealHTML = `${medDealStatus.medDealForUsername}`;
newMedDealClass = 'btn btn-med-deal-default btn-med-deal-set';
newMedDealDisabled = !(state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true);
medDealBtn.onclick = (e) => handlers.debouncedHandleMedDealToggle(opponentId, opponentName, false, medDealStatus.medDealForUserId || opponentId, medDealStatus.medDealForUsername || opponentName, e.currentTarget);
} else {
newMedDealHTML = 'Set Deal';
newMedDealClass = 'btn btn-med-deal-default btn-med-deal-inactive';
newMedDealDisabled = false;
medDealBtn.onclick = (state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true)
? (e) => ui.openMedDealSetterModal(opponentId, opponentName, e.currentTarget)
: (e) => handlers.debouncedHandleMedDealToggle(opponentId, opponentName, true, state.user.tornId, state.user.tornUsername, e.currentTarget);
}
if (medDealBtn.innerHTML !== newMedDealHTML) medDealBtn.innerHTML = newMedDealHTML;
if (medDealBtn.className !== newMedDealClass) medDealBtn.className = newMedDealClass;
if (medDealBtn.disabled !== newMedDealDisabled) medDealBtn.disabled = newMedDealDisabled;
} else {
medDealBtn.style.display = 'none';
}
}
// --- Conditional Med Deal Button Update END ---
// Retal Button
let retalBtn = subrow.querySelector('.retal-btn');
if (retalBtn) ui.updateRetaliationButton(retalBtn, opponentId, opponentName);
}
});
});
// utils.perf.stop('updateRankedWarUI');
},
updateFactionPageUI: (container) => {
utils.perf.start('updateFactionPageUI');
const members = container.querySelectorAll('.f-war-list .table-body > li.table-row');
if (!members.length) {
// utils.perf.stop('updateFactionPageUI');
return;
}
members.forEach(memberLi => {
const controlsContainer = memberLi.querySelector('.tdm-controls-container');
if (!controlsContainer) return;
const memberIdLink = memberLi.querySelector('a[href*="profiles.php?XID="]');
if (!memberIdLink) return;
const opponentId = memberIdLink.href.match(/XID=(\d+)/)[1];
const opponentName = String(memberIdLink.textContent || '').trim() || `Opponent (${opponentId})`;
const dibsCell = controlsContainer.querySelector('.dibs-cell');
const notesCell = controlsContainer.querySelector('.notes-cell');
// --- Update Dibs & Med Deal Cell ---
if (state.page.isMyFactionPage) {
dibsCell.style.display = 'none';
} else {
dibsCell.style.display = 'flex';
const dibsButton = dibsCell.querySelector('.dibs-button');
const medDealButton = dibsCell.querySelector('.med-deal-button');
// --- Conditional Dibs Button Update START ---
const activeDibs = state.dibsData.find(d => d.opponentId === opponentId && d.dibsActive);
let newDibsText, newDibsClass, newDibsDisabled;
if (activeDibs) {
newDibsText = activeDibs.userId === state.user.tornId ? 'YOU Dibbed' : activeDibs.username;
newDibsClass = 'btn dibs-button ' + (activeDibs.userId === state.user.tornId ? 'btn-dibs-success-you' : 'btn-dibs-success-other');
newDibsDisabled = !(activeDibs.userId === state.user.tornId || (state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true));
dibsButton.onclick = (e) => handlers.debouncedRemoveDibsForTarget(opponentId, e.currentTarget);
} else {
newDibsText = 'Dibs';
newDibsClass = 'btn dibs-button btn-dibs-inactive';
const opts = utils.getDibsStyleOptions();
const myCanon = utils.getMyCanonicalStatus();
newDibsDisabled = opts?.allowedUserStatuses && opts.allowedUserStatuses[myCanon] === false;
dibsButton.onclick = (state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true)
? (e) => ui.openDibsSetterModal(opponentId, opponentName, e.currentTarget)
: (e) => handlers.debouncedDibsTarget(opponentId, opponentName, e.currentTarget);
if (newDibsDisabled) dibsButton.title = `Disabled by policy: Your status (${myCanon})`;
}
if (dibsButton.textContent !== newDibsText) dibsButton.textContent = newDibsText;
if (dibsButton.className !== newDibsClass) dibsButton.className = newDibsClass;
if (dibsButton.disabled !== newDibsDisabled) dibsButton.disabled = newDibsDisabled;
// --- Conditional Dibs Button Update END ---
// --- Conditional Med Deal Button Update START ---
if (state.warData.warType === 'Termed War') {
medDealButton.style.display = 'flex';
const medDealStatus = state.opponentStatuses[opponentId];
const isMedDealActive = medDealStatus?.isMedDeal;
const isMyMedDeal = isMedDealActive && medDealStatus.medDealForUserId === state.user.tornId;
let newMedDealHTML, newMedDealClass, newMedDealDisabled;
if (isMyMedDeal) {
newMedDealHTML = 'Remove Deal';
newMedDealClass = 'btn med-deal-button btn-med-deal-mine';
newMedDealDisabled = false;
medDealButton.onclick = (e) => handlers.debouncedHandleMedDealToggle(opponentId, opponentName, false, state.user.tornId, state.user.tornUsername, e.currentTarget);
} else if (isMedDealActive) {
newMedDealHTML = `${medDealStatus.medDealForUsername || 'Someone'}`;
newMedDealClass = 'btn med-deal-button btn-med-deal-set';
newMedDealDisabled = !(state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true);
medDealButton.onclick = (e) => handlers.debouncedHandleMedDealToggle(opponentId, opponentName, false, medDealStatus.medDealForUserId, medDealStatus.medDealForUsername, e.currentTarget);
} else {
newMedDealHTML = 'Set Deal';
newMedDealClass = 'btn med-deal-button btn-med-deal-inactive';
newMedDealDisabled = false;
medDealButton.onclick = (state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true)
? (e) => ui.openMedDealSetterModal(opponentId, opponentName, e.currentTarget)
: (e) => handlers.debouncedHandleMedDealToggle(opponentId, opponentName, true, state.user.tornId, state.user.tornUsername, e.currentTarget);
}
if (medDealButton.innerHTML !== newMedDealHTML) medDealButton.innerHTML = newMedDealHTML;
if (medDealButton.className !== newMedDealClass) medDealButton.className = newMedDealClass;
if (medDealButton.disabled !== newMedDealDisabled) medDealButton.disabled = newMedDealDisabled;
} else {
medDealButton.style.display = 'none';
}
// --- Conditional Med Deal Button Update END ---
}
// --- Conditional Notes Cell Update START ---
const noteButton = notesCell.querySelector('.note-button');
const userNote = state.userNotes[opponentId];
const newNoteText = userNote?.noteContent || 'Note';
const newNoteClass = 'btn note-button ' + (userNote?.noteContent ? 'active-note-button' : 'inactive-note-button');
if (noteButton.textContent !== newNoteText) {
noteButton.textContent = newNoteText;
noteButton.title = userNote?.noteContent || '';
}
if (noteButton.className !== newNoteClass) noteButton.className = newNoteClass;
noteButton.onclick = (e) => ui.openNoteModal(opponentId, opponentName, userNote?.noteContent || '', e.currentTarget);
noteButton.disabled = false;
// --- Conditional Notes Cell Update END ---
});
// utils.perf.stop('updateFactionPageUI');
},
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;
utils.perf.start('injectAttackPageUI');
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);
}
const opponentName = document.querySelector('.players___eKiHL')?.querySelectorAll('span[id^="playername_"]')[1]?.textContent.trim() ?? `Opponent ID (${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');
if (!state.user.hasReachedScoreCap) {
if (existingWarning) existingWarning.remove();
return;
}
(async () => {
try {
const opponentUser = await api.getTornUser(state.user.actualTornApiKey, opponentId);
if (opponentUser?.faction?.faction_id?.toString() === state.warData.opponentFactionId?.toString()) {
if (!existingWarning) {
const scoreCapWarning = utils.createElement('div', {
className: 'score-cap-warning',
style: { padding: '10px', marginBottom: '10px', backgroundColor: config.CSS.colors.error, color: 'white', borderRadius: '5px', fontWeight: 'bold' },
textContent: 'SCORE CAP REACHED! Do not attack.'
});
attackContainer.insertBefore(scoreCapWarning, attackContainer.firstChild);
}
} else if (existingWarning) {
existingWarning.remove();
}
} catch (error) {
console.error("[TDM] 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.opponentStatuses[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 dibs-btn', style: { minWidth: '70px', maxWidth: '70px', minHeight: '24px', boxSizing: 'border-box', fontSize: '0.75em', padding: '4px 6px', borderColor: '#004d4f !important', borderRadius: '3px' } });
if (opponentDibs) {
if (opponentDibs.userId === state.user.tornId) {
dibsBtn.textContent = 'YOU Dibbed';
dibsBtn.classList.add('btn-dibs-success-you');
dibsBtn.onclick = (e) => handlers.debouncedRemoveDibsForTarget(opponentId, e.currentTarget);
} else {
dibsBtn.textContent = opponentDibs.username;
dibsBtn.classList.add('btn-dibs-success-other');
dibsBtn.disabled = !(state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true);
if (!dibsBtn.disabled) dibsBtn.onclick = (e) => handlers.debouncedRemoveDibsForTarget(opponentId, e.currentTarget);
}
} else {
dibsBtn.textContent = 'Dibs';
dibsBtn.classList.add('btn-dibs-inactive');
dibsBtn.onclick = (state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true)
? (e) => ui.openDibsSetterModal(opponentId, opponentName, e.currentTarget)
: (e) => handlers.debouncedDibsTarget(opponentId, opponentName, e.currentTarget);
// Light, non-blocking policy gating for attack page button
(async () => {
try {
const opts = utils.getDibsStyleOptions();
const myCanon = utils.getMyCanonicalStatus();
if (opts.allowedUserStatuses && opts.allowedUserStatuses[myCanon] === false) {
dibsBtn.disabled = true;
dibsBtn.title = `Disabled by policy: Your status (${myCanon}) cannot place dibs`;
return;
}
// If we can afford one status check, validate opponent allowed statuses
const oppStatus = await utils.getUserStatus(opponentId);
const canonOpp = oppStatus.canonical;
if (opts.allowStatuses && opts.allowStatuses[canonOpp] === false) {
dibsBtn.disabled = true;
dibsBtn.title = `Disabled by policy: Opponent status (${canonOpp})`;
}
} catch (_) { /* non-fatal */ }
})();
}
buttonRow.appendChild(dibsBtn);
if (state.warData.warType === 'Termed War') {
const medDealBtn = utils.createElement('button', { className: 'btn med-deal-btn', style: { minWidth: '70px', maxWidth: '70px', minHeight: '24px', boxSizing: 'border-box', fontSize: '0.75em', padding: '4px 6px', borderColor: '#004d4f !important', borderRadius: '3px' } });
const isMedDealActive = opponentMedDeal?.isMedDeal;
const isMyMedDeal = isMedDealActive && opponentMedDeal.medDealForUserId === state.user.tornId;
if (isMyMedDeal) {
medDealBtn.innerHTML = 'Remove Deal';
medDealBtn.classList.add('btn-med-deal-mine');
medDealBtn.onclick = (e) => handlers.debouncedHandleMedDealToggle(opponentId, opponentName, false, state.user.tornId, state.user.tornUsername, e.currentTarget);
} else if (isMedDealActive) {
medDealBtn.innerHTML = `${opponentMedDeal.medDealForUsername}`;
medDealBtn.classList.add('btn-med-deal-set');
medDealBtn.style.whiteSpace = 'normal';
medDealBtn.disabled = !(state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true);
if (!medDealBtn.disabled) medDealBtn.onclick = (e) => handlers.debouncedHandleMedDealToggle(opponentId, opponentName, false, opponentMedDeal.medDealForUserId, opponentMedDeal.medDealForUsername, e.currentTarget);
} else {
medDealBtn.innerHTML = 'Set Deal';
medDealBtn.classList.add('btn-med-deal-inactive');
medDealBtn.style.whiteSpace = 'normal';
medDealBtn.onclick = (state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true)
? (e) => ui.openMedDealSetterModal(opponentId, opponentName, e.currentTarget)
: (e) => handlers.debouncedHandleMedDealToggle(opponentId, opponentName, true, state.user.tornId, state.user.tornUsername, e.currentTarget);
}
buttonRow.appendChild(medDealBtn);
}
const noteContent = opponentNote?.noteContent || '';
const notesBtn = utils.createElement('button', { textContent: noteContent || 'Note', title: noteContent, className: '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', borderColor: '#004d4f !important', 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 retal-btn btn-retal-inactive', style: { minWidth: '70px', maxWidth: '70px', minHeight: '24px', boxSizing: 'border-box', fontSize: '0.75em', padding: '4px 6px', borderColor: '#004d4f !important', 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 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', borderColor: '#004d4f !important', 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);
// --- FIX END ---
} catch (error) {
console.error("[TDM] Error in attack page UI injection:", error);
attackContainer.innerHTML = '<p style="color: #ff6b6b;">Error loading attack page UI</p>';
}
utils.perf.stop('injectAttackPageUI');
},
sendAssistanceRequest: (message, opponentId, opponentName) => {
const opponentLink = `<a href="https://www.torn.com/loader.php?sid=attack&user2ID=${opponentId}" target="_blank">${opponentName}</a>`;
const fullMessage = `TDM - ${message} ${opponentLink}`;
const facId = state.user.factionId;
// Try to locate currently open chat textarea
let chatTextbox = document.querySelector('div.root___WUd1h textarea.textarea___V8HsV');
// Or get the faction button by id
const chatButton = document.querySelector(`#channel_panel_button\\:faction-${facId}`);
if (!chatTextbox && chatButton) {
chatButton.click();
setTimeout(() => ui.populateChatMessage(fullMessage),700);
} else if (chatTextbox) {
setTimeout(() => ui.populateChatMessage(fullMessage),500);
}
},
populateChatMessage(message) {
const chatTextArea = document.querySelector('textarea.textarea___V8HsV');
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
}
console.log('[TreeDibsMapper] Textarea value set and DOM mutation triggered.');
ui.showMessageBox('Message added to faction chat. Click send manually.', 'success');
} else {
console.log('[TreeDibsMapper] Chat textbox not found, message:', message);
document.execCommand('copy'); // Use document.execCommand('copy') for clipboard operations in iframes
ui.showMessageBox('Chat not found. Message copied to clipboard.', 'info');
}
},
sendRetaliationAlert: (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 opponentLink = `<a href="https://www.torn.com/loader.php?sid=attack&user2ID=${opponentId}" target="_blank">${opponentName}</a>`;
const fullMessage = `Retal Available ${opponentLink} time left: ${timeStr} Hospitalize`;
const facId = state.user.factionId;
let chatTextbox = document.querySelector('div.root___WUd1h textarea.textarea___V8HsV');
const chatButton = document.querySelector(`#channel_panel_button\\:faction-${facId}`);
if (!chatTextbox && chatButton) {
chatButton.click();
setTimeout(() => ui.populateChatMessage(fullMessage),700);
} else if (chatTextbox) {
setTimeout(() => ui.populateChatMessage(fullMessage),500);
}
},
updateRetaliationButton: (button, opponentId, opponentName) => {
// Clear any prior interval tied to this button
if (button._retalIntervalId) {
clearInterval(button._retalIntervalId);
button._retalIntervalId = null;
}
const show = () => {
button.style.display = 'inline-block';
button.disabled = false;
button.innerHTML = '';
button.className = 'btn retal-btn btn-retal-active';
button.onclick = () => ui.sendRetaliationAlert(opponentId, opponentName);
};
const hide = () => {
button.style.display = 'none';
button.disabled = true;
button.innerHTML = '';
button.className = 'btn retal-btn btn-retal-inactive';
if (button._retalIntervalId) {
clearInterval(button._retalIntervalId);
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 = setInterval(() => {
const stillActive = computeAndRender();
if (!stillActive) {
// interval cleared in hide(); just be safe
if (button._retalIntervalId) {
clearInterval(button._retalIntervalId);
button._retalIntervalId = null;
}
}
}, 1000);
},
openNoteModal: (tornID, tornUsername, currentNoteContent, buttonElement) => {
state.ui.currentNoteButtonElement = buttonElement;
if (state.ui.currentNoteButtonElement) {
state.ui.currentNoteButtonElement.dataset.originalText = state.ui.currentNoteButtonElement.textContent;
state.ui.currentNoteButtonElement.disabled = true;
state.ui.currentNoteButtonElement.innerHTML = '<span class="dibs-spinner"></span> Loading...';
}
if (!state.ui.noteModal) {
state.ui.noteModal = utils.createElement('div', { id: 'user-note-modal', style: { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', backgroundColor: '#1a1a1a', border: '1px solid #333', borderRadius: '8px', padding: '20px', zIndex: 10000, boxShadow: '0 4px 8px rgba(0,0,0,0.5)', maxWidth: '400px', width: '90%', color: 'white' } });
state.ui.noteModal.innerHTML = `
<h3 style="margin-top: 0;">Edit User Note for ${tornUsername} (${tornID})</h3>
<label for="note-textarea" style="display: block; margin-bottom: 5px;">Note Content:</label>
<textarea id="note-textarea" rows="8" style="width: calc(100% - 10px); background-color: #222; border: 1px solid #555; color: white; padding: 5px; border-radius: 4px; margin-bottom: 10px; resize: vertical;"></textarea>
<button id="save-note-button" style="background-color: #4CAF50; color: white; border: none; border-radius: 4px; padding: 8px 15px; cursor: pointer; margin-right: 10px;">Save</button>
<button id="cancel-note-button" style="background-color: #f44336; color: white; border: none; border-radius: 4px; padding: 8px 15px; cursor: pointer;">Cancel</button>
`;
document.body.appendChild(state.ui.noteModal);
state.ui.noteTextarea = document.getElementById('note-textarea');
document.getElementById('save-note-button').onclick = () => handlers.debouncedHandleSaveUserNote(state.ui.currentNoteTornID, state.ui.noteTextarea.value.trim(), state.ui.currentNoteButtonElement);
document.getElementById('cancel-note-button').onclick = ui.closeNoteModal;
}
state.ui.currentNoteTornID = tornID;
state.ui.currentNoteTornUsername = tornUsername;
state.ui.noteTextarea.value = currentNoteContent;
state.ui.noteModal.querySelector('h3').textContent = `Edit User Note for ${tornUsername} (${tornID})`;
state.ui.noteModal.style.display = 'block';
if (state.ui.currentNoteButtonElement) {
state.ui.currentNoteButtonElement.disabled = false;
}
},
closeNoteModal: () => {
if (state.ui.noteModal) state.ui.noteModal.style.display = 'none';
state.ui.currentNoteTornID = null;
state.ui.currentNoteTornUsername = null;
if (state.ui.currentNoteButtonElement) {
state.ui.currentNoteButtonElement.disabled = false;
state.ui.currentNoteButtonElement.textContent = state.ui.currentNoteButtonElement.dataset.originalText;
state.ui.currentNoteButtonElement = 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;
buttonElement.disabled = true;
buttonElement.innerHTML = '<span class="dibs-spinner"></span> Working...';
}
if (type === 'medDeal') {
const mds = (state.opponentStatuses || {})[opponentId];
const isMyMedDeal = !!(mds && mds.isMedDeal && String(mds.medDealForUserId) === String(state.user.tornId));
// Toggle med deal for current user
await handlers.debouncedHandleMedDealToggle(
opponentId,
opponentName,
!isMyMedDeal, // set if not mine; remove if mine
state.user.tornId,
state.user.tornUsername,
buttonElement
);
} 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) {
await handlers.debouncedRemoveDibsForTarget(opponentId, buttonElement);
} else {
await handlers.debouncedDibsTarget(opponentId, opponentName, buttonElement);
}
}
} catch (_) {
// noop; handlers already show messages
} finally {
if (buttonElement) {
buttonElement.disabled = false;
buttonElement.textContent = buttonElement.dataset.originalText || defaultText;
}
}
return; // do not open modal
}
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.currentButtonElement) {
state.ui.currentButtonElement.disabled = true;
state.ui.currentButtonElement.innerHTML = '<span class="dibs-spinner"></span> Loading...';
}
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 {
ui.populateSetterList();
} catch (error) {
console.error(`[TDM] 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;
if (state.ui.currentButtonElement) {
state.ui.currentButtonElement.disabled = false;
state.ui.currentButtonElement.textContent = buttonElement.dataset.originalText || defaultText;
}
}
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();
},
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 () => {
if (state.ui.currentSetterType === 'medDeal') {
await handlers.debouncedHandleMedDealToggle(state.ui.currentOpponentId, state.ui.currentOpponentName, true, member.id, member.name, state.ui.currentButtonElement);
} else {
await handlers.debouncedAssignDibs(state.ui.currentOpponentId, state.ui.currentOpponentName, member.id, member.name, state.ui.currentButtonElement);
}
ui.closeSetterModal();
}
});
state.ui.setterList.appendChild(li);
});
},
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';
});
},
showMessageBox: (message, type = 'info', duration = 5000, onClick = null) => {
const messageBox = utils.createElement('div', {
className: 'message-box-on-top',
textContent: message,
style: { backgroundColor: config.CSS.colors[type] || config.CSS.colors.info, zIndex: 9000000005 }
});
let isRemoved = false;
const removeMessageBox = () => {
if (isRemoved) return;
isRemoved = true;
messageBox.style.opacity = '0';
setTimeout(() => { if (messageBox.parentNode) messageBox.parentNode.removeChild(messageBox); }, 500);
};
messageBox.addEventListener('click', async (e) => {
e.preventDefault(); e.stopPropagation();
if (onClick) await onClick();
removeMessageBox();
});
document.body.appendChild(messageBox);
setTimeout(() => messageBox.style.opacity = '1', 10);
setTimeout(removeMessageBox, duration);
},
showConfirmationBox: (message, showCancel = true) => {
return new Promise(resolve => {
const confirmBox = utils.createElement('div', { style: { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', backgroundColor: '#1a1a1a', border: '1px solid #333', borderRadius: '8px', padding: '20px', zIndex: 10001, boxShadow: '0 4px 8px rgba(0,0,0,0.5)', maxWidth: '350px', width: '90%', color: 'white', textAlign: 'center' } });
const messagePara = utils.createElement('p', { style: { marginBottom: '20px' }, textContent: message });
const buttonsContainer = utils.createElement('div', { style: { display: 'flex', justifyContent: 'center' } });
const okButton = utils.createElement('button', { id: 'confirm-ok', style: { backgroundColor: config.CSS.colors.success, color: 'white', border: 'none', borderRadius: '4px', padding: '8px 15px', cursor: 'pointer', marginRight: showCancel ? '10px' : '0' }, textContent: showCancel ? 'Yes' : 'OK', onclick: () => { confirmBox.remove(); resolve(true); } });
buttonsContainer.appendChild(okButton);
if (showCancel) {
const cancelButton = utils.createElement('button', { id: 'confirm-cancel', style: { backgroundColor: config.CSS.colors.error, color: 'white', border: 'none', borderRadius: '4px', padding: '8px 15px', cursor: 'pointer' }, textContent: 'No', onclick: () => { confirmBox.remove(); resolve(false); } });
buttonsContainer.appendChild(cancelButton);
}
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 style="background: linear-gradient(to bottom, #00b300, #008000); border: 2px solid #ffcc00; border-radius: 4px; box-sizing: border-box; color: #ffffff; text-shadow: 1px 1px 1px #000000; 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, #ff5722, #e64a19); border: 2px solid #ffcc00; border-radius: 4px; box-sizing: border-box; color: #ffffff; text-shadow: 1px 1px 1px #000000; 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
},
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) {
settingsPopup.remove();
return;
}
const contentTitle = document.querySelector('div.content-title.m-bottom10');
const contentWrapper = document.querySelector('.content-wrapper');
if (!contentTitle && !contentWrapper) return;
// utils.perf.start('toggleSettingsPopup'); // Start timer
// Refresh latest faction settings for the panel
try {
const latest = await api.get('getFactionSettings', { factionId: state.user.factionId });
if (latest) state.script.factionSettings = latest;
} catch (e) { /* non-fatal */ }
settingsPopup = utils.createElement('div', { id: 'tdm-settings-popup', style: { width: '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' } });
const header = utils.createElement('div', { style: { padding: '10px', backgroundColor: config.CSS.colors.mainColor, 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}</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' } });
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();
// utils.perf.stop('toggleSettingsPopup'); // Stop timer
},
updateSettingsContent: () => {
const content = document.getElementById('tdm-settings-content');
if (!content) return;
// utils.perf.start('updateSettingsContent');
const warType = state.warData.warType || 'War Type Not Set';
const termType = state.warData.termType || 'Set Term Type';
const scoreCap = state.warData.scoreCap ?? 0;
const scoreType = state.warData.scoreType || 'Set Score Type';
const opponentFactionName = state.warData.opponentFactionName || state.lastOpponentFactionName;
const opponentFactionId = state.warData.opponentFactionId || state.lastOpponentFactionId;
const termedWarDisplay = warType === 'Termed War' ? 'block' : 'none';
const ocReminderEnabled = storage.get('ocReminderEnabled', true); // Default to true
// Determine admin status for settings edits
const isAdmin = !!state.script.canAdministerMedDeals && (storage.get('adminFunctionality', true) === true);
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 }
};
// Build new, compact, collapsible sections
content.innerHTML = `
<div class="settings-section collapsible" data-section="latest-war">
<div class="settings-header collapsible-header">Latest Ranked War Details <span class="chevron">▾</span></div>
<div class="collapsible-content">
<div style="margin-top: 4px; padding: 6px; background-color: #222; border-radius: 5px;">
<div id="war-type-container" style="margin-bottom: 4px; display: block;">
<label style="display: block; margin-bottom: 4px; color: #ccc;">War Type:</label>
${state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true ? `
<select id="war-type-select" class="settings-input">
<option value="" disabled ${!warType || warType === 'War Type Not Set' ? 'selected' : ''}>War Type 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="margin-bottom: 4px; display: ${termedWarDisplay};">
<label style="display: block; margin-bottom: 4px; color: #ccc;">Term Type:</label>
${state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true ? `
<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="margin-bottom: 4px; display: ${termedWarDisplay};">
<label style="display: block; margin-bottom: 4px; color: #ccc;">Score Cap:</label>
${state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true ? `
<input type="number" id="score-cap-input" value="${scoreCap}" min="0" class="settings-input">
` : `<div class="settings-input-display">${scoreCap}</div>`}
</div>
<div id="score-type-container" style="margin-bottom: 4px; display: ${termedWarDisplay};">
<label style="display: block; margin-bottom: 4px; color: #ccc;">Score Type:</label>
${state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true ? `
<select id="score-type-select" class="settings-input">
<option value="Attacks" ${scoreType === 'Attacks' ? 'selected' : ''}>Attacks</option>
<option value="Respect" ${scoreType === 'Respect' ? 'selected' : ''}>Respect</option>
<option value="Respect (no chain)" ${scoreType === 'Respect (no chain)' ? 'selected' : ''}>Respect (no chain)</option>
</select>
` : `<div class="settings-input-display">${scoreType}</div>`}
</div>
<div style="margin-bottom: 4px;">
<label style="display: block; margin-bottom: 4px; color: #ccc;">Opponent Faction:</label>
<div class="settings-input-display">${opponentFactionName} ${opponentFactionId ? `(ID: ${opponentFactionId})` : ''}</div>
</div>
<div style="margin-top: 6px; text-align: center;">
<button id="save-war-data-btn" class="settings-btn settings-btn-green" style="display: ${storage.get('adminFunctionality', true) ? 'inline-block' : 'none'};">Save War Data</button>
</div>
<div class="settings-subheader" style="margin-top:8px; margin-bottom:4px; color:#93c5fd; text-align:center;">Dibs Style (Faction)</div>
<div style="display:flex; gap:8px; flex-wrap:wrap; width:100%;">
<label style="flex:1; min-width:200px; color:#ccc;">Keep Dibs Until Inactive (unchecked = permanent)
<input type="checkbox" id="dibs-keep-inactive" ${dibsStyle.keepTillInactive ? 'checked' : ''} ${isAdmin ? '' : 'disabled'} style="margin-left:6px;" />
</label>
<label style="flex:1; min-width:220px; color:#ccc;">Inactivity Timeout (seconds)
<input type="number" id="dibs-inactivity-seconds" min="60" step="30" value="${parseInt(dibsStyle.inactivityTimeoutSeconds||300)}" ${isAdmin ? '' : 'disabled'} class="settings-input" />
</label>
<label style="flex:1; min-width:200px; color:#ccc;">Require Re-dib After Success
<input type="checkbox" id="dibs-redib-after-success" ${dibsStyle.mustRedibAfterSuccess ? 'checked' : ''} ${isAdmin ? '' : 'disabled'} style="margin-left:6px;" />
</label>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap; width:100%; margin-top:4px;">
<div style="flex:1; min-width:260px; color:#ccc;">
Allowed Opponent Statuses:
<div style="display:flex; gap:10px; flex-wrap:wrap; margin-top:6px;">
${['Okay','Hospital','Travel','Abroad','Jail'].map(s => `
<label style="color:#ccc;">
<input type="checkbox" class="dibs-allow-status" data-status="${s}" ${dibsStyle.allowStatuses?.[s] ? 'checked' : ''} ${isAdmin ? '' : 'disabled'} /> ${s}
</label>
`).join('')}
</div>
</div>
<div style="flex:1; min-width:260px; color:#ccc;">
Dib Opponent Max Hosp Release Time (minutes):
<div style="margin-top:6px;">
<input type="number" id="dibs-max-hosp-minutes" min="0" step="1" value="${Number(dibsStyle.maxHospitalReleaseMinutes||0)}" ${isAdmin ? '' : 'disabled'} class="settings-input" />
<div style="font-size:11px; color:#aaa; margin-top:2px;">Blank or 0 = no limit. If set (e.g. 5), only allow dib when Hospital release time is under this many minutes.</div>
</div>
</div>
<div style="flex:1; min-width:260px; color:#ccc;">
Allowed User Statuses:
<div style="display:flex; gap:10px; flex-wrap:wrap; margin-top:6px;">
${(() => { 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 style=\"color:#ccc;\">
<input type=\"checkbox\" class=\"dibs-allow-user-status\" data-status=\"${s}\" ${(aus[s] ?? dflt[s]) ? 'checked' : ''} ${isAdmin ? '' : 'disabled'} /> ${s}
</label>
`).join(''); })()}
</div>
</div>
<label style="flex:1; min-width:220px; color:#ccc;">Remove If Opponent Travels
<input type="checkbox" id="dibs-remove-on-fly" ${dibsStyle.removeOnFly ? 'checked' : ''} ${isAdmin ? '' : 'disabled'} style="margin-left:6px;" />
</label>
<label style="flex:1; min-width:220px; color:#ccc;">Remove If You Travel
<input type="checkbox" id="dibs-remove-user-travel" ${(dibsStyle.removeWhenUserTravels ? 'checked' : '')} ${isAdmin ? '' : 'disabled'} style="margin-left:6px;" />
</label>
</div>
<div style="margin-top:6px; text-align:center;">
<button id="save-dibs-style-btn" class="settings-btn settings-btn-green" style="display:${isAdmin ? 'inline-block' : 'none'};">Save 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 settings-section-divided collapsible" data-section="ranked-war-tools">
<div class="settings-header collapsible-header">Ranked War Tools <span class="chevron">▾</span></div>
<div class="collapsible-content" style="display:flex; gap:6px; align-items:center; flex-wrap:wrap;">
<select id="ranked-war-id-select" class="settings-input" style="flex-grow: 1;"><option value="">Loading wars...</option></select>
<button id="show-ranked-war-summary-btn" class="settings-btn settings-btn-green">War Summary</button>
<button id="view-war-attacks-btn" class="settings-btn settings-btn-blue">War Attacks</button>
</div>
</div>
<div class="settings-section settings-section-divided collapsible" data-section="column-visibility">
<div class="settings-header collapsible-header">Column Visibility <span class="chevron">▾</span></div>
<div class="collapsible-content">
<div id="column-visibility-buttons" class="settings-button-group"></div>
</div>
</div>
${state.script.canAdministerMedDeals && storage.get('adminFunctionality', true) === true ? `
<div class="settings-section settings-section-divided collapsible" data-section="admin-settings">
<div class="settings-header collapsible-header">Admin Settings <span class="chevron">▾</span></div>
<div class="collapsible-content">
<div class="settings-button-group">
<button id="admin-functionality-btn" class="column-toggle-btn ${storage.get('adminFunctionality', true) ? 'active' : 'inactive'}">Manage Others Dibs/Deals</button>
${storage.get('adminFunctionality', true) ? `
<button id="view-unauthorized-attacks-btn" class="settings-btn">View Unauthorized Attacks</button>
` : ''}
</div>
</div>
</div>` : ''}
<div class="settings-section settings-section-divided collapsible" data-section="general-settings">
<div class="settings-header collapsible-header">Settings <span class="chevron">▾</span></div>
<div class="collapsible-content">
<div class="settings-button-group">
<button id="chain-timer-btn" class="settings-btn ${storage.get('chainTimerEnabled', true) ? 'settings-btn-green' : 'settings-btn-red'}">Chain Timer: ${storage.get('chainTimerEnabled', true) ? 'Enabled' : 'Disabled'}</button>
<button id="inactivity-timer-btn" class="settings-btn ${storage.get('inactivityTimerEnabled', true) ? 'settings-btn-green' : 'settings-btn-red'}">Inactivity Timer: ${storage.get('inactivityTimerEnabled', true) ? 'Enabled' : 'Disabled'}</button>
<button id="opponent-status-btn" class="settings-btn ${storage.get('opponentStatusTimerEnabled', true) ? 'settings-btn-green' : 'settings-btn-red'}">Opponent Status: ${storage.get('opponentStatusTimerEnabled', true) ? 'Enabled' : 'Disabled'}</button>
<button id="api-usage-btn" class="settings-btn ${storage.get('apiUsageCounterEnabled', true) ? 'settings-btn-green' : 'settings-btn-red'}">API Counter: ${storage.get('apiUsageCounterEnabled', true) ? 'Shown' : 'Hidden'}</button>
<button id="oc-reminder-btn" class="settings-btn ${ocReminderEnabled ? 'settings-btn-green' : 'settings-btn-red'}">OC Reminder: ${ocReminderEnabled ? 'Enabled' : 'Disabled'}</button>
<button id="reset-settings-btn" class="settings-btn settings-btn-red">Reset All Settings</button>
<button id="tdm-adoption-btn" class="settings-btn settings-btn-blue">TDM Adoption Info</button>
</div>
</div>
</div>`;
// Re-attach all event listeners
document.getElementById('war-type-select')?.addEventListener('change', (e) => {
const isTermed = e.currentTarget.value === 'Termed War';
document.getElementById('term-type-container').style.display = isTermed ? 'block' : 'none';
document.getElementById('score-cap-container').style.display = isTermed ? 'block' : 'none';
document.getElementById('score-type-container').style.display = isTermed ? 'block' : 'none';
});
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.scoreCap = parseInt(document.getElementById('score-cap-input').value) || 0;
warDataToSave.scoreType = document.getElementById('score-type-select').value;
}
await handlers.debouncedSetFactionWarData(warDataToSave, e.currentTarget);
});
const visibilityButtonsContainer = document.getElementById('column-visibility-buttons');
if (visibilityButtonsContainer) {
const rankedWarHeaderId = 'ranked-war-header';
const membersListHeaderId = 'members-list-header';
const rankedWarColumns = [
{ key: 'lvl', label: 'Level' },
{ key: 'factionIcon', label: 'Faction Icon' }
];
const membersListColumns = [
{ key: 'lvl', label: 'Level' },
{ key: 'memberIcons', label: 'Member Icons' },
{ key: 'position', label: 'Position' },
{ key: 'days', label: 'Days' },
{ key: 'factionIcon', label: 'Faction Icon' },
{ key: 'dibsDeals', label: 'Dibs/Med Deals' },
{ key: 'notes', label: 'Notes' }
];
// Ranked War Header
if (!document.getElementById(rankedWarHeaderId)) {
const rankedWarHeader = utils.createElement('div', { id: rankedWarHeaderId, textContent: 'Ranked War Table Columns', className: 'settings-header', style: { marginTop: '4px' } });
visibilityButtonsContainer.appendChild(rankedWarHeader);
}
// Ranked War Buttons
rankedWarColumns.forEach(col => {
if (!visibilityButtonsContainer.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 button = utils.createElement('button', {
className: `column-toggle-btn ${active}`,
dataset: { column: col.key, table: 'rankedWar' },
textContent: col.label,
onclick: () => {
const vis = storage.get('columnVisibility', config.DEFAULT_COLUMN_VISIBILITY);
if (!vis.rankedWar) vis.rankedWar = {};
vis.rankedWar[col.key] = !vis.rankedWar[col.key];
storage.set('columnVisibility', vis);
ui.updateColumnVisibilityStyles();
ui.updateSettingsContent();
}
});
visibilityButtonsContainer.appendChild(button);
}
});
// Members List Header
if (!document.getElementById(membersListHeaderId)) {
const membersListHeader = utils.createElement('div', { id: membersListHeaderId, textContent: 'Members List Table Columns', className: 'settings-header', style: { marginTop: '4px' } });
visibilityButtonsContainer.appendChild(membersListHeader);
}
// Members List Buttons
membersListColumns.forEach(col => {
if (!visibilityButtonsContainer.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 button = utils.createElement('button', {
className: `column-toggle-btn ${active}`,
dataset: { column: col.key, table: 'membersList' },
textContent: col.label,
onclick: () => {
const vis = storage.get('columnVisibility', config.DEFAULT_COLUMN_VISIBILITY);
if (!vis.membersList) vis.membersList = {};
vis.membersList[col.key] = !vis.membersList[col.key];
storage.set('columnVisibility', vis);
ui.updateColumnVisibilityStyles();
ui.updateSettingsContent();
}
});
visibilityButtonsContainer.appendChild(button);
}
});
}
document.getElementById('admin-functionality-btn')?.addEventListener('click', () => {
storage.set('adminFunctionality', !storage.get('adminFunctionality', true));
ui.updateSettingsContent();
});
document.getElementById('view-unauthorized-attacks-btn')?.addEventListener('click', () => ui.showUnauthorizedAttacksModal());
// Collapsible toggles
document.querySelectorAll('#tdm-settings-content .collapsible-header').forEach(h => {
h.addEventListener('click', () => {
const sec = h.closest('.collapsible');
if (!sec) return;
sec.classList.toggle('collapsed');
});
});
// Attach event listeners
document.getElementById('chain-timer-btn')?.addEventListener('click', () => {
const cur = storage.get('chainTimerEnabled', true);
storage.set('chainTimerEnabled', !cur);
if (!cur) ui.ensureChainTimer(); else ui.removeChainTimer();
ui.updateSettingsContent();
});
document.getElementById('inactivity-timer-btn')?.addEventListener('click', () => {
const cur = storage.get('inactivityTimerEnabled', true);
storage.set('inactivityTimerEnabled', !cur);
if (!cur) ui.ensureInactivityTimer(); else ui.removeInactivityTimer();
ui.updateSettingsContent();
});
document.getElementById('opponent-status-btn')?.addEventListener('click', () => {
const cur = storage.get('opponentStatusTimerEnabled', true);
storage.set('opponentStatusTimerEnabled', !cur);
if (!cur) ui.ensureOpponentStatus(); else ui.removeOpponentStatus();
ui.updateSettingsContent();
});
document.getElementById('api-usage-btn')?.addEventListener('click', () => {
const cur = storage.get('apiUsageCounterEnabled', true);
storage.set('apiUsageCounterEnabled', !cur);
if (!cur) ui.ensureApiUsageBadge();
ui.updateApiUsageBadge();
ui.updateSettingsContent();
});
document.getElementById('oc-reminder-btn')?.addEventListener('click', () => {
const currentState = storage.get('ocReminderEnabled', true);
storage.set('ocReminderEnabled', !currentState);
ui.updateSettingsContent(); // Re-render settings
});
document.getElementById('reset-settings-btn')?.addEventListener('click', async () => {
if (await ui.showConfirmationBox('Are you sure you want to reset all settings?')) {
Object.keys(localStorage).filter(k => k.startsWith('tdm_')).forEach(k => localStorage.removeItem(k));
ui.showMessageBox('Settings reset. Reloading...', 'info');
setTimeout(() => location.reload(), 1500);
}
});
document.getElementById('tdm-adoption-btn')?.addEventListener('click', ui.showTDMAdoptionModal);
// 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 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,
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,
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');
} 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');
if (rankedWarSelect && showRankedWarSummaryBtn && viewWarAttacksBtn) {
showRankedWarSummaryBtn.disabled = true;
viewWarAttacksBtn.disabled = true;
(async () => {
try {
const rankedWars = state.rankWars.length > 0 ? state.rankWars : await api.get('getRankedWars', { factionId: state.user.factionId });
state.rankWars = rankedWars;
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) {
console.error("[TDM] Error loading ranked wars into settings panel:", error);
rankedWarSelect.innerHTML = '<option value="">Error loading wars</option>';
}
})();
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 {
// This single GET call now updates and retrieves the summary from the backend
const summary = await api.get('rankedWarSummary', { rankedWarId: selectedWarId, factionId: state.user.factionId });
ui.showRankedWarSummaryModal(summary, selectedWarId);
} catch (error) {
ui.showMessageBox(`Error fetching war summary: ${error.message || 'Unknown error'}`, 'error');
console.error("[TDM] 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');
console.error("[TDM] War Attacks Error:", error);
} finally {
viewWarAttacksBtn.disabled = false;
viewWarAttacksBtn.textContent = 'War Attacks';
}
});
}
// utils.perf.stop('updateSettingsContent');
},
applyGeneralStyles: () => {
if (document.getElementById('dibs-general-styles')) return;
const styleTag = utils.createElement('style', {
type: 'text/css',
id: 'dibs-general-styles',
textContent: `
/* --- Main Controls Container --- */
.tdm-controls-container {
min-width: 120px;
max-width: 120px;
padding: 2px !important;
display: flex;
gap: 2px;
flex-direction: row;
}
/* Make the notes column fill the space on our own faction page */
.f-war-list .table-body > li:has(.dibs-cell[style*="display: none"]) .notes-cell {
width: 100%;
}
/* Header for the controls column */
#col-header-dibs-notes {
min-width: 120px;
max-width: 120px;
}
/* --- 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 */
}
/* --- Button Styling --- */
.tdm-controls-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-controls-container .med-deal-button {
white-space: normal; /* Allow med deal text to wrap */
}
.tdm-controls-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, #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 button, .notes-cell button, .notes-container button, .notes-container .btn, #col-header-notes button { flex: 0 0 48%; width: 100% !important; height: 48% !important; margin: 1%; box-sizing: border-box; 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: 0 0 100%; margin: 0; width: 100% !important; height: 100% !important; }
/* Button Colors */
.btn-dibs-inactive { background-color: ${config.CSS.colors.dibsInactive} !important; color: #fff !important; }
.btn-dibs-inactive:hover { background-color: ${config.CSS.colors.dibsInactiveHover} !important; }
.btn-dibs-success-you { background-color: ${config.CSS.colors.dibsSuccess} !important; color: #fff !important; }
.btn-dibs-success-you:hover { background-color: ${config.CSS.colors.dibsSuccessHover} !important; }
.btn-dibs-success-other { background-color: ${config.CSS.colors.dibsOther} !important; color: #fff !important; }
.btn-dibs-success-other:hover { background-color: ${config.CSS.colors.dibsOtherHover} !important; }
.inactive-note-button, .note-button { background-color: ${config.CSS.colors.noteInactive} !important; color: #fff !important; }
.inactive-note-button:hover, .note-button:hover { background-color: ${config.CSS.colors.noteInactiveHover} !important; }
.active-note-button { background-color: ${config.CSS.colors.noteActive} !important; color: #fff !important; }
.active-note-button:hover { background-color: ${config.CSS.colors.noteActiveHover} !important; }
.btn-med-deal-inactive, .btn-med-deal-default { background-color: ${config.CSS.colors.medDealInactive} !important; color: #fff !important; }
.btn-med-deal-inactive:hover, .btn-med-deal-default:hover { background-color: ${config.CSS.colors.medDealInactiveHover} !important; }
.btn-med-deal-set { background-color: ${config.CSS.colors.medDealSet} !important; color: #fff !important; }
.btn-med-deal-set:hover { background-color: ${config.CSS.colors.medDealSetHover} !important; }
.btn-med-deal-mine { background-color: ${config.CSS.colors.medDealMine} !important; color: #fff !important; }
.btn-med-deal-mine:hover { background-color: ${config.CSS.colors.medDealMineHover} !important; }
.req-assist-button { background-color: ${config.CSS.colors.assistButton} !important; color: #fff !important; }
.req-assist-button:hover { background-color: ${config.CSS.colors.assistButtonHover} !important; }
.btn-retal-inactive { background-color: #555555 !important; color: #ccc !important; }
.btn-retal-inactive:hover { background-color: #444444 !important; color: #ddd !important; }
.btn-retal-active { background-color: #ff5722 !important; color: #fff !important; }
.btn-retal-active:hover { background-color: #e64a19 !important; color: #fff !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: inherit !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; }
.members-list > 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 3px; border-radius: 3px; margin-left: 0; margin-right: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Settings Panel Styles (Compacted) */
.settings-section { margin-bottom: 4px; }
.settings-section-divided { padding-top: 6px; border-top: 1px solid #374151; display: flex; gap: 6px; justify-content: center; align-items: center; flex-wrap: wrap; }
.settings-header { font-size: 12px; font-weight: 600; margin: 0 0 6px 0; text-align: center; color: #93c5fd; background-color: #1a1a1a; padding: 4px; border-radius: 4px; width: 100%; user-select: none; }
.settings-subheader { font-size: 12px; font-weight: 600; }
.settings-button-group { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; width: 100%; }
.settings-input, .settings-input-display { width: 100%; padding: 4px; background-color: #333; color: white; border: 1px solid #555; border-radius: 4px; box-sizing: border-box; }
.settings-input-display { padding: 6px 4px; }
.settings-btn { background-color: #4b5563; color: #eee; border: 1px solid #6b7280; padding: 5px 10px; font-size: 0.85em; border-radius: 5px; cursor: pointer; transition: background-color 0.2s; }
.settings-btn:hover { background-color: #606d7a; }
.settings-btn-green { background-color: #4CAF50; border-color: #4CAF50; }
.settings-btn-green:hover { background-color: #45a049; }
.settings-btn-red { background-color: #f44336; border-color: #f44336; }
.settings-btn-red:hover { background-color: #e53935; }
.war-type-controls { display: flex; gap: 8px; justify-content: center; margin-bottom: 15px; }
.war-type-controls .settings-btn { flex: 1; }
.column-toggle-btn { padding: 8px 16px; border: 2px solid #555; border-radius: 6px; background: #2c2c2c; color: #ccc; cursor: pointer; transition: all 0.3s ease; font-size: 0.9em; font-weight: 500; min-width: 100px; text-align: center; }
.column-toggle-btn.active { background: #4CAF50; border-color: #4CAF50; color: white; }
.column-toggle-btn.active:hover { background: #45a049; border-color: #45a049; color: white; }
.column-toggle-btn.inactive { background: #f44336; border-color: #f44336; color: white; }
/* Collapsible sections */
.collapsible .collapsible-header { cursor: pointer; position: relative; }
.collapsible .chevron { float: right; font-size: 12px; opacity: 0.8; }
.collapsible.collapsed .collapsible-content { display: none !important; }
.collapsible.collapsed .chevron { transform: rotate(-90deg); }
/* 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; }
`
});
document.head.appendChild(styleTag);
},
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 = '';
// Update column visibility for both tables using table-specific keys
const vis = storage.get('columnVisibility', config.DEFAULT_COLUMN_VISIBILITY);
// Members List selectors
const membersSelectors = {
lvl: ['.f-war-list .table-header .lvl.torn-divider.divider-vertical', '.f-war-list .table-body .lvl', '.f-war-list .table-header .level', '.f-war-list .table-body .level'],
memberIcons: ['.f-war-list .table-header .member-icons.torn-divider.divider-vertical', '.f-war-list .table-body .member-icons'],
position: ['.f-war-list .table-header .position', '.f-war-list .table-body .position'],
days: ['.f-war-list .table-header .days', '.f-war-list .table-body .days'],
factionIcon: ['.f-war-list .table-header .factionWrap___GhZMa', '.f-war-list .table-body .factionWrap___GhZMa', '.table-row .factionWrap___GhZMa'],
dibsDeals: ['.f-war-list .table-header #col-header-dibs', '.f-war-list .table-body .dibs-cell', '.f-war-list .table-header #col-header-med-deal', '.f-war-list .table-body .med-deal-button'],
notes: ['.f-war-list .table-header #col-header-notes', '.f-war-list .table-body .notes-cell']
};
// Ranked War selectors
const rankedWarSelectors = {
lvl: ['.faction-war .table-header .level', '.faction-war .table-body .level'],
factionIcon: ['.faction-war .factionWrap___GhZMa']
};
// Hide columns for Members List
for (const colName in membersSelectors) {
if (vis.membersList?.[colName] === false) {
const selectors = membersSelectors[colName];
if (selectors) css += `${selectors.join(', ')} { display: none !important; }\n`;
}
}
// 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`;
}
}
styleTag.textContent = css;
},
showCurrentWarAttacksModal: async function(warId) {
let modal = document.getElementById('current-war-attacks-modal');
if (!modal) {
modal = utils.createElement('div', {
id: 'current-war-attacks-modal',
style: {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: config.CSS.colors.modalBg,
border: `1px solid ${config.CSS.colors.modalBorder}`,
borderRadius: '8px',
padding: '20px',
zIndex: 10002,
boxShadow: '0 4px 8px rgba(0,0,0,0.5)',
maxWidth: '90%',
width: '1000px',
color: 'white',
maxHeight: '80vh',
overflowY: 'auto'
}
});
document.body.appendChild(modal);
}
modal.innerHTML = `
<h3 style="margin-top: 0; text-align: center;">War Attacks (ID: ${warId})</h3>
<p style="text-align: center; margin-bottom: 7px;"><span class="dibs-spinner"></span> Loading war attacks...</p>
`;
modal.style.display = 'block';
try {
const allAttacks = await api.get('getRankedWarAttacksFromFirestore', { rankedWarId: warId, factionId: state.user.factionId });
if (!allAttacks || allAttacks.length === 0) {
modal.innerHTML = `
<h3 style="margin-top: 0; text-align: center;">War Attacks (ID: ${warId})</h3>
<p>No attacks found for this war.</p>
<div style="text-align:center;"><button id="close-attacks-modal" class="settings-btn settings-btn-red">Close</button></div>
`;
modal.querySelector('#close-attacks-modal').addEventListener('click', () => { modal.style.display = 'none'; });
return;
}
// --- Sorting, Filtering, Records per page ---
let currentPage = 1;
let attacksPerPage = 50;
let sortColumn = 'attackTime';
let sortDirection = 'desc';
let filterAttacker = '';
let filterDefender = '';
// Unique attacker/defender names
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();
function getFilteredSortedAttacks() {
let filtered = allAttacks.filter(a => {
let attackerMatch = !filterAttacker || a.attacker?.name === filterAttacker;
let defenderMatch = !filterDefender || a.defender?.name === filterDefender;
return attackerMatch && defenderMatch;
});
filtered = filtered.slice(); // shallow copy
filtered.sort((a, b) => {
let valA, valB;
switch (sortColumn) {
case 'attackTime':
valA = (a.ended || a.started) || 0;
valB = (b.ended || b.started) || 0;
break;
case 'attacker':
valA = a.attacker?.name || '';
valB = b.attacker?.name || '';
break;
case 'defender':
valA = a.defender?.name || '';
valB = b.defender?.name || '';
break;
case 'result':
valA = a.result || '';
valB = b.result || '';
break;
default:
valA = (a.ended || a.started) || 0;
valB = (b.ended || b.started) || 0;
}
if (valA < valB) return sortDirection === 'asc' ? -1 : 1;
if (valA > valB) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
return filtered;
}
function renderPage(page) {
currentPage = page;
const filteredAttacks = getFilteredSortedAttacks();
const totalPages = Math.max(1, Math.ceil(filteredAttacks.length / attacksPerPage));
const start = (currentPage - 1) * attacksPerPage;
const end = start + attacksPerPage;
const pageAttacks = filteredAttacks.slice(start, end);
// --- Controls ---
let controlsHTML = `
<div style="display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<div>
<label for="sort-column">Sort by: </label>
<select id="sort-column" class="settings-input" style="width: auto;">
<option value="attackTime" ${sortColumn === 'attackTime' ? 'selected' : ''}>Time</option>
<option value="attacker" ${sortColumn === 'attacker' ? 'selected' : ''}>Attacker</option>
<option value="defender" ${sortColumn === 'defender' ? 'selected' : ''}>Defender</option>
<option value="result" ${sortColumn === 'result' ? 'selected' : ''}>Result</option>
</select>
<select id="sort-direction" class="settings-input" style="width: auto;">
<option value="desc" ${sortDirection === 'desc' ? 'selected' : ''}>Descending</option>
<option value="asc" ${sortDirection === 'asc' ? 'selected' : ''}>Ascending</option>
</select>
</div>
<div>
<label for="attacks-per-page">Records per page: </label>
<input id="attacks-per-page" type="number" min="1" max="5000" value="${attacksPerPage}" style="width: 60px;" class="settings-input">
</div>
<div>
<label for="filter-attacker">Attacker: </label>
<select id="filter-attacker" class="settings-input" style="width: auto;">
<option value="">All</option>
${uniqueAttackers.map(n => `<option value="${n}" ${filterAttacker === n ? 'selected' : ''}>${n}</option>`).join('')}
</select>
<label for="filter-defender">Defender: </label>
<select id="filter-defender" class="settings-input" style="width: auto;">
<option value="">All</option>
${uniqueDefenders.map(n => `<option value="${n}" ${filterDefender === n ? 'selected' : ''}>${n}</option>`).join('')}
</select>
</div>
</div>
`;
// --- Table ---
let tableHTML = `<table class="summary-table" style="width: 100%; border-collapse: collapse;">
<thead><tr>
<th>Time</th>
<th>Attacker</th>
<th>Defender</th>
<th>Result</th>
<th>Log</th>
</tr></thead>
<tbody>`;
pageAttacks.forEach(attack => {
const ourId = state.user.factionId;
const attackerIsOurs = attack.attacker?.faction?.id?.toString() === ourId;
const defenderIsOurs = attack.defender?.faction?.id?.toString() === ourId;
const attackTime = new Date((attack.ended || attack.started) * 1000).toLocaleString();
tableHTML += `
<tr style="background-color: #222; color: #c9c9c9ff;">
<td style="color: #ffffff;">${attackTime}</td>
<td><a href="/profiles.php?XID=${attack.attacker.id}" target="_blank" style="color: ${attackerIsOurs ? '#007204ff' : '#ff0000ff'}; font-weight: bold; text-decoration: underline;">${attack.attacker.name}</a></td>
<td><a href="/profiles.php?XID=${attack.defender.id}" target="_blank" style="color: ${defenderIsOurs ? '#007204ff' : '#ff0000ff'}; font-weight: bold; text-decoration: underline;">${attack.defender.name}</a></td>
<td style="color: #ffffff;">${attack.result || 'Unknown'}</td>
<td style="color: #ffffff;">${attack.code ? `<a href=\"https://www.torn.com/loader.php?sid=attackLog&ID=${attack.code}\" target=\"_blank\">View</a>` : 'N/A'}</td>
</tr>`;
});
tableHTML += `</tbody></table>`;
// --- Pagination ---
const paginationHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px;">
<button id="prev-page-btn" class="settings-btn" ${currentPage === 1 ? 'disabled' : ''}>Previous</button>
<span>Page ${currentPage} of ${totalPages} (${filteredAttacks.length} total)</span>
<button id="next-page-btn" class="settings-btn" ${currentPage >= totalPages ? 'disabled' : ''}>Next</button>
</div>`;
modal.innerHTML = `
<h3 style="margin-top: 0; text-align: center;">War Attacks (ID: ${warId})</h3>
${controlsHTML}
<div id="attacks-table-container">${tableHTML}</div>
${paginationHTML}
<div style="text-align: center; margin-top: 15px;">
<button id="close-attacks-modal" class="settings-btn settings-btn-red">Close</button>
</div>
`;
// --- Event Listeners ---
modal.querySelector('#close-attacks-modal').addEventListener('click', () => { modal.style.display = 'none'; });
modal.querySelector('#prev-page-btn').addEventListener('click', () => renderPage(currentPage - 1));
modal.querySelector('#next-page-btn').addEventListener('click', () => renderPage(currentPage + 1));
modal.querySelector('#sort-column').addEventListener('change', e => {
sortColumn = e.target.value;
renderPage(1);
});
modal.querySelector('#sort-direction').addEventListener('change', e => {
sortDirection = e.target.value;
renderPage(1);
});
modal.querySelector('#attacks-per-page').addEventListener('change', e => {
let val = parseInt(e.target.value) || 1;
attacksPerPage = Math.max(1, Math.min(500, val));
renderPage(1);
});
modal.querySelector('#filter-attacker').addEventListener('change', e => {
filterAttacker = e.target.value;
renderPage(1);
});
modal.querySelector('#filter-defender').addEventListener('change', e => {
filterDefender = e.target.value;
renderPage(1);
});
}
renderPage(1);
} catch (e) {
modal.innerHTML = `
<h3 style="margin-top: 0; text-align: center;">War Attacks (ID: ${warId})</h3>
<p style="color:${config.CSS.colors.error};">Error loading attacks: ${e.message}</p>
<div style="text-align:center;"><button id="close-attacks-modal" class="settings-btn settings-btn-red">Close</button></div>
`;
modal.querySelector('#close-attacks-modal').addEventListener('click', () => { modal.style.display = 'none'; });
}
},
showUnauthorizedAttacksModal: async function() {
// Ensure unauthorized attacks are loaded
if (!state.unauthorizedAttacks || !state.unauthorizedAttacks.length) {
await handlers.fetchUnauthorizedAttacks();
}
let modal = document.getElementById('unauthorized-attacks-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'unauthorized-attacks-modal';
modal.style.cssText = `
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background-color: #1a1a1a; border: 1px solid #333; border-radius: 8px;
padding: 20px; z-index: 10002; box-shadow: 0 4px 8px rgba(0,0,0,0.5);
max-width: 800px; width: 90%; color: white; max-height: 80vh; overflow-y: auto;
`;
document.body.appendChild(modal);
}
let html = `<h3 style="margin-top: 0; text-align: center;">Unauthorized Attacks</h3><span id="unauthorized-attacks-close" style="cursor: pointer; font-size: 18px;">×</span>
<table style="width:100%;color:white;"><tr><th>Time</th><th>Attacker</th><th>Defender</th><th>Violation</th><th>Result</th></tr>`;
(state.unauthorizedAttacks || []).forEach(a => {
html += `<tr>
<td style="color: #ffffff;">${new Date(a.attackTime * 1000).toLocaleString()}</td>
<td><a href="https://www.torn.com/profiles.php?XID=${a.attackerUserId}" target="_blank" style="color: #ff6b6b; text-decoration: underline; font-weight: bold;">${a.attackerUsername || a.attackerUserId}</a></td>
<td><a href="https://www.torn.com/profiles.php?XID=${a.defenderUserId}" target="_blank" style="color: #ffffff; text-decoration: underline; font-weight: bold;">${a.defenderUsername || a.defenderUserId}</a></td>
<td style="color: #ffffff;">${a.violationType}</td>
<td style="color: #ffffff;">${a.result || ''}</td>
</tr>`;
});
html += `</table>
<div style="text-align:center;"><button id="close-unauthorized-attacks-btn" style="background:#f44336;color:white;border:none;border-radius:4px;padding:8px 15px;cursor:pointer;font-weight:bold;z-index:10001;">Close</button></div>`;
modal.innerHTML = html;
document.getElementById('unauthorized-attacks-close').onclick = () => { modal.style.display = 'none'; };
document.getElementById('close-unauthorized-attacks-btn').onclick = () => { modal.style.display = 'none'; };
modal.style.display = 'block';
},
showRankedWarSummaryModal: function(summaryData, rankedWarId) {
// Use the utility function to create the modal if it doesn't exist
let modal = document.getElementById('ranked-war-summary-modal');
if (!modal) {
modal = utils.createElement('div', {
id: 'ranked-war-summary-modal',
style: {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: config.CSS.colors.modalBg,
border: `1px solid ${config.CSS.colors.modalBorder}`,
borderRadius: '8px',
padding: '20px',
zIndex: 10002,
boxShadow: '0 4px 8px rgba(0,0,0,0.5)',
maxWidth: '90%',
width: '1000px',
color: 'white',
maxHeight: '80vh',
overflowY: 'auto'
}
});
document.body.appendChild(modal);
}
// Handle case where no data is available
if (!summaryData || !Array.isArray(summaryData) || summaryData.length === 0) {
modal.innerHTML = `
<h3 style="margin-top: 0; text-align: center;">Ranked War Summary (ID: ${rankedWarId})</h3>
<p style="text-align: center; color: ${config.CSS.colors.error}; margin-bottom: 15px;">
No summary data available for this war.
</p>
<div style="text-align: center;">
<button id="close-ranked-war-summary-btn" class="settings-btn settings-btn-red">Close</button>
</div>
`;
modal.querySelector('#close-ranked-war-summary-btn').addEventListener('click', () => modal.style.display = 'none');
modal.style.display = 'block';
return;
}
// Group data by faction
const factionGroups = {};
summaryData.forEach(attacker => {
const factionId = attacker.attackerFaction || 'Unknown';
const factionName = attacker.attackerFactionName || 'Unknown Faction';
if (!factionGroups[factionId]) {
factionGroups[factionId] = {
name: factionName,
attackers: [],
totalAttacks: 0,
totalRespect: 0,
totalRespectNoChain: 0,
totalAssists: 0
};
}
factionGroups[factionId].attackers.push(attacker);
factionGroups[factionId].totalAttacks += attacker.totalAttacks || 0;
factionGroups[factionId].totalRespect += attacker.totalRespectGain || 0;
factionGroups[factionId].totalRespectNoChain += attacker.totalRespectGainNoChain || 0;
factionGroups[factionId].totalAssists += attacker.assistCount || 0;
});
// Build modal content with template literals
let contentHTML = `
<h3 style="margin-top: 0; text-align: center;">Ranked War Summary (ID: ${rankedWarId})</h3>
<div style="display: flex; justify-content: space-between; margin-bottom: 10px; align-items: center;">
<div>
<label for="sort-column">Sort by: </label>
<select id="sort-column" class="settings-input" style="width: auto;">
<option value="attackerName">Name</option>
<option value="totalAttacks" selected>Attacks</option>
<option value="totalRespectGain">Respect</option>
<option value="totalRespectGainNoChain">Respect (No Chain)</option>
<option value="averageRespectGain">Avg Respect</option>
<option value="averageRespectGainNoChain">Avg Respect (No Chain)</option>
<option value="assistCount">Assists</option>
<option value="averageModifiers.fair_fight">Fair Fight</option>
</select>
</div>
<div>
<label for="sort-direction">Direction: </label>
<select id="sort-direction" class="settings-input" style="width: auto;">
<option value="desc" selected>Descending</option>
<option value="asc">Ascending</option>
</select>
</div>
</div>
<div id="faction-tabs-container" style="display: flex; margin-bottom: 10px; border-bottom: 1px solid #444;"></div>
<div id="faction-tables-container"></div>
<div style="text-align: center; margin-top: 15px;">
<button id="close-ranked-war-summary-btn" class="settings-btn settings-btn-red">Close</button>
</div>
`;
modal.innerHTML = contentHTML;
const tabsContainer = modal.querySelector('#faction-tabs-container');
const tablesContainer = modal.querySelector('#faction-tables-container');
// Create faction tabs and tables
Object.keys(factionGroups).forEach((factionId, index) => {
const faction = factionGroups[factionId];
const isActive = index === 0;
// Create Tab Button
const tabButton = utils.createElement('button', {
'data-faction-id': factionId,
className: `faction-tab ${isActive ? 'active-tab' : ''}`,
textContent: `${faction.name} (${faction.attackers.length})`,
style: `flex: 1; padding: 8px; background-color: ${isActive ? config.CSS.colors.success : '#555'}; color: white; border: none; border-top-left-radius: 4px; border-top-right-radius: 4px; cursor: pointer;`
});
tabsContainer.appendChild(tabButton);
// Create Table Container
const tableContainer = utils.createElement('div', {
id: `faction-table-${factionId}`,
className: 'faction-table',
style: { display: isActive ? 'block' : 'none' },
innerHTML: `
<div style="margin-bottom: 5px; text-align: right; font-size: 0.9em;">
<span style="margin-right: 5px;"><strong>Total Attacks:</strong> ${faction.totalAttacks}</span>
<span style="margin-right: 5px;"><strong>Total Respect:</strong> ${faction.totalRespect.toFixed(2)}</span>
<span><strong>Total Assists:</strong> ${faction.totalAssists}</span>
</div>
<table class="summary-table" style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th data-sort="attackerName">Name</th>
<th data-sort="totalAttacks">Attacks</th>
<th data-sort="totalRespectGain">Respect</th>
<th data-sort="totalRespectGainNoChain">RespectNoChain</th>
<th data-sort="averageRespectGain">Avg Respect</th>
<th data-sort="averageRespectGainNoChain">AvgRespectNoChain</th>
<th data-sort="assistCount">Assists</th>
<th data-sort="averageModifiers.fair_fight">FF</th>
</tr>
</thead>
<tbody id="summary-tbody-${factionId}"></tbody>
</table>
`
});
tablesContainer.appendChild(tableContainer);
});
// Function to sort data and update table rows
function sortAndUpdateTables() {
const sortColumn = modal.querySelector('#sort-column').value;
const sortDirection = modal.querySelector('#sort-direction').value;
Object.keys(factionGroups).forEach(factionId => {
const faction = factionGroups[factionId];
const tbody = modal.querySelector(`#summary-tbody-${factionId}`);
if (!tbody) return;
// Sort attackers based on selected column and direction
const sortedAttackers = [...faction.attackers].sort((a, b) => {
let aValue = sortColumn.includes('.') ? a.averageModifiers?.[sortColumn.split('.')[1]] || 0 : a[sortColumn] || 0;
let bValue = sortColumn.includes('.') ? b.averageModifiers?.[sortColumn.split('.')[1]] || 0 : b[sortColumn] || 0;
if (typeof aValue === 'string') {
return sortDirection === 'asc' ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue);
}
return sortDirection === 'asc' ? aValue - bValue : bValue - aValue;
});
// Populate table body
tbody.innerHTML = '';
sortedAttackers.forEach(attacker => {
const row = utils.createElement('tr', {
innerHTML: `
<td><a href="/profiles.php?XID=${attacker.attackerId}" target="_blank">${attacker.attackerName || `ID ${attacker.attackerId}`}</a></td>
<td>${attacker.totalAttacks || 0}</td>
<td>${(attacker.totalRespectGain || 0).toFixed(2)}</td>
<td>${(attacker.totalRespectGainNoChain || 0).toFixed(2)}</td>
<td>${(attacker.averageRespectGain || 0).toFixed(2)}</td>
<td>${(attacker.averageRespectGainNoChain || 0).toFixed(2)}</td>
<td>${attacker.assistCount || 0}</td>
<td>${(attacker.averageModifiers?.fair_fight || 0).toFixed(2)}</td>
`
});
tbody.appendChild(row);
});
});
}
// Add Event Listeners
modal.querySelector('#close-ranked-war-summary-btn').addEventListener('click', () => modal.style.display = 'none');
modal.querySelector('#sort-column').addEventListener('change', sortAndUpdateTables);
modal.querySelector('#sort-direction').addEventListener('change', sortAndUpdateTables);
modal.querySelectorAll('.faction-tab').forEach(tab => {
tab.addEventListener('click', (e) => {
const selectedFactionId = e.currentTarget.dataset.factionId;
modal.querySelectorAll('.faction-tab').forEach(t => {
t.classList.remove('active-tab');
t.style.backgroundColor = '#555';
});
e.currentTarget.classList.add('active-tab');
e.currentTarget.style.backgroundColor = config.CSS.colors.success;
modal.querySelectorAll('.faction-table').forEach(table => {
table.style.display = table.id === `faction-table-${selectedFactionId}` ? 'block' : 'none';
});
});
});
// Add styles for the modal table
const styleId = 'ranked-summary-modal-styles';
if (!document.getElementById(styleId)) {
const style = utils.createElement('style', {
id: styleId,
textContent: `
#ranked-war-summary-modal .summary-table th { padding: 2px; text-align: center; border-bottom: 1px solid #444; cursor: pointer; }
#ranked-war-summary-modal .summary-table th:hover { background-color: #333; }
#ranked-war-summary-modal .summary-table td { padding: 4px; border-bottom: 1px solid #333; text-align: center; color: #c9c9c9ff }
#ranked-war-summary-modal .summary-table td:first-child { text-align: left; }
#ranked-war-summary-modal .summary-table a { color: ${config.CSS.colors.success}; text-decoration: none; }
#ranked-war-summary-modal .summary-table a:hover { text-decoration: underline; }
#ranked-war-summary-modal .active-tab { font-weight: bold; }
`
});
document.head.appendChild(style);
}
// Initial population and display
sortAndUpdateTables();
modal.style.display = 'block';
},
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(clearInterval);
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="https://www.torn.com/loader.php?sid=attack&user2ID=${opp.attackerId}" target="_blank" style="color:#ff6b6b;font-weight:bold;">${opp.attackerName}</a>
<span> attacked ${opp.defenderName} - </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
clearInterval(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 = 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(clearInterval);
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() {
let modal = document.getElementById('tdm-adoption-modal');
if (!modal) {
modal = utils.createElement('div', {
id: 'tdm-adoption-modal',
style: {
position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
backgroundColor: config.CSS.colors.modalBg, border: `2px solid ${config.CSS.colors.modalBorder}`,
borderRadius: '4px', padding: '4px', zIndex: 10000, color: 'white',
width: '90%', maxWidth: '1000px', minWidth: '320px',
maxHeight: '80vh', overflowY: 'auto', overflowX: 'auto', boxShadow: '0 4px 8px rgba(0,0,0,0.5)'
}
});
document.body.appendChild(modal);
}
modal.innerHTML = `
<button class='tdm-adoption-close' style='position:absolute;top:8px;right:12px;background:${config.CSS.colors.error};color:white;border:none;border-radius:4px;padding:4px 10px;cursor:pointer;font-weight:bold;z-index:10001;'>X</button>
<h2 style='margin-top:0;'>Faction TDM Adoption</h2>
<div id='tdm-adoption-loading'>Loading adoption stats...</div>
`;
modal.style.display = 'block';
// Bind close button early so it works even if we return on error
if (!modal._tdmCloseBound) {
modal.addEventListener('click', (event) => {
if (event.target.classList.contains('tdm-adoption-close')) {
modal.style.display = 'none';
}
});
modal._tdmCloseBound = true;
}
// 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 });
// Defensive: ensure array
tdmUsers = Array.isArray(apiUsers) ? apiUsers : [];
// Convert lastVerified safely (avoid reading undefined._seconds)
tdmUsers = tdmUsers.map(u => ({
...u,
lastVerified: toSafeDate(u.lastVerified)
}));
// remove users whos position is 'Resting in Elysian Fields'
tdmUsers = tdmUsers.filter(u => u.position !== 'Resting in Elysian Fields');
} catch (e) {
const loadingDiv = modal.querySelector('#tdm-adoption-loading');
if (loadingDiv) loadingDiv.remove();
console.error('Error fetching TDM users:', e);
modal.innerHTML += `<p style='color:${config.CSS.colors.error};'>Error fetching TDM user data.</p>`;
// Close button already bound above
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;
const loadingDiv = modal.querySelector('#tdm-adoption-loading');
if (loadingDiv) loadingDiv.remove();
modal.innerHTML += `
<div style='margin-bottom:16px;'>
<div style='font-size:1.1em;'>${adoptedCount} of ${totalCount} members have installed TDM (${percent}%)</div>
<div style='background:#333; border-radius:6px; height:22px; width:100%; margin-top:8px; position:relative;'>
<div style='background:${config.CSS.colors.success}; height:100%; border-radius:6px; width:${percent}%; transition:width 0.5s;'></div>
<div style='position:absolute; left:50%; top:0; transform:translateX(-50%); color:white; font-weight:bold; line-height:22px;'>${percent}%</div>
</div>
</div>
<div style='margin-bottom:8px;'><b>Sortable Member Table</b></div>
<table id='tdm-adoption-table' style='width:100%; min-width:900px; border-collapse:collapse; background:#222; color:white;'>
<thead>
<tr style='background:#333;'>
<th style='padding:6px; cursor:pointer;' data-sort='name'>Name</th>
<th style='padding:6px; cursor:pointer;' data-sort='level'>Lvl</th>
<th style='padding:6px; cursor:pointer;' data-sort='position'>Position</th>
<th style='padding:6px; cursor:pointer;' data-sort='days'>Days</th>
<th style='padding:6px; cursor:pointer;' data-sort='isTDM'>TDM?</th>
<th style='padding:6px; cursor:pointer;' data-sort='tdmVersion'>Version</th>
<th style='padding:6px; cursor:pointer;' data-sort='last_action'>Last Action</th>
<th style='padding:6px; cursor:pointer;' data-sort='lastVerified'>Last Verified</th>
</tr>
</thead>
<tbody>
${merged.map(m => `
<tr style='background:#2c2c2c; color:white;'>
<td style='padding:6px; color:white;'>${m.name || ''}</td>
<td style='padding:6px; color:white; text-align:center;'>${m.level || ''}</td>
<td style='padding:6px; color:white;'>${m.position || ''}</td>
<td style='padding:6px; color:white; text-align:center;'>${m.days || ''}</td>
<td style='padding:6px; color:white; text-align:center;'>${m.isTDM ? '✅' : ''}</td>
<td style='padding:6px; color:white;'>${m.tdmVersion || ''}</td>
<td style='padding:6px; color:white;'>${m.last_action ? m.last_action.toLocaleDateString(undefined, { year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''}</td>
<td style='padding:6px; color:white;'>${m.lastVerified ? m.lastVerified.toLocaleDateString(undefined, { year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''}</td>
</tr>
`).join('')}
</tbody>
</table>
<div style='margin-top:12px; font-size:0.95em; color:#aaa;'>TDM = TreeDibsMapper userscript installed and verified with backend.</div>
`;
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);
}
const table = modal.querySelector('#tdm-adoption-table');
if (table) {
let sortKey = 'lastVerified', sortAsc = false;
const asSortable = (val) => {
if (val instanceof Date) return val.getTime();
if (typeof val === 'number') return val;
if (typeof val === 'boolean') return val ? 1 : 0;
return (val || '').toString().toLowerCase();
};
const renderRows = () => {
const sorted = [...merged].sort((a, b) => {
const A = asSortable(a[sortKey]);
const B = asSortable(b[sortKey]);
if (A === B) return 0;
return sortAsc ? (A > B ? 1 : -1) : (A < B ? 1 : -1);
});
table.querySelector('tbody').innerHTML = sorted.map(m => `
<tr style='background:#2c2c2c; color:white;'>
<td style='padding:6px; color:white;'>${m.name || ''}</td>
<td style='padding:6px; color:white; text-align:center;'>${m.level || ''}</td>
<td style='padding:6px; color:white;'>${m.position || ''}</td>
<td style='padding:6px; color:white; text-align:center;'>${m.days || ''}</td>
<td style='padding:6px; color:white; text-align:center;'>${m.isTDM ? '✅' : ''}</td>
<td style='padding:6px; color:white;'>${m.tdmVersion || ''}</td>
<td style='padding:6px; color:white;'>${m.last_action ? m.last_action.toLocaleDateString(undefined, { year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''}</td>
<td style='padding:6px; color:white;'>${m.lastVerified ? m.lastVerified.toLocaleDateString(undefined, { year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''}</td>
</tr>
`).join('');
};
table.querySelectorAll('th').forEach(th => {
th.onclick = () => {
const key = th.getAttribute('data-sort');
if (sortKey === key) sortAsc = !sortAsc;
else { sortKey = key; sortAsc = true; }
renderRows();
};
});
renderRows();
}
}
};
//======================================================================
// 6. EVENT HANDLERS & CORE LOGIC
//======================================================================
const handlers = {
fetchGlobalData: async () => {
utils.perf.start('fetchGlobalData');
// Re-entrancy/throttle guard (PDA can trigger rapid duplicate calls)
const nowMsFG = Date.now();
if (state.script._lastGlobalFetch && (nowMsFG - state.script._lastGlobalFetch) < config.MIN_GLOBAL_FETCH_INTERVAL_MS) {
utils.perf.stop('fetchGlobalData');
return;
}
state.script._lastGlobalFetch = nowMsFG;
if (!state.user.tornId || !state.user.actualTornApiKey) {
console.warn("[TDM] fetchGlobalData: User/API key missing.");
utils.perf.stop('fetchGlobalData');
return;
}
try {
// Call new backend endpoint
const clientTimestamps = state.dataTimestamps;
const globalData = await api.post('getGlobalDataForUser', {
tornId: state.user.tornId,
tornApiKey: state.user.actualTornApiKey,
factionId: state.user.factionId,
clientTimestamps,
lastActivityTime: state.script.lastActivityTime,
visibleOpponentIds: utils.getVisibleOpponentIds(),
clientNoteTimestamps: utils.getClientNoteTimestamps()
});
const TRACKED_COLLECTIONS = [
'dibs',
'userNotes',
'medDeals',
'rankedWars',
'rankedWars_attacks',
'rankedWars_summary',
'unauthorizedAttacks',
'attackerActivity',
'TornAPICalls_rankedwars',
'warData'
];
// Check if collections have changed
if (utils.isCollectionChanged(clientTimestamps, globalData.masterTimestamps, 'TornAPICalls_rankedwars')) {
storage.updateStateAndStorage('rankWars', globalData.tornApi.rankWars);
storage.updateStateAndStorage('lastRankWar', state.rankWars.length > 0 ? state.rankWars[0] : state.lastRankWar);
if (state.lastRankWar && state.lastRankWar.factions) {
const opponentFaction = Object.values(state.lastRankWar.factions).find(f => f.id !== parseInt(state.user.factionId));
if (opponentFaction) {
if (state.warData.opponentFactionId && state.warData.opponentFactionId != opponentFaction.id) {
storage.updateStateAndStorage('warData', { opponentFactionId: opponentFaction.id, opponentFactionName: opponentFaction.name });
}
storage.updateStateAndStorage('lastOpponentFactionId', opponentFaction.id);
storage.updateStateAndStorage('lastOpponentFactionName', opponentFaction.name);
}
}
state.dataTimestamps.TornAPICalls_rankedwars = globalData.masterTimestamps.TornAPICalls_rankedwars;
storage.set('dataTimestamps', state.dataTimestamps);
}
if (utils.isCollectionChanged(clientTimestamps, globalData.masterTimestamps, 'rankedWars')) {
console.log(`[TDM][rankedwars][Master]`, globalData.tornApi.warData)
console.log(`[TDM][rankedwars][Client]`, state.warData)
storage.updateStateAndStorage('warData', globalData.tornApi.warData);
state.dataTimestamps.rankedWars = globalData.masterTimestamps.rankedWars;
storage.set('dataTimestamps', state.dataTimestamps);
}
if (utils.isCollectionChanged(clientTimestamps, globalData.masterTimestamps, 'dibs')) {
console.log(`[TDM][dibs][Master]`, globalData.firebase.dibsData)
console.log(`[TDM][dibs][Client]`, state.dibsData)
// Dibs data
storage.updateStateAndStorage('dibsData', globalData.firebase.dibsData);
state.dataTimestamps.dibs = globalData.masterTimestamps.dibs;
storage.set('dataTimestamps', state.dataTimestamps);
}
if (utils.isCollectionChanged(clientTimestamps, globalData.masterTimestamps, 'medDeals')) {
console.log(`[TDM][medDeals][Master]`, globalData.firebase.opponentStatuses)
console.log(`[TDM][medDeals][Client]`, state.opponentStatuses)
// Opponent statuses aka medical deals
var medDeals = {};
if (Array.isArray(globalData.firebase.opponentStatuses)) {
globalData.firebase.opponentStatuses.forEach(status => { medDeals[status.id] = status; });
}
storage.updateStateAndStorage('opponentStatuses', medDeals);
state.dataTimestamps.medDeals = globalData.masterTimestamps.medDeals;
storage.set('dataTimestamps', state.dataTimestamps);
}
if (utils.isCollectionChanged(clientTimestamps, globalData.masterTimestamps, '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);
}
}
if (utils.isCollectionChanged(clientTimestamps, globalData.masterTimestamps, 'dibsNotifications')) {
console.log(`[TDM][dibsNotifications][Master]`, globalData.firebase.dibsNotifications)
console.log(`[TDM][dibsNotifications][Client]`, state.dataTimestamps.dibsNotifications)
// Dibs notifications
if (Array.isArray(globalData.firebase.dibsNotifications) && globalData.firebase.dibsNotifications.length > 0) {
const notifications = globalData.firebase.dibsNotifications;
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;
isMarked = true;
await api.post('markDibsNotificationAsRead', { notificationId: notification.id });
};
ui.showMessageBox(message, 'warning', 60000, markAsRead);
setTimeout(markAsRead, 60000);
});
}
state.dataTimestamps.dibsNotifications = globalData.masterTimestamps.dibsNotifications;
storage.set('dataTimestamps', state.dataTimestamps);
}
if (utils.isCollectionChanged(clientTimestamps, globalData.masterTimestamps, 'rankedWars_summary')) {
// console.log(`[TDM][rankedWars_summary][Master]`, globalData.firebase.rankedWarSummary)
// console.log(`[TDM][rankedWars_summary][Client]`, state.rankedWarSummary)
// Ranked war summary (for score cap check)
storage.updateStateAndStorage('rankedWarSummary', globalData.firebase.rankedWarSummary);
state.dataTimestamps.rankedWars_summary = globalData.masterTimestamps.rankedWars_summary;
storage.set('dataTimestamps', state.dataTimestamps);
}
// Score cap check
await handlers.checkTermedWarScoreCap();
ui.updateAllPages();
// Run enforcement pass (auto-removals) after UI updates
handlers.enforceDibsPolicies?.();
} catch (error) {
console.error("[TDM] Error fetching global data:", error);
}
utils.perf.stop('fetchGlobalData');
},
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 [];
}
console.warn('[TDM] Non-critical error fetching unauthorized attacks:', error.message || 'Unknown error');
storage.updateStateAndStorage('unauthorizedAttacks', []);
return [];
}
},
fetchRetaliationOpportunities: async () => {
// Compute locally using user's limited key
try {
const nowMsRet = Date.now();
if (state.script._lastRetalsFetch && (nowMsRet - state.script._lastRetalsFetch) < config.MIN_RETALS_FETCH_INTERVAL_MS) {
return;
}
state.script._lastRetalsFetch = nowMsRet;
const apiKey = state?.user?.actualTornApiKey;
if (!apiKey) return;
const now = Math.floor(Date.now() / 1000);
const sixMinutesAgo = now - 360; // buffer window
const url = `https://api.torn.com/v2/faction/attacks?sort=DESC&from=${sixMinutesAgo}&key=${apiKey}&comment=TreeDibsMapperRetals`;
let data = null;
try {
const res = await fetch(url);
data = await res.json();
utils.incrementApiCalls(1);
} catch (e) {
// Fallback to GM request if fetch blocked (PDA)
data = await new Promise((resolve) => {
try {
state.gm?.rD_xmlhttpRequest?.({
method: 'GET', url,
onload: r => { try { const parsed = JSON.parse(r.responseText); utils.incrementApiCalls(1); resolve(parsed); } catch { resolve({}); } },
onerror: () => resolve({})
});
} catch { resolve({}); }
});
}
if (data && data.error) return;
const attacks = Object.values(data?.attacks || {});
const incoming = {}; // keyed by attackerId
const outgoing = {}; // keyed by defenderId (attacker we hit)
const our = state.user.factionId?.toString();
for (const atk of attacks) {
const defFac = atk?.defender?.faction?.id?.toString();
const atkFac = atk?.attacker?.faction?.id?.toString();
const validRes = atk?.result !== 'Lost' && atk?.result !== 'Stalemate';
if (!validRes) continue;
// Incoming: they attacked our member
if (defFac === our && atkFac !== our) {
const prev = incoming[atk.attacker.id];
if (!prev || atk.ended > prev.ended) incoming[atk.attacker.id] = atk;
}
// Outgoing: our member attacked them
if (atkFac === our) {
const prev = outgoing[atk.defender.id];
if (!prev || atk.ended > prev.ended) outgoing[atk.defender.id] = atk;
}
}
const newRetaliationOpportunities = {};
for (const [attackerId, atk] of Object.entries(incoming)) {
const fulfilled = !!outgoing[attackerId] && outgoing[attackerId].ended > atk.ended;
if (!fulfilled) {
const timeRemaining = (atk.ended + 300) - now; // 5 minutes
if (timeRemaining > -60) {
newRetaliationOpportunities[attackerId] = {
attackerId: Number(attackerId),
attackerName: atk.attacker?.name,
defenderId: atk.defender?.id,
defenderName: atk.defender?.name,
retaliationEndTime: atk.ended + 300,
timeRemaining: timeRemaining > 0 ? timeRemaining : 0,
expired: timeRemaining <= 0
};
}
}
}
storage.updateStateAndStorage('retaliationOpportunities', newRetaliationOpportunities);
ui.updateRetalsButtonCount?.();
} catch (error) {
console.error('[TDM] Local retals error:', error);
}
},
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;
isMarked = true;
await api.post('markDibsNotificationAsRead', { notificationId: notification.id, factionId: state.user.factionId });
};
ui.showMessageBox(message, 'warning', 60000, markAsRead);
setTimeout(markAsRead, 60000);
});
}
} catch (error) {
console.error('[TDM] Error fetching dibs notifications:', error);
}
},
dibsTarget: async (opponentId, opponentName, buttonElement) => {
const originalText = buttonElement.textContent;
buttonElement.disabled = true;
buttonElement.innerHTML = '<span class="dibs-spinner"></span>';
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.`);
}
// 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.`);
}
}
}
await api.post('dibsTarget', { userid: state.user.tornId, username: state.user.tornUsername, opponentId, opponentname: opponentName, warType: state.warData.warType, factionId: state.user.factionId });
ui.showMessageBox(`Successfully dibbed ${opponentName}!`, 'success');
await handlers.fetchGlobalData();
} catch (error) {
// Friendly handling if target is already dibbed
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');
// Update button to reflect current dib state immediately
buttonElement.className = 'btn dibs-btn btn-dibs-success-other';
buttonElement.textContent = dibberName;
// Admins can remove; regular users cannot
const canRemove = state.script.canAdministerMedDeals && (storage.get('adminFunctionality', true) === true);
buttonElement.disabled = !canRemove;
if (canRemove) {
buttonElement.onclick = (e) => handlers.debouncedRemoveDibsForTarget(opponentId, e.currentTarget);
}
// Also kick off a background refresh to hydrate full state
handlers.debouncedFetchGlobalData();
} else {
ui.showMessageBox(`Failed to dib target: ${msg}`, 'error');
buttonElement.disabled = false;
buttonElement.textContent = originalText;
}
}
},
removeDibsForTarget: async (opponentId, buttonElement) => {
const originalText = buttonElement.textContent;
buttonElement.disabled = true;
buttonElement.innerHTML = '<span class="dibs-spinner"></span>';
try {
const dib = state.dibsData.find(d => d.opponentId === opponentId && d.dibsActive);
if (dib) {
const confirmed = await ui.showConfirmationBox(`Remove ${dib.username}'s dibs for ${dib.opponentname}?`);
if (confirmed) {
await api.post('removeDibs', { dibsDocId: dib.id, removedByUsername: state.user.tornUsername, factionId: state.user.factionId });
ui.showMessageBox('Dibs removed!', 'success');
await handlers.fetchGlobalData();
} else {
buttonElement.disabled = false;
buttonElement.textContent = originalText;
}
} else {
ui.showMessageBox('No active dibs to remove.', 'info');
buttonElement.disabled = false;
buttonElement.textContent = originalText;
}
} 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.innerHTML;
buttonElement.disabled = true;
buttonElement.innerHTML = '<span class="dibs-spinner"></span>';
try {
if (!setMedDeal) {
const confirmed = await ui.showConfirmationBox(`Remove Med Deal with ${opponentName}?`);
if (!confirmed) {
buttonElement.disabled = false;
buttonElement.innerHTML = originalText;
return;
}
}
await api.post('updateMedDeal', {
actionInitiatorUserId: state.user.tornId,
actionInitiatorUsername: state.user.tornUsername,
targetOpponentId: opponentId,
opponentName,
setMedDeal,
medDealForUserId,
medDealForUsername,
factionId: state.user.factionId
});
ui.showMessageBox(`Med Deal with ${opponentName} ${setMedDeal ? 'set' : 'removed'}.`, 'success');
await handlers.fetchGlobalData();
} catch (error) {
ui.showMessageBox(`Failed to update Med Deal: ${error.message}`, 'error');
buttonElement.disabled = false;
buttonElement.innerHTML = originalText;
}
},
checkTermedWarScoreCap: async () => {
// Only run this check if it's a termed war with a score cap set
if (state.warData.warType !== 'Termed War' || !state.warData.scoreCap || state.warData.scoreCap <= 0) {
return;
}
// --- NEW LOGIC START ---
const warId = state.lastRankWar?.id;
if (!warId) return; // Exit if we don't have a valid war ID
const storageKey = `scoreCapAcknowledged_${warId}`;
// Check if the user has already acknowledged the cap for this specific war
if (storage.get(storageKey, false)) {
// console.log(`acknowledge already: ${storage.get(storageKey, false)}`);
state.user.hasReachedScoreCap = true; // Set session state for attack page warnings
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) {
// console.log('backupcheck...');
return;
}
// utils.perf.start('checkTermedWarScoreCap');
try {
// console.log('Getting Score');
// Get the latest war summary, which includes the user's score
const summary = await api.get('rankedWarSummary', { rankedWarId: warId, factionId: state.user.factionId });
if (!summary || summary.length === 0) return;
const userSummary = summary.find(s => s.attackerId == state.user.tornId);
// console.log(`userSummary`, userSummary);
if (!userSummary) return;
const scoreType = state.warData.scoreType || 'Respect'; // Default to Respect if not set
let userScore = 0;
if (scoreType === 'Respect') {
userScore = userSummary.totalRespectGain || 0;
} else if (scoreType === 'Respect (no chain)') {
userScore = userSummary.totalRespectGainNoChain || 0;
} else { // Attacks
userScore = userSummary.totalAttacks || 0;
}
console.log(`Current Score: ${userScore} ${scoreType}`);
// Check if the user's score has met or exceeded the cap
if (userScore >= state.warData.scoreCap) {
state.user.hasReachedScoreCap = true; // Set session flag immediately
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 // This shows an "OK" button instead of "Yes/No"
);
if (confirmed) {
storage.set(storageKey, true);
// Call the backend to deactivate everything for the user
await api.post('deactivateDibsAndDealsForUser', { userId: state.user.tornId, factionId: state.user.factionId });
// Refresh data to show the deactivated status
await handlers.fetchGlobalData();
}
}
} catch (error) {
console.error("[TDM] Error checking score cap:", error);
// Don't show an error to the user, just log it.
}
// 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) => {
const originalText = buttonElement.textContent;
buttonElement.disabled = true;
buttonElement.innerHTML = '<span class="dibs-spinner"></span>';
try {
await api.post('updateUserNote', { noteTargetId, noteContent, factionId: state.user.factionId });
ui.showMessageBox('[TDM] Note saved!', 'success');
state.userNotes[noteTargetId] = { noteContent };
ui.closeNoteModal();
await handlers.fetchGlobalData();
} catch (error) {
ui.showMessageBox(`[TDM] Failed to save note: ${error.message}`, 'error');
buttonElement.disabled = false;
buttonElement.textContent = originalText;
}
},
assignDibs: async (opponentId, opponentName, dibsForUserId, dibsForUsername, buttonElement) => {
const originalText = buttonElement.textContent;
buttonElement.disabled = true;
buttonElement.innerHTML = '<span class="dibs-spinner"></span>';
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.`);
}
}
}
await api.post('dibsTarget', {
userid: dibsForUserId,
username: dibsForUsername,
opponentId,
opponentname: opponentName,
warType: state.warData.warType,
factionId: state.user.factionId
});
ui.showMessageBox(`[TDM] Assigned dibs on ${opponentName} to ${dibsForUsername}!`, 'success');
await handlers.fetchGlobalData();
} 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');
// Setter button may not be the row dibs button; do a light refresh so row updates
handlers.debouncedFetchGlobalData();
} else {
ui.showMessageBox(`[TDM] Failed to assign dibs: ${msg}`, 'error');
}
buttonElement.disabled = false;
buttonElement.textContent = originalText;
}
},
// 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;
// Check opponent travel removal
if (opts.removeOnFly) {
try {
const oppStat = await utils.getUserStatus(myActive.opponentId);
if (oppStat.canonical === 'Travel' || oppStat.canonical === 'Abroad') {
await api.post('removeDibs', { dibsDocId: myActive.id, removedByUsername: state.user.tornUsername, factionId: state.user.factionId });
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) {
try {
const me = await utils.getUserStatus(null);
if (me.canonical === 'Travel' || me.canonical === 'Abroad') {
await api.post('removeDibs', { dibsDocId: myActive.id, removedByUsername: state.user.tornUsername, factionId: state.user.factionId });
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);
// console.log("[TDM] Current user in OC check:", currentUser.is_in_oc);
if (currentUser && !currentUser.is_in_oc) {
ui.showConfirmationBox('[TDM] JOIN AN OC!');
storage.set('ocReminderShown', true); // Ensure it only shows once per load
}
}
}
};
//======================================================================
// 7. INITIALIZATION & MAIN EXECUTION
//======================================================================
const main = {
init: async () => {
utils.perf.start('main.init');
main.setupGmFunctions();
main.registerTampermonkeyMenuCommands();
// Non-blocking update check (cached)
main.checkForUserscriptUpdate().catch(() => {});
state.user.actualTornApiKey = await state.gm.rD_getApiKey();
const isReady = await main.initializeUserAndApiKey();
if (isReady) {
main.initializeDebouncedHandlers();
await main.initializeScriptLogic();
main.startPolling();
main.setupActivityListeners();
}
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();
clearTimeout(state.script.activityTimeoutId);
if (state.script.currentRefreshInterval !== config.REFRESH_INTERVAL_ACTIVE_MS) {
state.script.currentRefreshInterval = config.REFRESH_INTERVAL_ACTIVE_MS;
main.startPolling();
}
state.script.activityTimeoutId = setTimeout(() => {
state.script.currentRefreshInterval = config.REFRESH_INTERVAL_INACTIVE_MS;
main.startPolling();
}, config.ACTIVITY_TIMEOUT_MS);
} catch (_) { /* non-fatal */ }
},
initializeDebouncedHandlers: () => {
handlers.debouncedFetchGlobalData = utils.debounce(handlers.fetchGlobalData, 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);
handlers.debouncedFetchRetaliationOpportunities = utils.debounce(handlers.fetchRetaliationOpportunities, 1000);
},
// 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 () => {
const current = await state.gm.rD_getApiKey();
const input = prompt('TreeDibsMapper: Enter your Torn API Key:', current || '');
if (input && input.trim()) {
await state.gm.rD_setValue('torn_api_key', input.trim());
state.user.actualTornApiKey = input.trim();
ui.showMessageBox('API Key saved. Reloading...', 'info');
setTimeout(() => location.reload(), 300);
}
});
state.gm.rD_registerMenuCommand('TreeDibs: Clear Torn API Key', async () => {
if (confirm('Clear the stored Torn API Key? You will be prompted again next time.')) {
await state.gm.rD_deleteValue('torn_api_key');
state.user.actualTornApiKey = null;
ui.showMessageBox('API Key cleared. Reloading...', 'info');
setTimeout(() => location.reload(), 300);
}
});
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));
} catch (e) {
console.error('[TDM] Failed to register Tampermonkey menu commands:', e);
}
},
checkForUserscriptUpdate: async (force = false) => {
try {
const now = Date.now();
const lastCheck = storage.get('lastUpdateCheck', 0);
if (!force && (now - lastCheck) < 6 * 60 * 60 * 1000) return; // 6h throttle
storage.set('lastUpdateCheck', now);
const metaText = await utils.httpGetText(config.GREASYFORK.updateMetaUrl);
if (!metaText) return;
const m = metaText.match(/@version\s+([^\n\r]+)/);
const latest = m ? m[1].trim() : null;
if (!latest) return;
storage.set('lastKnownLatestVersion', latest);
if (utils.compareVersions(config.VERSION, latest) < 0) {
const go = await ui.showConfirmationBox(`A newer TreeDibsMapper version is available (v${latest}). Update now?`, true);
if (go) {
try { window.open(config.GREASYFORK.downloadUrl, '_blank'); }
catch (_) { location.href = config.GREASYFORK.downloadUrl; }
} else {
// Provide a persistent toast with clickable link
ui.showMessageBox(`Update available: v${latest}. Click to open update URL.`, 'warning', 8000, () => {
try { window.open(config.GREASYFORK.downloadUrl, '_blank'); }
catch (_) { location.href = config.GREASYFORK.downloadUrl; }
});
}
}
} catch (e) {
// Silent fail; not critical
}
},
setupGmFunctions: () => {
state.script.isPDA = (config.PDA_API_KEY_PLACEHOLDER[0] !== '#');
if (state.script.isPDA) {
state.gm.rD_setValue = (name, value) => localStorage.setItem(name, value);
state.gm.rD_getValue = (name, def) => localStorage.getItem(name) ?? def;
state.gm.rD_deleteValue = (name) => localStorage.removeItem(name);
state.gm.rD_addStyle = (css) => { const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); };
state.gm.rD_getApiKey = async () => config.PDA_API_KEY_PLACEHOLDER;
if (window.flutter_inappwebview && typeof window.flutter_inappwebview.callHandler === 'function') {
state.gm.rD_xmlhttpRequest = (details) => {
return new Promise((resolve, reject) => {
const { method, 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 || {});
pdaPromise.then(response => {
const responseObj = {
status: response?.status || 200,
statusText: response?.statusText || 'OK',
responseText: response?.responseText || '',
finalUrl: url
};
if (details.onload) details.onload(responseObj);
resolve(responseObj);
}).catch(error => {
if (details.onerror) details.onerror(error);
reject(error);
});
});
};
} else {
state.gm.rD_xmlhttpRequest = GM_xmlhttpRequest;
}
} else {
state.gm.rD_setValue = GM_setValue;
state.gm.rD_getValue = GM_getValue;
state.gm.rD_deleteValue = GM_deleteValue;
state.gm.rD_addStyle = GM_addStyle;
state.gm.rD_xmlhttpRequest = GM_xmlhttpRequest;
state.gm.rD_getApiKey = async () => GM_getValue('torn_api_key', null);
state.gm.rD_registerMenuCommand = GM_registerMenuCommand;
}
},
initializeUserAndApiKey: async () => {
// utils.perf.start('initializeUserAndApiKey');
// Check Key access level and prompt if the level is too low
const keyInfo = await api.getKeyInfo(state.user.actualTornApiKey);
state.user.actualTornApiKeyAccess = keyInfo?.info?.access?.level || 0;
if (keyInfo && keyInfo.info && keyInfo.info.access && keyInfo.info.access.level < 3) {
ui.showMessageBox("[TDM] Your API Key has insufficient access level. Limited is now needed. Please upgrade your API Key.", "error");
}
if (!state.user.actualTornApiKey || state.user.actualTornApiKey.trim() === "") {
if (state.script.isPDA) {
ui.showMessageBox("PDA API Key placeholder not replaced. Please enter a limited Key.", "error");
// utils.perf.stop('initializeUserAndApiKey');
return false;
} else {
let userInput = prompt("TreeDibsMapper: Please enter Limited Torn API Key. See userscript for Torn TOS info", '');
if (userInput?.trim()) {
await state.gm.rD_setValue('torn_api_key', userInput.trim());
state.user.actualTornApiKey = userInput.trim();
ui.showMessageBox("API Key updated. Reloading...", "info");
location.reload();
// utils.perf.stop('initializeUserAndApiKey');
return false;
} else {
ui.showMessageBox("No API key provided. Script features will not work.", "warning");
// utils.perf.stop('initializeUserAndApiKey');
return false;
}
}
}
try {
const tornUser = await api.getTornUser(state.user.actualTornApiKey);
if (!tornUser) {
// utils.perf.stop('initializeUserAndApiKey');
return false; }
state.user.tornUserObject = tornUser;
state.user.tornId = tornUser.player_id.toString();
state.user.tornUsername = tornUser.name;
state.user.factionId = tornUser.faction?.faction_id.toString() || null;
storage.updateStateAndStorage('user', state.user);
// Determine admin rights via backend settings for this faction
try {
const settings = await api.get('getFactionSettings', { factionId: state.user.factionId });
state.script.factionSettings = settings;
state.script.currentUserPosition = tornUser.faction?.position;
const adminRoles = Array.isArray(settings?.adminRoles) ? settings.adminRoles : config.ALLOWED_POSITIONS_FOR_WAR_CONTROLS;
state.script.canAdministerMedDeals = !!state.script.currentUserPosition && adminRoles.includes(state.script.currentUserPosition);
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
}
} catch (_) { /* fallback silently */ }
const factionData = await api.getTornFaction(state.user.actualTornApiKey, 'members');
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
}));
}
// utils.perf.stop('initializeUserAndApiKey');
return true;
} catch (error) {
ui.showMessageBox(`API Key Error: ${error.message}. Please check your key.`, "error");
await state.gm.rD_deleteValue('torn_api_key');
// utils.perf.stop('initializeUserAndApiKey');
return false;
}
},
initializeScriptLogic: async () => {
utils.perf.start('initializeScriptLogic');
state.script.hasProcessedRankedWarTables = false;
state.script.hasProcessedFactionList = false;
// if (state.script.canAdministerMedDeals) {
// }
await handlers.fetchGlobalData();
ui.updatePageContext();
ui.applyGeneralStyles();
ui.updateColumnVisibilityStyles();
if (state.page.isFactionPage || state.page.isAttackPage) ui.createSettingsButton();
if (state.page.isAttackPage) await ui.injectAttackPageUI();
if (state.dom.factionListContainer) {
await ui.processFactionPageMembers(state.dom.factionListContainer);
state.script.hasProcessedFactionList = true;
ui.updateFactionPageUI(state.dom.factionListContainer);
}
// On SPA hash changes, ensure timers and counters even if fetch is throttled
ui.updateRetalsButtonCount();
ui.ensureChainTimer();
ui.ensureInactivityTimer();
ui.ensureOpponentStatus();
handlers.checkOCReminder();
main.setupMutationObserver();
utils.perf.stop('initializeScriptLogic');
},
startPolling: () => {
if (state.script.mainRefreshIntervalId) clearInterval(state.script.mainRefreshIntervalId);
state.script.mainRefreshIntervalId = setInterval(() => {
handlers.debouncedFetchGlobalData();
handlers.debouncedFetchRetaliationOpportunities?.();
handlers.enforceDibsPolicies?.();
}, state.script.currentRefreshInterval);
},
setupMutationObserver: () => {
if (state.script.mutationObserver) state.script.mutationObserver.disconnect();
const observerTarget = document.body;
if (!observerTarget) return;
state.script.mutationObserver = new MutationObserver(utils.debounce(async (mutations, obs) => {
ui.updatePageContext();
if (state.dom.rankwarContainer && !state.script.hasProcessedRankedWarTables) {
await ui.processRankedWarTables();
}
if (state.dom.factionListContainer && !state.script.hasProcessedFactionList) {
await ui.processFactionPageMembers(state.dom.factionListContainer);
state.script.hasProcessedFactionList = true;
ui.updateFactionPageUI(state.dom.factionListContainer);
}
if (state.page.isAttackPage || (state.script.hasProcessedRankedWarTables && state.script.hasProcessedFactionList)) {
obs.disconnect();
}
}, 200));
state.script.mutationObserver.observe(observerTarget, { childList: true, subtree: true });
},
setupActivityListeners: () => {
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', () => {
state.script.isWindowActive = !document.hidden;
if (state.script.isWindowActive) {
handlers.debouncedFetchGlobalData();
handlers.debouncedFetchRetaliationOpportunities?.();
resetActivityTimer();
} else {
clearInterval(state.script.mainRefreshIntervalId);
clearTimeout(state.script.activityTimeoutId);
}
});
window.addEventListener('hashchange', main.initializeScriptLogic);
// Initialize timer state immediately
resetActivityTimer();
}
};
setTimeout(() => {
ui.updatePageContext();
if (!state.page.isFactionPage && !state.page.isAttackPage) {
return;
}
console.log(`[TreeDibsMapper] Script execution started (setTimeout). Hardcoded Version: ${config.VERSION}`);
main.init();
}, 0);
})();